From 1280fa52c1ada9c43b6ec811be68513dcb3115bb Mon Sep 17 00:00:00 2001 From: Shane B Date: Wed, 19 Aug 2026 21:31:27 +0200 Subject: [PATCH 01/44] Give Java a way to drive the iCloud flow, and one lock to do it under MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service layer the §7 screens sit on: open a client, list what the keychain can be recovered from, unlock with a device passcode, fetch, take records. An interface, because every failure worth showing a user - an account with nothing to recover from, a service having a bad day, a rejected passcode - is unreachable from a test that needs real credentials, so those are exactly the paths that would otherwise never be covered. **The lock moved, and that is the part with a bug in it if it goes wrong.** `PythonAppleService` kept a private `ReentrantLock` because FindMy.py drives one asyncio event loop and two threads in `run_until_complete` on it fails permanently, then keeps failing. The iCloud flow drives the *same* loop - deliberately, since a second account would be a second device to Apple - so a keychain unlock collides with the 60-second location refresh exactly as two fetches would, and a second private lock in a second class would have been no lock at all. So it is `PythonLock` now, shared, and it takes the work rather than handing out lock/unlock. That is not tidiness: the iCloud flow is several calls with a person answering a dialog between them, and holding the lock across one of those waits would freeze every location update in the app until they got round to typing. Each step takes it, finishes, gives it back; the waiting happens in Java with nothing held. Failures cross as `ICloudException` carrying an `ICloudFailure` enum rather than a message, so the wording stays in strings.xml. `fromWire` falls back to UNKNOWN rather than throwing - a reason added in Python later must not crash the screen that reports failures - and that tolerance is exactly why `ICloudFailureWireTest` walks the Python module rather than a hand-written list: the drift it catches is silent by design. Deleting one case turns two of its three tests red, which was checked. 248 instrumented tests pass. Co-Authored-By: Claude Opus 5 --- .../python/icloud/ICloudFailureWireTest.java | 103 +++++++++ .../python/PythonAppleService.java | 44 +--- .../opentagviewer/python/PythonLock.java | 61 ++++++ .../python/icloud/AccessoryRecords.java | 33 +++ .../python/icloud/ICloudAccessory.java | 44 ++++ .../python/icloud/ICloudException.java | 28 +++ .../python/icloud/ICloudFailure.java | 84 ++++++++ .../python/icloud/ICloudFetch.java | 41 ++++ .../python/icloud/ICloudService.java | 79 +++++++ .../python/icloud/PythonICloudService.java | 199 ++++++++++++++++++ .../python/icloud/RecoverableDevice.java | 25 +++ 11 files changed, 706 insertions(+), 35 deletions(-) create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailureWireTest.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/PythonLock.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/icloud/AccessoryRecords.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudAccessory.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudException.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailure.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFetch.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudService.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/icloud/PythonICloudService.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/python/icloud/RecoverableDevice.java diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailureWireTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailureWireTest.java new file mode 100644 index 00000000..c3763947 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailureWireTest.java @@ -0,0 +1,103 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.chaquo.python.PyObject; +import com.chaquo.python.Python; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.List; + +/** + * That the two sides of the bridge mean the same things by the same strings. + * + *

The failure this exists for does not throw. A reason added in Python and not added + * here does not break anything visibly - {@link ICloudFailure#fromWire} deliberately falls back + * to {@link ICloudFailure#UNKNOWN} rather than exploding, because a new failure mode must not + * become a crash on the screen that reports failures. The cost is paid quietly instead: the user + * gets a generic "something went wrong" where a specific, actionable screen was written for them. + * + *

The one that would hurt most is {@code service_unsure} degrading to {@code UNKNOWN}, because + * the specific screen for it is the one that says "try again later" rather than "this account + * owns no tags" - the difference between a user coming back tomorrow and a user giving up. + * + *

So this walks the module rather than a list written by hand. A list would have to be + * updated by the same person who forgot to update the enum. + */ +@RunWith(AndroidJUnit4.class) +public class ICloudFailureWireTest { + + private static final String MODULE = "icloud_bridge"; + + private static List reasonConstants() { + final Python py = Python.getInstance(); + final PyObject module = py.getModule(MODULE); + + final List found = new ArrayList<>(); + for (final PyObject name : py.getBuiltins().callAttr("dir", module).asList()) { + if (name.toString().startsWith("REASON_")) { + found.add(name.toString()); + } + } + + return found; + } + + @Test + public void everyReasonPythonCanReportHasAScreenBehindIt() { + final Python py = Python.getInstance(); + final PyObject module = py.getModule(MODULE); + final List constants = reasonConstants(); + + // Without this the whole test passes by finding nothing, which is the failure mode of + // every test written against reflection. + assertTrue("found no REASON_ constants at all - this test is checking nothing", + constants.size() >= 5); + + for (final String constant : constants) { + final String wire = module.get(constant).toString(); + final ICloudFailure mapped = ICloudFailure.fromWire(wire); + + if ("REASON_UNKNOWN".equals(constant)) { + assertEquals("REASON_UNKNOWN is the one that is meant to land there", + ICloudFailure.UNKNOWN, mapped); + continue; + } + + assertNotEquals( + constant + " (\"" + wire + "\") has no case in ICloudFailure.fromWire, so the" + + " screen written for it will never be shown", + ICloudFailure.UNKNOWN, mapped); + } + } + + @Test + public void thetwoEmptyAnswersStayDistinct() { + // Asserted on the wire values themselves rather than the enum, because collapsing them + // is a one-character edit on either side and only this compares the two. + final PyObject module = Python.getInstance().getModule(MODULE); + + final ICloudFailure nothing = + ICloudFailure.fromWire(module.get("REASON_NOTHING_TO_RECOVER_FROM").toString()); + final ICloudFailure unsure = + ICloudFailure.fromWire(module.get("REASON_SERVICE_UNSURE").toString()); + + assertEquals(ICloudFailure.NOTHING_TO_RECOVER_FROM, nothing); + assertEquals(ICloudFailure.SERVICE_UNSURE, unsure); + assertNotEquals("an account with no Apple device and a service outage are not the same" + + " thing, and only one of them is worth coming back for", nothing, unsure); + } + + @Test + public void anunrecognisedReasonDegradesRatherThanThrowing() { + assertEquals(ICloudFailure.UNKNOWN, ICloudFailure.fromWire("something_added_later")); + assertEquals(ICloudFailure.UNKNOWN, ICloudFailure.fromWire(null)); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java index 5b857913..37fc5741 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java @@ -12,7 +12,6 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.concurrent.locks.ReentrantLock; import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; import io.reactivex.rxjava3.core.Observable; @@ -36,34 +35,15 @@ public static PythonAppleService getInstance() { return INSTANCE; } - /** - * Serialises all calls into Python. - *
- * FindMy.py's synchronous AppleAccount wraps an async one and drives it with a single - * asyncio event loop. RxJava schedules our fetches on a thread pool, and the periodic - * refresh in MapsActivity fires every 60 seconds regardless of whether the previous - * fetch has finished. Two fetches overlapping means two threads calling - * run_until_complete on the same loop, which fails with - * "RuntimeError: This event loop is already running" and then keeps failing. - *
- * A fetch that takes longer than the refresh interval is entirely normal for an - * accessory with no alignment yet, so this is not a rare race. - *
- * Serialising alone is not enough: the periodic refresh would still queue up behind a - * slow fetch, one entry per minute, and then fire the whole stale backlog at once when - * it finally drained. Callers on the periodic path should check {@link #isBusy()} and - * skip their turn instead - a refresh that is minutes late has no value. - */ - private static final ReentrantLock PYTHON_LOCK = new ReentrantLock(); - /** * Whether a call into Python is currently in progress. - *
- * Advisory only. A caller that acts on this can still be beaten to the lock, which is - * harmless: it just waits, exactly as it did before. + * + *

Delegates to {@link PythonLock}, which is where the lock moved when the iCloud flow + * started driving the same event loop. Kept here because the periodic refresh asks this + * service, and where the lock lives is not its business. */ public static boolean isBusy() { - return PYTHON_LOCK.isLocked(); + return PythonLock.isBusy(); } private PythonAppleService(PythonAppleAccount account) { @@ -76,8 +56,7 @@ public Observable getLastReports(final List reque return emptyResult(); } - PYTHON_LOCK.lock(); - try { + return PythonLock.holding(() -> { var py = Python.getInstance(); var module = py.getModule(MODULE_MAIN); @@ -94,9 +73,7 @@ public Observable getLastReports(final List reque } return mapResults(returned); - } finally { - PYTHON_LOCK.unlock(); - } + }); }).subscribeOn(Schedulers.io()); } @@ -106,8 +83,7 @@ public Observable getReportsBetween(final List re return emptyResult(); } - PYTHON_LOCK.lock(); - try { + return PythonLock.holding(() -> { var py = Python.getInstance(); var module = py.getModule(MODULE_MAIN); @@ -125,9 +101,7 @@ public Observable getReportsBetween(final List re } return mapResults(returned); - } finally { - PYTHON_LOCK.unlock(); - } + }); }).subscribeOn(Schedulers.io()); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonLock.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonLock.java new file mode 100644 index 00000000..cda507bf --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonLock.java @@ -0,0 +1,61 @@ +package dev.wander.android.opentagviewer.python; + +import java.util.concurrent.Callable; +import java.util.concurrent.locks.ReentrantLock; + +/** + * The one lock that every call into Python must be made under. + * + *

FindMy.py's synchronous {@code AppleAccount} wraps an async one and drives it with a single + * asyncio event loop. RxJava schedules our work on a thread pool, and the periodic refresh in + * {@code MapsActivity} fires every 60 seconds regardless of whether the previous fetch has + * finished. Two threads calling {@code run_until_complete} on the same loop fails with + * "RuntimeError: This event loop is already running" and then keeps failing. + * + *

A fetch that takes longer than the refresh interval is entirely normal for an accessory with + * no alignment yet, so this is not a rare race. + * + *

It lives here, rather than inside one service, because it is not one service's lock. + * The iCloud flow drives the same event loop - deliberately, since a second account would + * be a second device to Apple - so a location fetch and a keychain unlock collide exactly as two + * location fetches would. A second private lock in a second class would be no lock at all. + * + *

Serialising alone is not enough: the periodic refresh would still queue up behind a slow + * fetch, one entry per minute, and then fire the whole stale backlog at once when it finally + * drained. Callers on the periodic path should check {@link #isBusy()} and skip their turn + * instead - a refresh that is minutes late has no value. + */ +public final class PythonLock { + private static final ReentrantLock LOCK = new ReentrantLock(); + + private PythonLock() { + } + + /** + * Run one call into Python, with nothing else in there at the same time. + * + *

Scope it to the call and nothing more. The reason this is a method taking the + * work, rather than a lock to take and release, is the iCloud flow: it is several calls with + * a person answering a dialog between them, and holding this across one of those waits would + * stop every location refresh in the app until they got round to typing. Each step takes it, + * finishes, and gives it back; the waiting happens in Java with nothing held. + */ + public static T holding(final Callable work) throws Exception { + LOCK.lock(); + try { + return work.call(); + } finally { + LOCK.unlock(); + } + } + + /** + * Whether a call into Python is currently in progress. + * + *

Advisory only. A caller that acts on this can still be beaten to the lock, which is + * harmless: it just waits, exactly as it did before. + */ + public static boolean isBusy() { + return LOCK.isLocked(); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/AccessoryRecords.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/AccessoryRecords.java new file mode 100644 index 00000000..a57f46ad --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/AccessoryRecords.java @@ -0,0 +1,33 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * One accessory as the three plists a bundle carries for it. + * + *

The same documents the zip importer already reads, which is the point of the whole + * exercise: an accessory read from an account and one read from a bundle become the same rows in + * the same tables, with no second format and no zip in the middle. + */ +@Getter +@AllArgsConstructor +public class AccessoryRecords { + private final String beaconId; + + /** The {@code OwnedBeacons} record. Carries the key material. */ + private final String ownedBeaconPlist; + + /** + * Its {@code BeaconNamingRecord}, or null where CloudKit holds none. + * + *

Genuinely optional here, unlike in a bundle. A zip's importer inner-joins the two, so an + * accessory exported without one goes silently missing - but nothing is being written to a + * zip, the app left-joins, and a tag nothing ever named is a thing it already knows how to + * show. Inventing a name would put a tag in the user's list as though they had named it. + */ + private final String namingRecordPlist; + + /** Its {@code KeyAlignmentRecord}, if it has one. Absence is normal. */ + private final String keyAlignmentPlist; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudAccessory.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudAccessory.java new file mode 100644 index 00000000..2c4a63c5 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudAccessory.java @@ -0,0 +1,44 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * One accessory found in the account, described well enough to choose from. + * + *

No key material. A picker needs names; the records themselves come separately, for + * the ones the user actually picks - see {@link ICloudService#records}. + */ +@Getter +@AllArgsConstructor +public class ICloudAccessory { + private final String beaconId; + + /** What the owner called it, or null if nothing ever named it. */ + private final String name; + + private final String emoji; + + /** How to show it in a list, which for a nameless one is still not much. */ + private final String label; + + /** + * What kind of thing it is, its serial, and when it was paired. + * + *

This is what an accessory with no name has instead of one. "unnamed" three times over + * is not a list anybody can choose from, and a pairing date is often the thing a person + * recognises, because they remember buying it. + */ + private final String details; + + /** + * Whether a key alignment record came with it. + * + *

Without one, the first locate searches the tag's whole key history - tens of thousands + * of keys for an older tag, which is slow enough to look like abuse of the account. + */ + private final boolean hasAlignment; + + /** False when nothing ever named it. Not a problem to solve before importing. */ + private final boolean hasName; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudException.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudException.java new file mode 100644 index 00000000..0e6abdde --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudException.java @@ -0,0 +1,28 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import lombok.Getter; + +/** + * A step of the iCloud flow reporting why it did not work. + * + *

An exception rather than a result type because these travel back through RxJava, where a + * failure belongs in {@code onError} - a screen that has to unwrap a success value to find out + * it failed is a screen that will forget to. + * + *

Both halves are carried on purpose. {@link #getFailure()} is what the screen branches on, + * so its wording stays in {@code strings.xml}. {@link #getDetail()} is what Python said, for the + * log and for {@link ICloudFailure#UNKNOWN}, where there is nothing better to show - and it is + * never empty, which is the whole reason the bridge returns failures as values instead of + * letting exceptions cross the boundary with {@code str(e)} of nothing. + */ +@Getter +public class ICloudException extends RuntimeException { + private final ICloudFailure failure; + private final String detail; + + public ICloudException(final ICloudFailure failure, final String detail) { + super(failure + ": " + detail); + this.failure = failure; + this.detail = detail; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailure.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailure.java new file mode 100644 index 00000000..8cbb551e --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailure.java @@ -0,0 +1,84 @@ +package dev.wander.android.opentagviewer.python.icloud; + +/** + * Why a step of the iCloud flow did not work, in the terms the screen has to distinguish. + * + *

A closed set, not the exception text. The bridge reports a {@code reason} string + * precisely so the wording lives in {@code strings.xml} and can be translated - branching on a + * message would put English in the code and break the moment the message was reworded. The + * detail is carried alongside for the log and for the cases where there is genuinely nothing + * better to show. + */ +public enum ICloudFailure { + /** + * The account is not in a state that can talk to iCloud, or the client is not open. + * + *

Recovered from the same way an expired session is: send the user to sign in again. + */ + NOT_SIGNED_IN, + + /** + * This account has nothing that can unlock its keychain, and never will. + * + *

The expected answer for a real class of user rather than an error: an Apple ID that has + * never had an iPhone, iPad or Mac on it has never escrowed a keychain. It is also, in + * practice, the same person as "this account owns no tags" - only an iPhone or iPad can + * register one - so the flow stops here rather than at the fetch, before asking for a + * passcode they do not have. + * + *

The answer for them is the import path. Telling them to try again later is a lie that + * costs them an evening. + */ + NOTHING_TO_RECOVER_FROM, + + /** + * Nothing was reported usable at all, which reads as a service having a bad day. + * + *

Must not be shown as {@link #NOTHING_TO_RECOVER_FROM}. The advice is the opposite + * one: this is worth trying again later and that never will be. Collapsing the two tells + * somebody with a perfectly good account that they permanently own no tags, and sends them + * off to find a friend with a Mac. + */ + SERVICE_UNSURE, + + /** + * The escrow service did not accept the passcode. + * + *

Not proof it was wrong. FindMy.py's own first advice is to try the same passcode + * again, because the exchange has been seen to fail intermittently and then succeed. Copy + * that says "incorrect passcode" is stating something this app does not know. + */ + PASSCODE_REJECTED, + + /** A device was chosen that this session never listed. A bug in the screen, not the user. */ + NO_SUCH_RECORD, + + /** An accessory was asked for that this session never fetched. Also a bug in the screen. */ + NO_SUCH_ACCESSORY, + + /** Anything else. The detail carries what there is to say. */ + UNKNOWN; + + /** + * Map the bridge's wire value, defaulting to {@link #UNKNOWN} rather than throwing. + * + *

A reason added in Python and not yet known here must degrade to "something went wrong, + * here is what it said" - which is a poor screen but a working one. Throwing would turn a + * new failure mode into a crash on the screen reporting failures. + */ + public static ICloudFailure fromWire(final String reason) { + if (reason == null) { + return UNKNOWN; + } + + switch (reason) { + case "not_signed_in": return NOT_SIGNED_IN; + case "nothing_to_recover_from": return NOTHING_TO_RECOVER_FROM; + case "service_unsure": return SERVICE_UNSURE; + case "passcode_rejected": return PASSCODE_REJECTED; + case "no_such_record": return NO_SUCH_RECORD; + case "no_such_accessory": return NO_SUCH_ACCESSORY; + default: return UNKNOWN; + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFetch.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFetch.java new file mode 100644 index 00000000..e7badbad --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFetch.java @@ -0,0 +1,41 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * What one account holds: what can be imported, and what was set aside. + * + *

Both halves reach the screen. "Fewer tags than expected" and "some of those were never tags" + * look identical from outside, and the second is the common one - an account's own iPhones and + * Macs come back in the same records and are dropped for having no private key. + */ +@Getter +@AllArgsConstructor +public class ICloudFetch { + private final List accessories; + private final List skipped; + + /** + * An account that owns no tags at all. + * + *

Worth its own screen. In practice the flow usually stops earlier, at + * {@link ICloudFailure#NOTHING_TO_RECOVER_FROM}, because an account with no Apple device on + * it has no escrow record either - but an account that has a Mac and no tags reaches here + * instead, and lands on the same advice: import a bundle from somebody who owns them. + */ + public boolean isEmpty() { + return this.accessories.isEmpty(); + } + + @Getter + @AllArgsConstructor + public static class SkippedAccessory { + private final String beaconId; + + /** What it appears to be, with the evidence rather than a verdict. */ + private final String reason; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudService.java new file mode 100644 index 00000000..e817af0d --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudService.java @@ -0,0 +1,79 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import java.util.List; + +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Observable; + +/** + * Reading the tags in the user's own Apple account. + * + *

Four steps, in order, because a person answers something between them: + * + *

+ *   open() -> recoveryOptions() -> unlock(serial, passcode) -> fetch() -> records(ids)
+ * 
+ * + *

An interface so the screens can be tested without an Apple account. Every failure + * worth showing a user - an account with nothing to recover from, a service having a bad day, a + * rejected passcode - is unreachable from a test that needs real credentials, which means those + * are exactly the paths that would never be covered. {@code FakeICloudService} drives them all. + * + *

Failures arrive as {@link ICloudException} in {@code onError}, carrying an + * {@link ICloudFailure} to branch on so the wording stays in {@code strings.xml}. + * + *

Every call takes the shared Python lock for its own duration and gives it back, so the + * app's periodic location refresh is delayed by a step but never by the user's thinking time. + * Implementations must not hold it across a dialog. + */ +public interface ICloudService { + + /** + * Open the Find My client: a keychain session and a CloudKit client. + * + *

Nothing is decrypted yet - that needs keys, and keys need {@link #unlock}. + */ + Completable open(); + + /** + * What this account could unlock its keychain from. + * + *

Never empty on success: the two ways of being empty are + * {@link ICloudFailure#NOTHING_TO_RECOVER_FROM} and {@link ICloudFailure#SERVICE_UNSURE}, + * and they are errors precisely so a screen cannot accidentally treat them alike. + */ + Observable> recoveryOptions(); + + /** + * Recover the keychain keys with one device's screen-lock passcode. + * + *

One attempt per call. The retry lives with the dialog that spends it, and so does + * the cap - see {@link #MAX_UNLOCK_ATTEMPTS}, which has to be respected: attempts are + * probably a limited resource on Apple's end, and what this service allows is not + * established. + */ + Completable unlock(String serial, String passcode); + + /** + * How many times a passcode may be offered before the flow gives up. + * + *

A bound rather than a free retry, mirroring {@code exporter.icloud.MAX_UNLOCK_ATTEMPTS}. + * FindMy.py says Apple's escrow services generally cap attempts and that what this one allows + * is not established - which is a good reason not to find out on somebody's real account. + */ + int MAX_UNLOCK_ATTEMPTS = 3; + + /** Read and decrypt the account's accessories, described but without their key material. */ + Observable fetch(); + + /** The chosen accessories, as the plists the importer already reads. */ + Observable> records(List beaconIds); + + /** + * Close the client. + * + *

Call it from a {@code finally}: two of the steps hold sockets, and an abandoned session + * leaks them for the life of the process. Never throws. + */ + void close(); +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/PythonICloudService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/PythonICloudService.java new file mode 100644 index 00000000..89472464 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/PythonICloudService.java @@ -0,0 +1,199 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import android.util.Log; + +import com.chaquo.python.PyObject; +import com.chaquo.python.Python; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +import dev.wander.android.opentagviewer.python.PythonAppleAccount; +import dev.wander.android.opentagviewer.python.PythonLock; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * {@link ICloudService} over {@code icloud_bridge}, the Python module that drives + * {@code exporter.icloud}. + * + *

Thin on purpose. The flow, the retry policy and every decision about what a failure means + * live on the Python side, where the library is; this translates JSON into types and reasons into + * an enum, and does nothing else. A second implementation of the flow in Java would be a second + * thing to get wrong. + */ +public class PythonICloudService implements ICloudService { + private static final String TAG = PythonICloudService.class.getSimpleName(); + + private static final String MODULE = "icloud_bridge"; + + private final PyObject session; + + private PythonICloudService(final PyObject session) { + this.session = session; + } + + /** + * Start a session on the account the app is already signed in with. + * + *

Returns null when FindMy.py's account internals have moved and the flow cannot be driven + * - which the caller should treat exactly as an expired session, by sending the user to sign + * in again. A null here is not a crash and must not become one. + */ + public static PythonICloudService openFor(final PythonAppleAccount account) { + try { + final PyObject made = Python.getInstance().getModule(MODULE) + .callAttr("openSession", account.getAccountObj()); + + if (made == null || made.toJava(Object.class) == null) { + Log.e(TAG, "icloud_bridge.openSession returned nothing; the iCloud flow is" + + " unavailable on this account"); + return null; + } + + return new PythonICloudService(made); + } catch (Exception e) { + Log.e(TAG, "Could not start an iCloud session", e); + return null; + } + } + + @Override + public Completable open() { + return Completable.fromAction(() -> answered("open")) + .subscribeOn(Schedulers.io()); + } + + @Override + public Observable> recoveryOptions() { + return Observable.fromCallable(() -> { + final JSONArray devices = answered("recoveryOptions").getJSONArray("devices"); + + final List found = new ArrayList<>(); + for (int i = 0; i < devices.length(); i++) { + final JSONObject device = devices.getJSONObject(i); + found.add(new RecoverableDevice( + device.getString("serial"), device.getString("description"))); + } + + return found; + }).subscribeOn(Schedulers.io()); + } + + @Override + public Completable unlock(final String serial, final String passcode) { + return Completable.fromAction(() -> answered("unlock", serial, passcode)) + .subscribeOn(Schedulers.io()); + } + + @Override + public Observable fetch() { + return Observable.fromCallable(() -> { + final JSONObject answer = answered("fetch"); + + final JSONArray found = answer.getJSONArray("accessories"); + final List accessories = new ArrayList<>(); + for (int i = 0; i < found.length(); i++) { + final JSONObject one = found.getJSONObject(i); + accessories.add(new ICloudAccessory( + one.getString("beaconId"), + // Null rather than the string "null", which is what optString gives for + // a JSON null and would reach the screen as a tag called null. + one.isNull("name") ? null : one.getString("name"), + one.isNull("emoji") ? null : one.getString("emoji"), + one.getString("label"), + one.getString("details"), + one.getBoolean("hasAlignment"), + one.getBoolean("hasName"))); + } + + final JSONArray setAside = answer.getJSONArray("skipped"); + final List skipped = new ArrayList<>(); + for (int i = 0; i < setAside.length(); i++) { + final JSONObject one = setAside.getJSONObject(i); + skipped.add(new ICloudFetch.SkippedAccessory( + one.getString("beaconId"), one.getString("reason"))); + } + + return new ICloudFetch(accessories, skipped); + }).subscribeOn(Schedulers.io()); + } + + @Override + public Observable> records(final List beaconIds) { + return Observable.fromCallable(() -> { + final JSONArray selection = new JSONArray(); + for (final String beaconId : beaconIds) { + selection.put(new JSONObject().put("beaconId", beaconId)); + } + + final JSONArray taken = + answered("records", selection.toString()).getJSONArray("accessories"); + + final List records = new ArrayList<>(); + for (int i = 0; i < taken.length(); i++) { + final JSONObject one = taken.getJSONObject(i); + records.add(new AccessoryRecords( + one.getString("beaconId"), + one.getString("ownedBeaconPlist"), + // Null where nothing ever named it, which the importer handles. + one.isNull("namingRecordPlist") ? null : one.getString("namingRecordPlist"), + one.isNull("keyAlignmentPlist") ? null : one.getString("keyAlignmentPlist"))); + } + + return records; + }).subscribeOn(Schedulers.io()); + } + + @Override + public void close() { + try { + PythonLock.holding(() -> { + this.session.callAttr("close"); + return null; + }); + } catch (Exception e) { + // Callers close from a `finally`. Throwing here would replace whatever real failure + // sent them there with a confusing one about closing. + Log.w(TAG, "Closing the iCloud session failed", e); + } + } + + /** + * Make one call into the bridge and return its answer, or throw what it reported. + * + *

Two things happen here and both matter. + * + *

The Python lock is taken for the call and given straight back. The iCloud flow and + * the app's periodic location refresh drive the same asyncio event loop, and two threads in + * {@code run_until_complete} on one loop fails permanently. Scoping it to the call rather + * than the flow is what keeps a passcode dialog from freezing every location update in the + * app until the user types. + * + *

A reported failure becomes an exception; a raised one is not expected. The bridge + * returns failures as values precisely so their message is never empty, so anything actually + * thrown here is a bug rather than a user-facing condition - and is reported as + * {@link ICloudFailure#UNKNOWN} with whatever text it had. + */ + private JSONObject answered(final String step, final Object... arguments) throws Exception { + final String returned = PythonLock.holding( + () -> this.session.callAttr(step, arguments).toString()); + + final JSONObject answer = new JSONObject(returned); + + if (!answer.optBoolean("ok", false)) { + final ICloudFailure failure = ICloudFailure.fromWire(answer.optString("reason", null)); + final String detail = answer.optString("message", ""); + + Log.w(TAG, "icloud_bridge." + step + " reported " + failure + ": " + detail); + + throw new ICloudException(failure, detail); + } + + return answer; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/RecoverableDevice.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/RecoverableDevice.java new file mode 100644 index 00000000..e4eb551c --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/RecoverableDevice.java @@ -0,0 +1,25 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * One of the user's Apple devices whose passcode could unlock the keychain. + * + *

The serial is how the unlock step names it back to Python - the escrow record itself stays + * on the Python side, because it is a live object holding key material and not a thing to + * reconstruct from a string. + */ +@Getter +@AllArgsConstructor +public class RecoverableDevice { + private final String serial; + + /** + * FindMy.py's own description of the record - what kind of device, and when. + * + *

Shown as-is rather than reassembled here. It is the only thing telling two of a user's + * iPhones apart, and this app knows nothing about escrow records that the library does not. + */ + private final String description; +} From a9afcb544389cc4f902d2e43189320a813cd48be Mon Sep 17 00:00:00 2001 From: Shane B Date: Thu, 20 Aug 2026 11:12:44 +0200 Subject: [PATCH 02/44] Put screens on the iCloud flow, against the interface rather than Apple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six steps over one screen - open, choose whose passcode you have, unlock, and an overview of what is on the account - plus the two failure screens the bridge already distinguishes. The whole thing talks to `ICloudService` and nothing else, so every state it has to handle can be produced in a test. **That is not a convenience.** Reaching these screens for real needs an Apple account in conditions nobody can arrange on demand: one with no device to recover from, one whose keychain service is having a bad afternoon, one that refuses a passcode three times. A working account is in none of them, and they are exactly the states that decide whether somebody comes back tomorrow or gives up. The failure branching is the point. "Nothing on this account can be recovered from" is final and the answer is the import path; "nothing was reported usable at all" is very likely a bad day at Apple and is worth returning for. Collapsing them tells somebody with a perfectly good account that they permanently own no tags. Anything unrecognised lands on "try again later" with what it said - the safe half, since a guess about a cause nobody established is worse than a vague truth. Back is a step, not an exit: from the passcode step with more than one device it returns to the list, so picking the wrong one out of two is not an expensive mistake; with one device it leaves, because a list of one button is a dead end that reads as the button not working; and mid-call it does nothing, so an unlock already talking to Apple is not stranded. The empty device list gains a second button - fetch from the account, and import from a file - which is §7's "two buttons, not four". The copy under it said a zip was the only way in and now does not. Three things @parawanderer caught looking at it that no test had an opinion about: the results heading was `icloud_found_x_tags`, a format string, so it rendered a literal `%1$d`; there was an "Import Devices" button on what should be an overview, implying a choice that does not exist, since everything on the account comes in; and the cards were grey rather than the surface the Apple account block in Settings uses. Tests in three classes, because they answer different questions: the flow, back presses, and what the user is told when it does not work. 295 instrumented tests pass. Two Espresso traps on the way, both now written down: `pressBack()` throws "Pressed back and killed the app" when leaving is the expected outcome, and `onActivity` cannot run on a destroyed activity - so leaving is asserted from the scenario's lifecycle state instead. Nothing is written to the database yet. That is the next commit, and per @parawanderer the model is a cache of the account rather than an import: rows so history and offline viewing work, with the list refreshed from the account each time. Co-Authored-By: Claude Opus 5 --- .../FetchFromICloudBackPressTest.java | 214 +++++++++++ .../FetchFromICloudErrorsTest.java | 208 ++++++++++ .../FetchFromICloudFlowTest.java | 260 +++++++++++++ .../python/icloud/FakeICloudService.java | 197 ++++++++++ app/src/main/AndroidManifest.xml | 4 + .../FetchFromICloudActivity.java | 362 ++++++++++++++++++ .../opentagviewer/MyDevicesListActivity.java | 23 ++ .../opentagviewer/python/AppDependencies.java | 42 ++ .../python/PythonAppleService.java | 11 + .../res/layout/activity_fetch_from_icloud.xml | 316 +++++++++++++++ .../res/layout/activity_my_devices_list.xml | 28 +- .../main/res/layout/icloud_device_button.xml | 27 ++ .../res/layout/icloud_found_accessory.xml | 37 ++ app/src/main/res/values-de/strings.xml | 21 +- app/src/main/res/values-en/strings.xml | 21 +- app/src/main/res/values-fr/strings.xml | 21 +- app/src/main/res/values-ja/strings.xml | 21 +- app/src/main/res/values-ko/strings.xml | 21 +- app/src/main/res/values-nl/strings.xml | 21 +- app/src/main/res/values-ru/strings.xml | 21 +- app/src/main/res/values-zh-rCN/strings.xml | 21 +- app/src/main/res/values-zh-rTW/strings.xml | 21 +- app/src/main/res/values/strings.xml | 21 +- 23 files changed, 1927 insertions(+), 12 deletions(-) create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudBackPressTest.java create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudErrorsTest.java create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/FakeICloudService.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java create mode 100644 app/src/main/res/layout/activity_fetch_from_icloud.xml create mode 100644 app/src/main/res/layout/icloud_device_button.xml create mode 100644 app/src/main/res/layout/icloud_found_accessory.xml diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudBackPressTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudBackPressTest.java new file mode 100644 index 00000000..442a1321 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudBackPressTest.java @@ -0,0 +1,214 @@ +package dev.wander.android.opentagviewer; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.pressBack; +import static androidx.test.espresso.Espresso.pressBackUnconditionally; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import androidx.lifecycle.Lifecycle; +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.icloud.FakeICloudService; + +/** + * What back does on each step of reading the account. + * + *

Back is where a multi-step screen quietly goes wrong. The mistakes available are all + * silent: abandoning the whole errand from a step that had somewhere to go, returning to a step + * that no longer means anything, or leaving mid-call and stranding a keychain unlock that is + * already talking to Apple. None of them throw, and none of them show up in a screenshot. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class FetchFromICloudBackPressTest { + + private FakeICloudService icloud; + private ActivityScenario scenario; + + @After + public void putTheRealOneBack() { + if (this.scenario != null) { + this.scenario.close(); + } + AppDependencies.reset(); + } + + private void open(final FakeICloudService fake) { + this.icloud = fake; + AppDependencies.replaceICloud(() -> fake); + this.scenario = ActivityScenario.launch(FetchFromICloudActivity.class); + TestPace.afterAStep(); + } + + private boolean isShown(final int id) { + final boolean[] shown = {false}; + this.scenario.onActivity(activity -> + shown[0] = activity.findViewById(id).getVisibility() == android.view.View.VISIBLE); + return shown[0]; + } + + /** + * Whether the screen has gone. + * + *

Read from the scenario rather than by asking the activity: once it is destroyed, + * `onActivity` throws "Cannot run onActivity since Activity has been destroyed already" - + * which is the very outcome these tests want, so asking that way turns a pass into a + * confusing failure. + */ + private boolean hasLeft() { + return this.scenario.getState() == Lifecycle.State.DESTROYED; + } + + private void reachThePasscodeStep(final String serial) { + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + Eventually.perform("a device", () -> isShown(R.id.icloud_passcode_container), + () -> onView(withText(containsString(serial))).perform(click())); + TestPace.afterAStep(); + } + + /** + * From the passcode step, back goes to the device list rather than out. + * + *

Choosing the wrong device out of two is otherwise an expensive mistake: leaving costs + * the whole errand, and the user has to find the button again. + */ + @Test + public void backFromThePasscodeStepReturnsToTheDeviceList() { + this.open(FakeICloudService.withTags()); + this.reachThePasscodeStep(FakeICloudService.AN_IPHONE.getSerial()); + + TestPace.afterAStep(); + pressBack(); + TestPace.afterAStep(); + + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_passcode_container)) + .check(matches(not(isDisplayed())))); + assertTrue("the screen should still be open", !this.hasLeft()); + } + + /** And the other device can then be picked, which is the point of going back. */ + @Test + public void theotherDeviceCanBeChosenAfterGoingBack() { + this.open(FakeICloudService.withTags()); + this.reachThePasscodeStep(FakeICloudService.AN_IPHONE.getSerial()); + + TestPace.afterAStep(); + pressBack(); + TestPace.afterAStep(); + + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + Eventually.perform("the other device", () -> isShown(R.id.icloud_passcode_container), + () -> onView(withText(containsString(FakeICloudService.A_MAC.getSerial()))) + .perform(click())); + + onView(withId(R.id.icloud_passcode_input)).perform(replaceText("123456")); + Eventually.perform("unlock", () -> this.icloud.timesCalled("unlock") > 0, + () -> onView(withId(R.id.icloud_passcode_submit)).perform(click())); + + assertEquals("the passcode went to the device chosen after going back", + FakeICloudService.A_MAC.getSerial(), this.icloud.unlockedWith().get(0)); + } + + /** + * With one device there is nothing to go back to, so back leaves. + * + *

Returning to a list of one button is a dead end that reads as the button not working. + */ + @Test + public void backLeavesWhenThereWasNoChoiceOfDevice() { + this.open(FakeICloudService.withTags().withOneDevice()); + this.reachThePasscodeStep(FakeICloudService.AN_IPHONE.getSerial()); + + TestPace.afterAStep(); + pressBackUnconditionally(); + + Eventually.check(() -> assertTrue("back should leave when there was one device", + this.hasLeft())); + } + + /** From the device list itself, back leaves - there is no earlier step. */ + @Test + public void backFromTheDeviceListLeaves() { + this.open(FakeICloudService.withTags()); + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + + TestPace.afterAStep(); + pressBackUnconditionally(); + + Eventually.check(() -> assertTrue(this.hasLeft())); + } + + /** From a failure screen, back leaves rather than sitting there. */ + @Test + public void backFromAFailureScreenLeaves() { + this.open(FakeICloudService.withNothingToRecoverFrom()); + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(isDisplayed()))); + + TestPace.afterAStep(); + pressBackUnconditionally(); + + Eventually.check(() -> assertTrue(this.hasLeft())); + } + + /** And from the overview at the end. */ + @Test + public void backFromTheOverviewLeaves() { + this.open(FakeICloudService.withTags()); + this.reachThePasscodeStep(FakeICloudService.AN_IPHONE.getSerial()); + + onView(withId(R.id.icloud_passcode_input)).perform(replaceText("123456")); + Eventually.perform("unlock", () -> this.icloud.timesCalled("fetch") > 0, + () -> onView(withId(R.id.icloud_passcode_submit)).perform(click())); + + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + + TestPace.afterAStep(); + pressBackUnconditionally(); + + Eventually.check(() -> assertTrue(this.hasLeft())); + } + + /** + * Leaving always closes the session. + * + *

Two of these calls hold sockets. An abandoned session leaks them for the life of the + * process, and "the user pressed back" is by far the most common way this screen ends. + */ + @Test + public void leavingClosesTheSession() { + this.open(FakeICloudService.withTags()); + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + + TestPace.afterAStep(); + pressBackUnconditionally(); + this.scenario.close(); + this.scenario = null; + + Eventually.check(() -> assertTrue("the session was left open", this.icloud.wasClosed())); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudErrorsTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudErrorsTest.java new file mode 100644 index 00000000..53d590b8 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudErrorsTest.java @@ -0,0 +1,208 @@ +package dev.wander.android.opentagviewer; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.icloud.FakeICloudService; +import dev.wander.android.opentagviewer.python.icloud.ICloudException; +import dev.wander.android.opentagviewer.python.icloud.ICloudFailure; + +/** + * What the user is told when reading the account does not work. + * + *

Separate from the happy path because these are the cases that decide whether somebody comes + * back tomorrow or gives up - and none of them can be produced on a real account on demand. + * + *

The distinction that carries the most weight is between the two empty answers. An + * account with nothing to recover from is final and the import path is the answer; a service that + * reported nothing usable at all is very likely a bad afternoon at Apple. Showing the first when + * it is the second tells somebody with a perfectly good account that they permanently own no + * tags, and sends them off to find a friend with a Mac. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class FetchFromICloudErrorsTest { + + private FakeICloudService icloud; + private ActivityScenario scenario; + + @After + public void putTheRealOneBack() { + if (this.scenario != null) { + this.scenario.close(); + } + AppDependencies.reset(); + } + + private void open(final FakeICloudService fake) { + this.icloud = fake; + AppDependencies.replaceICloud(() -> fake); + this.scenario = ActivityScenario.launch(FetchFromICloudActivity.class); + } + + private boolean isShown(final int id) { + final boolean[] shown = {false}; + this.scenario.onActivity(activity -> + shown[0] = activity.findViewById(id).getVisibility() == android.view.View.VISIBLE); + return shown[0]; + } + + private void unlockWithTheFirstDevice() { + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + Eventually.perform("a device", () -> isShown(R.id.icloud_passcode_container), + () -> onView(withText(containsString(FakeICloudService.AN_IPHONE.getSerial()))) + .perform(click())); + + final long before = this.icloud.timesCalled("unlock"); + onView(withId(R.id.icloud_passcode_input)).perform(replaceText("123456")); + Eventually.perform("unlock", () -> this.icloud.timesCalled("unlock") > before, + () -> onView(withId(R.id.icloud_passcode_submit)).perform(click())); + } + + /** No device on the account can unlock the keychain: final, and import is the answer. */ + @Test + public void nothingToRecoverFromOffersTheImportPathAndNoRetry() { + this.open(FakeICloudService.withNothingToRecoverFrom()); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_import_button)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_retry_button)) + .check(matches(not(isDisplayed())))); + + TestPace.afterAStep(); + } + + /** And it explains the case that actually brought most of these people here. */ + @Test + public void ittellsThemWhySharingInFindMyIsNotEnough() { + this.open(FakeICloudService.withNothingToRecoverFrom()); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_shared_note)) + .check(matches(isDisplayed()))); + } + + /** A service having a bad day offers a retry, and never the "you own no tags" screen. */ + @Test + public void aserviceHavingABadDayOffersARetry() { + this.open(FakeICloudService.whereTheServiceIsUnsure()); + + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(not(isDisplayed())))); + + TestPace.afterAStep(); + } + + /** The two are different screens, which is the whole point. */ + @Test + public void thetwoEmptyAnswersAreNotTheSameScreen() { + assertNotEquals(ICloudFailure.NOTHING_TO_RECOVER_FROM, ICloudFailure.SERVICE_UNSURE); + + this.open(FakeICloudService.whereTheServiceIsUnsure()); + Eventually.check(() -> assertTrue(isShown(R.id.icloud_retry_container))); + this.scenario.close(); + AppDependencies.reset(); + + this.open(FakeICloudService.withNothingToRecoverFrom()); + Eventually.check(() -> assertTrue(isShown(R.id.icloud_no_tags_container))); + } + + /** + * A failure nothing anticipated lands on "try again later", with what it said. + * + *

Deliberately the safe half: "try again" about a cause nobody established is a great deal + * better than telling somebody their account is empty when it is not. + */ + @Test + public void anunrecognisedFailureSaysTryAgainRatherThanGuessing() { + this.open(FakeICloudService.withTags().whereFetchingFails( + new ICloudException(ICloudFailure.UNKNOWN, "CloudKit said something odd"))); + + this.unlockWithTheFirstDevice(); + + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_retry_body)) + .check(matches(withText(containsString("CloudKit said something odd"))))); + } + + /** An unexpected failure must never be reported as "this account owns no tags". */ + @Test + public void anunrecognisedFailureIsNeverTheEmptyAccountScreen() { + this.open(FakeICloudService.withTags().whereFetchingFails( + new ICloudException(ICloudFailure.UNKNOWN, "something odd"))); + + this.unlockWithTheFirstDevice(); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(not(isDisplayed())))); + } + + /** An account that unlocks and holds no tags: same advice, a step later. */ + @Test + public void anaccountWithNoTagsLandsOnTheSameAdvice() { + this.open(FakeICloudService.withNoTagsOnTheAccount()); + + this.unlockWithTheFirstDevice(); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_import_button)) + .check(matches(isDisplayed()))); + + TestPace.afterAStep(); + } + + /** + * A rejected passcode stays on the passcode step, with the library's own words. + * + *

Not "incorrect passcode": FindMy.py's first advice is to try the same one again, because + * the exchange has been seen to fail intermittently and then succeed. + */ + @Test + public void arejectedPasscodeStaysPutAndDoesNotCallItWrong() { + this.open(FakeICloudService.withTags().refusingThePasscode(1)); + + this.unlockWithTheFirstDevice(); + + Eventually.check(() -> onView(withId(R.id.icloud_passcode_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_passcode_error_container)) + .check(matches(isDisplayed()))); + } + + /** A rejection is not a reason to throw the user out of the flow. */ + @Test + public void arejectedPasscodeIsNotAFailureScreen() { + this.open(FakeICloudService.withTags().refusingThePasscode(1)); + + this.unlockWithTheFirstDevice(); + + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(not(isDisplayed())))); + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(not(isDisplayed())))); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java new file mode 100644 index 00000000..f3e50d87 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java @@ -0,0 +1,260 @@ +package dev.wander.android.opentagviewer; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; + +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.icloud.FakeICloudService; +import dev.wander.android.opentagviewer.python.icloud.ICloudService; + +/** + * Reading the account, driven end to end with iCloud replaced. + * + *

Every state here needs an Apple account nobody can arrange on demand - one with no device + * to recover from, one whose keychain service is having a bad afternoon, one that refuses a + * passcode three times. A working account is in none of them, so without a fake these screens + * could only ever be reasoned about, which is how a screen ends up telling somebody with a + * perfectly good account that they permanently own no tags. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class FetchFromICloudFlowTest { + + private static final String PASSCODE = "123456"; + + private FakeICloudService icloud; + private ActivityScenario scenario; + + @After + public void putTheRealOneBack() { + if (this.scenario != null) { + this.scenario.close(); + } + AppDependencies.reset(); + } + + private void open(final FakeICloudService fake) { + this.icloud = fake; + AppDependencies.replaceICloud(() -> fake); + this.scenario = ActivityScenario.launch(FetchFromICloudActivity.class); + TestPace.afterAStep(); + } + + private void chooseTheFirstDevice() { + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + + Eventually.perform("the device button", + () -> isShown(R.id.icloud_passcode_container), + () -> onView(withText(containsString(FakeICloudService.AN_IPHONE.getSerial()))) + .perform(click())); + TestPace.afterAStep(); + } + + private void typeThePasscode() { + final long before = this.icloud.timesCalled("unlock"); + + onView(withId(R.id.icloud_passcode_input)).perform(replaceText(PASSCODE)); + TestPace.afterAStep(); + + Eventually.perform("unlock", () -> this.icloud.timesCalled("unlock") > before, + () -> onView(withId(R.id.icloud_passcode_submit)).perform(click())); + TestPace.afterAStep(); + } + + private boolean isShown(final int id) { + final boolean[] shown = {false}; + this.scenario.onActivity(activity -> + shown[0] = activity.findViewById(id).getVisibility() == android.view.View.VISIBLE); + return shown[0]; + } + + /** The whole errand: choose a device, unlock, see what is on the account. */ + @Test + public void thewholeFlowReachesTheTagsOnTheAccount() { + this.open(FakeICloudService.withTags().alsoSkipping("My MacBook", "My iPhone")); + + this.chooseTheFirstDevice(); + this.typeThePasscode(); + + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_results_found)) + .check(matches(withText(containsString("2"))))); + Eventually.check(() -> onView(withText(containsString("Bike"))) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + } + + /** The passcode goes to the device that was actually chosen, not the first in the list. */ + @Test + public void thepasscodeGoesToTheChosenDevice() { + this.open(FakeICloudService.withTags()); + + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + Eventually.perform("the second device", + () -> isShown(R.id.icloud_passcode_container), + () -> onView(withText(containsString(FakeICloudService.A_MAC.getSerial()))) + .perform(click())); + + this.typeThePasscode(); + + Eventually.check(() -> assertEquals( + List.of(FakeICloudService.A_MAC.getSerial()), this.icloud.unlockedWith())); + } + + /** + * An account with nothing that can unlock its keychain. + * + *

Final, and the answer is the import path - so the screen offers that rather than a + * retry that will never work. + */ + @Test + public void anaccountWithNothingToRecoverFromIsToldSo() { + this.open(FakeICloudService.withNothingToRecoverFrom()); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_shared_note)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(not(isDisplayed())))); + TestPace.afterAStep(); + } + + /** + * And a service having a bad day is emphatically not that screen. + * + *

Collapsing the two tells somebody with a perfectly good account that they permanently + * own no tags, and sends them off to find a friend with a Mac. + */ + @Test + public void aserviceHavingABadDayOffersARetryInstead() { + this.open(FakeICloudService.whereTheServiceIsUnsure()); + + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(not(isDisplayed())))); + TestPace.afterAStep(); + } + + /** Retrying starts a fresh session rather than reusing the one that failed. */ + @Test + public void retryingAsksAgain() { + this.open(FakeICloudService.whereTheServiceIsUnsure()); + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(isDisplayed()))); + + final long before = this.icloud.timesCalled("recoveryOptions"); + Eventually.perform("try again", + () -> this.icloud.timesCalled("recoveryOptions") > before, + () -> onView(withId(R.id.icloud_retry_button)).perform(click())); + } + + /** An account with a Mac on it and no tags lands on the same advice, a step later. */ + @Test + public void anaccountWithNoTagsOnItSaysSoAfterFetching() { + this.open(FakeICloudService.withNoTagsOnTheAccount()); + + this.chooseTheFirstDevice(); + this.typeThePasscode(); + + Eventually.check(() -> onView(withId(R.id.icloud_no_tags_container)) + .check(matches(isDisplayed()))); + } + + /** + * A rejected passcode can be tried again, and the wording does not call it wrong. + * + *

FindMy.py's own first advice is to try the same one again, because the exchange has + * been seen to fail intermittently and then succeed. + */ + @Test + public void arejectedPasscodeCanBeTriedAgain() { + this.open(FakeICloudService.withTags().refusingThePasscode(1)); + + this.chooseTheFirstDevice(); + this.typeThePasscode(); + + Eventually.check(() -> onView(withId(R.id.icloud_passcode_error_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_attempts_text)) + .check(matches(isDisplayed()))); + TestPace.afterAStep(); + + this.typeThePasscode(); + + Eventually.check(() -> onView(withId(R.id.icloud_results_container)) + .check(matches(isDisplayed()))); + } + + /** + * Attempts run out rather than going on for ever. + * + *

Apple's escrow services generally cap attempts and what this one allows is not + * established, which is a good reason not to find out on somebody's real account. + */ + @Test + public void theattemptsAreCapped() { + this.open(FakeICloudService.withTags() + .refusingThePasscode(ICloudService.MAX_UNLOCK_ATTEMPTS + 5)); + + this.chooseTheFirstDevice(); + for (int i = 0; i < ICloudService.MAX_UNLOCK_ATTEMPTS; i++) { + this.typeThePasscode(); + } + + Eventually.check(() -> assertTrue( + "more attempts were spent than the cap allows", + this.icloud.timesCalled("unlock") <= ICloudService.MAX_UNLOCK_ATTEMPTS)); + Eventually.check(() -> onView(withId(R.id.icloud_passcode_container)) + .check(matches(not(isDisplayed())))); + } + + /** An empty passcode must not spend one of them. */ + @Test + public void anemptyPasscodeSpendsNothing() { + this.open(FakeICloudService.withTags()); + this.chooseTheFirstDevice(); + + onView(withId(R.id.icloud_passcode_submit)).perform(click()); + + assertEquals("an empty box must not cost an attempt", 0, this.icloud.timesCalled("unlock")); + } + + /** The session is closed when the screen goes, or its sockets leak for the process's life. */ + @Test + public void thesessionIsClosedOnTheWayOut() { + this.open(FakeICloudService.withTags()); + Eventually.check(() -> onView(withId(R.id.icloud_device_container)) + .check(matches(isDisplayed()))); + + this.scenario.close(); + this.scenario = null; + + Eventually.check(() -> assertTrue("the iCloud session was never closed", + this.icloud.wasClosed())); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/FakeICloudService.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/FakeICloudService.java new file mode 100644 index 00000000..d04ed86b --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/FakeICloudService.java @@ -0,0 +1,197 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Observable; + +/** + * An iCloud that behaves however a test needs it to. + * + *

Every state worth getting right needs an Apple account nobody can arrange. An + * account with no device to recover from, a keychain service having a bad afternoon, a passcode + * refused three times - none of those can be produced on demand, and a working account will + * never be in any of them. So the screen driving them could only ever be checked by reasoning + * about it, which is how a screen ends up telling somebody with a perfectly good account that + * they permanently own no tags. + * + *

Records what it was asked, because several of the mistakes available here are about the + * screen calling the wrong thing rather than drawing the wrong thing: unlocking with a device + * the user did not pick, or spending an attempt on an empty passcode. + */ +public final class FakeICloudService implements ICloudService { + + public static final RecoverableDevice AN_IPHONE = + new RecoverableDevice("F2LX9Q", "iPhone 15, serial F2LX9Q, last used 2 days ago"); + public static final RecoverableDevice A_MAC = + new RecoverableDevice("C02XK", "MacBook Pro, serial C02XK, last used 3 months ago"); + + public static final ICloudAccessory A_BIKE = new ICloudAccessory( + "F1C4A0E2-1111-4222-8333-444455556666", "Bike", "🚲", "🚲 Bike", + "AirTag, serial HXXXXXXXXXXX, paired 2024-03-01", true, true); + public static final ICloudAccessory A_NAMELESS_ONE = new ICloudAccessory( + "0A0B0C0D-2222-4333-8444-555566667777", null, null, "unnamed", + "AirTag, serial HYYYYYYYYYYY, paired 2023-11-14", false, false); + + private List devices = List.of(AN_IPHONE, A_MAC); + private List accessories = List.of(A_BIKE, A_NAMELESS_ONE); + private List skipped = List.of(); + + private ICloudException openFailsWith; + private ICloudException optionsFailsWith; + private ICloudException unlockFailsWith; + private ICloudException fetchFailsWith; + + /** How many times a passcode is refused before it starts being accepted. */ + private int refusalsBeforeAccepting = 0; + + private final List calls = new ArrayList<>(); + private final List unlockedWith = new ArrayList<>(); + private String lastPasscode; + private boolean closed; + + /** The ordinary case: two devices to choose from and two tags on the account. */ + public static FakeICloudService withTags() { + return new FakeICloudService(); + } + + /** + * An account with nothing that can unlock its keychain. + * + *

The real class of user this whole flow has to answer for: an Apple ID that has never + * had an iPhone, iPad or Mac on it has never escrowed a keychain, and no amount of retrying + * will change that. + */ + public static FakeICloudService withNothingToRecoverFrom() { + final FakeICloudService fake = new FakeICloudService(); + fake.optionsFailsWith = new ICloudException( + ICloudFailure.NOTHING_TO_RECOVER_FROM, + "No record on this account can currently be recovered from."); + return fake; + } + + /** Nothing reported usable at all, which reads as a service having a bad day. */ + public static FakeICloudService whereTheServiceIsUnsure() { + final FakeICloudService fake = new FakeICloudService(); + fake.optionsFailsWith = new ICloudException( + ICloudFailure.SERVICE_UNSURE, + "Nothing was reported usable at all."); + return fake; + } + + /** An account with a Mac on it and no tags - the empty fetch, one step later. */ + public static FakeICloudService withNoTagsOnTheAccount() { + final FakeICloudService fake = new FakeICloudService(); + fake.accessories = List.of(); + return fake; + } + + /** The passcode is refused this many times, then accepted. */ + public FakeICloudService refusingThePasscode(final int times) { + this.refusalsBeforeAccepting = times; + return this; + } + + /** Only one device can be recovered from, so there is nothing to choose between. */ + public FakeICloudService withOneDevice() { + this.devices = List.of(AN_IPHONE); + return this; + } + + /** Some records were set aside for being the account's own hardware. */ + public FakeICloudService alsoSkipping(final String... names) { + final List setAside = new ArrayList<>(); + for (final String name : names) { + setAside.add(new ICloudFetch.SkippedAccessory( + name, "no private key, so it is a device rather than a tag")); + } + this.skipped = setAside; + return this; + } + + public FakeICloudService whereFetchingFails(final ICloudException failure) { + this.fetchFailsWith = failure; + return this; + } + + @Override + public Completable open() { + this.calls.add("open"); + return this.openFailsWith == null + ? Completable.complete() : Completable.error(this.openFailsWith); + } + + @Override + public Observable> recoveryOptions() { + this.calls.add("recoveryOptions"); + return this.optionsFailsWith == null + ? Observable.just(this.devices) : Observable.error(this.optionsFailsWith); + } + + @Override + public Completable unlock(final String serial, final String passcode) { + this.calls.add("unlock"); + this.unlockedWith.add(serial); + this.lastPasscode = passcode; + + if (this.unlockFailsWith != null) { + return Completable.error(this.unlockFailsWith); + } + + if (this.timesCalled("unlock") <= this.refusalsBeforeAccepting) { + return Completable.error(new ICloudException( + ICloudFailure.PASSCODE_REJECTED, + "That was not accepted. Worth trying the same passcode again.")); + } + + return Completable.complete(); + } + + @Override + public Observable fetch() { + this.calls.add("fetch"); + return this.fetchFailsWith == null + ? Observable.just(new ICloudFetch(this.accessories, this.skipped)) + : Observable.error(this.fetchFailsWith); + } + + @Override + public Observable> records(final List beaconIds) { + this.calls.add("records"); + + final List taken = new ArrayList<>(); + for (final String beaconId : beaconIds) { + taken.add(new AccessoryRecords(beaconId, "", "", null)); + } + + return Observable.just(taken); + } + + @Override + public void close() { + this.calls.add("close"); + this.closed = true; + } + + public List calls() { + return this.calls; + } + + public long timesCalled(final String call) { + return this.calls.stream().filter(call::equals).count(); + } + + /** Which devices' passcodes were offered, in order. */ + public List unlockedWith() { + return this.unlockedWith; + } + + public String lastPasscode() { + return this.lastPasscode; + } + + public boolean wasClosed() { + return this.closed; + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8826f7cc..ef6ff8bd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,6 +64,10 @@ android:name=".MyDevicesListActivity" android:exported="false" android:theme="@style/Theme.OpenTagViewer.GenericGreyishActivity" /> + The flow is four calls with a person answering something between them - open, list what the + * keychain can be recovered from, unlock with a device passcode, fetch - and this is the screen + * around them. It talks only to {@link ICloudService}, so every state it has to handle can be + * produced in a test; the real implementation needs an Apple account in conditions nobody can + * arrange on demand, and the states worth getting right are precisely the ones a working account + * will never be in. + * + *

The session is closed in {@code onDestroy}, in a finally-shaped way: two of those + * calls hold sockets, and an abandoned session leaks them for the life of the process. + */ +public class FetchFromICloudActivity extends AppCompatActivity { + private static final String TAG = FetchFromICloudActivity.class.getSimpleName(); + + /** Set when the screen should leave and let the caller open the file picker instead. */ + public static final String RESULT_WANTS_FILE_IMPORT = "wantsFileImport"; + + private ICloudService icloud; + + private List devices = List.of(); + + private RecoverableDevice chosenDevice; + + /** + * Which attempt the next press will be, starting at 1. + * + *

Capped at {@link ICloudService#MAX_UNLOCK_ATTEMPTS}, and the cap is respected here + * rather than in Python because this is where the button is. Attempts are probably a limited + * resource on Apple's end and what this service allows is not established, so spending them + * is deliberate. + */ + private int attempt = 1; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + this.setContentView(R.layout.activity_fetch_from_icloud); + WindowPaddingUtil.insertUITopPadding(this.findViewById(R.id.icloud_scroll)); + + if (this.getSupportActionBar() != null) { + this.getSupportActionBar().hide(); + } + + this.findViewById(R.id.icloud_passcode_submit) + .setOnClickListener(v -> this.submitPasscode()); + this.findViewById(R.id.icloud_retry_button) + .setOnClickListener(v -> this.start()); + this.findViewById(R.id.icloud_no_tags_import_button) + .setOnClickListener(v -> this.leaveForFileImport()); + this.findViewById(R.id.icloud_results_done_button) + .setOnClickListener(v -> this.finish()); + + this.getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + onBackWithin(); + } + }); + + this.start(); + } + + /** + * Back, meaning "the step before this one" where there is one. + * + *

Not simply closing the screen. From the passcode step the step before it is the + * device list, and a back press that abandoned the whole errand instead would make choosing + * the wrong device out of two an expensive mistake - it costs the sign-in, the wait, and + * finding the button again. + * + *

Swallowed entirely while a call is in flight. There is nothing to go back to mid-call, + * and leaving then would strand a keychain unlock that is already talking to Apple. + */ + private void onBackWithin() { + if (this.isShowing(R.id.icloud_loading_container)) { + Log.d(TAG, "Back pressed while a call was in flight; ignoring"); + return; + } + + if (this.isShowing(R.id.icloud_passcode_container) && this.devices.size() > 1) { + // Only worth going back to when there was a choice. With one device the list is a + // single button and returning to it is a dead end that looks like a bug. + this.showDevices(this.devices); + return; + } + + this.finish(); + } + + private boolean isShowing(final int stepId) { + return this.findViewById(stepId).getVisibility() == VISIBLE; + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (this.icloud != null) { + this.icloud.close(); + this.icloud = null; + } + } + + /** Open a session and ask what the keychain can be recovered from. */ + private void start() { + this.showOnly(R.id.icloud_loading_container, R.string.icloud_unlock_title); + this.setLoadingText(R.string.icloud_fetch_my_tags); + + if (this.icloud != null) { + this.icloud.close(); + } + + this.icloud = AppDependencies.icloud(); + + if (this.icloud == null) { + // No usable signed-in account. Same recovery as a session that has expired, and + // handled by whoever launched this rather than by showing a dead screen. + Log.e(TAG, "No signed-in account, so the iCloud flow cannot start"); + this.finish(); + return; + } + + var async = this.icloud.open() + .andThen(this.icloud.recoveryOptions()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::showDevices, this::showFailure); + } + + private void showDevices(final List recoverable) { + this.devices = recoverable; + + final LinearLayout list = this.findViewById(R.id.icloud_device_list); + list.removeAllViews(); + + for (final RecoverableDevice device : recoverable) { + final View row = this.getLayoutInflater() + .inflate(R.layout.icloud_device_button, list, false); + + final Button button = row.findViewById(R.id.icloud_device_button); + // FindMy.py's own description, shown as-is. It is the only thing telling two of + // somebody's iPhones apart, and this app knows nothing about escrow records that + // the library does not. + button.setText(device.getDescription()); + button.setOnClickListener(v -> this.chooseDevice(device)); + + list.addView(row); + } + + this.showOnly(R.id.icloud_device_container, R.string.icloud_unlock_title); + } + + private void chooseDevice(final RecoverableDevice device) { + this.chosenDevice = device; + this.attempt = 1; + + ((TextView) this.findViewById(R.id.icloud_passcode_device)) + .setText(device.getDescription()); + ((TextInputEditText) this.findViewById(R.id.icloud_passcode_input)).setText(""); + this.findViewById(R.id.icloud_passcode_error_container).setVisibility(GONE); + + this.updateAttemptCounter(); + this.showOnly(R.id.icloud_passcode_container, R.string.icloud_unlock_title); + } + + private void updateAttemptCounter() { + final TextView counter = this.findViewById(R.id.icloud_attempts_text); + counter.setText(this.getString( + R.string.icloud_attempt_x_of_y, this.attempt, ICloudService.MAX_UNLOCK_ATTEMPTS)); + // Hidden on the first go: "Attempt 1 of 3" before anything has been tried reads as a + // warning, and there is nothing to warn about yet. + counter.setVisibility(this.attempt > 1 ? VISIBLE : GONE); + } + + private void submitPasscode() { + final TextInputEditText input = this.findViewById(R.id.icloud_passcode_input); + final String passcode = input.getText() == null ? "" : input.getText().toString(); + + if (passcode.isEmpty()) { + return; + } + + this.showOnly(R.id.icloud_loading_container, R.string.icloud_unlock_title); + this.setLoadingText(R.string.icloud_unlock_title); + + var async = this.icloud.unlock(this.chosenDevice.getSerial(), passcode) + .andThen(this.icloud.fetch()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::showResults, this::onUnlockFailed); + } + + /** + * A rejected passcode, which is not proof it was wrong. + * + *

FindMy.py's first advice is to try the same one again, because the exchange has been + * seen to fail intermittently and then succeed. The copy says that; do not reword it into + * "incorrect passcode". + */ + private void onUnlockFailed(final Throwable error) { + final ICloudFailure failure = error instanceof ICloudException + ? ((ICloudException) error).getFailure() : ICloudFailure.UNKNOWN; + + if (failure != ICloudFailure.PASSCODE_REJECTED) { + this.showFailure(error); + return; + } + + this.attempt++; + + if (this.attempt > ICloudService.MAX_UNLOCK_ATTEMPTS) { + Log.w(TAG, "Out of unlock attempts for " + this.chosenDevice.getSerial()); + this.showOnly(R.id.icloud_retry_container, R.string.icloud_service_unsure_title); + ((TextView) this.findViewById(R.id.icloud_retry_body)) + .setText(R.string.icloud_passcode_rejected); + return; + } + + this.findViewById(R.id.icloud_passcode_error_container).setVisibility(VISIBLE); + this.updateAttemptCounter(); + this.showOnly(R.id.icloud_passcode_container, R.string.icloud_unlock_title); + } + + private void showResults(final ICloudFetch fetched) { + if (fetched.isEmpty()) { + // An account with a Mac on it but no tags. Same advice as having nothing to recover + // from, reached a step later. + this.showOnly(R.id.icloud_no_tags_container, R.string.icloud_no_tags_title); + return; + } + + ((TextView) this.findViewById(R.id.icloud_results_found)).setText( + this.getString(R.string.icloud_found_x_tags, fetched.getAccessories().size())); + + final TextView skipped = this.findViewById(R.id.icloud_results_skipped); + // Named rather than dropped quietly: "fewer tags than expected" and "some of those were + // never tags" look identical from outside, and the second is the common one. + skipped.setVisibility(fetched.getSkipped().isEmpty() ? GONE : VISIBLE); + skipped.setText(this.getString( + R.string.icloud_x_were_not_tags, fetched.getSkipped().size())); + + final LinearLayout list = this.findViewById(R.id.icloud_results_list); + list.removeAllViews(); + + for (final ICloudAccessory accessory : fetched.getAccessories()) { + final View row = this.getLayoutInflater() + .inflate(R.layout.icloud_found_accessory, list, false); + + ((TextView) row.findViewById(R.id.icloud_found_label)).setText(accessory.getLabel()); + + final TextView details = row.findViewById(R.id.icloud_found_details); + // What an accessory with no name has instead of one: what kind of thing it is, its + // serial, when it was paired. "unnamed" three times over is not a list to choose + // from. + details.setText(accessory.getDetails()); + details.setVisibility(accessory.getDetails().isEmpty() ? GONE : VISIBLE); + + list.addView(row); + } + + // **Not `icloud_found_x_tags`**, which is a format string with a `%1$d` in it - used as + // a heading it renders the placeholder literally, which is what shipped in the first + // screenshot of this screen. + this.showOnly(R.id.icloud_results_container, R.string.icloud_results_title); + } + + /** Whatever went wrong, on the screen written for it. */ + private void showFailure(final Throwable error) { + final ICloudFailure failure = error instanceof ICloudException + ? ((ICloudException) error).getFailure() : ICloudFailure.UNKNOWN; + final String detail = error instanceof ICloudException + ? ((ICloudException) error).getDetail() : String.valueOf(error.getMessage()); + + Log.w(TAG, "iCloud flow stopped: " + failure + " - " + detail); + + switch (failure) { + case NOTHING_TO_RECOVER_FROM: + // Final. This account has nothing that can ever unlock its keychain, so the + // import path is the answer rather than a retry. + this.showOnly(R.id.icloud_no_tags_container, R.string.icloud_no_tags_title); + break; + + case NOT_SIGNED_IN: + Log.e(TAG, "The account is not usable, so this screen has nothing to do"); + this.finish(); + break; + + case SERVICE_UNSURE: + default: + // Everything unrecognised lands here on purpose: "try again later" is the safe + // thing to say about a failure whose cause is not established, and it is a long + // way better than telling somebody they own no tags. + this.showOnly(R.id.icloud_retry_container, R.string.icloud_service_unsure_title); + ((TextView) this.findViewById(R.id.icloud_retry_body)).setText( + failure == ICloudFailure.SERVICE_UNSURE + ? this.getString(R.string.icloud_service_unsure_body) + : detail); + break; + } + } + + private void leaveForFileImport() { + final android.content.Intent data = new android.content.Intent(); + data.putExtra(RESULT_WANTS_FILE_IMPORT, true); + this.setResult(RESULT_OK, data); + this.finish(); + } + + private void setLoadingText(final int stringResId) { + ((TextView) this.findViewById(R.id.icloud_loading_text)).setText(stringResId); + } + + /** + * Show one step and hide the rest. + * + *

Listed once so showing a step is "show this one" rather than every caller remembering + * to hide each of the others - the mistake that leaves two steps stacked on each other. + */ + private void showOnly(final int stepId, final int titleResId) { + for (final int candidate : new int[] { + R.id.icloud_loading_container, + R.id.icloud_device_container, + R.id.icloud_passcode_container, + R.id.icloud_no_tags_container, + R.id.icloud_retry_container, + R.id.icloud_results_container, + }) { + this.findViewById(candidate).setVisibility(candidate == stepId ? VISIBLE : GONE); + } + + ((TextView) this.findViewById(R.id.icloud_step_title)).setText(titleResId); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java index 9b72dc95..f86f01e7 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java @@ -86,6 +86,25 @@ public class MyDevicesListActivity extends AppCompatActivity { } ); + /** + * Reading the account, which can end by asking for the file picker instead. + * + *

An account with nothing to recover from, or with no tags on it, has one useful answer - + * import a bundle from somebody who owns them - so that screen hands the user straight back + * here with a flag rather than making them find the other button themselves. + */ + private final ActivityResultLauncher fetchFromICloudLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + (ActivityResult result) -> { + final Intent data = result.getData(); + if (data != null + && data.getBooleanExtra( + FetchFromICloudActivity.RESULT_WANTS_FILE_IMPORT, false)) { + this.handleStartImport(); + } + } + ); + private final ActivityResultLauncher deviceInfoActivityLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), (ActivityResult result) -> { @@ -136,6 +155,10 @@ protected void onCreate(Bundle savedInstanceState) { findViewById(R.id.my_devices_empty_import_button) .setOnClickListener(v -> this.handleStartImport()); + findViewById(R.id.my_devices_empty_fetch_button) + .setOnClickListener(v -> this.fetchFromICloudLauncher.launch( + new Intent(this, FetchFromICloudActivity.class))); + findViewById(R.id.my_devices_empty_wiki_link) .setOnClickListener(v -> this.openExportGuide()); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java index b04f7505..48cbafc1 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java @@ -7,9 +7,12 @@ import androidx.annotation.VisibleForTesting; import java.util.function.Function; +import java.util.function.Supplier; import dev.wander.android.opentagviewer.anisette.AnisetteSource; import dev.wander.android.opentagviewer.anisette.LocalAnisette; +import dev.wander.android.opentagviewer.python.icloud.ICloudService; +import dev.wander.android.opentagviewer.python.icloud.PythonICloudService; import dev.wander.android.opentagviewer.db.repo.model.UserSettings; import dev.wander.android.opentagviewer.service.web.AnisetteServerTesterService; @@ -67,6 +70,44 @@ public interface AnisetteFactory { */ private static HardwareDescriber hardwareDescriber = new ChaquopyHardwareDescriber(); + /** + * Opens a conversation with iCloud on the signed-in account. + * + *

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

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

Null is not a crash: the caller reports it as needing a sign-in, which is the same + * recovery as a session that has expired. + */ + public static ICloudService icloud() { + return icloudFactory.get(); + } + + @VisibleForTesting + public static void replaceICloud(final Supplier replacement) { + icloudFactory = replacement; + } + public static AppleAuthService authService() { return authService; } @@ -111,5 +152,6 @@ public static void reset() { anisetteFactory = LocalAnisette::new; serverTesterFactory = AnisetteServerTesterService::new; hardwareDescriber = new ChaquopyHardwareDescriber(); + icloudFactory = AppDependencies::openRealICloud; } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java index 37fc5741..d96c6e1d 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java @@ -50,6 +50,17 @@ private PythonAppleService(PythonAppleAccount account) { this.account = account; } + /** + * The signed-in account, for the iCloud flow. + * + *

This one, not a second one restored from the same stored JSON. One install is + * one device to Apple (rule 11), and a parallel account would be a second HTTP session on a + * second event loop presenting the same identity. + */ + public PythonAppleAccount getAccount() { + return this.account; + } + public Observable getLastReports(final List requests, final int hoursToGoBack) { return Observable.fromCallable(() -> { if (requests.isEmpty()) { diff --git a/app/src/main/res/layout/activity_fetch_from_icloud.xml b/app/src/main/res/layout/activity_fetch_from_icloud.xml new file mode 100644 index 00000000..7a040530 --- /dev/null +++ b/app/src/main/res/layout/activity_fetch_from_icloud.xml @@ -0,0 +1,316 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +