diff --git a/.github/ISSUE_TEMPLATE/app-bug.yml b/.github/ISSUE_TEMPLATE/app-bug.yml index e7060db5..37c1ba21 100644 --- a/.github/ISSUE_TEMPLATE/app-bug.yml +++ b/.github/ISSUE_TEMPLATE/app-bug.yml @@ -66,14 +66,23 @@ body: id: exporter attributes: label: Which exporter made the zip? - description: >- - Only if this is about importing or about missing locations. The ⋮ menu → - **Information** prints it under the version, and the app's error page prints it too — - copy it from either. + description: | + Only if this is about importing or about missing locations. + + **It is per tag, not per app** — you can have tags from several exports, and from your + Apple account, all in one list. So open the tags this is about, one at a time, and read + the **Exported with** row on each. Three things you might see: + + - `OpenTagViewer.wizard:1.3.0`, or `.cli`, or `.android` — that is the answer, paste it + - **Not recorded — an older exporter** — the export predates the app writing this down, + which is itself useful: it dates the bundle + - **no such row at all**, and Source says *Read from your Apple account* — that tag never + came from a file, so no exporter was involved and there is nothing to paste + **Do not open the zip to find out.** It holds the keys to your tags, newer ones are password-protected on purpose, and unpacking it to read a version line is not worth the risk of what you might then attach. - placeholder: "OpenTagViewer.wizard:1.3.0 — or 1.3.0, or whenever I downloaded it" + placeholder: "Bike: OpenTagViewer.wizard:1.3.0, Keys: from my Apple account" - type: textarea id: log diff --git a/AGENTS.md b/AGENTS.md index a24c5a4a..462c1891 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,16 @@ So releasing is two steps, in this order: 1. Commit the `VERSION` bump to `main` 2. Tag that commit `exporter-v` and publish the release +**And the app's release goes out before the exporter's, whenever the exporter's changes what a +bundle is.** They are separate releases with separate tags, which makes them look independent; +they are not. Exporter 1.4.0 locks bundles by default, and an app older than 1.1.0 cannot decrypt +one at all — it fails with a message about the zip rather than about a code. Publish the exporter +first and every bundle written that day is unopenable by whoever receives it, and the recipient is +the one person in that transaction who chose none of it and can fix none of it. + +Nothing enforces this — `release_version.py` checks a tag against a version, not one release +against another — so it is a thing to remember, which is why it is written here. + `scripts/release_version.py --kind exporter --tag ` enforces it, and runs in `test-release-version` before any build job. A tag that disagrees fails the release rather than shipping a build that lies about itself. `macos-exporter-v` is the old spelling and still resolves, because tags already diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d7568a3..37764c96 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -96,8 +96,8 @@ android { applicationId = "dev.wander.android.opentagviewer" minSdk = 24 targetSdk = 35 - versionCode = 3 - versionName = "1.0.5" + versionCode = 4 + versionName = "1.1.0" // Null unless a build type sets it - see the debug block. A release is built from a tag // and its versionName is exactly right, so there is nothing a commit would add; the diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/Shot.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/Shot.java index 7ec1b44d..c5b6394d 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/Shot.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/Shot.java @@ -1,21 +1,36 @@ package dev.wander.android.opentagviewer; +import android.app.Activity; +import android.app.Dialog; import android.graphics.Bitmap; +import android.graphics.Canvas; import android.util.Log; +import android.view.View; import androidx.test.platform.app.InstrumentationRegistry; +import com.google.android.material.color.MaterialColors; + import java.io.File; import java.io.FileOutputStream; /** - * A picture of whatever is on the screen, dialogs included. + * A picture of what is on screen, for looking at afterwards. + * + *

{@link #ofTheScreen} needs a device with a display, and says so rather than lying. + * {@code UiAutomation.takeScreenshot()} photographs the compositor's output, which is the only + * way to capture an activity and a dialog together - and on the headless managed device it + * returns a completely black bitmap, without failing and without warning. Five screens + * were captured that way and every one was black; nothing in the run said so, and the pictures + * were believed to be evidence until somebody opened them. + * + *

So it now checks. A frame that came back entirely one colour is not written at all: a + * missing file gets noticed, a black one gets mistaken for a screen that renders nothing. * - *

Whole-screen, unlike the {@code view.draw(canvas)} pattern used elsewhere. That one - * needs the view to be in the activity's own hierarchy, and a dialog is not - it lives in its - * own window, so drawing the activity produces the page behind it with a hole where the dialog - * should be. {@code UiAutomation} photographs the compositor's output instead, which is what a - * person actually sees. + *

{@link #of(Activity, String)} and {@link #of(Dialog, String)} draw a window's view hierarchy + * instead, which works on both kinds of device - and is what every other screenshot test here + * does. The catch is that a window is all they can draw: an activity drawn while a dialog is up + * is the page behind it, with a hole where the dialog should be. * *

Writes into the directory AGP passes as {@code additionalTestOutputDir} and does nothing * when there isn't one, so it is free in an ordinary run. Names are @@ -31,9 +46,9 @@ private Shot() {} private static final String TAG = "Shot"; + /** Everything the compositor is showing, dialogs included. Needs a display. */ public static void ofTheScreen(final String name) { - final String dir = InstrumentationRegistry.getArguments() - .getString("additionalTestOutputDir"); + final String dir = outputDir(); if (dir == null) { return; } @@ -45,14 +60,106 @@ public static void ofTheScreen(final String name) { Log.w(TAG, "the screen could not be photographed for " + name); return; } - try (FileOutputStream out = - new FileOutputStream(new File(new File(dir), name + ".png"))) { - bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); + + if (isBlank(bitmap)) { + // The headless case. Writing it would produce a black PNG that looks like a + // rendering bug in the app rather than an absent display. + Log.w(TAG, "the screenshot for " + name + " came back blank, which means this" + + " device has no display - use Shot.of(activity/dialog) instead"); + bitmap.recycle(); + return; } - bitmap.recycle(); + + write(bitmap, dir, name); } catch (final Exception e) { // A screenshot explains a failure; it is never the reason for one. Log.w(TAG, "could not write " + name, e); } } + + /** The activity's own window, drawn rather than photographed. Works headless. */ + public static void of(final Activity activity, final String name) { + draw(activity.getWindow().getDecorView(), name); + } + + /** + * A dialog's window, which is a separate one from the activity's. + * + *

Drawing the activity while a dialog is up gives the page behind it and a hole where the + * dialog should be - they are different windows, and on a device with no compositor there is + * nothing to put them back together. + */ + public static void of(final Dialog dialog, final String name) { + if (dialog.getWindow() == null) { + Log.w(TAG, "the dialog has no window yet, so nothing was drawn for " + name); + return; + } + draw(dialog.getWindow().getDecorView(), name); + } + + private static void draw(final View view, final String name) { + final String dir = outputDir(); + if (dir == null) { + return; + } + + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + try { + if (view.getWidth() == 0 || view.getHeight() == 0) { + // Not laid out yet, and a 0x0 bitmap throws. Said out loud rather than + // skipped quietly, for the same reason the blank check exists. + Log.w(TAG, "nothing to draw for " + name + " - the view measured 0x0"); + return; + } + + final Bitmap bitmap = Bitmap.createBitmap( + view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(bitmap); + + // Several layouts here have no background of their own, and a transparent pixel + // flattened into a PNG reads as black - the very thing this class exists to stop + // being mistaken for a real screen. + canvas.drawColor(MaterialColors.getColor( + view, com.google.android.material.R.attr.colorSurface)); + view.draw(canvas); + + write(bitmap, dir, name); + } catch (final Exception e) { + Log.w(TAG, "could not write " + name, e); + } + }); + } + + /** + * Whether every pixel is the same colour, sampled on a grid. + * + *

Sampled rather than exhaustive: a 1080x2400 frame is 2.6 million pixels and this runs + * between steps of a UI test. A grid catches the case that matters - a frame that is entirely + * one colour - and a real screen differs somewhere within a few samples. + */ + private static boolean isBlank(final Bitmap bitmap) { + final int first = bitmap.getPixel(0, 0); + + for (int x = 0; x < bitmap.getWidth(); x += Math.max(1, bitmap.getWidth() / 32)) { + for (int y = 0; y < bitmap.getHeight(); y += Math.max(1, bitmap.getHeight() / 32)) { + if (bitmap.getPixel(x, y) != first) { + return false; + } + } + } + + return true; + } + + private static String outputDir() { + return InstrumentationRegistry.getArguments().getString("additionalTestOutputDir"); + } + + private static void write(final Bitmap bitmap, final String dir, final String name) + throws java.io.IOException { + try (FileOutputStream out = new FileOutputStream(new File(new File(dir), name + ".png"))) { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); + } + bitmap.recycle(); + } } diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/dao/WhichExportersMadeTheseTagsTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/dao/WhichExportersMadeTheseTagsTest.java deleted file mode 100644 index a9e23dca..00000000 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/dao/WhichExportersMadeTheseTagsTest.java +++ /dev/null @@ -1,130 +0,0 @@ -package dev.wander.android.opentagviewer.db.room.dao; - -import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import androidx.room.Room; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.filters.SmallTest; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.List; - -import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; -import dev.wander.android.opentagviewer.db.room.entity.Import; - -/** - * Which exporters produced the bundles on this install. - * - *

The question the Information screen used to answer with {@code getMostRecent}. That - * names one producer and reads as though it accounts for every tag on the phone - and importing - * twice is ordinary: a second Mac, a re-export after buying a tag, an old bundle alongside a - * current one. A report saying "exported with 1.3.0" when half the tags came out of 1.1.0 sends - * whoever reads it looking in the wrong place, which is the whole failure this screen exists to - * prevent. - * - *

An in-memory database rather than the device's own, so this says nothing about whatever the - * emulator happens to have imported and cannot disturb it. - */ -@SmallTest -@RunWith(AndroidJUnit4.class) -public class WhichExportersMadeTheseTagsTest { - - private OpenTagViewerDatabase db; - - @Before - public void openAnEmptyOne() { - this.db = Room.inMemoryDatabaseBuilder( - getInstrumentation().getTargetContext(), OpenTagViewerDatabase.class) - .allowMainThreadQueries() - .build(); - } - - @After - public void closeIt() { - if (this.db != null) { - this.db.close(); - } - } - - /** Every producer, not the last one. */ - @Test - public void twoBundlesFromTwoExportersAreBothNamed() { - this.imported("OpenTagViewer.wizard:1.1.0", 1_000L); - this.imported("OpenTagViewer.cli:1.3.0", 2_000L); - - assertEquals( - List.of("OpenTagViewer.cli:1.3.0", "OpenTagViewer.wizard:1.1.0"), - this.dao().getDistinctProducers()); - } - - /** - * Most recently used first, which is not the same as most recently inserted. - * - *

Somebody who imports from 1.3.0, then re-imports an older bundle, then imports from - * 1.3.0 again has three rows and two producers. The ordering is by each producer's newest - * import - so 1.3.0 leads, despite its first row being the oldest of the three. - */ - @Test - public void theOrderIsByEachProducersNewestImport() { - this.imported("OpenTagViewer.wizard:1.3.0", 1_000L); - this.imported("OpenTagViewer.wizard:1.1.0", 2_000L); - this.imported("OpenTagViewer.wizard:1.3.0", 3_000L); - - assertEquals( - List.of("OpenTagViewer.wizard:1.3.0", "OpenTagViewer.wizard:1.1.0"), - this.dao().getDistinctProducers()); - } - - /** The same producer twice is one answer, not two. */ - @Test - public void thesameExporterTwiceIsNamedOnce() { - this.imported("OpenTagViewer.wizard:1.3.0", 1_000L); - this.imported("OpenTagViewer.wizard:1.3.0", 2_000L); - - assertEquals(1, this.dao().getDistinctProducers().size()); - } - - /** - * An export from before {@code via:} existed contributes nothing rather than a blank. - * - *

Null and empty are both real in this column - format 0.0.1 predates the field entirely. - * Letting either through puts "Tags imported from , OpenTagViewer.wizard:1.3.0" on the - * screen, which reads as a rendering bug rather than as an old bundle. - */ - @Test - public void anexportThatNeverRecordedItselfIsNotAnEmptyEntry() { - this.imported(null, 1_000L); - this.imported("", 2_000L); - this.imported("OpenTagViewer.wizard:1.3.0", 3_000L); - - assertEquals(List.of("OpenTagViewer.wizard:1.3.0"), this.dao().getDistinctProducers()); - } - - /** Nothing imported is an empty list, which the screen turns into words of its own. */ - @Test - public void nothingImportedIsEmptyRatherThanNull() { - final List producers = this.dao().getDistinctProducers(); - - assertTrue("expected no producers, got " + producers, producers.isEmpty()); - } - - private ImportDao dao() { - return this.db.importDao(); - } - - private void imported(final String via, final long at) { - this.dao().insert(Import.builder() - .version("0.0.2") - .importedAt(at) - .exportedAt(at) - .sourceUser("someone@example.com") - .exportedVia(via) - .build()); - } -} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/WhatTheInformationScreenAnswersTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/WhatTheInformationScreenAnswersTest.java deleted file mode 100644 index c6e9f701..00000000 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/WhatTheInformationScreenAnswersTest.java +++ /dev/null @@ -1,128 +0,0 @@ -package dev.wander.android.opentagviewer.ui; - -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withText; -import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.startsWith; - -import android.content.Context; - -import androidx.test.core.app.ActivityScenario; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.filters.LargeTest; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.List; - -import dev.wander.android.opentagviewer.Eventually; -import dev.wander.android.opentagviewer.InformationActivity; -import dev.wander.android.opentagviewer.R; -import dev.wander.android.opentagviewer.Shot; -import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; -import dev.wander.android.opentagviewer.db.room.dao.ImportDao; -import dev.wander.android.opentagviewer.db.room.entity.Import; - -/** - * The screen a bug report sends people to, and whether it answers what the report asks. - * - *

Two questions, and until now it answered one. The app version was here; which - * exporter produced the bundle was not - and the only place that lives is {@code - * OPENTAGVIEWER.yml} inside the export zip, a file holding the private keys to somebody's tags - * and one the issue template tells them in bold not to open. Asking a question whose answer is - * inside a file you have told people not to open is asking them to ignore you. - * - *

Both states matter. An install connected straight to an Apple account has no bundle behind - * it at all, and "nothing" is a real answer rather than a gap - it tells a maintainer the - * exporter is not involved, which rules out a whole class of cause. - */ -@LargeTest -@RunWith(AndroidJUnit4.class) -public class WhatTheInformationScreenAnswersTest { - - private static final String AN_EXPORTER = "OpenTagViewer.wizard:1.3.0"; - - private ActivityScenario scenario; - - /** Put back whatever the device had, so this cannot bleed into another test. */ - private List before; - - @Before - public void rememberWhatWasThere() { - this.before = imports().getAll(); - for (final Import existing : this.before) { - imports().delete(existing); - } - } - - @After - public void putItBack() { - if (this.scenario != null) { - this.scenario.close(); - } - for (final Import existing : imports().getAll()) { - imports().delete(existing); - } - for (final Import original : this.before) { - imports().insert(original); - } - } - - /** - * The exporter that made the bundle, on screen, so nobody opens the zip to find it. - */ - @Test - public void awhichExporterMadeTheBundle() { - imports().insert(Import.builder() - .version("0.0.2") - .importedAt(System.currentTimeMillis()) - .exportedAt(System.currentTimeMillis()) - .sourceUser("someone@example.com") - .exportedVia(AN_EXPORTER) - .build()); - - this.scenario = ActivityScenario.launch(InformationActivity.class); - - // The read is a database call on a background thread, so the line arrives after the - // screen does. - Eventually.check(() -> onView(withId(R.id.appImportedFrom)) - .check(matches(withText(containsString(AN_EXPORTER))))); - - // And the version is still the thing above it, which is the other half the report wants. - onView(withId(R.id.appVersion)).check(matches(withText(startsWith("Version")))); - - Shot.ofTheScreen("the_information_screen-imported_from_an_exporter"); - } - - /** - * And "nothing" said out loud, rather than an empty line. - * - *

A blank where an answer should be reads as a bug in this screen. It is not - it is the - * answer for anybody who connected an Apple account instead of importing a zip, and saying - * so rules the exporter out of whatever they are reporting. - */ - @Test - public void bnothingImportedIsAlsoAnAnswer() { - this.scenario = ActivityScenario.launch(InformationActivity.class); - - final Context context = getInstrumentation().getTargetContext(); - Eventually.check(() -> onView(withId(R.id.appImportedFrom)) - .check(matches(withText(context.getString(R.string.imported_from_nothing))))); - onView(withId(R.id.appImportedFrom)).check(matches(isDisplayed())); - - Shot.ofTheScreen("the_information_screen-nothing_imported"); - } - - private static ImportDao imports() { - return OpenTagViewerDatabase - .getInstance(getInstrumentation().getTargetContext().getApplicationContext()) - .importDao(); - } -} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ExportingTagsReachesTheBugPageTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ExportingTagsReachesTheBugPageTest.java new file mode 100644 index 00000000..6a57137f --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ExportingTagsReachesTheBugPageTest.java @@ -0,0 +1,261 @@ +package dev.wander.android.opentagviewer.ui.mydevices; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.longClick; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.intent.Intents.intended; +import static androidx.test.espresso.intent.Intents.intending; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasType; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; + +import android.app.Activity; +import android.app.Instrumentation.ActivityResult; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.intent.Intents; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.wander.android.opentagviewer.MyDevicesListActivity; +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.BeaconNamingRecord; +import dev.wander.android.opentagviewer.db.room.entity.Import; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.BundleBuilder; +import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity; + +/** + * Exporting tags, driven from the list, when the app cannot do it. + * + *

The whole flow, not the pieces. Its sibling proves {@code TagExporter} refuses and + * throws the right things; this one proves the screen acts on that - long press a tag, pick + * Export Tags, choose somewhere to save it, and then have the builder fail. What the person is + * shown at that moment is the entire value of the feature to them, and it is reached through five + * layers that each have their own idea of what an error is. + * + *

The state is not reachable on a working device: it means a Python interpreter that will not + * start. {@code AppDependencies.replaceBundleBuilder} is why it can be tested at all. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class ExportingTagsReachesTheBugPageTest { + + private static final String A_TAG = "export-flow-test-tag"; + private static final String A_NAME = "Export Me"; + private static final String A_TEST_USER = "exportflow@example.invalid"; + + /** + * A real accessory plist, not a skeleton. + * + *

The first version of this carried only `identifier` and `name`, and the tag never + * appeared in the list at all - `BeaconDataParser` reads `privateKey` and the rest + * unconditionally, so a partial record is dropped before anything is drawn. The test failed + * on "no view matching Export Me", which points at the list and not at the fixture. + */ + private static final String A_PLIST = "" + + "" + + "batteryLevel1" + + "model" + + "pairingDate2025-02-27T20:03:32Z" + + "privateKeykey" + + "databm90LWEtcmVhbC1rZXk=" + + "productId21760" + + "stableIdentifier2001~#0~#A0" + + "systemVersion2.0.73" + + "vendorId76" + + ""; + + /** The naming record is a different shape: an identifier and what the user calls it. */ + private static final String A_NAMING_RECORD = + "" + + "identifier" + A_TAG + "" + + "name" + A_NAME + ""; + + private OpenTagViewerDatabase db; + private ActivityScenario scenario; + private File written; + + @Before + public void seedOneExportableTag() { + final Context context = getInstrumentation().getTargetContext(); + this.db = OpenTagViewerDatabase.getInstance(context); + this.forgetIt(); + + final long importId = this.db.importDao().insert(Import.builder() + .version("0.0.2") + .importedAt(1_700_000_000_000L) + .exportedAt(1_699_000_000_000L) + .sourceUser(A_TEST_USER) + .exportedVia("OpenTagViewer.wizard:test") + .build()); + + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(A_TAG).importId(importId).content(A_PLIST) + .version("0.0.2").fromAccount(false).isRemoved(false).build()); + + this.db.beaconNamingRecordDao().insertAll(BeaconNamingRecord.builder() + .id(A_TAG).importId(importId).content(A_NAMING_RECORD) + .version("0.0.2").isRemoved(false).build()); + + this.written = new File(context.getCacheDir(), "export-flow-test.zip"); + + Intents.init(); + + // The document picker, answered with somewhere this test owns. Everything else the screen + // can fire is named too - a matcher broad enough to catch the launch intent stubs the + // activity under test and hangs the run. + intending(anyOf( + hasAction(Intent.ACTION_CREATE_DOCUMENT), + hasComponent(ErrorReportActivity.class.getName()))) + .respondWith(new ActivityResult( + Activity.RESULT_OK, + new Intent().setData(Uri.fromFile(this.written)))); + } + + @After + public void putEverythingBack() { + if (this.scenario != null) { + this.scenario.close(); + } + Intents.release(); + AppDependencies.reset(); + this.forgetIt(); + if (this.written != null) { + this.written.delete(); + } + } + + private void forgetIt() { + this.db.ownedBeaconDao().delete(OwnedBeacon.builder().id(A_TAG).build()); + this.db.beaconNamingRecordDao().delete(BeaconNamingRecord.builder().id(A_TAG).build()); + for (final Import stale : this.db.importDao().getImportsFromUser(A_TEST_USER)) { + this.db.importDao().delete(stale); + } + } + + /** Long press the tag, open the selection menu, and pick Export Tags. */ + private void exportIt() { + this.scenario = ActivityScenario.launch(MyDevicesListActivity.class); + + dev.wander.android.opentagviewer.Eventually.check(() -> + onView(withText(A_NAME)).check(matches(isDisplayed()))); + onView(withText(A_NAME)).perform(longClick()); + + dev.wander.android.opentagviewer.Eventually.check(() -> + onView(withId(R.id.selection_menu_button)).check(matches(isDisplayed()))); + onView(withId(R.id.selection_menu_button)).perform(click()); + + dev.wander.android.opentagviewer.Eventually.check(() -> + onView(withText(R.string.export_tags)).check(matches(isDisplayed()))); + onView(withText(R.string.export_tags)).perform(click()); + } + + /** + * What the picker is asked for, since the picker itself is Android's and stubbed. + * + *

Espresso cannot drive the document picker - it is another app in another process - so + * every test here intercepts the intent instead. That makes the request the only part of it + * this repo can be responsible for, and the parts that matter are the ones nothing else + * checks: a suggested name ending in {@code .zip}, and {@code application/zip} so the picker + * offers somewhere sensible. Get the extension wrong and the recipient receives a file this + * app will not offer to import. + */ + @Test + public void athepickerIsAskedForAZipWithAName() { + AppDependencies.replaceBundleBuilder((accessories, via, user, at) -> { + final Map entries = new LinkedHashMap<>(); + entries.put("OPENTAGVIEWER.yml", ("via: " + via).getBytes()); + return new BundleBuilder.Built(entries, null); + }); + + this.exportIt(); + + dev.wander.android.opentagviewer.Eventually.check(() -> intended(allOf( + hasAction(Intent.ACTION_CREATE_DOCUMENT), + hasType("application/zip")))); + } + + /** + * The one this exists for: a builder that cannot run lands on the report page. + * + *

And on the page's export wording, not the protocol one. "Something came back that + * this app does not know how to read" is false here - nothing came back from anywhere, the app + * failed to build a file - and a page that misdescribes what happened is worse than a generic + * one, because the reader corrects for it and stops trusting the rest. + */ + @Test + public void abuilderThatCannotRunSendsThemToTheReportPage() { + AppDependencies.replaceBundleBuilder((accessories, via, user, at) -> { + throw new BundleBuilder.BundleBuildException("Python did not start."); + }); + + this.exportIt(); + + dev.wander.android.opentagviewer.Eventually.check(() -> intended(allOf( + hasComponent(ErrorReportActivity.class.getName()), + hasExtra(ErrorReportActivity.EXTRA_BODY, R.string.error_report_body_export)))); + } + + /** And the cause travels with them, so the report says something. */ + @Test + public void bthecauseIsCarriedToThePage() { + AppDependencies.replaceBundleBuilder((accessories, via, user, at) -> { + throw new BundleBuilder.BundleBuildException("Python did not start."); + }); + + this.exportIt(); + + dev.wander.android.opentagviewer.Eventually.check(() -> intended(hasExtra( + ErrorReportActivity.EXTRA_CAUSE, + "BundleBuildException: Python did not start."))); + } + + /** + * And a working export does not go near it. + * + *

Worth its own test, because a page that appears when nothing is wrong is one people + * learn to dismiss - and then it is worth nothing on the day it is right. The code dialog + * turning up instead is what success looks like here. + */ + @Test + public void caworkingExportShowsTheCodeAndNoBugPage() { + AppDependencies.replaceBundleBuilder((accessories, via, user, at) -> { + final Map entries = new LinkedHashMap<>(); + entries.put("OPENTAGVIEWER.yml", ("via: " + via + "\n").getBytes()); + return new BundleBuilder.Built(entries, null); + }); + + this.exportIt(); + + dev.wander.android.opentagviewer.Eventually.check(() -> + onView(withId(R.id.exported_bundle_code)).check(matches(isDisplayed()))); + + // The two things that make the code usable: it says it cannot be shown again, and it + // says to send it separately from the file. + onView(withId(R.id.exported_bundle_not_recoverable)).check(matches(isDisplayed())); + onView(withId(R.id.exported_bundle_body)).check(matches(isDisplayed())); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ExportingTagsThatGoesWrongTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ExportingTagsThatGoesWrongTest.java new file mode 100644 index 00000000..e2cc0a69 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ExportingTagsThatGoesWrongTest.java @@ -0,0 +1,236 @@ +package dev.wander.android.opentagviewer.ui.mydevices; + +import static androidx.test.espresso.intent.Intents.intended; +import static androidx.test.espresso.intent.Intents.intending; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import android.app.Activity; +import android.app.Instrumentation.ActivityResult; +import android.content.Intent; + +import androidx.test.espresso.intent.Intents; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import dev.wander.android.opentagviewer.db.room.entity.BeaconNamingRecord; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.BundleBuilder; +import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity; +import dev.wander.android.opentagviewer.util.export.TagExporter; + +/** + * What an export does when it cannot be done. + * + *

The failure path is the one worth driving, and it is the one that cannot happen on + * demand. A successful export is exercised by hand constantly; a Python interpreter that will + * not start is not, and neither is a record the shared format refuses. Both leave somebody holding + * no file and no explanation, having just decided to share the keys to their tags with another + * person - so what they are told at that moment is the whole of what this feature does for them. + * + *

Three failures, three answers, and the difference is the point. A tag that cannot go in a + * bundle is something the user picked and can change. A file that will not write is the disk. + * Anything else is the app failing at something it should manage, and that one earns the report + * page - which is worth nothing at all if it also turns up for the first two. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class ExportingTagsThatGoesWrongTest { + + private static final String A_PLIST = + "" + + "identifiera-tag"; + + @Before + public void catchWhatLeavesTheApp() { + Intents.init(); + intending(anyOf( + hasComponent(ErrorReportActivity.class.getName()), + hasComponent(dev.wander.android.opentagviewer.MapsActivity.class.getName()))) + .respondWith(new ActivityResult(Activity.RESULT_CANCELED, null)); + } + + @After + public void putTheRealOnesBack() { + Intents.release(); + AppDependencies.reset(); + } + + private static List onePairing(final BeaconNamingRecord naming) { + final OwnedBeacon beacon = OwnedBeacon.builder() + .id("a-tag") + .content(A_PLIST) + .version("0.0.2") + .build(); + + final List selection = new ArrayList<>(); + selection.add(new TagExporter.Pairing(beacon, naming, "The cat")); + return selection; + } + + private static BeaconNamingRecord aNamingRecord() { + return BeaconNamingRecord.builder().id("a-tag").content(A_PLIST).version("0.0.2").build(); + } + + /** + * A tag with no naming record is refused by name, before anything is written. + * + *

The importer inner-joins the two records and drops anything it cannot pair - so a bundle + * exported without one imports successfully and contains nothing. That is the worst available + * outcome: the sender is told it worked, and the recipient finds out it did not. + */ + @Test + public void atagWithNoNamingRecordIsNamedRatherThanSilentlySkipped() { + AppDependencies.replaceBundleBuilder(everBuilding()); + + final TagExporter.NothingToExportException thrown = assertThrowsNothingToExport( + () -> TagExporter.writeTo(new ByteArrayOutputStream(), onePairing(null), + "OpenTagViewer.android:test", "someone", 1L)); + + assertEquals("The cat", thrown.getMessage()); + } + + /** And an empty selection never reaches the interpreter at all. */ + @Test + public void anemptySelectionIsRefusedWithoutStartingPython() { + final boolean[] called = {false}; + AppDependencies.replaceBundleBuilder((accessories, via, user, at) -> { + called[0] = true; + return new BundleBuilder.Built(new LinkedHashMap<>(), null); + }); + + assertThrowsNothingToExport(() -> TagExporter.writeTo( + new ByteArrayOutputStream(), new ArrayList<>(), + "OpenTagViewer.android:test", "someone", 1L)); + + assertTrue("an empty selection should not have started anything", !called[0]); + } + + /** + * And a builder that cannot run reaches the report page, cause and all. + * + *

This is what the seam exists for. "Python did not start" is not reachable on a working + * device, and it is exactly the state where a user has nothing to say in a bug report unless + * the app says it for them. + */ + @Test + public void abuilderThatCannotRunIsReportable() throws Exception { + AppDependencies.replaceBundleBuilder((accessories, via, user, at) -> { + throw new BundleBuilder.BundleBuildException("The export could not be built."); + }); + + try { + TagExporter.writeTo(new ByteArrayOutputStream(), onePairing(aNamingRecord()), + "OpenTagViewer.android:test", "someone", 1L); + fail("a builder that throws should not produce a bundle"); + } catch (final BundleBuilder.BundleBuildException expected) { + // What the screen would put on the page, verbatim. + assertEquals("BundleBuildException: The export could not be built.", + ErrorReportActivity.describe(expected)); + } + + // And the page it lands on says the export failed rather than something about a zip. + final Intent intent = ErrorReportActivity.intentFor( + getInstrumentation().getTargetContext(), + "BundleBuildException: The export could not be built.", + dev.wander.android.opentagviewer.R.string.error_report_body_export); + + getInstrumentation().getTargetContext().startActivity( + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + + intended(allOf( + hasComponent(ErrorReportActivity.class.getName()), + hasExtra(ErrorReportActivity.EXTRA_BODY, + dev.wander.android.opentagviewer.R.string.error_report_body_export))); + } + + /** + * A destination that will not take it does not lose the code, because there is none yet. + * + *

Ordering matters here and is easy to get backwards: generating a code, showing it, and + * then failing to write leaves somebody holding the code to a file that does not exist. The + * write has to succeed before anything is shown. + */ + @Test + public void adestinationThatRefusesTheWriteProducesNoCode() { + AppDependencies.replaceBundleBuilder(everBuilding()); + + final OutputStream refuses = new OutputStream() { + @Override + public void write(final int b) throws IOException { + throw new IOException("No space left on device"); + } + }; + + try { + TagExporter.writeTo(refuses, onePairing(aNamingRecord()), + "OpenTagViewer.android:test", "someone", 1L); + fail("a destination that throws should not report success"); + } catch (final Exception expected) { + assertTrue(expected instanceof IOException); + } + } + + /** The happy path still works, and the code it hands back is one the importer would accept. */ + @Test + public void asuccessfulExportHandsBackAUsableCode() throws Exception { + AppDependencies.replaceBundleBuilder(everBuilding()); + + final TagExporter.Exported written = TagExporter.writeTo( + new ByteArrayOutputStream(), onePairing(aNamingRecord()), + "OpenTagViewer.android:test", "someone", 1L); + + assertEquals(1, written.getCount()); + assertEquals(12, written.getPasscode().length()); + assertEquals(written.getPasscode(), + dev.wander.android.opentagviewer.util.parse.BundlePasscode.normalise( + dev.wander.android.opentagviewer.util.parse.BundlePasscode.format( + written.getPasscode()))); + } + + /** A builder that always produces one entry, so the zip write has something to do. */ + private static BundleBuilder everBuilding() { + return (accessories, via, user, at) -> { + final Map entries = new LinkedHashMap<>(); + entries.put("OPENTAGVIEWER.yml", ("via: " + via + "\n").getBytes()); + return new BundleBuilder.Built(entries, null); + }; + } + + private interface Throwing { + void run() throws Exception; + } + + private static TagExporter.NothingToExportException assertThrowsNothingToExport( + final Throwing what) { + try { + what.run(); + } catch (final TagExporter.NothingToExportException expected) { + return expected; + } catch (final Exception other) { + fail("expected NothingToExportException, got " + other); + } + fail("expected NothingToExportException, nothing was thrown"); + return null; + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/WalkingThroughSharingATagTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/WalkingThroughSharingATagTest.java new file mode 100644 index 00000000..bb72ccb1 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/WalkingThroughSharingATagTest.java @@ -0,0 +1,256 @@ +package dev.wander.android.opentagviewer.ui.mydevices; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.longClick; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.intent.Intents.intended; +import static androidx.test.espresso.intent.Intents.intending; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent; +import static androidx.test.espresso.matcher.RootMatchers.isDialog; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertTrue; + +import android.app.Activity; +import android.app.Instrumentation.ActivityResult; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.intent.Intents; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.MyDevicesListActivity; +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.Shot; +import dev.wander.android.opentagviewer.TestPace; +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.BeaconNamingRecord; +import dev.wander.android.opentagviewer.db.room.entity.Import; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.BundleBuilder; +import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity; + +/** + * Giving somebody a tag, at a pace a person can follow. + * + *

Its siblings assert; this one is for watching. Pick a tag, choose Export Tags, save it + * somewhere, and read the code off the screen - which is the whole feature, and takes about two + * seconds at machine speed. + * + *

It still asserts as it goes, because a demo that can pass while showing the wrong screen is + * decoration. The assertions are the ones a viewer is looking at anyway. + * + *

See {@code AGENTS.md} under "Showing a UI test to a person" for how to run it on a device + * with a window. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class WalkingThroughSharingATagTest { + + private static final String A_TAG = "share-walkthrough-tag"; + private static final String A_NAME = "Bike Keys"; + private static final String A_TEST_USER = "sharewalkthrough@example.invalid"; + + private static final String A_PLIST = "" + + "" + + "batteryLevel1" + + "model" + + "pairingDate2025-02-27T20:03:32Z" + + "privateKeykey" + + "databm90LWEtcmVhbC1rZXk=" + + "productId21760" + + "stableIdentifier2001~#0~#A0" + + "systemVersion2.0.73" + + "vendorId76" + + ""; + + private static final String A_NAMING_RECORD = + "" + + "identifier" + A_TAG + "" + + "name" + A_NAME + ""; + + private OpenTagViewerDatabase db; + private ActivityScenario scenario; + private File written; + + @Before + public void seedATagWorthSharing() { + final Context context = getInstrumentation().getTargetContext(); + this.db = OpenTagViewerDatabase.getInstance(context); + this.forgetIt(); + + final long importId = this.db.importDao().insert(Import.builder() + .version("0.0.2").importedAt(1_700_000_000_000L).exportedAt(1_699_000_000_000L) + .sourceUser(A_TEST_USER).exportedVia("OpenTagViewer.wizard:test").build()); + + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(A_TAG).importId(importId).content(A_PLIST) + .version("0.0.2").fromAccount(false).isRemoved(false).build()); + + this.db.beaconNamingRecordDao().insertAll(BeaconNamingRecord.builder() + .id(A_TAG).importId(importId).content(A_NAMING_RECORD) + .version("0.0.2").isRemoved(false).build()); + + this.written = new File(context.getCacheDir(), "share-walkthrough.zip"); + + Intents.init(); + intending(anyOf( + hasAction(Intent.ACTION_CREATE_DOCUMENT), + hasComponent(ErrorReportActivity.class.getName()))) + .respondWith(new ActivityResult( + Activity.RESULT_OK, new Intent().setData(Uri.fromFile(this.written)))); + + // A real bundle would take a Python call; the point here is the screens, and a fake keeps + // the pacing honest rather than showing somebody an interpreter warming up. + AppDependencies.replaceBundleBuilder((accessories, via, user, at) -> { + final Map entries = new LinkedHashMap<>(); + entries.put("OPENTAGVIEWER.yml", ("via: " + via + "\n").getBytes()); + entries.put("OwnedBeacons/" + A_TAG + ".plist", A_PLIST.getBytes()); + return new BundleBuilder.Built(entries, null); + }); + } + + @After + public void putEverythingBack() { + if (this.scenario != null) { + this.scenario.close(); + } + Intents.release(); + AppDependencies.reset(); + this.forgetIt(); + if (this.written != null) { + this.written.delete(); + } + } + + private void forgetIt() { + this.db.ownedBeaconDao().delete(OwnedBeacon.builder().id(A_TAG).build()); + this.db.beaconNamingRecordDao().delete(BeaconNamingRecord.builder().id(A_TAG).build()); + for (final Import stale : this.db.importDao().getImportsFromUser(A_TEST_USER)) { + this.db.importDao().delete(stale); + } + } + + /** + * The whole thing, in the order somebody does it. + * + *

Find the tag, pick it, choose Export Tags, save the file, and read the code that opens + * it - then copy the code, because the file and the code have to travel separately and the + * app should not make transcribing twelve characters a person's problem. + */ + @Test + public void givingSomebodyATagIsFourTapsAndACode() { + this.scenario = ActivityScenario.launch(MyDevicesListActivity.class); + + // 1. The tag, in the list it lives in. + Eventually.check(() -> onView(withText(A_NAME)).check(matches(isDisplayed()))); + Shot.ofTheScreen("sharing_a_tag-the_list"); + TestPace.afterAStep(); + + // 2. Long press picks it. Sharing is per tag, deliberately: handing over a household's + // whole set and lending one tag are different acts, and exported keys cannot be taken + // back afterwards. + onView(withText(A_NAME)).perform(longClick()); + Eventually.check(() -> onView(withId(R.id.selection_menu_button)) + .check(matches(isDisplayed()))); + Shot.ofTheScreen("sharing_a_tag-one_selected"); + TestPace.afterAStep(); + + // 3. The menu, where Export Tags sat listed and disabled for two releases. + onView(withId(R.id.selection_menu_button)).perform(click()); + Eventually.check(() -> onView(withText(R.string.export_tags)) + .check(matches(isDisplayed()))); + Shot.ofTheScreen("sharing_a_tag-the_menu"); + TestPace.afterAStep(); + + // 4. Which asks where to put the file. Stubbed here; on a phone this is the document + // picker, so the file lands somewhere the sender can find and attach. + onView(withText(R.string.export_tags)).perform(click()); + intended(hasAction(Intent.ACTION_CREATE_DOCUMENT)); + TestPace.afterAStep(); + + // 5. And then the code, which is the part that matters. It exists here and nowhere else: + // the zip keeps only what AES needs to check it, and the app forgets it on dismissal. + Eventually.check(() -> onView(withId(R.id.exported_bundle_code)) + .check(matches(isDisplayed()))); + onView(withId(R.id.exported_bundle_code)).inRoot(isDialog()) + .check(matches(withText(containsString("-")))); + Shot.ofTheScreen("sharing_a_tag-the_code"); + TestPace.afterAStep(); + + // 6. It says the two things that decide whether this is safe: the code cannot be shown + // again, and it must not travel with the file. + onView(withId(R.id.exported_bundle_not_recoverable)).inRoot(isDialog()) + .check(matches(isDisplayed())); + onView(withId(R.id.exported_bundle_body)).inRoot(isDialog()) + .check(matches(withText(containsString("separately")))); + TestPace.afterAStep(); + + // 7. Copy, so nobody has to read twelve characters aloud. + onView(withText(R.string.exported_tags_copy_code)).inRoot(isDialog()).perform(click()); + assertTrue("the code did not reach the clipboard", theClipboardHasACode()); + Shot.ofTheScreen("sharing_a_tag-copied"); + TestPace.afterAStep(); + + // 8. And the dialog stays up after copying, rather than taking the code off screen at the + // moment somebody is checking they got it. + onView(withId(R.id.exported_bundle_code)).inRoot(isDialog()) + .check(matches(isDisplayed())); + TestPace.afterAStep(); + } + + /** Whether what was copied looks like one of ours: grouped, and from the right alphabet. */ + private static boolean theClipboardHasACode() { + final CharSequence[] held = new CharSequence[1]; + getInstrumentation().runOnMainSync(() -> { + final ClipboardManager clipboard = getInstrumentation().getTargetContext() + .getSystemService(ClipboardManager.class); + final android.content.ClipData clip = + clipboard == null ? null : clipboard.getPrimaryClip(); + held[0] = clip == null || clip.getItemCount() == 0 + ? null : clip.getItemAt(0).getText(); + }); + + return held[0] != null && held[0].toString().matches("[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){2}"); + } + + /** Guards the demo against quietly showing nothing: an empty dialog would still "pass" above. */ + @Test + public void zthedialogIsNotEmpty() { + this.scenario = ActivityScenario.launch(MyDevicesListActivity.class); + + Eventually.check(() -> onView(withText(A_NAME)).check(matches(isDisplayed()))); + onView(withText(A_NAME)).perform(longClick()); + Eventually.check(() -> onView(withId(R.id.selection_menu_button)) + .check(matches(isDisplayed()))); + onView(withId(R.id.selection_menu_button)).perform(click()); + Eventually.check(() -> onView(withText(R.string.export_tags)) + .check(matches(isDisplayed()))); + onView(withText(R.string.export_tags)).perform(click()); + + Eventually.check(() -> onView(withId(R.id.exported_bundle_code)) + .check(matches(not(withText(""))))); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/util/export/WhatWeExportWeCanImportTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/export/WhatWeExportWeCanImportTest.java new file mode 100644 index 00000000..1d9fa6db --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/export/WhatWeExportWeCanImportTest.java @@ -0,0 +1,301 @@ +package dev.wander.android.opentagviewer.util.export; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import android.content.Context; +import android.net.Uri; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import dev.wander.android.opentagviewer.BuildConfig; +import dev.wander.android.opentagviewer.db.repo.model.ImportData; +import dev.wander.android.opentagviewer.db.room.entity.BeaconNamingRecord; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.util.parse.AppleZipImporterUtil; +import dev.wander.android.opentagviewer.util.parse.BundlePasscode; +import dev.wander.android.opentagviewer.util.parse.ZipImporterException; + +/** + * A bundle this app writes, imported back by this app. + * + *

The two halves have never met. {@code ALockedBundleCanBeOpenedAgainTest} proves zip4j + * round-trips bytes; {@code test_export_bridge.py} proves Python builds the right files. Both + * pass while the format is wrong, because neither has ever handed its output to the thing that + * reads it - and the format is exactly where a mistake hides: a directory named in the singular, + * a naming record filed under the wrong identifier, an entry path with a backslash in it. + * + *

Nothing is faked here. The real Chaquopy builder runs, zip4j writes a real AES archive, and + * {@code AppleZipImporterUtil} opens it the same way it opens a bundle from the desktop exporter - + * including validating {@code OPENTAGVIEWER.yml} against the schema, which is the check that + * would catch a manifest this app got subtly wrong. + * + *

The failure this exists to prevent is the worst one available: a sender told the export + * worked, and a recipient - on another phone, days later, after the sender deleted their copy - + * discovering it did not. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class WhatWeExportWeCanImportTest { + + private static final String A_TAG = "F612A183-492B-45A8-A5A2-233CA9062A94"; + private static final String A_NAME = "Round Trip"; + + /** + * A complete accessory record. + * + *

Complete is the point, and the first draft was not. It carried a privateKey and + * stopped there, and the export was refused with "missing sharedSecret" - FindMy.py reads all + * three key fields unconditionally when a bundle is imported, so a partial record produces an + * accessory that fails conversion on the recipient's phone. The shared package checks for + * them on the way out, which is why that failure arrived here rather than there. + * + *

Same shape and same fake key material as {@code FakeICloudService}, which is where these + * values come from. None of it belongs to anybody. + */ + private static final String A_PLIST = "" + + "" + + "batteryLevel1" + + "identifier" + A_TAG + "" + + "model" + + "pairingDate2025-02-27T20:03:32Z" + + "privateKeykeydata" + + "J1AAk7qStLSbMhZT/XEve6by7hI0H7CslD/Oh7SrOc+mlmLnAO8c" + + "5FGnhi/s3TDlWNiL3SMy19NQuCWg6oTS+YfBZN79RiUmZtssTp9f" + + "UvZjmqMX3g==" + + "" + + "productId21760" + + "publicKeykeydata" + + "k6fWaOxFGbClYV6tu/ZK4vXdyWl2joSbJhbzu12Pfmf5p09w5LxKIvnABRfysSFkOAlo/F3Ii9Dq" + + "" + + "secondarySharedSecretkeydata" + + "1pWMT+FI3flAWmgbUEW5H6omZy+yZOzp30zZGxEa2A8=" + + "sharedSecretkeydata" + + "vM2ZjU/sKW/novHcwzTlY5xwGLOUOZjpgcZa9cNx2Y8=" + + "stableIdentifier" + + "2001~#001234a12345aaac~#A02BCDEFG1AB" + + "systemVersion2.0.73" + + "vendorId76" + + ""; + + private static final String A_NAMING_RECORD = "" + + "" + + "identifier6C68CF6D-0A57-4D66-8646-E4B62CFBF1CB" + + "associatedBeacon" + A_TAG + "" + + "name" + A_NAME + "" + + ""; + + private File bundle; + + @Before + public void somewhereToWriteIt() { + this.bundle = new File( + getInstrumentation().getTargetContext().getCacheDir(), "round-trip.zip"); + } + + @After + public void tidyUp() { + AppDependencies.reset(); + if (this.bundle != null) { + this.bundle.delete(); + } + } + + /** Export one tag through the whole real stack, and hand back the code it was locked with. */ + private String exportOneTag() throws Exception { + final List selection = new ArrayList<>(); + selection.add(new TagExporter.Pairing( + OwnedBeacon.builder().id(A_TAG).content(A_PLIST).version("0.0.2").build(), + BeaconNamingRecord.builder().id(A_TAG).content(A_NAMING_RECORD) + .version("0.0.2").build(), + A_NAME)); + + try (OutputStream out = new FileOutputStream(this.bundle)) { + return TagExporter.writeTo( + out, + selection, + "OpenTagViewer.android:" + BuildConfig.VERSION_NAME, + "OpenTagViewer on a test device", + System.currentTimeMillis()).getPasscode(); + } + } + + private ImportData importItBack(final String passcode) throws ZipImporterException { + final Context context = getInstrumentation().getTargetContext(); + return new AppleZipImporterUtil(context) + .extractZip(Uri.fromFile(this.bundle), passcode); + } + + /** + * The whole loop: write it, then read it. + * + *

The importer is not lenient about the format - it validates the manifest against + * {@code opentagviewer_schema.json} and inner-joins the beacon and naming records - so + * reaching an {@code ImportData} at all is most of the claim. + */ + @Test + public void abundleThisAppWritesIsOneThisAppCanRead() throws Exception { + final String passcode = exportOneTag(); + + final ImportData read = importItBack(passcode); + + assertNotNull(read); + assertEquals("the accessory did not survive the round trip", + 1, read.getOwnedBeacons().size()); + assertEquals(A_TAG, read.getOwnedBeacons().get(0).id); + } + + /** + * And the naming record comes with it. + * + *

Separate from the count above because the importer inner-joins these: an + * accessory whose naming record went missing or landed in the wrong directory is dropped + * silently, and the import still reports success with nothing in it. That is the failure + * mode this whole class exists for, and it is invisible from the writing side. + */ + @Test + public void bthenameSurvivesTooRatherThanBeingDroppedInTheJoin() throws Exception { + final ImportData read = importItBack(exportOneTag()); + + assertEquals("the naming record was dropped, which silently loses the accessory", + 1, read.getBeaconNamingRecords().size()); + assertTrue(read.getBeaconNamingRecords().get(0).content.contains(A_NAME)); + } + + /** + * The manifest says this app produced it, and the importer accepts that. + * + *

Three programs write this format. {@code via:} is the only thing in a zip that says + * which, and it reaches the recipient's Information screen and device pages - so a bundle + * that lies about its producer makes a bug report unanswerable, and one whose manifest fails + * validation does not import at all. + */ + @Test + public void cthemanifestNamesThisAppAndPasses() throws Exception { + final ImportData read = importItBack(exportOneTag()); + + assertEquals("OpenTagViewer.android:" + BuildConfig.VERSION_NAME, + read.getAnImport().exportedVia); + } + + /** + * The key material arrives byte for byte. + * + *

The one failure that is silent all the way through: a bundle whose private key was + * mangled imports perfectly and then locates nothing, and nobody finds out until the tag has + * been missing for a while. + */ + @Test + public void dtheprivateKeyIsTheSameKeyOnTheOtherSide() throws Exception { + final ImportData read = importItBack(exportOneTag()); + + final String content = read.getOwnedBeacons().get(0).content; + assertTrue("the private key did not survive", + content.contains("J1AAk7qStLSbMhZT")); + } + + /** + * And it really is locked - the code is not decoration. + * + *

Reading it without one has to fail, or every other test here would pass just as well + * against a bundle with no encryption at all. + */ + @Test + public void eitcannotBeOpenedWithoutTheCode() throws Exception { + exportOneTag(); + + assertThrows(ZipImporterException.class, () -> importItBack(null)); + } + + /** And not with the wrong one either. */ + @Test + public void fthewrongCodeIsRefused() throws Exception { + final String real = exportOneTag(); + final String wrong = real.equals("00000000000A") ? "00000000000B" : "00000000000A"; + + assertThrows(ZipImporterException.class, () -> importItBack(wrong)); + } + + /** + * The code as the sender reads it aloud is the code the recipient types. + * + *

This is the contract that spans two programs and a person: the app shows a grouped code, + * somebody types it into the import dialog, and {@code normalise} has to return the exact + * bytes the zip was locked with. A mismatch tells them their correct code is wrong, and there + * is no way to recover the right one. + */ + @Test + public void gthegroupedCodeOpensItAfterBeingNormalised() throws Exception { + final String passcode = exportOneTag(); + + final String asShown = BundlePasscode.format(passcode); + assertTrue("not grouped for reading", asShown.contains("-")); + + final ImportData read = importItBack(BundlePasscode.normalise(asShown)); + + assertEquals(1, read.getOwnedBeacons().size()); + } + + /** + * And the confusable letters a person writes instead still work. + * + *

Crockford's alphabet drops I, L, O and U precisely because they get written for 1, 1 and + * 0 - so a code read off a screen and copied by hand is very likely to come back with one of + * them in it. Both sides fold them, and this proves the folding survives all the way to the + * archive rather than only to the string comparison. + */ + @Test + public void hacodeWrittenDownByHandStillOpensIt() throws Exception { + final String passcode = exportOneTag(); + + // What somebody writes on paper: grouped, spaced, and with the digits mistaken for the + // letters they look like. + final String byHand = BundlePasscode.format(passcode) + .replace('-', ' ') + .replace('0', 'O') + .replace('1', 'I'); + + final ImportData read = importItBack(BundlePasscode.normalise(byHand)); + + assertEquals("a hand-copied code did not open the bundle", + 1, read.getOwnedBeacons().size()); + } + + /** + * The bundle is a real file with real bytes, not an empty archive that happens to parse. + */ + @Test + public void ithefileIsNotEmpty() throws Exception { + exportOneTag(); + + assertTrue("nothing was written", this.bundle.length() > 0); + + // And the listing is readable without the code, which is a property of the zip format + // rather than a choice - worth pinning so nobody assumes the file is opaque. + final byte[] head = new byte[2]; + try (java.io.InputStream in = new java.io.FileInputStream(this.bundle)) { + if (in.read(head) != 2) { + fail("the bundle is too short to be a zip"); + } + } + assertEquals("PK", new String(head, StandardCharsets.UTF_8)); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java index ea7a34c4..3b6e0b07 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java @@ -27,14 +27,10 @@ import java.util.List; import dev.wander.android.opentagviewer.databinding.ActivityInformationBinding; -import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; import dev.wander.android.opentagviewer.ui.widget.FlowLayout; import dev.wander.android.opentagviewer.util.android.PropertiesUtil; import dev.wander.android.opentagviewer.util.android.WebLink; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.schedulers.Schedulers; public class InformationActivity extends AppCompatActivity { private static final String TAG = InformationActivity.class.getSimpleName(); @@ -68,7 +64,6 @@ protected void onCreate(Bundle savedInstanceState) { throw new RuntimeException(e); } - this.showWhereTheTagsCameFrom(); this.showContributors(); ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> { @@ -78,48 +73,6 @@ protected void onCreate(Bundle savedInstanceState) { }); } - /** - * Says which exporter produced the bundle these tags were imported from. - * - *

Because the issue template asks, and the honest alternative was worse. The - * {@code via:} line is inside {@code OPENTAGVIEWER.yml} in the export zip - a file holding - * the private keys to somebody's tags, which the template tells them in bold not to open. - * Asking a question whose answer is in a file you have told people not to open is asking - * them to ignore you. - * - *

All of them, not the most recent one. Importing twice is ordinary, and naming - * only the newest bundle would state one producer as though it accounted for every tag on - * the phone. Where a report says 1.3.0 and half the tags came out of 1.1.0, whoever reads it - * goes looking in the wrong place - which is the same failure as the import error that named - * the wrong phase, one level up. - * - *

Off the main thread: Room refuses a query on it, so doing this inline would not be slow, - * it would throw. - */ - private void showWhereTheTagsCameFrom() { - final TextView importedFrom = this.findViewById(R.id.appImportedFrom); - if (importedFrom == null) { - return; - } - - var async = Observable.fromCallable(() -> OpenTagViewerDatabase - .getInstance(this.getApplicationContext()) - .importDao().getDistinctProducers()) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - producers -> importedFrom.setText(producers.isEmpty() - ? this.getString(R.string.imported_from_nothing) - : this.getString(R.string.imported_from_x, - String.join(", ", producers))), - error -> { - // A line that cannot be read is not worth a broken screen; the - // version above it is still the more important half. - Log.w(TAG, "Could not read where the tags were imported from", error); - importedFrom.setVisibility(View.GONE); - }); - } - /** * Fills the avatar grid from the list bundled at build time. *
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java index d8b88d0f..195227c6 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -1004,12 +1004,7 @@ private void onFirstFetchAfterImportFailed(final Throwable error) { * offers, and a screenful of frames is not something anybody reads off a phone. */ private static String describe(final Throwable error) { - if (error == null) { - return "unknown"; - } - return error.getMessage() == null - ? error.getClass().getSimpleName() - : error.getClass().getSimpleName() + ": " + error.getMessage(); + return ErrorReportActivity.describe(error); } /** diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java index 762fc96c..774a17c1 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java @@ -20,6 +20,7 @@ import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.databinding.DataBindingUtil; import androidx.recyclerview.widget.ItemTouchHelper; @@ -52,7 +53,10 @@ import dev.wander.android.opentagviewer.db.repo.KeychainMembershipRepository; import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; +import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity; import dev.wander.android.opentagviewer.ui.mydevices.DeviceListAdaptor; +import dev.wander.android.opentagviewer.ui.mydevices.ExportedBundleDialog; +import dev.wander.android.opentagviewer.util.export.TagExporter; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import dev.wander.android.opentagviewer.util.android.PropertiesUtil; import dev.wander.android.opentagviewer.util.android.WebLink; @@ -132,6 +136,25 @@ public class MyDevicesListActivity extends AppCompatActivity { * import a bundle from somebody who owns them - so that screen hands the user straight back * here with a flag rather than making them find the other button themselves. */ + /** + * Where to put a bundle of tags to share. + * + *

The document picker rather than a share sheet: the recipient gets this through whatever + * they already use, and a file they chose the location of is one they can find again. It also + * makes the two-step nature honest - the file goes one way, the code goes another, and they + * must not travel together. + */ + private final ActivityResultLauncher createBundleZipLauncher = registerForActivityResult( + new ActivityResultContracts.CreateDocument("application/zip"), + uri -> { + if (uri == null) { + // Cancelled. Not an error, and the selection is left alone so it can be + // tried again without picking every tag a second time. + return; + } + this.writeBundleZip(uri, this.pendingExport); + }); + private final ActivityResultLauncher fetchFromICloudLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), (ActivityResult result) -> { @@ -403,6 +426,149 @@ private void endSelection() { * is needed, and the file lands where the user chose rather than somewhere they have to go * looking for. */ + /** + * Write the selected tags as a bundle somebody else can import. + * + *

Sharing, not backing up. An owner signed into their own account does not need a + * bundle - their tags arrive when they sign in. This is for giving a tag to another person, + * and the act is irreversible: exported key material cannot be withdrawn, and the only way to + * revoke it is to unpair the accessory. + */ + private void exportTagsForSelection() { + this.pendingExport = this.deviceListAdaptor.getSelectedBeacons(); + + if (this.pendingExport.isEmpty()) { + return; + } + + this.createBundleZipLauncher.launch(this.suggestedBundleName()); + } + + /** + * What the recipient sees as "exported by" on each tag's page. + * + *

A label, and deliberately not the Apple ID. The shared package asks for one and + * says so, and it is right: this string travels inside a file that goes to another person and + * often onward from there, and the address it would otherwise carry is the one that signs in + * to the account these tags belong to. + * + *

The desktop exporter uses the operating system's user name, which is the same kind of + * thing. Android has no equivalent, so the device model stands in - it tells a recipient which + * phone a bundle came from, which is the useful half, without naming anybody. + */ + private static String exportedByLabel() { + return android.os.Build.MODEL == null || android.os.Build.MODEL.isBlank() + ? "OpenTagViewer for Android" + : "OpenTagViewer on " + android.os.Build.MODEL; + } + + /** Dated, so exporting twice does not ask about overwriting. */ + private String suggestedBundleName() { + return "opentagviewer-tags-" + + DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.systemDefault()) + .format(Instant.now()) + + ".zip"; + } + + /** + * Reads each selected tag's stored records, builds the bundle, locks it, writes it. + * + *

Off the main thread throughout: a Python call and a zip write. + * + *

Three failures, three different screens, and the difference matters. A tag that + * cannot go in a bundle is something the user picked and can change, so it is named in a + * toast. A file that will not write is the disk, and says so. Anything else is the app + * failing at something it should be able to do - and that one goes to the report page, + * because there is nothing for the user to change and no way for them to say what happened + * without one. + */ + private void writeBundleZip(final Uri destination, final List beacons) { + // **The write is a `map`, not something done inside `subscribe`.** + // + // Rx cannot deliver an exception thrown in the onNext consumer to the onError consumer - + // it is already in the terminal handler - so it goes to RxJavaPlugins.onError and takes + // the process with it. The first version of this had the write in the consumer, and an + // export that failed crashed the app instead of showing anything at all. Inside the + // stream, the same throw reaches onError and becomes a screen. + var async = Observable.fromIterable(beacons) + .concatMap(beacon -> this.beaconRepo.getById(beacon.getBeaconId()) + .map(data -> new TagExporter.Pairing( + data.getOwnedBeaconInfo(), + data.getBeaconNamingRecord(), + beacon.getName()))) + .toList() + .map(pairings -> { + try (OutputStream out = + this.getContentResolver().openOutputStream(destination)) { + if (out == null) { + throw new IOException("the picker returned nothing to write to"); + } + return TagExporter.writeTo( + out, + pairings, + "OpenTagViewer.android:" + BuildConfig.VERSION_NAME, + exportedByLabel(), + System.currentTimeMillis()); + } + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(written -> { + if (written.getWarning() != null) { + Log.w(TAG, "The bundle was written without something: " + + written.getWarning()); + } + + this.endSelection(); + final AlertDialog dialog = + ExportedBundleDialog.show(this, written.getPasscode()); + ExportedBundleDialog.wireCopy(dialog, written.getPasscode()); + }, this::onBundleExportFailed); + } + + /** + * What to say when an export did not happen, and where to send them. + * + *

The report page is for the third case only. Offering it for a full disk, or for a tag + * that was never going to work, is how a page that means "this is a bug" stops meaning + * anything - see {@code ErrorReportActivity}. + */ + private void onBundleExportFailed(final Throwable error) { + Log.e(TAG, "Could not export the selected tags", error); + + // Already on the main thread - the stream observes there - so nothing here hops. + for (Throwable cause = error; cause != null; cause = cause.getCause()) { + if (cause instanceof TagExporter.NothingToExportException) { + Toast.makeText(this, + this.getString(R.string.export_tags_nothing_to_export, cause.getMessage()), + LENGTH_LONG).show(); + return; + } + if (cause instanceof IOException) { + Toast.makeText(this, R.string.export_tags_could_not_write, LENGTH_LONG).show(); + return; + } + if (cause == cause.getCause()) { + break; + } + } + + // **The cause, not the wrapper.** Rx wraps what a `map` throws, so `describe(error)` here + // would put "RuntimeException" on the page and bury the sentence the reporter needs. + this.startActivity(ErrorReportActivity.intentFor( + this, ErrorReportActivity.describe(rootOf(error)), + R.string.error_report_body_export)); + } + + /** The innermost cause, which is the one that says what actually happened. */ + private static Throwable rootOf(final Throwable error) { + Throwable cause = error; + while (cause.getCause() != null && cause.getCause() != cause) { + cause = cause.getCause(); + } + return cause; + } + private void exportHistoryForSelection() { this.pendingExport = this.deviceListAdaptor.getSelectedBeacons(); @@ -611,6 +777,7 @@ private void showSelectionMenu() { // is harder to learn than one where an item is visibly not available yet. final boolean anythingSelected = !this.deviceListAdaptor.getSelectedBeacons().isEmpty(); menu.getMenu().findItem(R.id.action_export_history).setEnabled(anythingSelected); + menu.getMenu().findItem(R.id.action_export_tags).setEnabled(anythingSelected); menu.getMenu().findItem(R.id.action_remove_devices).setEnabled(anythingSelected); menu.getMenu().findItem(R.id.action_move_to_top).setEnabled(anythingSelected); @@ -621,6 +788,10 @@ private void showSelectionMenu() { this.exportHistoryForSelection(); return true; } + if (id == R.id.action_export_tags) { + this.exportTagsForSelection(); + return true; + } if (id == R.id.action_move_to_top) { this.moveSelectionToTop(); return true; diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/ImportDao.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/ImportDao.java index 2fb4dc9a..0fc0ac63 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/ImportDao.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/ImportDao.java @@ -34,25 +34,6 @@ public interface ImportDao { @Query("SELECT * FROM Import ORDER BY imported_at DESC LIMIT 1") Import getMostRecent(); - /** - * Every distinct exporter that produced a bundle on this install, newest first. - * - *

Distinct, because {@link #getMostRecent()} answers a different question than it - * looks like it does. Importing twice is ordinary - a second Mac, a re-export after a - * new tag, a bundle from an older wizard alongside a current one - and asking only for the - * latest names one producer while implying it accounts for everything on the phone. A report - * saying "exported with 1.3.0" when half the tags came out of 1.1.0 sends whoever reads it - * looking in the wrong place. - * - *

{@code GROUP BY} rather than {@code SELECT DISTINCT}, because the ordering is by - * something not in the result: SQLite rejects an {@code ORDER BY} on a column a - * {@code DISTINCT} query does not select, and {@code MAX(imported_at)} per producer is the - * sort that actually means "most recently used". - */ - @Query("SELECT via FROM Import WHERE via IS NOT NULL AND via != ''" - + " GROUP BY via ORDER BY MAX(imported_at) DESC") - List getDistinctProducers(); - @Insert long insert(Import importData); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java index 4b74cb1b..6cfc11fb 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java @@ -1,212 +1,232 @@ -package dev.wander.android.opentagviewer.python; - -import android.content.Context; -import android.location.Geocoder; - -import org.chromium.net.CronetEngine; - -import androidx.annotation.VisibleForTesting; - -import java.util.Locale; -import java.util.function.BiFunction; -import java.util.function.Function; -import java.util.function.Supplier; - -import dev.wander.android.opentagviewer.anisette.AnisetteSource; -import dev.wander.android.opentagviewer.anisette.LocalAnisette; -import dev.wander.android.opentagviewer.python.icloud.ICloudService; -import dev.wander.android.opentagviewer.python.icloud.PythonICloudService; -import dev.wander.android.opentagviewer.db.repo.model.UserSettings; -import dev.wander.android.opentagviewer.service.web.AnisetteServerTesterService; -import dev.wander.android.opentagviewer.util.android.AddressLookup; - -/** - * What the sign-in screen depends on, in one place a test can replace. - * - *

The screen builds everything it needs inside {@code onCreate}, which is the ordinary - * Android shape and fine right up until you want to launch it. Two of those things reach the - * network before a single view is drawn: signing in runs Python against Apple, and local - * Anisette downloads Apple's ADI libraries from their CDN. Neither can be arranged in a test, - * so the whole four-page flow - the part of the app with the most transitions and the least - * coverage - could only ever be checked by hand with a real account and a real phone. - * - *

A settable global rather than constructor injection because an activity is - * constructed by the framework, and this app has no DI container to teach otherwise. The - * alternative shapes all cost more than they are worth here: an Application subclass holding - * these is the same global with more indirection, and a whole framework is a large change to - * this codebase for one screen. Production never calls the setters; they are for tests, and - * {@link #reset()} in a teardown puts the real ones back. - */ -public final class AppDependencies { - - private AppDependencies() {} - - private static AppleAuthService authService = new PythonAppleAuthService(); - - /** - * How to build Anisette for a given settings object. A factory rather than an instance - * because the real one needs a Context and the current settings, and neither exists when - * this class is loaded. - */ - private static AnisetteFactory anisetteFactory = LocalAnisette::new; - - /** Builds the Anisette source for a screen, given where it is running and who is signed in. */ - public interface AnisetteFactory { - AnisetteSource create(Context context, UserSettings settings, boolean hasExistingSession); - } - - /** - * How to build the thing that asks an Anisette server whether it is alive. - * - *

Here for the same reason as the rest: the sign-in screen tests a server before it - * will let anybody past, so a test of the fall-back path would otherwise depend on a - * stranger's machine being up. - */ - private static Function serverTesterFactory = - AnisetteServerTesterService::new; - - /** - * Names an accessory from its plist, through the shared Python heuristic. - * - *

Here for the same reason as the rest: the real one starts Chaquopy and imports a - * package, so a screen that used it directly could not be launched in a test. It also makes - * "an accessory nothing recognises" renderable on demand, rather than needing such a tag. - */ - private static HardwareDescriber hardwareDescriber = new ChaquopyHardwareDescriber(); - - /** - * Strips personal identifiers out of a log before it is offered to anybody. - * - *

Here for the usual reason and one sharper one: the screen that offers a log is the error - * page, which exists because something already broke. A test of it has to be able to - * produce a working redactor and one that cannot run, and the second is the case that decides - * whether an unredacted log can escape. - */ - private static LogRedactor logRedactor = new ChaquopyLogRedactor(); - - /** - * Turns coordinates into something a person recognises. - * - *

Here because a screen with no geocoder does not look broken. The card falls back - * to the raw latitude and longitude, which is a perfectly reasonable thing for it to show - * when an address genuinely cannot be found - so a geocoder that answers nothing at all is - * indistinguishable, on screen and in a screenshot, from one that answered honestly. - * - *

Which is the state every instrumented run is in: the {@code aosp-atd} image carries no - * geocoding backend, so {@code getFromLocation} returns an empty list for every point on - * earth and the whole path - the rounding, the cache, the fallback - is exercised by - * nothing. A test that wants to assert a place name has to be able to supply one. - * - *

A factory rather than an instance, because a {@link Geocoder} is built per screen from - * that screen's context and the current locale. - */ - private static BiFunction geocoderFactory = - (context, locale) -> AddressLookup.through(new Geocoder(context, locale)); - - public static AddressLookup geocoder(final Context context, final Locale locale) { - return geocoderFactory.apply(context, locale); - } - - @VisibleForTesting - public static void replaceGeocoder( - final BiFunction replacement) { - geocoderFactory = replacement; - } - - /** - * Opens a conversation with iCloud on the signed-in account. - * - *

A supplier rather than an instance because a session is not reusable: it holds a - * keychain session and a CloudKit client, both with sockets, and it is closed when the - * screen that opened it goes away. - * - *

Here for the usual reason, more sharply than most. Every failure this flow has to - * handle - an account with nothing to recover from, a service having a bad day, a rejected - * passcode - needs an Apple account in a state nobody can arrange on demand, and the ones - * that matter most are the ones a real account will never be in. - */ - private static Supplier icloudFactory = AppDependencies::openRealICloud; - - private static ICloudService openRealICloud() { - final PythonAppleService signedIn = PythonAppleService.getInstance(); - if (signedIn == null || signedIn.getAccount() == null) { - return null; - } - - return PythonICloudService.openFor(signedIn.getAccount()); - } - - /** - * A new iCloud session, or null when there is no usable signed-in account. - * - *

Null is not a crash: the caller reports it as needing a sign-in, which is the same - * recovery as a session that has expired. - */ - public static ICloudService icloud() { - return icloudFactory.get(); - } - - @VisibleForTesting - public static void replaceICloud(final Supplier replacement) { - icloudFactory = replacement; - } - - public static AppleAuthService authService() { - return authService; - } - - public static HardwareDescriber hardwareDescriber() { - return hardwareDescriber; - } - - public static LogRedactor logRedactor() { - return logRedactor; - } - - public static AnisetteServerTesterService serverTester(final CronetEngine engine) { - return serverTesterFactory.apply(engine); - } - - @VisibleForTesting - public static void replaceServerTester(final AnisetteServerTesterService replacement) { - serverTesterFactory = engine -> replacement; - } - - public static AnisetteSource anisette( - final Context context, final UserSettings settings, final boolean hasExistingSession) { - return anisetteFactory.create(context, settings, hasExistingSession); - } - - @VisibleForTesting - public static void replaceAuthService(final AppleAuthService replacement) { - authService = replacement; - } - - @VisibleForTesting - public static void replaceHardwareDescriber(final HardwareDescriber replacement) { - hardwareDescriber = replacement; - } - - @VisibleForTesting - public static void replaceLogRedactor(final LogRedactor replacement) { - logRedactor = replacement; - } - - @VisibleForTesting - public static void replaceAnisette(final Function replacement) { - anisetteFactory = (context, settings, hasSession) -> replacement.apply(settings); - } - - /** Put the real ones back. Call from a teardown, or the next test inherits a fake. */ - @VisibleForTesting - public static void reset() { - authService = new PythonAppleAuthService(); - anisetteFactory = LocalAnisette::new; - serverTesterFactory = AnisetteServerTesterService::new; - hardwareDescriber = new ChaquopyHardwareDescriber(); - logRedactor = new ChaquopyLogRedactor(); - icloudFactory = AppDependencies::openRealICloud; - geocoderFactory = (context, locale) -> - AddressLookup.through(new Geocoder(context, locale)); - } -} +package dev.wander.android.opentagviewer.python; + +import android.content.Context; +import android.location.Geocoder; + +import org.chromium.net.CronetEngine; + +import androidx.annotation.VisibleForTesting; + +import java.util.Locale; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; + +import dev.wander.android.opentagviewer.anisette.AnisetteSource; +import dev.wander.android.opentagviewer.anisette.LocalAnisette; +import dev.wander.android.opentagviewer.python.icloud.ICloudService; +import dev.wander.android.opentagviewer.python.icloud.PythonICloudService; +import dev.wander.android.opentagviewer.db.repo.model.UserSettings; +import dev.wander.android.opentagviewer.service.web.AnisetteServerTesterService; +import dev.wander.android.opentagviewer.util.android.AddressLookup; + +/** + * What the sign-in screen depends on, in one place a test can replace. + * + *

The screen builds everything it needs inside {@code onCreate}, which is the ordinary + * Android shape and fine right up until you want to launch it. Two of those things reach the + * network before a single view is drawn: signing in runs Python against Apple, and local + * Anisette downloads Apple's ADI libraries from their CDN. Neither can be arranged in a test, + * so the whole four-page flow - the part of the app with the most transitions and the least + * coverage - could only ever be checked by hand with a real account and a real phone. + * + *

A settable global rather than constructor injection because an activity is + * constructed by the framework, and this app has no DI container to teach otherwise. The + * alternative shapes all cost more than they are worth here: an Application subclass holding + * these is the same global with more indirection, and a whole framework is a large change to + * this codebase for one screen. Production never calls the setters; they are for tests, and + * {@link #reset()} in a teardown puts the real ones back. + */ +public final class AppDependencies { + + private AppDependencies() {} + + private static AppleAuthService authService = new PythonAppleAuthService(); + + /** + * How to build Anisette for a given settings object. A factory rather than an instance + * because the real one needs a Context and the current settings, and neither exists when + * this class is loaded. + */ + private static AnisetteFactory anisetteFactory = LocalAnisette::new; + + /** Builds the Anisette source for a screen, given where it is running and who is signed in. */ + public interface AnisetteFactory { + AnisetteSource create(Context context, UserSettings settings, boolean hasExistingSession); + } + + /** + * How to build the thing that asks an Anisette server whether it is alive. + * + *

Here for the same reason as the rest: the sign-in screen tests a server before it + * will let anybody past, so a test of the fall-back path would otherwise depend on a + * stranger's machine being up. + */ + private static Function serverTesterFactory = + AnisetteServerTesterService::new; + + /** + * Names an accessory from its plist, through the shared Python heuristic. + * + *

Here for the same reason as the rest: the real one starts Chaquopy and imports a + * package, so a screen that used it directly could not be launched in a test. It also makes + * "an accessory nothing recognises" renderable on demand, rather than needing such a tag. + */ + private static HardwareDescriber hardwareDescriber = new ChaquopyHardwareDescriber(); + + /** + * Strips personal identifiers out of a log before it is offered to anybody. + * + *

Here for the usual reason and one sharper one: the screen that offers a log is the error + * page, which exists because something already broke. A test of it has to be able to + * produce a working redactor and one that cannot run, and the second is the case that decides + * whether an unredacted log can escape. + */ + private static LogRedactor logRedactor = new ChaquopyLogRedactor(); + + /** + * Builds an export bundle's files. + * + *

Here because the failure path is the one that matters and cannot be reached on + * demand. An export that throws leaves somebody with no file and no explanation, having + * just decided to share the keys to their tags - and producing that state for real means + * breaking the interpreter. A fake produces it in a line. + */ + private static BundleBuilder bundleBuilder = new ChaquopyBundleBuilder(); + + /** + * Turns coordinates into something a person recognises. + * + *

Here because a screen with no geocoder does not look broken. The card falls back + * to the raw latitude and longitude, which is a perfectly reasonable thing for it to show + * when an address genuinely cannot be found - so a geocoder that answers nothing at all is + * indistinguishable, on screen and in a screenshot, from one that answered honestly. + * + *

Which is the state every instrumented run is in: the {@code aosp-atd} image carries no + * geocoding backend, so {@code getFromLocation} returns an empty list for every point on + * earth and the whole path - the rounding, the cache, the fallback - is exercised by + * nothing. A test that wants to assert a place name has to be able to supply one. + * + *

A factory rather than an instance, because a {@link Geocoder} is built per screen from + * that screen's context and the current locale. + */ + private static BiFunction geocoderFactory = + (context, locale) -> AddressLookup.through(new Geocoder(context, locale)); + + public static AddressLookup geocoder(final Context context, final Locale locale) { + return geocoderFactory.apply(context, locale); + } + + @VisibleForTesting + public static void replaceGeocoder( + final BiFunction replacement) { + geocoderFactory = replacement; + } + + /** + * Opens a conversation with iCloud on the signed-in account. + * + *

A supplier rather than an instance because a session is not reusable: it holds a + * keychain session and a CloudKit client, both with sockets, and it is closed when the + * screen that opened it goes away. + * + *

Here for the usual reason, more sharply than most. Every failure this flow has to + * handle - an account with nothing to recover from, a service having a bad day, a rejected + * passcode - needs an Apple account in a state nobody can arrange on demand, and the ones + * that matter most are the ones a real account will never be in. + */ + private static Supplier icloudFactory = AppDependencies::openRealICloud; + + private static ICloudService openRealICloud() { + final PythonAppleService signedIn = PythonAppleService.getInstance(); + if (signedIn == null || signedIn.getAccount() == null) { + return null; + } + + return PythonICloudService.openFor(signedIn.getAccount()); + } + + /** + * A new iCloud session, or null when there is no usable signed-in account. + * + *

Null is not a crash: the caller reports it as needing a sign-in, which is the same + * recovery as a session that has expired. + */ + public static ICloudService icloud() { + return icloudFactory.get(); + } + + @VisibleForTesting + public static void replaceICloud(final Supplier replacement) { + icloudFactory = replacement; + } + + public static AppleAuthService authService() { + return authService; + } + + public static HardwareDescriber hardwareDescriber() { + return hardwareDescriber; + } + + public static LogRedactor logRedactor() { + return logRedactor; + } + + public static BundleBuilder bundleBuilder() { + return bundleBuilder; + } + + public static AnisetteServerTesterService serverTester(final CronetEngine engine) { + return serverTesterFactory.apply(engine); + } + + @VisibleForTesting + public static void replaceServerTester(final AnisetteServerTesterService replacement) { + serverTesterFactory = engine -> replacement; + } + + public static AnisetteSource anisette( + final Context context, final UserSettings settings, final boolean hasExistingSession) { + return anisetteFactory.create(context, settings, hasExistingSession); + } + + @VisibleForTesting + public static void replaceAuthService(final AppleAuthService replacement) { + authService = replacement; + } + + @VisibleForTesting + public static void replaceHardwareDescriber(final HardwareDescriber replacement) { + hardwareDescriber = replacement; + } + + @VisibleForTesting + public static void replaceLogRedactor(final LogRedactor replacement) { + logRedactor = replacement; + } + + @VisibleForTesting + public static void replaceBundleBuilder(final BundleBuilder replacement) { + bundleBuilder = replacement; + } + + @VisibleForTesting + public static void replaceAnisette(final Function replacement) { + anisetteFactory = (context, settings, hasSession) -> replacement.apply(settings); + } + + /** Put the real ones back. Call from a teardown, or the next test inherits a fake. */ + @VisibleForTesting + public static void reset() { + authService = new PythonAppleAuthService(); + anisetteFactory = LocalAnisette::new; + serverTesterFactory = AnisetteServerTesterService::new; + hardwareDescriber = new ChaquopyHardwareDescriber(); + logRedactor = new ChaquopyLogRedactor(); + bundleBuilder = new ChaquopyBundleBuilder(); + icloudFactory = AppDependencies::openRealICloud; + geocoderFactory = (context, locale) -> + AddressLookup.through(new Geocoder(context, locale)); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/BundleBuilder.java b/app/src/main/java/dev/wander/android/opentagviewer/python/BundleBuilder.java new file mode 100644 index 00000000..b9ad8c31 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/BundleBuilder.java @@ -0,0 +1,80 @@ +package dev.wander.android.opentagviewer.python; + +import java.util.List; +import java.util.Map; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Builds the files of an export bundle, which Java then zips. + * + *

The format lives in Python and is shared with the desktop exporter - + * {@code opentagviewer_export.build_export}, whitelisted into the APK. Three programs write this + * format and a second implementation of it would drift; the symptom of drift is a bundle that + * imports into one version of this app and not another. + * + *

Behind an interface for the usual reason, and one more. The usual one: the real + * implementation needs a running interpreter, so a screen that called it directly could not be + * tested without one. The extra one: **this is the path that has to fail well.** An export that + * throws leaves somebody holding no file and no explanation, having just decided to share the + * keys to their tags with another person - so the failure path needs driving in a test, and it is + * not reachable on demand any other way. + */ +public interface BundleBuilder { + + /** What Python produced: the files, and anything it had to leave out. */ + @AllArgsConstructor + @Getter + class Built { + /** Path within the zip, to its bytes. Insertion-ordered, so the archive is predictable. */ + private final Map entries; + + /** + * What was dropped to make this work, or null. + * + *

The case that exists today is an unusable key alignment record. Losing one costs the + * recipient a slow first fetch; refusing the export over it would cost them everything, + * so the bundle is written and this says what happened. Logged, not shown: the user + * cannot act on it and the export did what they asked. + */ + private final String warning; + } + + /** One accessory, as the app stores it. The naming record is not optional. */ + @AllArgsConstructor + @Getter + class Accessory { + private final String ownedBeaconPlist; + private final String namingRecordPlist; + /** Null when the app holds none, which is normal for an older import. */ + private final String alignmentPlist; + } + + /** + * @param via what to stamp as the producer, {@code OpenTagViewer.android:}. + * Passed rather than built here because the shared package refuses to invent one, + * and rightly: three programs write this format and {@code via:} is the only thing + * in a zip that says which. + * @throws BundleBuildException if the bundle cannot be built, carrying a sentence to show. + */ + Built build(List accessories, String via, String sourceUser, long exportedAtMs) + throws BundleBuildException; + + /** + * Something about the selection cannot be exported, with a reason worth reading. + * + *

Checked rather than unchecked, deliberately: this is the one call in the export path + * that is expected to fail on real input - a record with no key material, an accessory with + * no naming record - and a caller that forgets to handle it should not compile. + */ + class BundleBuildException extends Exception { + public BundleBuildException(final String message) { + super(message); + } + + public BundleBuildException(final String message, final Throwable cause) { + super(message, cause); + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyBundleBuilder.java b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyBundleBuilder.java new file mode 100644 index 00000000..bf83ad68 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyBundleBuilder.java @@ -0,0 +1,110 @@ +package dev.wander.android.opentagviewer.python; + +import android.util.Base64; +import android.util.Log; + +import com.chaquo.python.PyObject; +import com.chaquo.python.Python; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * {@link BundleBuilder} over {@code main.buildExportBundle}, which calls the same + * {@code opentagviewer_export.build_export} the desktop exporter does. + * + *

Blocking, and needs a started interpreter. Never call it on the main thread. + * + *

Bytes cross as base64 inside JSON. A plist is not UTF-8 and an {@code OwnedBeacons} record + * carries the accessory's private key, so anything lossy in the crossing produces a bundle that + * imports cleanly and then locates nothing - a failure discovered days later, by the recipient, + * after the sender has deleted their copy. + */ +public class ChaquopyBundleBuilder implements BundleBuilder { + private static final String TAG = ChaquopyBundleBuilder.class.getSimpleName(); + + private static final String MODULE = "main"; + + @Override + public Built build( + final List accessories, + final String via, + final String sourceUser, + final long exportedAtMs) throws BundleBuildException { + + final String reply; + try { + final PyObject module = Python.getInstance().getModule(MODULE); + reply = module.callAttr( + "buildExportBundle", + describe(accessories), + via, + sourceUser, + exportedAtMs).toString(); + } catch (final Exception e) { + // Python did not start, or the module is not in the APK. Not something a user can act + // on, so it goes up as-is and the screen offers a bug report. + throw new BundleBuildException("The export could not be built.", e); + } + + return read(reply); + } + + /** The selection, as the shape {@code buildExportBundle} documents. */ + private static String describe(final List accessories) throws BundleBuildException { + try { + final JSONArray described = new JSONArray(); + + for (final Accessory accessory : accessories) { + final JSONObject one = new JSONObject(); + one.put("ownedBeaconPlist", accessory.getOwnedBeaconPlist()); + one.put("namingRecordPlist", accessory.getNamingRecordPlist()); + if (accessory.getAlignmentPlist() != null) { + one.put("alignmentPlist", accessory.getAlignmentPlist()); + } + described.put(one); + } + + return described.toString(); + } catch (final Exception e) { + throw new BundleBuildException("The export could not be built.", e); + } + } + + private static Built read(final String reply) throws BundleBuildException { + try { + final JSONObject answer = new JSONObject(reply); + + // A refusal with a reason, which the shared package wrote and a person can read. + if (answer.has("error")) { + throw new BundleBuildException(answer.getString("error")); + } + + final JSONObject entries = answer.getJSONObject("entries"); + + // Linked, so the archive comes out in the order Python built it rather than in + // whatever order a hash gives. Nothing depends on it; a reproducible file is simply + // easier to reason about when two of them differ. + final Map files = new LinkedHashMap<>(); + for (final java.util.Iterator names = entries.keys(); names.hasNext(); ) { + final String name = names.next(); + files.put(name, Base64.decode(entries.getString(name), Base64.DEFAULT)); + } + + final String warning = answer.optString("warning", null); + if (warning != null) { + Log.w(TAG, "The bundle was written without something: " + warning); + } + + return new Built(files, warning); + } catch (final BundleBuildException e) { + throw e; + } catch (final Exception e) { + throw new BundleBuildException("The export could not be built.", e); + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/error/ErrorReportActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/error/ErrorReportActivity.java index 4cd7c65a..2e6d8b5a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/error/ErrorReportActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/error/ErrorReportActivity.java @@ -82,6 +82,25 @@ public static Intent intentFor( .putExtra(EXTRA_BODY, bodyRes); } + /** + * The failure in the words it arrived in, for pasting into a report. + * + *

Class name and message rather than a stack trace: the trace is in the log the page + * offers, and a screenful of frames is not something anybody reads off a phone. + * + *

Here rather than beside each caller, because every screen that can reach this page needs + * exactly this string and two of them writing it slightly differently makes two reports of + * one bug look like two bugs. + */ + public static String describe(final Throwable error) { + if (error == null) { + return "unknown"; + } + return error.getMessage() == null + ? error.getClass().getSimpleName() + : error.getClass().getSimpleName() + ": " + error.getMessage(); + } + /** The redacted log, held once prepared so the share button is instant and cannot re-fail. */ private LogRedactor.Redacted log; diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/ExportedBundleDialog.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/ExportedBundleDialog.java new file mode 100644 index 00000000..cf18e113 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/ExportedBundleDialog.java @@ -0,0 +1,85 @@ +package dev.wander.android.opentagviewer.ui.mydevices; + +import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.os.Build; +import android.view.View; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.util.parse.BundlePasscode; + +/** + * The code a freshly written bundle was locked with. + * + *

Shown once, because it exists once. Nothing keeps it - the zip holds only what AES + * needs to verify it, the log does not have it, and the app forgets it when this closes. A bundle + * whose code was never read is a bundle nobody can ever open, so this is a dialog somebody has to + * dismiss rather than a toast they can miss while looking at the share sheet. + * + *

The wizard's equivalent says the same things for the same reasons - see {@code + * _show_the_code} in {@code wizard.py}. Two programs, one message, because the recipient's + * experience is identical either way. + */ +public final class ExportedBundleDialog { + + private ExportedBundleDialog() {} + + /** + * @param passcode the undelimited code. Displayed grouped; copied grouped, because the import + * dialog folds hyphens straight back out and grouped is what a person can + * read aloud without losing their place. + */ + public static AlertDialog show(final Activity activity, final String passcode) { + final View view = activity.getLayoutInflater() + .inflate(R.layout.exported_bundle_dialog, null); + + final EditText shown = view.findViewById(R.id.exported_bundle_code); + shown.setText(BundlePasscode.format(passcode)); + // Read-only without being disabled, so it can still be selected by hand. + shown.setKeyListener(null); + + return new MaterialAlertDialogBuilder(activity) + .setTitle(R.string.exported_tags_title) + .setView(view) + .setNeutralButton(R.string.exported_tags_copy_code, null) + .setPositiveButton(R.string.ok, null) + // **Not cancellable.** Everywhere else a dialog closing by accident costs a tap; + // here it costs the only copy of the code, and the file has already been written. + .setCancelable(false) + .show(); + } + + /** + * Wire the copy button after showing, so pressing it does not dismiss the dialog. + * + *

A neutral button with a listener closes the dialog when tapped, which for Copy is + * exactly wrong: it takes the code off the screen at the moment somebody is checking they + * got it. Reaching for the button afterwards is the documented way round that. + */ + public static void wireCopy(final AlertDialog dialog, final String passcode) { + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> { + final ClipboardManager clipboard = + dialog.getContext().getSystemService(ClipboardManager.class); + if (clipboard == null) { + return; + } + + clipboard.setPrimaryClip( + ClipData.newPlainText("OpenTagViewer code", BundlePasscode.format(passcode))); + + // Android 13 shows its own confirmation, and a toast on top of it reads as a bug. + // Below that there is nothing, and silence after a tap looks like a dead button. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText(dialog.getContext(), R.string.exported_tags_code_copied, + Toast.LENGTH_SHORT).show(); + } + }); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/export/BundleZipWriter.java b/app/src/main/java/dev/wander/android/opentagviewer/util/export/BundleZipWriter.java new file mode 100644 index 00000000..02ca5029 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/export/BundleZipWriter.java @@ -0,0 +1,87 @@ +package dev.wander.android.opentagviewer.util.export; + +import net.lingala.zip4j.io.outputstream.ZipOutputStream; +import net.lingala.zip4j.model.ZipParameters; +import net.lingala.zip4j.model.enums.AesKeyStrength; +import net.lingala.zip4j.model.enums.CompressionMethod; +import net.lingala.zip4j.model.enums.EncryptionMethod; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Map; + +/** + * Puts the files of an export bundle into a zip, locked with a code. + * + *

Java writes the container because Python cannot. The format itself is built by + * {@code opentagviewer_export.build_export}, shared with the desktop exporter so there is one + * implementation of it - but that package's own sink needs {@code pyzipper} for an encrypted + * archive, which is not in Chaquopy's pip list and pulls a native crypto dependency. zip4j is + * already here for reading locked bundles, and it writes them too. So Python owns the + * format and Java owns the container, and neither has to grow a dependency for the other. + * + *

AES-256 under the WinZip scheme, which is what {@code zipsink.py} produces and what + * {@code AppleZipImporterUtil} already opens. Not ZipCrypto: it is the format's legacy scheme, + * broken since the nineties, and a bundle protected by it would be protected in name only. + * + *

The listing is not encrypted, only the entries. Anybody holding the file can see how many + * accessories are in it and what their identifiers are; they cannot read a key without the code. + * That is a property of the zip format rather than a choice made here, and it is worth knowing + * before treating the file as opaque. + */ +public final class BundleZipWriter { + + private BundleZipWriter() {} + + /** + * @param entries path within the zip to its bytes, as {@code build_export} produced them. + * Iteration order is preserved, so a caller handing over a {@code + * LinkedHashMap} gets a predictable archive. + * @param passcode the code to lock it with, or null for an unlocked bundle. Null exists for + * recipients on an app older than 1.1.0, which cannot decrypt anything at + * all - see the exporter's own checkbox for the same reason. + * @throws IOException if the destination will not take it. The caller is writing to a place + * the user picked, so a full disk or a removed drive is ordinary. + */ + public static void write( + final OutputStream destination, + final Map entries, + final String passcode) throws IOException { + + final boolean locked = passcode != null && !passcode.isEmpty(); + + // **char[], because that is what zip4j takes.** It clears the array after use; handing it + // a String would leave the code in the string pool for as long as the process lives, on a + // device somebody else may later pick up. + try (ZipOutputStream zip = locked + ? new ZipOutputStream(destination, passcode.toCharArray()) + : new ZipOutputStream(destination)) { + + for (final Map.Entry entry : entries.entrySet()) { + zip.putNextEntry(parametersFor(entry.getKey(), locked)); + zip.write(entry.getValue()); + zip.closeEntry(); + } + } + } + + /** + * How one file goes in. + * + *

Built per entry rather than once and reused: zip4j reads the file name off these, so a + * shared instance would need mutating between entries and that is a footgun in a loop. + */ + private static ZipParameters parametersFor(final String name, final boolean locked) { + final ZipParameters parameters = new ZipParameters(); + parameters.setFileNameInZip(name); + parameters.setCompressionMethod(CompressionMethod.DEFLATE); + + if (locked) { + parameters.setEncryptFiles(true); + parameters.setEncryptionMethod(EncryptionMethod.AES); + parameters.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_256); + } + + return parameters; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/export/TagExporter.java b/app/src/main/java/dev/wander/android/opentagviewer/util/export/TagExporter.java new file mode 100644 index 00000000..5e169d67 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/export/TagExporter.java @@ -0,0 +1,133 @@ +package dev.wander.android.opentagviewer.util.export; + +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +import dev.wander.android.opentagviewer.db.room.entity.BeaconNamingRecord; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.BundleBuilder; +import dev.wander.android.opentagviewer.util.parse.BundlePasscode; + +/** + * Turning a selection of tags into a locked zip somebody else can import. + * + *

Sharing, not backing up. An owner signed into their own account does not need this - + * their tags arrive when they sign in. What a bundle is for is giving a tag to another person, + * and that is worth saying because the act is irreversible: exported key material cannot be + * withdrawn, and the only way to revoke it is to unpair the accessory. + * + *

Blocking throughout - a Python call and a zip write. Never on the main thread. + */ +public final class TagExporter { + + private TagExporter() {} + + /** What was written, and the code it was locked with. */ + public static final class Exported { + private final String passcode; + private final String warning; + private final int count; + + Exported(final String passcode, final String warning, final int count) { + this.passcode = passcode; + this.warning = warning; + this.count = count; + } + + /** Never null: this app always locks what it writes. */ + public String getPasscode() { + return this.passcode; + } + + /** What the bundle had to be written without, or null. Worth logging, not showing. */ + public String getWarning() { + return this.warning; + } + + public int getCount() { + return this.count; + } + } + + /** + * A tag that cannot go in a bundle, and why. + * + *

Separate from {@link BundleBuilder.BundleBuildException} because the two want different + * screens: this one names something the user picked and can change, and the other is the app + * failing at something it should be able to do. + */ + public static class NothingToExportException extends Exception { + public NothingToExportException(final String message) { + super(message); + } + } + + /** + * Build the bundle, lock it, write it. + * + *

Always locked. The desktop exporter keeps an opt-out for recipients running an app + * older than 1.1.0; this one does not need it, because a bundle written by 1.1.0 is being sent + * by somebody who has 1.1.0, and the recipient they are most likely to be helping install it + * will get the same version. Adding a switch here would mostly serve people who do not know + * what it does. + * + * @param destination where the user chose to put it. Closed by the caller, which opened it. + * @param via {@code OpenTagViewer.android:}, from {@code BuildConfig}. + * @throws NothingToExportException if the selection cannot make a bundle + * @throws BundleBuilder.BundleBuildException if the app failed at building one + * @throws java.io.IOException if the destination will not take it + */ + public static Exported writeTo( + final OutputStream destination, + final List selection, + final String via, + final String sourceUser, + final long exportedAtMs) + throws NothingToExportException, BundleBuilder.BundleBuildException, + java.io.IOException { + + if (selection.isEmpty()) { + throw new NothingToExportException("Nothing was selected."); + } + + final List accessories = new ArrayList<>(selection.size()); + for (final Pairing pairing : selection) { + // **A tag read from the Apple account has no naming record of its own here**, and the + // importer inner-joins the two - so exporting one would produce a bundle that imports + // and silently contains nothing. Refused with a name rather than written. + if (pairing.naming == null || pairing.naming.content == null) { + throw new NothingToExportException(pairing.displayName); + } + accessories.add(new BundleBuilder.Accessory( + pairing.beacon.content, + pairing.naming.content, + pairing.beacon.alignmentPlist)); + } + + final BundleBuilder.Built built = AppDependencies.bundleBuilder() + .build(accessories, via, sourceUser, exportedAtMs); + + final String passcode = BundlePasscode.generate(); + BundleZipWriter.write(destination, built.getEntries(), passcode); + + return new Exported(passcode, built.getWarning(), accessories.size()); + } + + /** One tag's two records, joined, with a name for saying which one went wrong. */ + public static final class Pairing { + private final OwnedBeacon beacon; + private final BeaconNamingRecord naming; + private final String displayName; + + public Pairing( + final OwnedBeacon beacon, + final BeaconNamingRecord naming, + final String displayName) { + this.beacon = beacon; + this.naming = naming; + this.displayName = displayName; + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BundlePasscode.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BundlePasscode.java index 7b5c5809..ece438d2 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BundlePasscode.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BundlePasscode.java @@ -1,5 +1,6 @@ package dev.wander.android.opentagviewer.util.parse; +import java.security.SecureRandom; import java.util.Locale; /** @@ -31,8 +32,58 @@ public final class BundlePasscode { /** Anything a person might put between groups, including what a paste can drag in. */ private static final String SEPARATORS = " -_\t\r\n"; + /** How the code is broken up for reading. Display only - the password has no hyphens. */ + private static final int GROUP = 4; + private BundlePasscode() {} + /** + * A new code, for a bundle this app is about to write. + * + *

{@link SecureRandom}, and it is not a formality. The zip format derives its key + * with PBKDF2-HMAC-SHA1 at 1000 iterations, which is fixed by the format and cannot be + * raised - so the code itself is the whole of the security, and twelve characters of this + * alphabet is about sixty bits. A predictable generator would reduce that to the seed, and + * an exported accessory cannot be revoked except by unpairing it. + * + *

Matches {@code generate_passcode} in {@code opentagviewer_export/passcode.py}: same + * alphabet, same length. It has to, because either program's bundle is opened by this app. + */ + public static String generate() { + final SecureRandom random = new SecureRandom(); + final StringBuilder code = new StringBuilder(LENGTH); + + for (int i = 0; i < LENGTH; i++) { + code.append(ALPHABET.charAt(random.nextInt(ALPHABET.length()))); + } + + return code.toString(); + } + + /** + * The code as a person should see it: {@code H4K2-9WMR-7TQX}. + * + *

Grouping is for reading and typing, and nothing else. The password is the + * undelimited string - {@link #normalise} folds these hyphens straight back out, which is + * what lets somebody paste either form into the import dialog. + * + *

Twelve unbroken characters is what people mis-transcribe; the alphabet already drops the + * letters that get misread, and grouping handles the rest of the problem, which is losing + * your place. + */ + public static String format(final String code) { + final StringBuilder grouped = new StringBuilder(code.length() + code.length() / GROUP); + + for (int i = 0; i < code.length(); i++) { + if (i > 0 && i % GROUP == 0) { + grouped.append('-'); + } + grouped.append(code.charAt(i)); + } + + return grouped.toString(); + } + /** * The exact string the bundle was encrypted with. * diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 1b8a22ee..9ff035ce 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -1597,3 +1597,135 @@ def isOwnDeviceHardware(plistXml: str) -> str | None: except Exception: print(f"isOwnDeviceHardware failed, carrying on without it: {traceback.format_exc()}") return None + + +def buildExportBundle( + accessoriesJson: str, + via: str, + sourceUser: str, + exportedAtMs: int, +) -> str: + """ + Build the files of an export bundle, for the app to zip. + + **The app is the third producer of this format**, beside the desktop wizard and its CLI, and + it writes through the same `opentagviewer_export.build_export` they do. One implementation of + the format is most of why that package exists: a second one in Java would drift, and the + symptom of drift is a bundle that imports into one version of this app and not another. + + **It returns the files rather than a zip, deliberately.** `zipsink.write_zip` needs + `pyzipper` for an encrypted archive, which is not in Chaquopy's pip list and pulls a native + crypto dependency; the app already carries zip4j for *reading* locked bundles, and zip4j + writes them too. So Python owns the format and Java owns the container, which is the split + that costs nothing. + + Base64 over JSON because these are bytes crossing a language boundary. A plist is not UTF-8 + and must not be round-tripped through a string - `OwnedBeacons` records carry raw key + material, and a lossy decode there produces a bundle that imports and then cannot locate + anything, which is the worst kind of failure this could have. + + :param accessoriesJson: A JSON list of objects with `ownedBeaconPlist` and + `namingRecordPlist`, each an XML plist as the app stores it, and optionally + `alignmentPlist`. The naming record is **not** optional: the importer inner-joins the two + and silently drops an accessory it cannot pair with one. + :param via: `OpenTagViewer.android:`. Passed from Java rather than built here, + because `build_export` refuses to invent it and the version lives in `BuildConfig`. + :param sourceUser: What the recipient sees as "exported by". A label, never an Apple ID. + :param exportedAtMs: Milliseconds since the epoch, passed rather than read from the clock. + :returns: JSON. On success, `entries` maps each path in the zip to its base64 content, and + `warning` is present if something optional had to be left out. On failure, `error` carries + a sentence to show the user. + """ + import base64 + import plistlib + + from opentagviewer_export import AccessoryExport, ExportError, build_export + + try: + accessories = [] + for item in json.loads(accessoriesJson): + alignment = item.get("alignmentPlist") + accessories.append( + AccessoryExport( + owned_beacon=plistlib.loads(item["ownedBeaconPlist"].encode("utf-8")), + naming_record=plistlib.loads(item["namingRecordPlist"].encode("utf-8")), + # Absence is normal - not every accessory has one - but passing it whenever + # there is one is what stops the recipient's first fetch searching the tag's + # entire key history. See rule 6. + key_alignment_record=( + plistlib.loads(alignment.encode("utf-8")) if alignment else None + ), + ), + ) + except Exception: + print(f"Could not read the accessories to export: {traceback.format_exc()}") + return json.dumps({"error": "The bundle could not be built."}) + + try: + bundle = build_export( + accessories, via=via, source_user=sourceUser, exported_at_ms=exportedAtMs, + ) + warning = None + except ExportError as refused: + bundle, warning = _withoutTheAlignmentRecords( + accessories, refused, via, sourceUser, exportedAtMs, + ) + if bundle is None: + # Handed back rather than raised: the caller shows it, and a Chaquopy traceback is + # not a sentence anybody can act on. + return json.dumps({"error": warning}) + except Exception: + print(f"Failed to build an export bundle: {traceback.format_exc()}") + return json.dumps({"error": "The bundle could not be built."}) + + answer: dict[str, Any] = { + "entries": { + name: base64.b64encode(content).decode("ascii") + for name, content in bundle.entries.items() + }, + } + if warning: + answer["warning"] = warning + + return json.dumps(answer) + + +def _withoutTheAlignmentRecords(accessories, refused, via, sourceUser, exportedAtMs): + """ + Try again with the optional half dropped, because the alternative is exporting nothing. + + **The format layer's own error asks for this.** It says "pass no alignment record at all + rather than an unreadable one: the import is then slow, not broken" - and it is right, so the + caller should act on it rather than relay it. An alignment record is an optimisation: without + one the recipient's first fetch searches the tag's whole key history, which is slow and looks + like abuse of the account, but it works. Refusing the whole export because an optional record + is malformed trades something that works badly for nothing at all. + + Only worth attempting when there was one to drop. Otherwise the refusal is about the + accessories themselves - no key material, a missing naming record - and retrying changes + nothing. + + :returns: `(bundle, warning)` on success, or `(None, message)` when it still cannot be built. + """ + from dataclasses import replace + + from opentagviewer_export import ExportError, build_export + + if not any(a.key_alignment_record is not None for a in accessories): + return None, str(refused) + + try: + bundle = build_export( + [replace(a, key_alignment_record=None) for a in accessories], + via=via, + source_user=sourceUser, + exported_at_ms=exportedAtMs, + ) + except ExportError: + # Not the alignment records after all. Report the first refusal, which is the one that + # describes what is actually wrong. + return None, str(refused) + + print(f"Exporting without the key alignment records, which were unusable: {refused}") + + return bundle, str(refused) diff --git a/app/src/main/res/layout/activity_device_info.xml b/app/src/main/res/layout/activity_device_info.xml index 5d32857b..8644886e 100644 --- a/app/src/main/res/layout/activity_device_info.xml +++ b/app/src/main/res/layout/activity_device_info.xml @@ -143,9 +143,18 @@ app:onClickMenu="@{handleClickMenu}" app:pageTitle="@{pageTitle}" /> + + android:layout_height="match_parent" + android:clipToPadding="false" + android:paddingBottom="32dp"> - - - + + + + + + + + + + + diff --git a/app/src/main/res/menu/device_selection_menu.xml b/app/src/main/res/menu/device_selection_menu.xml index 8b79fc55..f5d5b0a1 100644 --- a/app/src/main/res/menu/device_selection_menu.xml +++ b/app/src/main/res/menu/device_selection_menu.xml @@ -2,8 +2,9 @@

@@ -14,7 +15,6 @@ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7ad973dc..176ac17a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -275,8 +275,6 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Weiter Seriennummer: ^1 %1$s (%2$s) - Tags importiert aus %1$s - Keine Tags aus einer Datei importiert Das ist ein Fehler in der App Es kam etwas zurück, das diese App nicht lesen kann. Ein erneuter Versuch hilft daher nicht, und an deinen Einstellungen ist nichts zu ändern.\n\nMeist bedeutet das, dass Apple etwas geändert hat. Ein Bericht mit angehängtem Protokoll macht es behebbar – das Protokoll verrät fast immer, welcher Teil betroffen ist. Fehler melden @@ -298,4 +296,14 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Protokoll gespeichert. Es wurde zuvor bereinigt: %1$s Exportiert mit Nicht vermerkt – ein älterer Exporter + Gespeichert und gesperrt + Wer diese Datei von dir bekommt, braucht den Code unten, um sie zu öffnen.\n\nSchicke ihn getrennt davon – ein Code in derselben Nachricht wie die Datei liegt am selben Ort wie die Datei, und wer beides hat, kann diese Tags orten, bis du sie entkoppelst. + Code kopieren + Kopiert + Dieser Code wird nirgends gespeichert und kann nicht erneut angezeigt werden. + „%1$s“ kann nicht exportiert werden: Der App fehlt der zugehörige Namenseintrag, und ein Bundle ohne ihn importiert nichts. Tags aus deinem Apple-Konto teilst du über „Wo ist?“ statt über eine Datei. + Die Datei konnte nicht geschrieben werden. Es wurde nichts gespeichert. + Die App konnte aus den gewählten Tags kein Bundle erstellen und kann nicht sagen, warum – ein erneuter Versuch mit denselben Tags hilft also nicht.\n\nEs wurde nichts verschickt und auf diesem Telefon hat sich nichts geändert. Deine Tags sind weiterhin da. + Zuletzt importiert aus %1$s + Keine Tags aus einer Datei importiert \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 5d4fbc36..12812363 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -275,8 +275,6 @@ You can set this up now, or any time later from Settings. Next Serial Number: ^1 %1$s (%2$s) - Tags imported from %1$s - No tags imported from a file This one is a bug Something came back that this app does not know how to read, so retrying will not help and there is nothing to change in your settings.\n\nThat usually means Apple altered something. A report with the log attached is what makes it fixable — the log almost always says which part. Report this @@ -298,4 +296,14 @@ You can set this up now, or any time later from Settings. Log saved. It was cleaned first: %1$s Exported with Not recorded — an older exporter + Sent, and locked + The person you send this file to needs the code below to open it.\n\nSend it to them separately — a code sent in the same message as the file is in the same place as the file, and anyone who has both can locate these tags until you unpair them. + Copy the code + Copied + This code is not stored anywhere and cannot be shown again. + “%1$s” cannot be exported: this app has no naming record for it, and a bundle without one imports as nothing. Tags read from your Apple account are shared through Find My rather than through a file. + The file could not be written. Nothing was saved. + The app could not build a bundle from the tags you picked, and cannot say why — so trying again with the same tags will not help.\n\nNothing was sent and nothing on this phone has changed. Your tags are still here. + Most recently imported from %1$s + No tags imported from a file \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8a1288eb..6e042a7e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -275,8 +275,6 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< Suivant Numéro de série : ^1 %1$s (%2$s) - Balises importées depuis %1$s - Aucune balise importée depuis un fichier Ceci est un bug L’app a reçu quelque chose qu’elle ne sait pas lire : réessayer n’y changera rien, et il n’y a rien à modifier dans vos réglages.\n\nCela signifie généralement qu’Apple a changé quelque chose. Un rapport accompagné du journal est ce qui rend la correction possible : le journal indique presque toujours quelle partie est en cause. Signaler @@ -298,4 +296,14 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< Journal enregistré. Il a d’abord été nettoyé : %1$s Exporté avec Non enregistré — un exportateur plus ancien + Enregistré et verrouillé + La personne à qui vous envoyez ce fichier aura besoin du code ci-dessous pour l’ouvrir.\n\nEnvoyez-le séparément : un code envoyé dans le même message que le fichier se trouve au même endroit que le fichier, et quiconque a les deux peut localiser ces tags jusqu’à ce que vous les dissociiez. + Copier le code + Copié + Ce code n’est stocké nulle part et ne pourra pas être réaffiché. + « %1$s » ne peut pas être exporté : l’app n’a pas l’enregistrement de nom correspondant, et une archive sans lui n’importe rien. Les tags lus depuis votre compte Apple se partagent via Localiser, pas via un fichier. + Le fichier n’a pas pu être écrit. Rien n’a été enregistré. + L’app n’a pas pu créer d’archive à partir des tags choisis et ne sait pas dire pourquoi : réessayer avec les mêmes tags n’y changera rien.\n\nRien n’a été envoyé et rien n’a changé sur ce téléphone. Vos tags sont toujours là. + Dernier import depuis %1$s + Aucun tag importé depuis un fichier \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index c5bae931..1b6e2e49 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -275,8 +275,6 @@ 次へ シリアル番号:^1 %1$s(%2$s) - %1$s からインポートしたタグ - ファイルからインポートしたタグはありません これは不具合です このアプリが解釈できないものが返ってきました。再試行しても解決せず、設定を変える必要もありません。\n\nたいていは Apple 側の変更が原因です。ログを添えて報告していただければ修正できます。どの部分かは、ほぼ必ずログに書かれています。 報告する @@ -298,4 +296,14 @@ ログを保存しました。事前に次のものを取り除いています:%1$s エクスポート元 記録なし(古いエクスポーター) + 書き出しと同時にロックしました + このファイルを渡す相手には、下のコードが必要です。\n\nコードはファイルとは別に送ってください。同じメッセージで送れば、コードはファイルと同じ場所に残ります。両方を持つ人は、ペアリングを解除するまでこれらのタグの位置を追えます。 + コードをコピー + コピーしました + このコードはどこにも保存されず、再表示できません。 + 「%1$s」は書き出せません。対応する名前レコードがアプリにないため、これを欠いたバンドルは何も取り込めません。Apple アカウントから読み込んだタグは、ファイルではなく「探す」で共有します。 + ファイルを書き出せませんでした。何も保存していません。 + 選んだタグからバンドルを作成できず、その理由も判別できませんでした。同じタグで再試行しても解決しません。\n\n何も送信されておらず、この端末側も変わっていません。タグはそのまま残っています。 + 直近の取得元:%1$s + ファイルから取得したタグはありません \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 22204570..af1690ad 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -275,8 +275,6 @@ 다음 일련번호: ^1 %1$s (%2$s) - %1$s에서 가져온 태그 - 파일에서 가져온 태그가 없습니다 이건 버그입니다 이 앱이 읽을 수 없는 응답이 돌아왔습니다. 다시 시도해도 해결되지 않으며, 설정에서 바꿀 것도 없습니다.\n\n대개 Apple 쪽에서 무언가 바뀐 경우입니다. 로그를 첨부해 신고해 주시면 고칠 수 있습니다. 어느 부분인지는 대부분 로그에 나옵니다. 신고하기 @@ -298,4 +296,14 @@ 로그를 저장했습니다. 먼저 다음을 제거했습니다: %1$s 내보낸 도구 기록 없음 — 오래된 내보내기 도구 + 저장했고 잠갔습니다 + 이 파일을 받는 사람에게는 아래 코드가 필요합니다.\n\n코드는 파일과 따로 보내세요. 같은 메시지로 보내면 코드가 파일과 같은 곳에 남습니다. 둘 다 가진 사람은 페어링을 해제하기 전까지 이 태그의 위치를 계속 확인할 수 있습니다. + 코드 복사 + 복사했습니다 + 이 코드는 어디에도 저장되지 않으며 다시 표시할 수 없습니다. + ‘%1$s’은(는) 내보낼 수 없습니다. 앱에 해당 이름 레코드가 없어서, 이것이 빠진 번들은 아무것도 가져오지 못합니다. Apple 계정에서 읽어 온 태그는 파일이 아니라 \'나의 찾기\'로 공유합니다. + 파일을 저장하지 못했습니다. 아무것도 저장되지 않았습니다. + 선택한 태그로 번들을 만들지 못했고, 이유도 알 수 없습니다. 같은 태그로 다시 시도해도 해결되지 않습니다.\n\n아무것도 전송되지 않았고 이 휴대폰에서 바뀐 것도 없습니다. 태그는 그대로 있습니다. + 가장 최근에 가져온 곳: %1$s + 파일에서 가져온 태그가 없습니다 \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index c91078ec..215e08ba 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -275,8 +275,6 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. Volgende Serienummer: ^1 %1$s (%2$s) - Tags geïmporteerd uit %1$s - Geen tags uit een bestand geïmporteerd Dit is een bug Er kwam iets terug dat deze app niet kan lezen, dus opnieuw proberen helpt niet en er valt niets aan je instellingen te wijzigen.\n\nMeestal betekent dat dat Apple iets heeft veranderd. Een melding met het logbestand erbij maakt het oplosbaar — het log zegt bijna altijd welk deel. Dit melden @@ -298,4 +296,14 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. Log opgeslagen. Het is eerst geschoond: %1$s Geëxporteerd met Niet vastgelegd — een oudere exporter + Opgeslagen en vergrendeld + Degene aan wie je dit bestand stuurt, heeft de onderstaande code nodig om het te openen.\n\nStuur die apart — een code in hetzelfde bericht als het bestand ligt op dezelfde plek als het bestand, en wie beide heeft kan deze tags volgen totdat je ze ontkoppelt. + Code kopiëren + Gekopieerd + Deze code wordt nergens bewaard en kan niet opnieuw worden getoond. + ‘%1$s’ kan niet worden geëxporteerd: de app heeft er geen naamrecord voor, en een bundel zonder dat importeert niets. Tags uit je Apple-account deel je via Zoek mijn, niet via een bestand. + Het bestand kon niet worden weggeschreven. Er is niets opgeslagen. + De app kon van de gekozen tags geen bundel maken en kan niet zeggen waarom — het opnieuw proberen met dezelfde tags helpt dus niet.\n\nEr is niets verstuurd en er is niets veranderd op deze telefoon. Je tags staan er nog. + Laatst geïmporteerd uit %1$s + Geen tags uit een bestand geïmporteerd \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9263a9cf..e6c2db53 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -275,8 +275,6 @@ Далее Серийный номер: ^1 %1$s (%2$s) - Метки импортированы из %1$s - Метки из файла не импортировались Это ошибка приложения Пришло что-то, что приложение не умеет читать, поэтому повторная попытка не поможет и менять настройки не нужно.\n\nОбычно это значит, что Apple что-то изменила. Отчёт с приложенным журналом делает ошибку исправимой — журнал почти всегда указывает, где именно. Сообщить об ошибке @@ -298,4 +296,14 @@ Журнал сохранён. Перед этим он был очищен: %1$s Экспортировано через Не записано — более старый экспортер + Сохранено и защищено + Тому, кому вы отправите этот файл, понадобится код ниже, чтобы его открыть.\n\nОтправьте код отдельно: код в том же сообщении, что и файл, лежит там же, где файл, и любой, у кого есть и то и другое, сможет находить эти метки, пока вы их не отвяжете. + Скопировать код + Скопировано + Этот код нигде не хранится и повторно показан не будет. + «%1$s» нельзя экспортировать: у приложения нет соответствующей записи имени, а пакет без неё не импортирует ничего. Метки из вашей учётной записи Apple передаются через «Локатор», а не файлом. + Файл не удалось записать. Ничего не сохранено. + Приложение не смогло собрать пакет из выбранных меток и не может сказать почему, — повторная попытка с теми же метками не поможет.\n\nНичего не отправлено, и на телефоне ничего не изменилось. Ваши метки на месте. + Последний импорт из %1$s + Метки из файла не импортировались \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 7b98e188..b94a932d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -275,8 +275,6 @@ 下一步 序列号:^1 %1$s(%2$s) - 标签导入自 %1$s - 没有从文件导入的标签 这是一个程序缺陷 返回了本应用无法解读的内容,因此重试没有用,你的设置也不需要改动。\n\n这通常意味着 Apple 改动了什么。附上日志的报告才能让它被修复——日志几乎总能指出是哪一部分。 报告问题 @@ -298,4 +296,14 @@ 日志已保存。已先行清理:%1$s 导出工具 未记录——较旧的导出工具 + 已保存并加锁 + 你把这个文件发给谁,对方就需要下面这个码才能打开。\n\n请分开发送——和文件放在同一条消息里的码,就和文件在同一个地方。同时拿到两者的人,在你解除配对之前都能定位这些标签。 + 复制此码 + 已复制 + 此码不会保存在任何地方,也无法再次显示。 + 无法导出“%1$s”:应用没有它的命名记录,缺少这项的包导入后什么都没有。从 Apple 账户读取的标签请通过“查找”共享,而不是文件。 + 文件写入失败,什么都没有保存。 + 应用无法用你选中的标签生成包,也说不出原因——用同样的标签重试没有用。\n\n没有发送任何东西,这台手机上也没有任何改变。你的标签都还在。 + 最近一次导入自 %1$s + 没有从文件导入的标签 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0526db56..def43345 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -275,8 +275,6 @@ 下一步 序號:^1 %1$s(%2$s) - 標籤匯入自 %1$s - 沒有從檔案匯入的標籤 這是一個程式缺陷 回傳了本 App 無法解讀的內容,因此重試沒有用,你的設定也不需要更動。\n\n這通常表示 Apple 改動了什麼。附上日誌的回報才能讓它被修復——日誌幾乎總能指出是哪一部分。 回報問題 @@ -298,4 +296,14 @@ 日誌已儲存。已先行清理:%1$s 匯出工具 未記錄——較舊的匯出工具 + 已儲存並加鎖 + 你把這個檔案發給誰,對方就需要下面這個碼才能開啟。\n\n請分開傳送——和檔案放在同一則訊息裡的碼,就和檔案在同一個地方。同時拿到兩者的人,在你解除配對之前都能定位這些標籤。 + 複製此碼 + 已複製 + 此碼不會儲存在任何地方,也無法再次顯示。 + 無法匯出「%1$s」:App 沒有它的命名記錄,缺少這項的包匯入後什麼都沒有。從 Apple 帳戶讀取的標籤請透過「尋找」分享,而不是檔案。 + 檔案寫入失敗,什麼都沒有儲存。 + App 無法用你選取的標籤產生包,也說不出原因——用同樣的標籤重試沒有用。\n\n沒有傳送任何東西,這支手機上也沒有任何改變。你的標籤都還在。 + 最近一次匯入自 %1$s + 沒有從檔案匯入的標籤 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9fee4cf8..7e888dc4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -307,8 +307,6 @@ You can set this up now, or any time later from Settings. Next Serial Number: ^1 %1$s (%2$s) - Tags imported from %1$s - No tags imported from a file This one is a bug Something came back that this app does not know how to read, so retrying will not help and there is nothing to change in your settings.\n\nThat usually means Apple altered something. A report with the log attached is what makes it fixable — the log almost always says which part. Report this @@ -330,4 +328,14 @@ You can set this up now, or any time later from Settings. Log saved. It was cleaned first: %1$s Exported with Not recorded — an older exporter + Sent, and locked + The person you send this file to needs the code below to open it.\n\nSend it to them separately — a code sent in the same message as the file is in the same place as the file, and anyone who has both can locate these tags until you unpair them. + Copy the code + Copied + This code is not stored anywhere and cannot be shown again. + “%1$s” cannot be exported: this app has no naming record for it, and a bundle without one imports as nothing. Tags read from your Apple account are shared through Find My rather than through a file. + The file could not be written. Nothing was saved. + The app could not build a bundle from the tags you picked, and cannot say why — so trying again with the same tags will not help.\n\nNothing was sent and nothing on this phone has changed. Your tags are still here. + Most recently imported from %1$s + No tags imported from a file diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/export/ALockedBundleCanBeOpenedAgainTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/export/ALockedBundleCanBeOpenedAgainTest.java new file mode 100644 index 00000000..263981d2 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/export/ALockedBundleCanBeOpenedAgainTest.java @@ -0,0 +1,214 @@ +package dev.wander.android.opentagviewer.util.export; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import net.lingala.zip4j.io.inputstream.ZipInputStream; +import net.lingala.zip4j.model.LocalFileHeader; +import net.lingala.zip4j.model.enums.AesKeyStrength; +import net.lingala.zip4j.model.enums.EncryptionMethod; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.wander.android.opentagviewer.util.parse.BundlePasscode; + +/** + * A bundle this app writes, opened again. + * + *

On the JVM, because nothing here needs a device - it is zip4j writing bytes and zip4j + * reading them back. See AGENTS.md rule 13. The full round trip through {@code + * AppleZipImporterUtil} is instrumented, because that one wants a {@code Context} and a Python + * interpreter; what is proved here is the container, which is the half that can be wrong in a way + * nobody notices until a recipient tries to open the file. + * + *

The failure this exists for is not a crash. A bundle written with the wrong encryption + * scheme, or with entries silently truncated, is a file that looks exactly like a working one - + * and it is discovered by somebody else, on another phone, after the sender has deleted their + * copy. + */ +public class ALockedBundleCanBeOpenedAgainTest { + + /** Shaped like the real thing: a manifest, a beacon record, a naming record. */ + private static Map someEntries() { + final Map entries = new LinkedHashMap<>(); + entries.put("OPENTAGVIEWER.yml", + "version: 0.0.2\nvia: OpenTagViewer.android:1.1.0\n".getBytes(StandardCharsets.UTF_8)); + entries.put("OwnedBeacons/F612A183-492B-45A8-A5A2-233CA9062A94.plist", + "privateKey".getBytes(StandardCharsets.UTF_8)); + entries.put("BeaconNamingRecord/F612A183-492B-45A8-A5A2-233CA9062A94/6C68CF6D.plist", + "namecat" + .getBytes(StandardCharsets.UTF_8)); + return entries; + } + + private static byte[] zipped(final Map entries, final String passcode) + throws IOException { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + BundleZipWriter.write(out, entries, passcode); + return out.toByteArray(); + } + + /** Reads it all back, or throws the way a real reader would. */ + private static Map unzipped(final byte[] archive, final String passcode) + throws IOException { + final Map found = new LinkedHashMap<>(); + + try (ZipInputStream zip = passcode == null + ? new ZipInputStream(new ByteArrayInputStream(archive)) + : new ZipInputStream(new ByteArrayInputStream(archive), passcode.toCharArray())) { + + LocalFileHeader header; + while ((header = zip.getNextEntry()) != null) { + final ByteArrayOutputStream content = new ByteArrayOutputStream(); + final byte[] buffer = new byte[4096]; + int read; + while ((read = zip.read(buffer)) != -1) { + content.write(buffer, 0, read); + } + found.put(header.getFileName(), content.toByteArray()); + } + } + + return found; + } + + /** + * Everything that went in comes back, byte for byte. + * + *

Byte equality rather than "it parses", because a plist carries the private key. A bundle + * that survives a lossy round trip imports cleanly and then locates nothing, which is the + * worst failure available here: silent, delayed, and somebody else's. + */ + @Test + public void whatWentInComesBackOut() throws Exception { + final Map entries = someEntries(); + final String code = BundlePasscode.generate(); + + final Map back = unzipped(zipped(entries, code), code); + + assertEquals(entries.keySet(), back.keySet()); + for (final Map.Entry entry : entries.entrySet()) { + assertArrayEquals(entry.getKey() + " came back different", + entry.getValue(), back.get(entry.getKey())); + } + } + + /** + * AES-256, not ZipCrypto. + * + *

The format's legacy scheme has been broken since the nineties, and zip4j will happily + * write it. A bundle protected by it would be protected in name only - and would still open + * with the code, so every other test here would pass. + */ + @Test + public void itisLockedWithSomethingWorthHaving() throws Exception { + final String code = BundlePasscode.generate(); + final byte[] archive = zipped(someEntries(), code); + + // The right code, because zip4j builds the decrypter inside getNextEntry() - opening with + // a deliberately wrong one throws before there is a header to inspect. + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(archive), + code.toCharArray())) { + final LocalFileHeader first = zip.getNextEntry(); + + assertNotNull(first); + assertTrue("the entry is not encrypted at all", first.isEncrypted()); + assertEquals(EncryptionMethod.AES, first.getEncryptionMethod()); + assertEquals(AesKeyStrength.KEY_STRENGTH_256, + first.getAesExtraDataRecord().getAesKeyStrength()); + } + } + + /** The wrong code does not quietly produce rubbish. */ + @Test + public void thewrongCodeDoesNotOpenIt() throws Exception { + final byte[] archive = zipped(someEntries(), "H4K29WMR7TQX"); + + assertThrows(IOException.class, () -> unzipped(archive, "0000000000AA")); + } + + /** And no code at all is refused rather than returning empty files. */ + @Test + public void noCodeAtAllDoesNotOpenItEither() throws Exception { + final byte[] archive = zipped(someEntries(), "H4K29WMR7TQX"); + + assertThrows(Exception.class, () -> unzipped(archive, null)); + } + + /** + * The unlocked form still works, for a recipient on an app older than 1.1.0. + * + *

That app cannot decrypt anything at all, so this is the only bundle it can open - which + * is why the option exists in the exporter and has to keep existing here. + */ + @Test + public void anunlockedBundleNeedsNoCode() throws Exception { + final Map entries = someEntries(); + + final Map back = unzipped(zipped(entries, null), null); + + assertEquals(entries.keySet(), back.keySet()); + assertArrayEquals(entries.get("OPENTAGVIEWER.yml"), back.get("OPENTAGVIEWER.yml")); + } + + /** + * The generated code survives being shown to a person and typed back in. + * + *

This is the interoperability contract, and it spans two languages and a human. The app + * generates the code, groups it for display, and the recipient types the grouped form into + * the import dialog - where {@code normalise} has to return the exact bytes the zip was + * locked with. A mismatch tells somebody their correct code is wrong. + */ + @Test + public void thecodeShownIsTheCodeThatOpensIt() throws Exception { + for (int attempt = 0; attempt < 200; attempt++) { + final String code = BundlePasscode.generate(); + final String shown = BundlePasscode.format(code); + + assertEquals("H4K2-9WMR-7TQX is the shape", 14, shown.length()); + assertEquals(code, BundlePasscode.normalise(shown)); + } + } + + /** And it is a code the exporter would also have produced. */ + @Test + public void thegeneratedCodeUsesTheAgreedAlphabet() { + for (int attempt = 0; attempt < 200; attempt++) { + final String code = BundlePasscode.generate(); + + assertEquals(BundlePasscode.LENGTH, code.length()); + for (final char c : code.toCharArray()) { + assertTrue("'" + c + "' is not in the alphabet the importer accepts", + BundlePasscode.ALPHABET.indexOf(c) >= 0); + } + } + } + + /** Two exports are not the same code. */ + @Test + public void everyCodeIsANewOne() { + assertFalse(BundlePasscode.generate().equals(BundlePasscode.generate())); + } + + /** An empty map would produce a zip with nothing in it, which is not a bundle. */ + @Test + public void nothingToWriteProducesNothingReadable() throws Exception { + final Map back = + unzipped(zipped(new LinkedHashMap<>(), "H4K29WMR7TQX"), "H4K29WMR7TQX"); + + assertTrue(back.isEmpty()); + assertNull(back.get("OPENTAGVIEWER.yml")); + } +} diff --git a/app/src/test/python/test_export_bridge.py b/app/src/test/python/test_export_bridge.py new file mode 100644 index 00000000..a5bfdfa5 --- /dev/null +++ b/app/src/test/python/test_export_bridge.py @@ -0,0 +1,182 @@ +""" +The app building an export bundle of its own. + +**A third producer of the format**, beside the desktop wizard and its CLI. It writes through the +same ``opentagviewer_export.build_export`` they do, so what is tested here is the bridge - the +join from what the app stores to what that function wants - and not the format, which has its own +tests in ``python/opentagviewer_export/tests/``. + +Two things about that join can go wrong quietly, and both are covered below. A plist is not UTF-8 +and carries raw key material, so anything lossy in the crossing produces a bundle that imports and +then locates nothing. And ``via:`` is what tells anybody looking at a zip afterwards which of the +three programs built it, so a bundle that lies about that makes a bug report unanswerable. +""" + +from __future__ import annotations + +import base64 +import datetime +import json +import plistlib +from pathlib import Path + +import pytest + +import main + +FIXTURE = Path(__file__).resolve().parents[2] / "test" / "resources" / "19032025" +BEACON_ID = "F612A183-492B-45A8-A5A2-233CA9062A94" + +VIA = "OpenTagViewer.android:1.1.0" + + +@pytest.fixture +def one_accessory() -> str: + """A real accessory, in the shape the app holds it: two plists as XML strings.""" + owned = (FIXTURE / "OwnedBeacons" / f"{BEACON_ID}.plist").read_text(encoding="utf-8") + naming = next( + (FIXTURE / "BeaconNamingRecord" / BEACON_ID).glob("*.plist"), + ).read_text(encoding="utf-8") + + return json.dumps([{"ownedBeaconPlist": owned, "namingRecordPlist": naming}]) + + +def answer(accessories: str, **kwargs) -> dict: + """The whole reply: `entries` on success, `error` on refusal, `warning` when something was + left out.""" + defaults = {"via": VIA, "sourceUser": "someone", "exportedAtMs": 1_700_000_000_000} + defaults.update(kwargs) + + return json.loads(main.buildExportBundle(accessories, **defaults)) + + +def built(accessories: str, **kwargs) -> dict: + """ + Just the entries, and it insists the build actually succeeded. + + **Asserting that first is not ceremony.** The alignment test below originally reported "an + alignment record was dropped" when what had really happened was the whole export being + refused - a helper that hands back an empty mapping on failure makes every assertion about + content quietly vacuous, and the message points at the wrong thing. + """ + reply = answer(accessories, **kwargs) + + assert "error" not in reply, reply.get("error") + return reply["entries"] + + +class TestWhatItProduces: + + def test_thebundleCarriesTheThreeThingsAnImportNeeds(self, one_accessory): + entries = built(one_accessory) + + assert "OPENTAGVIEWER.yml" in entries + assert f"OwnedBeacons/{BEACON_ID}.plist" in entries + assert any(name.startswith(f"BeaconNamingRecord/{BEACON_ID}/") for name in entries) + + def test_itsaysTheAppProducedIt(self, one_accessory): + # **Not the wizard and not the CLI.** Three programs write this format, and `via:` is the + # only thing in a zip that says which - so a bug report about a bundle is answerable or + # not depending on this line being right. + manifest = base64.b64decode(built(one_accessory)["OPENTAGVIEWER.yml"]).decode("utf-8") + + assert f"via: {VIA}" in manifest + + def test_thekeyMaterialSurvivesTheCrossing(self, one_accessory): + # The failure this guards is not a crash. A plist carries the private key, and anything + # lossy between Python and Java produces a bundle that imports cleanly and then cannot + # locate the tag - discovered days later, by the recipient. + entries = built(one_accessory) + + written = plistlib.loads(base64.b64decode(entries[f"OwnedBeacons/{BEACON_ID}.plist"])) + original = plistlib.loads( + (FIXTURE / "OwnedBeacons" / f"{BEACON_ID}.plist").read_bytes()) + + assert written["privateKey"] == original["privateKey"] + + def test_everyEntryIsBase64AndDecodes(self, one_accessory): + for name, content in built(one_accessory).items(): + assert base64.b64decode(content), f"{name} decoded to nothing" + + +class TestWhatItRefuses: + """ + Handed back as a message rather than raised. The caller shows it, and a Chaquopy traceback is + not a sentence anybody can act on. + """ + + def test_anemptySelectionIsRefusedWithAReason(self): + reply = answer("[]") + + assert "error" in reply + assert "Nothing was selected" in reply["error"] + + def test_ablankViaIsRefused(self, one_accessory): + # build_export refuses to invent one, and this bridge must not invent one either: a + # bundle that cannot say what produced it is the thing `via:` exists to prevent. + reply = answer(one_accessory, via="") + + assert "error" in reply + + def test_somethingThatIsNotAPlistDoesNotEscapeAsATraceback(self): + reply = answer(json.dumps([ + {"ownedBeaconPlist": "not a plist", "namingRecordPlist": "nor this"}, + ])) + + assert "error" in reply + assert "could not be built" in reply["error"] + + +class TestTheAlignmentRecord: + """ + Optional, and worth passing whenever there is one - see AGENTS.md rule 6. Without it the + recipient's first fetch searches the tag's whole key history, which for an 18-month-old tag is + ~50,000 keys at Apple's ~290-per-request limit: slow enough to look like abuse of the account. + """ + + def with_alignment(self, one_accessory: str, alignment: dict) -> str: + accessory = json.loads(one_accessory)[0] + accessory["alignmentPlist"] = plistlib.dumps(alignment).decode() + return json.dumps([accessory]) + + def test_itisCarriedWhenTheAppHasOne(self, one_accessory): + entries = built(self.with_alignment(one_accessory, { + "identifier": "9A1D4C22-6E3B-4F7A-9C88-1B2E5D6F0A34", + "beaconIdentifier": BEACON_ID, + "lastIndexObserved": 12, + "lastIndexObservationDate": datetime.datetime( + 2025, 3, 20, tzinfo=datetime.timezone.utc), + })) + + assert any("KeyAlignmentRecord" in name for name in entries), \ + "an alignment record the app held was dropped on the way out" + + def test_anunusableOneCostsTheOptimisationAndNotTheExport(self, one_accessory): + """ + **The format layer refuses these and says what to do about it** - "pass no alignment + record at all rather than an unreadable one: the import is then slow, not broken". So the + bridge acts on that rather than relaying it. Refusing a whole export because an optional + record is malformed trades something that works badly for nothing at all. + """ + reply = answer(self.with_alignment(one_accessory, {"index": 12})) + + assert "error" not in reply, "a bad optional record killed the whole export" + assert reply["entries"], "nothing was written" + assert not any("KeyAlignmentRecord" in name for name in reply["entries"]) + assert "warning" in reply, "it dropped the record without saying so" + + def test_arealProblemIsStillReportedRatherThanSwallowed(self): + """The retry must not turn a broken accessory into a silent partial export.""" + reply = answer(json.dumps([{ + "ownedBeaconPlist": plistlib.dumps({"identifier": BEACON_ID}).decode(), + "namingRecordPlist": plistlib.dumps({"identifier": BEACON_ID}).decode(), + "alignmentPlist": plistlib.dumps({"index": 1}).decode(), + }])) + + assert "error" in reply + assert "entries" not in reply + + def test_itsabsenceIsNormalRatherThanAnError(self, one_accessory): + entries = built(one_accessory) + + assert not any("KeyAlignmentRecord" in name for name in entries) diff --git a/python/exporter/version.py b/python/exporter/version.py index b3ad54b7..b7ad3e75 100644 --- a/python/exporter/version.py +++ b/python/exporter/version.py @@ -23,7 +23,7 @@ import sys from pathlib import Path -VERSION = "1.3.0" +VERSION = "1.4.0" APP_TITLE = f"OpenTagViewer AirTag Exporter {VERSION}" diff --git a/python/exporter/wizard.py b/python/exporter/wizard.py index 1b7b05b8..5ee251b6 100644 --- a/python/exporter/wizard.py +++ b/python/exporter/wizard.py @@ -65,9 +65,12 @@ describe_build, ) from opentagviewer_export import ( + ExportBundle, ExportError, KeyFileError, build_export, + format_passcode, + generate_passcode, parse_key_file, write_zip, ) @@ -241,6 +244,22 @@ def _build(self) -> None: self.confirm_button = ttk.Button(buttons, text="Export…", command=self._export, state="disabled") self.confirm_button.grid(row=0, column=4) + # **On by default, and the default is the whole point.** A bundle holds key material that + # cannot be revoked - the only way to withdraw an exported accessory is to unpair it - and + # it then travels through a mail account or a chat app and outlives the conversation by + # years, sitting in a backup long after everyone has forgotten it is there. Whoever most + # needs the lock is whoever would never go looking for a checkbox to turn it on. + # + # The opt-out exists for one real case, the same one behind the CLI's --no-password: an + # app older than 1.1.0 cannot decrypt anything at all, so a recipient running one cannot + # open a locked bundle, and they did not choose the exporter's version. + self.lock_bundle = tk.BooleanVar(value=True) + ttk.Checkbutton( + buttons, + text="Lock with a code", + variable=self.lock_bundle, + ).grid(row=1, column=4, sticky="e", pady=(6, 0)) + def _save_logs(self) -> None: """ Copy the log somewhere the user can find, under a name nothing else here could be. @@ -682,26 +701,51 @@ def _export(self) -> None: messagebox.showerror("That selection cannot be exported", str(e)) return - # **No password yet, and the reason is release ordering rather than a missing feature.** - # The app on `main` imports locked bundles - zip4j is in `app/build.gradle.kts` and - # `AppleZipImporterUtil` uses it - but no *released* APK does: the newest is 1.0.5, from - # before that work, and `versionName` has not moved past it. - # - # So locking bundles now would produce files that nobody's installed app can open, and the - # people worst affected would be recipients, who did not choose the exporter's version and - # cannot fix it from their side. - # - # **What unblocks this is an Android release containing zip4j, not a change here.** Once - # one exists, this becomes a decision about how long to keep supporting the versions before - # it. See the CLI's --no-password, and docs/android-import-handover.md. - write_zip(bundle, path, password=None) + if self._write_it(bundle, path, len(exports)): + self.destroy() - messagebox.showinfo( - "Exported", - f"{len(exports)} accessory(s) written to:\n{path}\n\n" - "Anyone who has this file can locate them, and that cannot be undone.", - ) - self.destroy() + def _write_it(self, bundle: ExportBundle, path: str, count: int) -> bool: + """ + Write the zip, lock it unless told otherwise, and say what happened. + + **Locked by default, which it was not until app 1.1.0 existed.** What blocked it was + release ordering rather than a missing feature: before zip4j the app could not decrypt + anything at all, so a locked bundle was a file nobody\'s installed app could open, and the + people worst affected were recipients, who did not choose the exporter\'s version and + could not fix it from their side. + + 1.1.0 reads them, so the default flips. The checkbox stays for the versions before it. + + :returns: whether the window should close. False leaves it open on a failure, so the + export can be retried without starting over. + """ + passcode = generate_passcode() if self.lock_bundle.get() else None + + try: + write_zip(bundle, path, password=passcode) + except RuntimeError as e: + # A missing pyzipper is the only way this raises, and it is worth saying plainly + # rather than as a traceback: no file was written, and the fix is an install. + messagebox.showerror("That bundle could not be locked", str(e)) + return False + except OSError as e: + # A full disk, a path that went away, a removable drive pulled mid-write. The + # selection is still on screen, so leaving the window up costs nothing and saves + # reading the account again. + messagebox.showerror("That bundle could not be written", str(e)) + return False + + if passcode is None: + messagebox.showinfo( + "Exported", + f"{count} accessory(s) written to:\n{path}\n\n" + "This bundle is not locked. Anyone who has the file can locate these tags, and" + " that cannot be undone.", + ) + else: + _show_the_code(self, path, count, passcode) + + return True def _confirm_devices(self, chosen: list[Candidate]) -> list[Candidate] | None: """ @@ -1261,6 +1305,109 @@ def _report(exc_type, value, tb) -> None: sys.excepthook = _report +def _build_the_code_window(parent: tk.Misc, path: str, count: int, passcode: str) -> tk.Toplevel: + """ + The one moment this code exists in a form a person can read. + + **It is not stored anywhere and cannot be recovered.** Nothing in the bundle, the log or this + program keeps it - the zip holds only what AES needs to check it, and a lost code means + exporting again. So this dialog is modal and has to be dismissed deliberately: a toast, or a + message box that the window destroys itself behind, would let somebody close the program with + the only copy on screen. + + **Selectable rather than only displayed, and a Copy button as well.** A code nobody can copy + gets transcribed by hand, and this alphabet exists because hand transcription goes wrong - + Crockford's base32 drops I, L, O and U precisely because they are misread. Reading twelve + characters off a screen into a chat window is the case the whole alphabet is designed around, + so the program should do it instead. + + The advice about sending it separately is not boilerplate. A code pasted into the same chat as + the bundle is in the same backup as the bundle, and the file cannot be un-shared once it is + out: the only way to withdraw an exported accessory is to unpair it. + """ + window = tk.Toplevel(parent) + window.title("Exported, and locked") + window.resizable(False, False) + window.transient(parent.winfo_toplevel()) + + frame = ttk.Frame(window, padding=20) + frame.grid(sticky="nsew") + + ttk.Label( + frame, + text=f"{count} accessory(s) written to:", + wraplength=460, + ).grid(row=0, column=0, sticky="w") + + ttk.Label(frame, text=path, wraplength=460, foreground="#555").grid( + row=1, column=0, sticky="w", pady=(2, 14), + ) + + ttk.Label( + frame, + text="The code to open it is:", + wraplength=460, + ).grid(row=2, column=0, sticky="w") + + # An Entry rather than a Label so the text can be selected with a mouse. Read-only rather + # than disabled: disabled greys it out and blocks selection, which is the one thing it is for. + shown = tk.Entry(frame, font=("TkFixedFont", 16), justify="center", width=18) + shown.insert(0, format_passcode(passcode)) + shown.configure(state="readonly") + shown.grid(row=3, column=0, sticky="w", pady=(4, 10)) + + def _copy() -> None: + window.clipboard_clear() + # The grouped form, because that is what the person on the other end will be typing into + # three boxes. The importer folds spaces, hyphens and the confusable letters back out. + window.clipboard_append(format_passcode(passcode)) + copied.configure(text="Copied.") + + buttons = ttk.Frame(frame) + buttons.grid(row=4, column=0, sticky="w") + ttk.Button(buttons, text="Copy the code", command=_copy).grid(row=0, column=0) + copied = ttk.Label(buttons, text="", foreground="#177245") + copied.grid(row=0, column=1, padx=(10, 0)) + + ttk.Label( + frame, + text=( + "Write it down now — it is not stored anywhere and cannot be recovered.\n\n" + "Send it to the recipient separately from the file. A code sent in the same message " + "as the bundle is in the same backup as the bundle, and anyone who has both can " + "locate these tags for as long as they stay paired." + ), + wraplength=460, + justify="left", + foreground="#555", + ).grid(row=5, column=0, sticky="w", pady=(14, 14)) + + ttk.Button(frame, text="Done", command=window.destroy).grid(row=6, column=0, sticky="e") + + window.protocol("WM_DELETE_WINDOW", window.destroy) + shown.focus_set() + + return window + + +def _show_the_code(parent: tk.Misc, path: str, count: int, passcode: str) -> None: + """ + Put the window up and wait for it. + + **Split from building it so the dialog itself can be tested.** `wait_window` blocks until the + user closes it, so a test that called this would hang - and mocking the whole thing out, which + is what the first version of the tests did, leaves the only place the code is ever displayed + with no coverage at all. A dialog that renders the wrong string, or copies nothing, would have + passed. + + Modal, and closable only on purpose. The parent destroys itself the moment this returns, so a + non-modal dialog would be a window explaining a code belonging to a program that has gone. + """ + window = _build_the_code_window(parent, path, count, passcode) + window.grab_set() + parent.wait_window(window) + + def self_test() -> int: """ Prove a frozen build can reach everything it needs, without a network or an account. diff --git a/python/test/test_wizard_locks_by_default.py b/python/test/test_wizard_locks_by_default.py new file mode 100644 index 00000000..c5dc491e --- /dev/null +++ b/python/test/test_wizard_locks_by_default.py @@ -0,0 +1,229 @@ +""" +The wizard locks the bundles it writes, and shows the code once. + +**This default was blocked, not missing.** ``wizard.py`` hard-coded ``password=None`` with a +comment saying so: before the Android app carried zip4j it could not decrypt anything at all, so +a locked bundle was a file nobody's installed app could open - and the people worst affected were +recipients, who did not choose the exporter's version and could not fix it from their side. + +App 1.1.0 reads them. So the default flips, and these tests exist because nothing caught the old +behaviour either: no test asserted ``password=None``, so the flip would have gone unnoticed in +both directions. + +The code is the part with a permanent cost. It is not stored anywhere and cannot be recovered, so +a bundle written without the user being shown its code is a bundle nobody can ever open. +""" + +from __future__ import annotations + +from unittest import mock + +import pytest + +# Before anything that imports tkinter - see the note in test_save_logs_button.py. A skip inside a +# fixture is too late, because the module is imported during collection. +tk = pytest.importorskip("tkinter", reason="needs a Python built with Tk") + +from exporter import wizard # noqa: E402 - has to follow the importorskip above +from opentagviewer_export import ExportBundle # noqa: E402 + + +@pytest.fixture(scope="module") +def window(): + """One window for the module. Repeatedly building a Tk in one process is unstable on macOS.""" + try: + app = wizard.WizardApp() + except tk.TclError as e: # pragma: no cover - depends on the machine, not the code + pytest.skip(f"needs a display to build a window: {e}") + + app.withdraw() + try: + yield app + finally: + app.destroy() + + +@pytest.fixture +def bundle(): + return ExportBundle(entries={"OPENTAGVIEWER.yml": b"version: 0.0.2\n"}, exported_at_ms=0) + + +def write(window, bundle, path, *, locked: bool): + """Run the write step with the checkbox in a known state, and report what happened.""" + window.lock_bundle.set(locked) + + with mock.patch.object(wizard, "write_zip") as write_zip, \ + mock.patch.object(wizard, "_show_the_code") as shown, \ + mock.patch.object(wizard.messagebox, "showinfo") as info, \ + mock.patch.object(wizard.messagebox, "showerror") as error: + closed = window._write_it(bundle, str(path), 3) + + return write_zip, shown, info, error, closed + + +class TestTheDefault: + """ + A bundle holds key material that cannot be revoked, and travels through other people's + infrastructure. Whoever most needs the lock is whoever would never find a checkbox for it. + """ + + def test_the_checkbox_starts_ticked(self, window): + assert window.lock_bundle.get() is True + + def test_a_bundle_is_written_with_a_code(self, window, bundle, tmp_path): + write_zip, _shown, _info, _error, _closed = write( + window, bundle, tmp_path / "x.zip", locked=True) + + passcode = write_zip.call_args.kwargs["password"] + assert passcode, "the bundle was written unlocked while the box was ticked" + assert len(passcode) == 12 + + def test_the_code_uses_the_alphabet_the_importer_expects(self, window, bundle, tmp_path): + # Crockford's base32, minus I, L, O and U. The app folds the confusable letters back on + # input; a code containing one would still work, but it would defeat the point of the + # alphabet - which is that this gets read off a screen and typed somewhere else. + write_zip, *_ = write(window, bundle, tmp_path / "x.zip", locked=True) + + assert set(write_zip.call_args.kwargs["password"]) <= set( + "0123456789ABCDEFGHJKMNPQRSTVWXYZ") + + +class TestShowingTheCode: + """ + **The one moment it exists in a readable form.** Nothing keeps it - not the bundle, not the + log, not the program - so a bundle written without showing its code is one nobody can open. + """ + + def test_the_code_is_shown_and_it_is_the_one_that_was_used(self, window, bundle, tmp_path): + write_zip, shown, _info, _error, _closed = write( + window, bundle, tmp_path / "x.zip", locked=True) + + shown.assert_called_once() + assert shown.call_args.args[3] == write_zip.call_args.kwargs["password"] + + def test_an_unlocked_bundle_says_so_instead(self, window, bundle, tmp_path): + write_zip, shown, info, _error, _closed = write( + window, bundle, tmp_path / "x.zip", locked=False) + + assert write_zip.call_args.kwargs["password"] is None + shown.assert_not_called() + assert "not locked" in info.call_args.args[1] + + +class TestWhenItCannotBeWritten: + """ + Failing with the window still up, because the alternative is reading the account again. + """ + + def test_a_missing_pyzipper_is_said_plainly(self, window, bundle, tmp_path): + window.lock_bundle.set(True) + + with mock.patch.object(wizard, "write_zip", + side_effect=RuntimeError("pyzipper is not installed")), \ + mock.patch.object(wizard.messagebox, "showerror") as error: + closed = window._write_it(bundle, str(tmp_path / "x.zip"), 1) + + assert closed is False, "the window closed on a failure, losing the selection" + assert "pyzipper" in error.call_args.args[1] + + def test_a_disk_that_will_not_take_it_keeps_the_window(self, window, bundle, tmp_path): + window.lock_bundle.set(True) + + with mock.patch.object(wizard, "write_zip", side_effect=OSError("No space left")), \ + mock.patch.object(wizard.messagebox, "showerror") as error: + closed = window._write_it(bundle, str(tmp_path / "x.zip"), 1) + + assert closed is False + assert "No space left" in error.call_args.args[1] + + def test_a_successful_write_does_close_it(self, window, bundle, tmp_path): + *_rest, closed = write(window, bundle, tmp_path / "x.zip", locked=True) + + assert closed is True + + +class TestTheDialogItself: + """ + **The window, not the call to it.** Every test above mocks ``_show_the_code`` out, so they + prove the wiring and nothing about what a person sees. That is the same gap as asserting a + share sheet opened without looking at what it was handed: a dialog rendering the wrong string, + or a Copy button that copies nothing, passes all of them. + + It matters more here than most places, because this is the only moment the code exists in a + readable form. Nothing stores it. A dialog that fails to show it produces a bundle that can + never be opened, and the failure is silent at exactly the moment the user stops paying + attention. + """ + + CODE = "4RTZ9KMXP2W7" + + def build(self, window, tmp_path): + dialog = wizard._build_the_code_window( + window, str(tmp_path / "export.zip"), 3, self.CODE) + dialog.withdraw() + return dialog + + def text_in(self, widget) -> str: + """Everything the window says, however it is nested.""" + found = [] + for child in widget.winfo_children(): + try: + found.append(str(child.cget("text"))) + except tk.TclError: + pass + try: + found.append(str(child.get())) + except (tk.TclError, AttributeError, TypeError): + pass + found.append(self.text_in(child)) + return " ".join(found) + + def test_thecodeIsOnScreen_grouped_for_reading(self, window, tmp_path): + dialog = self.build(window, tmp_path) + try: + # Grouped, because it is read off a screen and typed into three boxes on a phone. + assert "4RTZ-9KMX-P2W7" in self.text_in(dialog) + finally: + dialog.destroy() + + def test_itsaysTheCodeCannotBeRecovered(self, window, tmp_path): + dialog = self.build(window, tmp_path) + try: + said = self.text_in(dialog) + assert "cannot be recovered" in said + # And the half people get wrong: the code must not travel with the file. + assert "separately" in said + finally: + dialog.destroy() + + def test_copyPutsTheCodeOnTheClipboard(self, window, tmp_path): + dialog = self.build(window, tmp_path) + try: + dialog.clipboard_clear() + dialog.clipboard_append("something else") + + self.press(dialog, "Copy the code") + + assert dialog.clipboard_get() == "4RTZ-9KMX-P2W7" + except tk.TclError as e: # pragma: no cover - some CI hosts have no clipboard + pytest.skip(f"no usable clipboard here: {e}") + finally: + dialog.destroy() + + def test_thepathIsShownSoTheyKnowWhichFileItOpens(self, window, tmp_path): + dialog = self.build(window, tmp_path) + try: + assert "export.zip" in self.text_in(dialog) + finally: + dialog.destroy() + + def press(self, widget, label) -> None: + """Find a button by its label and invoke it.""" + for child in widget.winfo_children(): + try: + if str(child.cget("text")) == label: + child.invoke() + return + except tk.TclError: + pass + self.press(child, label)