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 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:
+ *
+ * Three of the four states, with bit 4 set and bit 5 clear throughout - the two an
+ * AirTag sets against Table 5-5. A remainder that does not change across three battery
+ * states is a signature, not a field, so the objection to decoding this byte does not
+ * reach bits 6-7. They fall in the order the table gives, downward as a cell ages and
+ * upward when it is replaced. Catley's teardown independently records {@code 0x10}. 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:
+ *
+ * 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 @@
+
+ *
+ *
+ *
+ * 0x90 = 0b10010000 both tags, 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
+ *
+ *
+ *
+ * 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
+ *
+ *
+ *