diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml index f4858727..c5e4595a 100644 --- a/.github/workflows/build-debug.yml +++ b/.github/workflows/build-debug.yml @@ -78,6 +78,32 @@ jobs: run: | echo "MAPS_API_KEY=${{ secrets.MAPS_API_KEY || 'maps_key_default_value' }}" > secrets.properties + # **So every debug APK this repository publishes is signed by the same key.** + # + # A runner has no ~/.android/debug.keystore, so AGP generates one per run. That makes each + # debug APK unupgradeable over the last (INSTALL_FAILED_UPDATE_INCOMPATIBLE, and with + # allowBackup false an uninstall destroys the tester's imported beacons), and it makes the + # Maps SHA-1 restriction impossible to satisfy, which is why maps rendered blank here and + # not locally. + # + # Absent secret is not fatal: a fork without it falls back to the generated key and still + # builds, which is the behaviour a fork wants. + # + # Passed through `env` rather than interpolated into the script: the `secrets` context is + # not available in a step-level `if` at all, and a `${{ }}` inside a run block puts the + # value on the command line, where a shell trace or an injected newline can expose it. + - name: Write the shared debug keystore + env: + DEBUG_KEYSTORE_BASE64: ${{ secrets.DEBUG_KEYSTORE_BASE64 }} + run: | + if [ -z "$DEBUG_KEYSTORE_BASE64" ]; then + echo "No DEBUG_KEYSTORE_BASE64 secret; AGP will generate a debug key for this run." + echo "APKs from this run cannot be installed over ones from another." + exit 0 + fi + printf '%s' "$DEBUG_KEYSTORE_BASE64" | base64 -d > app/debug-keystore.jks + echo "Debug keystore written, $(wc -c < app/debug-keystore.jks) bytes" + # KVM is required for a hardware-accelerated emulator; without it the run times out. - name: Enable KVM run: | @@ -197,6 +223,32 @@ jobs: run: | echo "MAPS_API_KEY=${{ secrets.MAPS_API_KEY || 'maps_key_default_value' }}" > secrets.properties + # **So every debug APK this repository publishes is signed by the same key.** + # + # A runner has no ~/.android/debug.keystore, so AGP generates one per run. That makes each + # debug APK unupgradeable over the last (INSTALL_FAILED_UPDATE_INCOMPATIBLE, and with + # allowBackup false an uninstall destroys the tester's imported beacons), and it makes the + # Maps SHA-1 restriction impossible to satisfy, which is why maps rendered blank here and + # not locally. + # + # Absent secret is not fatal: a fork without it falls back to the generated key and still + # builds, which is the behaviour a fork wants. + # + # Passed through `env` rather than interpolated into the script: the `secrets` context is + # not available in a step-level `if` at all, and a `${{ }}` inside a run block puts the + # value on the command line, where a shell trace or an injected newline can expose it. + - name: Write the shared debug keystore + env: + DEBUG_KEYSTORE_BASE64: ${{ secrets.DEBUG_KEYSTORE_BASE64 }} + run: | + if [ -z "$DEBUG_KEYSTORE_BASE64" ]; then + echo "No DEBUG_KEYSTORE_BASE64 secret; AGP will generate a debug key for this run." + echo "APKs from this run cannot be installed over ones from another." + exit 0 + fi + printf '%s' "$DEBUG_KEYSTORE_BASE64" | base64 -d > app/debug-keystore.jks + echo "Debug keystore written, $(wc -c < app/debug-keystore.jks) bytes" + # No stub wheel to validate any more: it is generated from app/stubs/unicorn/ by # generateUnicornStubWheel during the build, rather than being checked in. diff --git a/AGENTS.md b/AGENTS.md index 5ea3843e..240cb664 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,8 +163,20 @@ message about the zip rather than about a code. Publish an exporter that locks b that app is out, 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. -**The wizard's lock is defaulted on**, in `wizard.py`'s `lock_bundle`, and -`test_wizard_bundle_locking.py` asserts that. +**The wizard has no lock switch at all**, and that is stronger than a default. `_write_it` calls +`generate_passcode()` unconditionally, so no path from that window produces an unlocked bundle; +`test_wizard_bundle_locking.py` asserts the control's *absence*, by attribute and by walking the +widgets, rather than asserting a value somebody can flip back. + +It was a ticked checkbox, and a ticked checkbox is one idle click from an unlocked zip holding +key material that cannot be revoked. That click gets made: a user sent @parawanderer their tags +in an unlocked bundle. **Do not restore it** — the person the lock protects is precisely the +person who would untick it to make a message go away. + +The escape hatch is `exporter.cli`'s `--no-password`, and its being CLI-only is the design rather +than an omission. Somebody who found a flag and typed it has chosen an unlocked bundle; somebody +clicking through a window has not, and offering both the same control treats those as one +decision. **It was flipped on before app 1.1.0 was published, which is not what the paragraph above describes, and the exception is worth understanding rather than copying.** The ordering exists to diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2dd4ddf4..e7c0ad06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,6 +115,46 @@ than replacing it. **Never uninstall a production install to force an install** `allowBackup` is false, so the beacons and location history are gone for good, and getting them back means redoing the macOS export. +### The shared debug keystore + +**Android signs debug builds with `~/.android/debug.keystore`, and a CI runner has no such file, +so it generates one per run.** Two things follow, and both were met before this was fixed: + +- **A debug APK cannot be installed over one from a different machine or a different CI run.** + Same `applicationId`, different signing key, so the install fails with + `INSTALL_FAILED_UPDATE_INCOMPATIBLE`. The only way forward is an uninstall, and an uninstall + destroys that device's imported beacons and location history. +- **Google Maps renders blank.** A Maps key is restricted by package name *and* signing SHA-1, + and a SHA-1 that changes every build cannot be whitelisted at all. It looks exactly like a + build with no API key in it. + +So CI writes one fixed keystore from the `DEBUG_KEYSTORE_BASE64` repository secret, and +`app/build.gradle.kts` uses `app/debug-keystore.jks` when that file is present. Its SHA-1 is +whitelisted against the Maps key, so **CI debug builds render maps and upgrade in place**. + +To make your local builds interchangeable with CI's, put the same keystore at +`app/debug-keystore.jks`: + +```bash +gh secret list -R parawanderer/OpenTagViewer # confirms it exists; secrets cannot be read back +# Ask a maintainer for the file, then: +ls -l app/debug-keystore.jks +keytool -list -v -keystore app/debug-keystore.jks -storepass android -alias androiddebugkey \ + | grep SHA1 +``` + +`*.jks` is gitignored, and the passwords are Android's well-known debug constants +(`android` / `androiddebugkey`) deliberately: the key proves nothing and guards nothing, and +giving it real secrets would only add something else to supply before the project builds. + +**Without the file, everything still builds** — Gradle logs a line saying so and falls back to +the generated key. That is the right behaviour for a fork, and it is also why a missing keystore +does not announce itself as an error when maps later come up blank. + +**Changing the debug key means one more uninstall, once.** Anything already installed was signed +with the old per-run key, so the first build after this lands still refuses to install over it. +After that they upgrade in place. + --- ## Testing diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 39c6432a..e24b5d7c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -186,6 +186,46 @@ android { keyAlias = System.getenv("KEY_ALIAS") keyPassword = System.getenv("KEY_PASSWORD") } + + // **A debug key that is the same key every time, when one is supplied.** + // + // Without this, AGP signs debug builds with `~/.android/debug.keystore`, which a fresh + // CI runner *generates on the spot*. Two consequences, both of which bit: + // + // 1. Every CI debug APK is signed by a different key, so installing a newer one over an + // older one fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE. The only way forward is to + // uninstall, and `allowBackup` is false, so that permanently destroys the imported + // beacons and location history on that device. Testing successive builds meant + // wiping the app every time. + // 2. A Google Maps key is restricted by package name *and* signing SHA-1, so a key whose + // SHA-1 changes per build cannot be whitelisted at all. Maps rendered blank in every + // CI debug build, which reads as the API key being missing from the build. + // + // Supplied through the environment rather than committed: it is a low-value key, but a + // signing key in a public repository is a bad habit to start, and `.gitignore` covers + // the filename. CI writes it from the DEBUG_KEYSTORE_BASE64 secret; see + // CONTRIBUTING.md for using the same one locally, which is what makes a locally built + // APK and a CI one interchangeable on the same device. + // + // **Falls back to AGP's default when absent**, so a clone with no keystore still builds. + // The passwords are Android's well-known debug constants on purpose: this key proves + // nothing and guards nothing, and inventing secrets for it would only mean another thing + // that has to be supplied before the project compiles. + getByName("debug") { + val supplied = file(System.getenv("DEBUG_KEYSTORE_FILE") ?: "debug-keystore.jks") + if (supplied.exists()) { + storeFile = supplied + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } else { + logger.lifecycle( + "No debug keystore at ${supplied.path}; using the default one. Debug APKs " + + "from this build will not match CI's, so installing one over the other " + + "needs an uninstall. See CONTRIBUTING.md." + ) + } + } } buildTypes { diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtilTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtilTest.java new file mode 100644 index 00000000..f1579b19 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtilTest.java @@ -0,0 +1,158 @@ +package dev.wander.android.opentagviewer.ui.compat; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; + +import android.content.Context; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Where the system-bar insets land. + * + *

This is a screenshot bug that no screenshot test catches, because the insets a test + * device reports are not the ones that break it: a gesture-navigation phone has a bottom inset of + * a few dp, and three-button navigation has around 48. So the values are dispatched here rather + * than waited for, which is also the only way to assert what happens on a *second* delivery. + * + *

Both failures being pinned have shipped. Padding applied twice grew the gap on every + * rotation; padding the root of a screen with a bottom sheet left the sheet stopping short of the + * screen edge, showing a band of the activity's background beneath it and clipping the last row + * of the list. + */ +@RunWith(AndroidJUnit4.class) +public class WindowPaddingUtilTest { + + private static final int STATUS_BAR = 60; + private static final int NAV_BAR = 48; + + private Context context; + + @Before + public void setUp() { + this.context = getInstrumentation().getTargetContext(); + } + + private static WindowInsetsCompat systemBars() { + return new WindowInsetsCompat.Builder() + .setInsets( + WindowInsetsCompat.Type.systemBars(), + Insets.of(0, STATUS_BAR, 0, NAV_BAR)) + .build(); + } + + /** Insets arrive on their own schedule, so a test has to hand them over itself. */ + private static void deliverInsetsTo(final View view) { + ViewCompat.dispatchApplyWindowInsets(view, systemBars()); + } + + @Test + public void awholeScreenIsKeptClearOfBothBars() { + final View screen = new FrameLayout(this.context); + + WindowPaddingUtil.insetForSystemBars(screen); + deliverInsetsTo(screen); + + assertEquals("the status bar would cover the heading", STATUS_BAR, screen.getPaddingTop()); + assertEquals("a button here would be behind the navigation bar", + NAV_BAR, screen.getPaddingBottom()); + } + + /** + * And the padding a layout already asked for is kept rather than replaced. + */ + @Test + public void bitaddsToThePaddingTheLayoutAlreadyHad() { + final View screen = new FrameLayout(this.context); + screen.setPadding(0, 7, 0, 11); + + WindowPaddingUtil.insetForSystemBars(screen); + deliverInsetsTo(screen); + + assertEquals(STATUS_BAR + 7, screen.getPaddingTop()); + assertEquals(NAV_BAR + 11, screen.getPaddingBottom()); + } + + /** + * Delivered twice, the gap does not double. + * + *

Insets arrive more than once - a rotation, a keyboard, switching to three-button + * navigation - and reading the view's current padding inside the listener would add to a + * value that already includes the last delivery. + */ + @Test + public void ctwodeliveriesDoNotStack() { + final View screen = new FrameLayout(this.context); + screen.setPadding(0, 7, 0, 11); + + WindowPaddingUtil.insetForSystemBars(screen); + deliverInsetsTo(screen); + deliverInsetsTo(screen); + + assertEquals(STATUS_BAR + 7, screen.getPaddingTop()); + assertEquals(NAV_BAR + 11, screen.getPaddingBottom()); + } + + /** + * The paired form puts the bottom on the content and leaves the root's alone. + * + *

The history screen's sheet has to reach the bottom of the display. Padding its root + * instead shortened everything inside it, so the sheet stopped above the navigation bar with + * the activity's background showing beneath it - a white band under a grey sheet - and the + * last row of the list clipped by the same gap. + */ + @Test + public void dthepairedFormGivesTheBottomToTheContent() { + final View root = new FrameLayout(this.context); + final View list = new FrameLayout(this.context); + + WindowPaddingUtil.insetForSystemBars(root, list); + deliverInsetsTo(root); + + assertEquals("the root still clears the status bar", STATUS_BAR, root.getPaddingTop()); + assertEquals("the root must reach the bottom edge, or the sheet stops short", + 0, root.getPaddingBottom()); + assertEquals("the list has to clear the navigation bar itself", + NAV_BAR, list.getPaddingBottom()); + } + + /** The content's own padding survives, and the root's does too. */ + @Test + public void ethepairedFormKeepsBothViewsOwnPadding() { + final View root = new FrameLayout(this.context); + final View list = new FrameLayout(this.context); + root.setPadding(0, 3, 0, 5); + list.setPadding(0, 0, 0, 9); + + WindowPaddingUtil.insetForSystemBars(root, list); + deliverInsetsTo(root); + + assertEquals(STATUS_BAR + 3, root.getPaddingTop()); + assertEquals("the root's own bottom padding is not the navigation bar's, and stays", + 5, root.getPaddingBottom()); + assertEquals(NAV_BAR + 9, list.getPaddingBottom()); + } + + /** And it does not stack on a second delivery either. */ + @Test + public void fthepairedFormDoesNotStackOnTheContent() { + final View root = new FrameLayout(this.context); + final View list = new FrameLayout(this.context); + list.setPadding(0, 0, 0, 9); + + WindowPaddingUtil.insetForSystemBars(root, list); + deliverInsetsTo(root); + deliverInsetsTo(root); + + assertEquals(NAV_BAR + 9, list.getPaddingBottom()); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/FetchFromAccountSettingTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/FetchFromAccountSettingTest.java index 61b09a7e..d13d23c9 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/FetchFromAccountSettingTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/FetchFromAccountSettingTest.java @@ -12,6 +12,10 @@ 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.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import android.app.Activity; import android.app.Instrumentation.ActivityResult; @@ -89,6 +93,17 @@ private void openSettings() { this.scenario = ActivityScenario.launch(SettingsActivity.class); } + /** + * The same screen, launched so that {@code getResult()} is allowed to answer. + * + *

{@code ActivityScenario.getResult()} throws unless the scenario was created with + * {@code launchActivityForResult}, which is not a detail that shows up until it runs - the + * ordinary {@code launch} compiles against it perfectly happily. + */ + private void openSettingsExpectingAResult() { + this.scenario = ActivityScenario.launchActivityForResult(SettingsActivity.class); + } + /** As if the app had already joined the account's keychain. */ private void givenTheAccountIsAlreadyLinked() { this.memberships.store(new KeychainMembership( @@ -152,4 +167,79 @@ public void tappingItReachesTheAccountScreen() { Eventually.check(() -> intended(hasComponent(FetchFromICloudActivity.class.getName()))); } + + /** + * And what that screen brought back is passed on, so the map rebuilds. + * + *

The map reads its tags once, when it is created, and holds them in memory. Reaching the + * account flow from here goes map, settings, iCloud - so coming back resumes the map instead + * of recreating it, and freshly imported tags were absent from it entirely. They showed in + * the device list as "No last location known", which reads as a fetch that failed rather + * than a screen that never learned they exist. Closing and reopening the app fixed it, which + * is the giveaway that nothing was wrong with the data. + * + *

{@code FetchFromICloudActivity} always set {@link FetchFromICloudActivity#RESULT_IMPORTED}; + * the map and the device list both act on it when they start that screen themselves. This + * screen used {@code startActivity}, which discards the result, so the one route through + * Settings was the one route that dropped it. + */ + @Test + public void whatTheAccountScreenImportedIsPassedBackToWhoeverOpenedSettings() { + final android.content.Intent imported = new android.content.Intent(); + imported.putExtra(FetchFromICloudActivity.RESULT_IMPORTED, true); + intending(hasComponent(FetchFromICloudActivity.class.getName())) + .respondWith(new ActivityResult(Activity.RESULT_OK, imported)); + + this.openSettingsExpectingAResult(); + + Eventually.check(() -> onView(withId(R.id.settings_fetch_from_account)) + .check(matches(isDisplayed()))); + onView(withId(R.id.settings_fetch_from_account)).perform(click()); + Eventually.check(() -> intended(hasComponent(FetchFromICloudActivity.class.getName()))); + + // **Backed out, not finished.** Calling finish() directly skips handleEndActivity(), + // which is the method that sets the result at all - so the test reported RESULT_CANCELED + // and said the flag had been dropped, for a screen that was never asked to report one. + // Espresso's back goes through onBackPressed and therefore through the real exit. + androidx.test.espresso.Espresso.pressBackUnconditionally(); + + // ActivityScenario.getResult() hands back Instrumentation.ActivityResult, the same type + // the stub above is built from. + final ActivityResult result = this.scenario.getResult(); + + assertEquals("Settings must report OK so the map looks at the data at all", + Activity.RESULT_OK, result.getResultCode()); + assertNotNull("nothing came back, so the map has nothing to act on", + result.getResultData()); + assertTrue("the import was not passed on, so the map never rebuilds and the tags stay" + + " invisible until the app is restarted", + result.getResultData() + .getBooleanExtra(FetchFromICloudActivity.RESULT_IMPORTED, false)); + } + + /** + * And it is not claimed when nothing was imported. + * + *

A result that always says "imported" costs a full rebuild of the map every time + * somebody opens this row and backs out, which is a visible flash and a refetch of every + * tag. The stub in {@link #answerTheFetchScreenAtTheDoor} cancels, which is what backing out + * of that screen does. + */ + @Test + public void backingOutOfItDoesNotClaimAnImport() { + this.openSettingsExpectingAResult(); + + Eventually.check(() -> onView(withId(R.id.settings_fetch_from_account)) + .check(matches(isDisplayed()))); + onView(withId(R.id.settings_fetch_from_account)).perform(click()); + Eventually.check(() -> intended(hasComponent(FetchFromICloudActivity.class.getName()))); + + androidx.test.espresso.Espresso.pressBackUnconditionally(); + + final android.content.Intent data = this.scenario.getResult().getResultData(); + if (data != null) { + assertFalse("a cancelled account screen was reported as an import", + data.getBooleanExtra(FetchFromICloudActivity.RESULT_IMPORTED, false)); + } + } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java index 62c027e4..f078b0d4 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java @@ -180,7 +180,6 @@ protected void onCreate(Bundle savedInstanceState) { .blockingFirst(); ActivityHistoryViewBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_history_view); - WindowPaddingUtil.insetForSystemBars(binding.getRoot()); binding.setHandleClickBack(this::finish); binding.setPageTitle(this.getCurrentBeaconName()); @@ -202,6 +201,14 @@ protected void onCreate(Bundle savedInstanceState) { this::handleOnClickHistoryListItem ); RecyclerView recyclerView = findViewById(R.id.recycler_view_history_items); + + // **The bottom inset goes on the list, not on the root.** Padding the root shortens the + // coordinator inside it, so the sheet stopped above the navigation bar and the white + // activity background showed through beneath a grey sheet - with the last history row + // clipped by the same gap. Paired call, so the bottom cannot be left out: see + // WindowPaddingUtil. + WindowPaddingUtil.insetForSystemBars(binding.getRoot(), recyclerView); + recyclerView.setLayoutManager(new LinearLayoutManager(this)); recyclerView.setAdapter(this.historyItemsAdapter); recyclerView.setItemAnimator(null); 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 25617335..0d740daf 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -395,11 +395,19 @@ public void setZIndex(String markerId, float zIndex) { this.handleSendToLogin(); return; } - // Both want the same thing - a full rebuild. The tags live in memory here - // and that model is what decides both what is drawn and what is fetched, - // so showing or hiding the owner's devices is not a redraw. + // All three want the same thing - a full rebuild. The tags live in memory + // here and that model is what decides both what is drawn and what is + // fetched, so showing or hiding the owner's devices is not a redraw. + // + // The third is an iCloud import started from Settings. Reached from the map + // or the device list, that screen's result comes straight back and is acted + // on; reached through Settings it was dropped, so freshly imported tags were + // missing from the map and sat in the device list reading "No last location + // known" until the app was closed and reopened. if (data != null && (data.getBooleanExtra("mapProviderChanged", false) - || data.getBooleanExtra("shownDevicesChanged", false))) { + || data.getBooleanExtra("shownDevicesChanged", false) + || data.getBooleanExtra( + FetchFromICloudActivity.RESULT_IMPORTED, false))) { this.recreate(); } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java index e1b16d20..e2363efd 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java @@ -29,6 +29,7 @@ import com.google.android.material.slider.Slider; import android.widget.Toast; +import androidx.activity.result.ActivityResult; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.appcompat.app.AlertDialog; @@ -136,6 +137,43 @@ public class SettingsActivity extends AppCompatActivity { */ private boolean shownDevicesChanged = false; + /** + * Whether connecting an iCloud account from here actually brought tags in. + * + *

Reported onward for the same reason as the two above, and it was not. The map + * holds its tags in memory and reads them once, when it is created. Reaching the account + * flow from here goes Map to Settings to iCloud, so returning resumes the map rather than + * recreating it, and tags that were just imported are absent until the app is closed and + * reopened. They sat in the device list reading "No last location known", which looks like + * a fetch that failed rather than a screen that never learned they exist. + * + *

{@code FetchFromICloudActivity} has always said so - it sets + * {@link FetchFromICloudActivity#RESULT_IMPORTED} on the way out, and both the map and the + * device list act on it when they launch that screen themselves. This screen started it with + * {@code startActivity}, which discards the result, so the one path through Settings was the + * one path that dropped the signal. + */ + private boolean importedFromAccount = false; + + /** + * Connecting an iCloud account, started from the row on this screen. + * + *

For a result, not fire-and-forget: see {@link #importedFromAccount}. + */ + private final ActivityResultLauncher fetchFromICloudLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + (ActivityResult result) -> { + final Intent data = result.getData(); + if (data != null + && data.getBooleanExtra(FetchFromICloudActivity.RESULT_IMPORTED, false)) { + this.importedFromAccount = true; + } + // The linked/unlinked subtitle is read when this screen is built, so without + // this it still says "not connected" underneath an account just connected. + this.sayWhetherTheAccountIsLinked(); + } + ); + /** * Where "help build full support" goes. * @@ -243,10 +281,13 @@ protected void onCreate(Bundle savedInstanceState) { } private void handleEndActivity() { - if (this.mapProviderChanged || this.shownDevicesChanged) { + if (this.mapProviderChanged || this.shownDevicesChanged || this.importedFromAccount) { Intent data = new Intent(); data.putExtra("mapProviderChanged", this.mapProviderChanged); data.putExtra("shownDevicesChanged", this.shownDevicesChanged); + // Carried under the name the iCloud screen uses, so the map reads one key whether + // that screen was reached from the map, the device list, or through here. + data.putExtra(FetchFromICloudActivity.RESULT_IMPORTED, this.importedFromAccount); setResult(RESULT_OK, data); } this.finish(); @@ -539,7 +580,9 @@ protected void onDestroy() { } private void onClickFetchFromAccount() { - this.startActivity(new Intent(this, FetchFromICloudActivity.class)); + // Launched for a result rather than with startActivity: what comes back decides whether + // the map has to rebuild. See importedFromAccount. + this.fetchFromICloudLauncher.launch(new Intent(this, FetchFromICloudActivity.class)); } private void onClickEditTheme() { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java index 0f44c48b..aaef5c8d 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FindMyAdvertisement.java @@ -92,6 +92,49 @@ public static FindMyAdvertisement parse(@Nullable final byte[] appleManufacturer return new FindMyAdvertisement(state, batteryLevelOf(status), status); } + /** + * Bits 6-7 of the status byte, read as a battery level. + * + *

Read on sight, unlike the same byte in a location report. + * {@code LocationReportFields} decodes its copy only when the whole byte conforms to Apple's + * Table 5-5 - bit 5 set, reserved bits clear - because an AirTag's does not, and decoding a + * non-conforming byte against that table produces a confident wrong answer. That gate is not + * applied here, and the difference is deliberate rather than an oversight: + * + *

+ * + *

What it still does not establish is what the four words are worth. Nothing here + * calibrates them: "medium" on a cell replaced minutes earlier is the tag's own opinion, and + * whether that reflects a weak cell, a measurement the tag has not retaken, or a scale that + * simply does not start at "full" is unknown. Only {@code 0b11}, critically low, has not + * been seen at all. + * + *

Still wanted, and a much smaller job than before: these bits read off tags whose actual + * charge is known, to attach numbers to the words. The raw byte is kept on the advertisement + * so any such report can quote it rather than only this reading. + */ private static BatteryLevel batteryLevelOf(final int statusByte) { switch ((statusByte >> 6) & 0b11) { case 0b01: return BatteryLevel.MEDIUM; diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java index e0ca3536..9f89aebf 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java @@ -56,6 +56,60 @@ public static void insetForSystemBars(final View view) { }); } + /** + * For a screen whose bottom edge belongs to something that has to reach it. + * + *

A bottom sheet is the case this exists for. Padding the root's bottom shortens + * everything inside it, so the sheet stops above the navigation bar and the activity's own + * background shows through underneath - a strip in the window's colour, below a sheet in the + * sheet's colour, which reads as a rendering fault rather than as padding. The history + * screen shipped that way: a grey sheet, its last row clipped, and a white band under it. + * + *

Both views are arguments because one of them always gets forgotten otherwise. + * That is not hypothetical - see {@link #insetForSystemBars(View)}, which exists in that + * shape because a top-only helper was applied to seven screens and the matching bottom call + * to one. A caller here cannot pad the top and quietly skip the bottom: there is nowhere to + * put the omission. + * + *

{@code content} is padded rather than the sheet itself, so the sheet's background still + * runs to the bottom of the screen. Give it {@code clipToPadding="false"} when it scrolls, + * or the padding becomes a dead band the list cannot use instead of somewhere the last row + * can scroll into. + * + * @param root Gets the status bar, and the left and right insets. Not the bottom. + * @param content Gets the bottom inset, added to whatever padding it already asks for. + */ + public static void insetForSystemBars(final View root, final View content) { + final int rootLeft = root.getPaddingLeft(); + final int rootTop = root.getPaddingTop(); + final int rootRight = root.getPaddingRight(); + final int rootBottom = root.getPaddingBottom(); + + final int contentLeft = content.getPaddingLeft(); + final int contentTop = content.getPaddingTop(); + final int contentRight = content.getPaddingRight(); + final int contentBottom = content.getPaddingBottom(); + + ViewCompat.setOnApplyWindowInsetsListener(root, (v, insets) -> { + final Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + + v.setPadding( + rootLeft + bars.left, + rootTop + bars.top, + rootRight + bars.right, + rootBottom + ); + content.setPadding( + contentLeft, + contentTop, + contentRight, + contentBottom + bars.bottom + ); + + return insets; + }); + } + /** * Keeps a bottom-anchored view clear of the navigation bar. * diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BatteryLevelDescription.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BatteryLevelDescription.java index f66488a1..5eecb6fd 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BatteryLevelDescription.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/BatteryLevelDescription.java @@ -49,9 +49,20 @@ *

It only means anything for a tag read from an Apple account. The field is updated by * Apple's own devices as they see the accessory, so a tag imported from a zip carries whatever * value was true when the export was made and never changes it again - possibly years ago. This - * is why nothing outside the debug panel uses any of it. Anyone who wants to put a battery icon - * on the device list should read this note first, and should probably only do it for account - * tags. + * is why nothing outside the device information page uses any of it. Anyone who wants to put a + * battery icon on the device list should read this note first, and should probably only do it + * for account tags. + * + *

There is a second battery reading in this app, and it is not this one. The badge on + * the map's tag cards - "Nearby · Battery …" - comes from the tag's own Bluetooth advertisement + * via {@code FindMyAdvertisement.BatteryLevel}, heard directly by this phone, and it has nothing + * to do with this field or this scale. They disagree routinely and both can be right: this one + * is what Apple last recorded, that one is what the tag said just now. Somebody reading the + * screen sees one word and no indication of which. + * + *

Worth knowing before answering a question about either. The values are not comparable - + * this field reserves 0 for "not reported" and so runs 1-4, while the advertisement's two bits + * run 0-3 - and neither has been validated against a tag at a known charge level. */ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class BatteryLevelDescription { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/LocationReportFields.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/LocationReportFields.java index 669c8381..d849f3a8 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/LocationReportFields.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/LocationReportFields.java @@ -97,8 +97,19 @@ * it observes is {@code 0x90}: bit 5 clear where the specification requires it set, and reserved * bit 4 set. Adam Catley's teardown records a real AirTag advertising {@code 0x10}, which breaks * the same two rules. The specification governs third-party MFi accessories; AirTag is Apple's own - * hardware and predates it. Decoding {@code 0x90} against Table 5-5 anyway yields "battery Low" - * for a tag whose own record reads Full - a confident, wrong answer, which is worse than none. + * hardware and predates it. + * + *

This paragraph used to end by saying that decoding {@code 0x90} yields "battery Low" for + * a tag whose record reads Full, and offering that as the proof that the byte lies. It was the + * wrong way round. @parawanderer's two tags read {@code 0x90} for months on cells that had + * not been changed in as long, over both Bluetooth and the Find My network; replacing the + * batteries moved them to {@code 0x10} and {@code 0x50}. "Low" was correct. What was stale was + * the accessory record saying Full - written by Apple's own devices, of which this user has + * none, so it had never been updated at all. + * + *

Worth keeping the correction visible rather than quietly deleting the claim: it was written + * confidently, it was load-bearing for the decision below, and the thing that disproved it was + * somebody changing two batteries and looking. * *

And the byte is not trustworthy even when it is well-formed. Caesar Creek Software's * write-up of this network puts it plainly: "it's supposed to indicate the battery level and @@ -114,6 +125,40 @@ * with several kinds of tag at several battery levels and writing down what each one emits. The * gate below exists so that the app stays useful and silent in the meantime, instead of guessing. * + *

The live Bluetooth path does read two of these bits, and that is not a contradiction of + * the paragraph above. {@code FindMyAdvertisement.batteryLevelOf} takes bits 6-7 off an + * advertisement this phone heard itself, and never consults the rest of the table - so the + * reserved bits an AirTag sets wrongly are the ones it does not look at. It also has no + * alternative: the battery on the account record is written by Apple's devices, so for a user + * without one it is years old or never written. What is refused here is decoding a + * non-conforming byte as though the whole table applied, which is a different claim. + * + *

Two AirTags on one account, watched across a battery change on 2026-09-16, give three of + * the four states - and the same remainder every time: + * + *

+ *   0x90 = 0b10010000   both tags, on cells months old        bits 6-7 = 0b10  Low
+ *   0x50 = 0b01010000   one tag, cell just replaced           bits 6-7 = 0b01  Medium
+ *   0x10 = 0b00010000   the other, cell just replaced         bits 6-7 = 0b00  Full
+ * 
+ * + *

Bit 4 is set and bit 5 clear in all three - the two that break this table - while only bits + * 6-7 move, and they move in the order Table 5-5 gives, downward as the cell ages and upward + * when it is replaced. A remainder constant across three battery states is a signature rather + * than a field. Catley's teardown independently records {@code 0x10}. + * + *

The gate below stays anyway, and not because of the claim just corrected. Asked + * directly, on 2026-09-16, @parawanderer's answer was that this is debug metadata and does not + * need decoding. That is the reason to keep in mind, because it does not depend on any of the + * protocol argument above: this row exists so somebody can quote what arrived, the raw byte is + * certainly right, and a label beside it would be the app's opinion competing with the tag's + * own on a screen meant for evidence. The battery reading people act on is on the map, from the + * live advertisement, where it is one word and not a bit pattern. + * + *

Written down because the paragraph above removed a justification without removing the + * decision. Anybody reading "the 0x90 objection was wrong" and concluding that this should now + * decode is following an argument nobody made. + * *

So {@link #status(long)} decodes only a byte that actually conforms to Table 5-5 - bit 5 set * and every reserved bit clear - and otherwise shows the number alone. A conforming byte is * annotated as what the beacon claimed, never as a measurement. Every value carries decimal, diff --git a/app/src/main/python/icloud_bridge.py b/app/src/main/python/icloud_bridge.py index 371fe07b..c2085031 100644 --- a/app/src/main/python/icloud_bridge.py +++ b/app/src/main/python/icloud_bridge.py @@ -44,7 +44,11 @@ import identity as app_identity from exporter import icloud from exporter.identity import written_by_opentagviewer -from findmy.errors import AppleServiceUnavailableError, InvalidCredentialsError +from findmy.errors import ( + AppleServiceUnavailableError, + InvalidCredentialsError, + UnauthorizedError, +) from findmy.keychain.enrolment import DeviceDescription from findmy.keychain.join import JoinedPeer from findmy.keychain.recovery import RecoveryError @@ -228,15 +232,34 @@ def _needsAFreshSignIn(error: BaseException | None) -> bool: the app's most ordinary auth failure arriving as `UNKNOWN` and being offered a retry that cannot work. The string is checked narrowly, and `account.py` raises it in exactly one place. - **`UnauthorizedError` is deliberately not here.** It means two different things depending on - where it came from - `request_pet` raises it when a second factor is being demanded, which the - app answers by asking for a code rather than signing out, while CloudKit raises the same type - for a genuine 401. Treating a 2FA prompt as a dead session would cost somebody a sign-in they - did not need, so it stays unclassified until the two can be told apart. + **`UnauthorizedError` counts, but only the CloudKit half of it.** The type means two opposite + things depending on where it came from: `request_pet` raises it when a second factor is being + demanded, which the app answers with a code rather than a sign-out, while CloudKit raises the + same type for a genuine 401 on a token that has expired. Treating a 2FA prompt as a dead + session would cost somebody a sign-in they did not need, which is why this was left + unclassified for a long time. + + **Leaving it unclassified turned out to cost more.** A dead CloudKit token reached the screen + as `UNKNOWN`, which is the retry screen - and retrying re-runs the identical call, so the + Settings button that reconnects the account led to the same failure every time with no way + back. Not a misleading message: a dead end, in the one flow whose whole job is recovering + from this. Reported in issue #225. + + **The two are distinguishable, and narrowly.** Both CloudKit 401 sites in + `findmy/cloudkit/client.py` open with "CloudKit rejected the" and both go on to say to log in + again; the `request_pet` ones are about re-authentication ending in the wrong state and share + no wording with them. Matching a message is unpleasant for the same reasons the `ValueError` + above is, and is done for the same reason - the alternative is a screen nobody can leave. """ if _isCausedBy(error, InvalidCredentialsError): return True + # Narrow on purpose: the prefix both CloudKit sites share, and nothing broader. A bare + # `UnauthorizedError` check here would swallow the 2FA demand as well and sign people out + # mid-flow. + if _isCausedBy(error, UnauthorizedError) and _saysAnyOf(error, ("CloudKit rejected the",)): + return True + # `not self._username or not self._password` in `_gsa_authenticate`, which is reached by # anything that tries to re-authenticate a restored session. return _isCausedBy(error, ValueError) and _saysAnyOf( diff --git a/app/src/main/res/layout/view_history_bottom_sheet.xml b/app/src/main/res/layout/view_history_bottom_sheet.xml index b043b9ac..9a2d4d8d 100644 --- a/app/src/main/res/layout/view_history_bottom_sheet.xml +++ b/app/src/main/res/layout/view_history_bottom_sheet.xml @@ -158,10 +158,16 @@ + + android:layout_height="match_parent" + android:clipToPadding="false" /> diff --git a/app/src/test/python/test_icloud_bridge.py b/app/src/test/python/test_icloud_bridge.py index 4819ace9..97dd87d6 100644 --- a/app/src/test/python/test_icloud_bridge.py +++ b/app/src/test/python/test_icloud_bridge.py @@ -1063,6 +1063,51 @@ def test_a_session_restored_without_a_password_is_the_same_situation(self): assert answer["reason"] == icloud_bridge.REASON_CREDENTIALS_REJECTED + def test_a_dead_cloudkit_token_is_the_same_situation(self): + """ + **Issue #225, and it was a dead end rather than a wrong sentence.** + + A CloudKit 401 arrived as UNKNOWN, which is the retry screen, and retrying re-runs the + identical call. The Settings button that reconnects the account therefore led to the + same failure every time, with nothing on screen offering a way back - in the one flow + whose entire job is recovering from this. + """ + try: + raise UnauthorizedError( + "CloudKit rejected the iCloud token. It has most likely expired;" + " logging in again will obtain a fresh one.") + except UnauthorizedError: + answer = json.loads(icloud_bridge._unexpected("checking the account")) + + assert answer["reason"] == icloud_bridge.REASON_CREDENTIALS_REJECTED + + def test_the_other_cloudkit_refusal_is_too(self): + # The second of the two 401 sites, worded differently and meaning the same thing. The + # match is on the prefix they share rather than on either sentence. + try: + raise UnauthorizedError("CloudKit rejected the token for this operation; log in again") + except UnauthorizedError: + answer = json.loads(icloud_bridge._unexpected("fetching the beacons")) + + assert answer["reason"] == icloud_bridge.REASON_CREDENTIALS_REJECTED + + def test_a_second_factor_being_demanded_is_emphatically_not(self): + """ + **The reason this type went unclassified for so long, and the thing not to break.** + + `request_pet` raises the same class when Apple wants a code. Answering that with a + forced sign-out costs somebody a working session and a re-login they never needed - so + the match is the CloudKit wording, not the type. + """ + try: + raise UnauthorizedError( + "Re-authentication ended in state LoginState.REQUIRE_2FA rather than" + " AUTHENTICATED, so no PET was issued.") + except UnauthorizedError: + answer = json.loads(icloud_bridge._unexpected("opening a keychain session")) + + assert answer["reason"] != icloud_bridge.REASON_CREDENTIALS_REJECTED + def test_it_is_found_underneath_a_wrapper(self): # These come back through run_until_complete and FindMy.py's own layers, so the # interesting error is rarely the outermost one. diff --git a/python/exporter/wizard.py b/python/exporter/wizard.py index 5cc0ca68..b7512f07 100644 --- a/python/exporter/wizard.py +++ b/python/exporter/wizard.py @@ -248,24 +248,29 @@ 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. + # **There is no "Lock with a code" checkbox here, and removing it was the point.** # - # Off until app 1.1.0 was released, because nothing older can decrypt a locked bundle at - # all and the person who met that failure was the recipient. That app is out, so this is - # back on - see AGENTS.md rule 9 for why the two releases are ordered. + # 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. # - # The opt-out stays for the recipient still running something older, which is most of them - # on any given day after a release. - 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)) + # It was a ticked checkbox, which is one idle click away from an unlocked bundle, and + # that click has been made: a user sent @parawanderer their tags in an unlocked zip. + # Not as an attack and not through ignorance of the consequences - it is simply what an + # unticked box in a corner produces, eventually, from somebody hurrying. The person best + # served by the lock is exactly the person who would untick it to make an error message + # go away. + # + # The escape hatch lives on the CLI, as `--no-password`, and that is deliberate rather + # than an oversight here. Somebody who found a flag, read what it does and typed it has + # demonstrably chosen an unlocked bundle. Somebody clicking through a window has not, and + # giving both the same affordance treats those as the same decision. + # + # The ordering argument that once justified an opt-out is spent: see AGENTS.md rule 9. + # It protected a recipient running an app too old to open a locked bundle, and no such + # recipient exists - every release up to 1.0.5 is refused at sign-in by Apple's edge, so + # an unlocked bundle buys its owner nothing. def _save_logs(self) -> None: """ @@ -783,20 +788,26 @@ def _export(self) -> None: def _write_it(self, bundle: ExportBundle, path: str, count: int) -> bool: """ - Write the zip, lock it unless told otherwise, and say what happened. + Write the zip, always locked, and say what happened. + + **Always, with no way to ask otherwise from this window.** It was a ticked checkbox until + app 1.1.0 shipped and then briefly afterwards, and what the checkbox actually produced + was unlocked bundles: one idle click, on the control that decides whether irrevocable key + material travels in the clear. Somebody has already sent their tags to a stranger that + way. See the comment where the checkbox used to be built. - **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. + Release ordering was what once justified an opt-out - before zip4j the app could not + decrypt anything, so a locked bundle was a file no recipient could open. That is spent: + 1.1.0 reads them, and every release before it is refused at sign-in by Apple regardless. - 1.1.0 reads them, so the default flips. The checkbox stays for the versions before it. + `exporter.cli` keeps `--no-password` for the case that genuinely needs it. The difference + is not the capability but who is asking: a flag somebody looked up is a decision, a box + in the corner of a window is not. :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 + passcode = generate_passcode() try: write_zip(bundle, path, password=passcode) @@ -812,15 +823,10 @@ def _write_it(self, bundle: ExportBundle, path: str, count: int) -> bool: 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) + # No unlocked branch: `generate_passcode` always returns one, so there is no path from + # this window to a bundle without a code, and nothing here has to explain what an + # unlocked bundle means. The CLI's `--no-password` still has that explanation. + _show_the_code(self, path, count, passcode) return True diff --git a/python/test/test_wizard_bundle_locking.py b/python/test/test_wizard_bundle_locking.py index eb444f6c..c5abee40 100644 --- a/python/test/test_wizard_bundle_locking.py +++ b/python/test/test_wizard_bundle_locking.py @@ -1,13 +1,15 @@ """ -The wizard can lock the bundles it writes, and shows the code once. +The wizard locks every bundle it writes, and shows the code once. -**The default is on, now that app 1.1.0 is released.** A locked bundle can only be opened by that -version or newer; anything older fails with a message about the zip rather than about a code, and -the person who meets that failure is the recipient - who chose neither the exporter nor its -version. That is why this waited for the app rather than shipping alongside it. +**There is no way to ask it not to, and that is the behaviour under test.** It was a ticked +checkbox, which is one idle click from an unlocked bundle holding key material that cannot be +revoked - and that click has been made in the field, by somebody who sent their tags to a +stranger in an unlocked zip. The escape hatch lives on the CLI's `--no-password`, where finding +a flag and typing it is evidence of a decision. -**These tests assert the default in both directions on purpose.** The value moved twice with no -test noticing either time, which is how it came to be wrong in the first place. +**Asserted as the absence of a control, not only as a default.** A default is a value somebody +can flip back; this suite fails if the window grows a way to turn locking off at all. The value +moved twice before without a test noticing either time. 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. @@ -47,10 +49,8 @@ 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) - +def write(window, bundle, path): + """Run the write step and report what happened. There is no state to set: it always locks.""" 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, \ @@ -60,32 +60,49 @@ def write(window, bundle, path, *, locked: bool): return write_zip, shown, info, error, closed -class TestTheDefault: +class TestThereIsNoWayToTurnItOff: """ - Off until an app that can open one is released - see the module docstring. - - A bundle holds key material that cannot be revoked and travels through other people's - infrastructure, so on is where this belongs eventually. It is not there yet. + The control is gone, not merely defaulted on - see the module docstring for what that cost. """ - def test_the_checkbox_starts_ticked(self, window): - assert window.lock_bundle.get() is True, ( - "the default is on now that app 1.1.0 is released and can open a locked bundle" + def test_the_window_has_no_locking_switch(self, window): + assert not hasattr(window, "lock_bundle"), ( + "the window grew a way to turn locking off again; the CLI's --no-password is where" + " that belongs, because typing a flag is a decision and clicking a box is not" + ) + + def test_no_checkbox_offers_it_either(self, window): + # The attribute could be renamed and the checkbox kept, which would pass the test above + # while putting the click back on screen. So the widgets are searched as well. + labels = [] + + def walk(widget): + for child in widget.winfo_children(): + try: + labels.append(str(child.cget("text")).lower()) + except tk.TclError: + pass + walk(child) + + walk(window) + + assert not any("lock" in label for label in labels), ( + f"something on the window still offers locking as a choice: {labels}" ) 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) + window, bundle, tmp_path / "x.zip") passcode = write_zip.call_args.kwargs["password"] - assert passcode, "the bundle was written unlocked while the box was ticked" + assert passcode, "the bundle was written unlocked" 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) + write_zip, *_ = write(window, bundle, tmp_path / "x.zip") assert set(write_zip.call_args.kwargs["password"]) <= set( "0123456789ABCDEFGHJKMNPQRSTVWXYZ") @@ -99,18 +116,21 @@ class TestShowingTheCode: 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) + window, bundle, tmp_path / "x.zip") 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) + def test_the_code_is_always_shown_because_there_is_always_one(self, window, bundle, tmp_path): + # There used to be an "Exported, and this bundle is not locked" path here. It is gone + # with the checkbox: every write from this window has a code, so every write shows one. + # If a no-code path ever comes back, this fails rather than silently writing a bundle + # whose only warning nobody wrote. + write_zip, shown, info, _error, _closed = write(window, bundle, tmp_path / "x.zip") - assert write_zip.call_args.kwargs["password"] is None - shown.assert_not_called() - assert "not locked" in info.call_args.args[1] + assert write_zip.call_args.kwargs["password"] is not None + shown.assert_called_once() + info.assert_not_called() class TestWhenItCannotBeWritten: @@ -119,7 +139,6 @@ class TestWhenItCannotBeWritten: """ 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")), \ @@ -130,7 +149,6 @@ def test_a_missing_pyzipper_is_said_plainly(self, window, bundle, tmp_path): 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: @@ -140,7 +158,7 @@ def test_a_disk_that_will_not_take_it_keeps_the_window(self, window, bundle, tmp 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) + *_rest, closed = write(window, bundle, tmp_path / "x.zip") assert closed is True