diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f646ed33..f610a85e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -287,7 +287,7 @@ chaquopy { // wheel for desktop platforms and a pure-Python `py3-none-any` one as well. // There is no Android wheel, so pip falls back to the pure-Python build - which // is correct but markedly slower. The messages here are small enough not to care. - install("git+https://github.com/parawanderer/FindMy.py@102dd8ea14767d2a2aa745186ac23276f32689f1") + install("git+https://github.com/parawanderer/FindMy.py@23a9b8d7109b405f8362ea1e69ebe51f9ca82fca") install("NSKeyedUnArchiver==1.5") } diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/AppleLoginFlowTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/AppleLoginFlowTest.java index c7be61d4..2bf086f7 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/AppleLoginFlowTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/AppleLoginFlowTest.java @@ -12,6 +12,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.containsString; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -264,6 +265,50 @@ public void awrongPasswordLeavesThemAbleToTryAgain() { onView(withId(R.id.login_button_main)).check(matches(isDisplayed())); } + /** + * A sign-in that could not reach Apple says so, in words. + * + *

The screen used to show "Login failed:" and nothing else. The most common real + * failure is a connection timeout, and `str(TimeoutError())` is the empty string, so the + * message it echoed was empty - leaving somebody unable to tell a wrong password from a dead + * network from a broken app. + * + *

Asserted on the text, not on "an error appeared". The previous version showed an error + * too; it just did not say anything. + */ + @Test + public void afailureToReachAppleSaysSoRatherThanShowingAnEmptyError() { + this.apple = FakeAppleAuthService.cannotReachApple(); + AppDependencies.replaceAuthService(this.apple); + + launch(); + signIn(); + + final String expected = getInstrumentation().getTargetContext() + .getString(R.string.login_failed_network); + + Eventually.check(() -> onView(withId(R.id.login_error_message_text)) + .check(matches(withText(expected)))); + } + + /** + * And an unrecognised failure still names something. + * + *

The fallback matters as much as the classified case: it is what anything unexpected + * lands in, and it must not be able to render as a bare colon again. + */ + @Test + public void anunrecognisedFailureStillShowsItsDetail() { + this.apple = FakeAppleAuthService.rejectsTheSignIn("Bad password"); + AppDependencies.replaceAuthService(this.apple); + + launch(); + signIn(); + + Eventually.check(() -> onView(withId(R.id.login_error_message_text)) + .check(matches(withText(containsString("Bad password"))))); + } + /** A rejected code says so, and gives the boxes back rather than stranding them. */ @Test public void aWrongCodeIsReportedAndTheBoxesComeBack() { diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentityTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentityTest.java index dc61615f..3cf5a07f 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentityTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentityTest.java @@ -142,10 +142,21 @@ public void everyProfileSuppliesTheSixFieldsFindMyExpects() throws Exception { } } - /** A fresh install is the new profile. The old one is only ever recovered, never chosen. */ + /** + * A fresh install claims the Mac. + * + *

It claimed the iPhone until Apple began answering 401 to {@code get_2fa_methods} for + * clients presenting that profile - provisioning and password auth both succeeded, and the + * very next request did not. The desktop exporter makes the same call against the same + * account and is answered, and this profile is byte-identical to what it provisions with. + * + *

So this asserts a decision taken from evidence, not a preference. If it ever changes + * back, that has to be because the 2FA question was answered - not because an iPhone icon + * looks better in a device list. + */ @Test - public void afreshIdentityIsAnIphone() { - assertEquals(Hardware.IPHONE, AdiDeviceIdentity.generate().hardware()); + public void afreshIdentityIsTheMacTheExporterAlsoUses() { + assertEquals(Hardware.LEGACY_MAC, AdiDeviceIdentity.generate().hardware()); } /** @@ -167,15 +178,15 @@ public void afreshIdentityHasTheShapesAdiAccepts() { } /** - * A fresh install's local user id is a UUID, because FindMy.py's is. + * The iPhone profile's local user id is a UUID, because FindMy.py's is. * *

Not cosmetic. The value Java provisions ADI with is handed to FindMy.py verbatim and * encoded there, so it has to be a string both sides can carry and that Apple has seen in * this shape before - which is the UUID every FindMy.py client already sends. */ @Test - public void afreshInstallsLocalUserIdIsAUuid() { - final String id = AdiDeviceIdentity.generate().localUserUuid(); + public void theiphoneProfilesLocalUserIdIsAUuid() { + final String id = Hardware.IPHONE.newLocalUserId(new java.security.SecureRandom()); assertEquals(36, id.length()); assertEquals(id.toUpperCase(java.util.Locale.ROOT), id); @@ -191,12 +202,11 @@ public void afreshInstallsLocalUserIdIsAUuid() { * itself to Apple as two. */ @Test - public void afreshInstallProvisionsUnderWhatFindMyWillSend() { - final AdiDeviceIdentity fresh = AdiDeviceIdentity.generate(); - final String whatJavaSends = - fresh.hardware().localUserHeader(fresh.localUserUuid()); + public void theiphoneProfileProvisionsUnderWhatFindMyWouldSend() { + final String id = Hardware.IPHONE.newLocalUserId(new java.security.SecureRandom()); + final String whatJavaSends = Hardware.IPHONE.localUserHeader(id); final String whatFindMyWillSend = Base64.encodeToString( - fresh.localUserUuid().getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); + id.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); assertEquals(whatFindMyWillSend, whatJavaSends); } diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/LocalAnisetteIdentityTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/LocalAnisetteIdentityTest.java index ff78cd3c..dea98a17 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/LocalAnisetteIdentityTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/anisette/LocalAnisetteIdentityTest.java @@ -102,11 +102,17 @@ public void anIdentityWrittenBeforeProfilesExistedIsStillTheMac() throws Excepti assertEquals("MacBookPro13,2", modelOf(subject().hardwareProfileJson())); } - /** Nothing stored at all is a genuinely new install, and gets the profile worth having. */ + /** + * Nothing stored at all is a genuinely new install - and it claims the Mac. + * + *

It claimed an iPhone until Apple answered 401 to {@code get_2fa_methods} for clients + * presenting that profile. See {@code AdiDeviceIdentity#generate()}. + */ @Test - public void afreshInstallIsAnIphone() throws Exception { - assertEquals(AdiDeviceIdentity.Hardware.IPHONE.toJson(), subject().hardwareProfileJson()); - assertEquals("iPhone15,2", modelOf(subject().hardwareProfileJson())); + public void afreshInstallIsTheMac() throws Exception { + assertEquals(AdiDeviceIdentity.Hardware.LEGACY_MAC.toJson(), + subject().hardwareProfileJson()); + assertEquals("MacBookPro13,2", modelOf(subject().hardwareProfileJson())); } /** @@ -140,7 +146,7 @@ public void readingALegacyProfileDoesNotWriteOneOverTheTopOfIt() { public void afreshInstallRecordsWhatItDecided() { subject().hardwareProfileJson(); - assertEquals(AdiDeviceIdentity.Hardware.IPHONE.name(), + assertEquals(AdiDeviceIdentity.Hardware.LEGACY_MAC.name(), this.preferences.getString(LocalAnisette.KEY_HARDWARE, null)); assertNotNull(this.preferences.getString(LocalAnisette.KEY_DEVICE_ID, null)); assertNotNull(this.preferences.getString(LocalAnisette.KEY_ADI_ID, null)); @@ -267,6 +273,11 @@ public void ahalfWrittenIdentityIsTreatedAsAbsent() { .putString(LocalAnisette.KEY_DEVICE_ID, OLD_DEVICE_ID) .commit()); - assertEquals(AdiDeviceIdentity.Hardware.IPHONE.toJson(), subject().hardwareProfileJson()); + assertEquals(AdiDeviceIdentity.Hardware.LEGACY_MAC.toJson(), + subject().hardwareProfileJson()); + // Not the point of this test, but worth pinning: it wrote a *new* identity rather than + // adopting the orphaned device id. + assertNotEquals(OLD_DEVICE_ID, + this.preferences.getString(LocalAnisette.KEY_DEVICE_ID, null)); } } diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java index 3a491b86..0f59adf9 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java @@ -143,6 +143,37 @@ public void amixOfBothKindsRefreshesTogether() { assertEquals("only the paired one needs converting", 1, converter.calls.size()); } + /** + * The crash a real import produced. + * + *

The import path built this map with {@code Collectors.toMap}, which throws on a null + * value - so importing a self-generated tag ended in a {@link NullPointerException} deep in + * the stream machinery, with nothing naming the tag or the import. It is the only kind of + * tag that arrives here with no plist, so it was also the only way to find it. + */ + @Test + public void amixOfBothKindsSurvivesBeingCollected() { + final Map fallbacks = BeaconRepository.plistFallbacks(List.of( + OwnedBeacon.builder().id("paired-1").content(PLIST).build(), + OwnedBeacon.builder().id("oh-1").content(null).accessoryJson(CUSTOM_JSON).build())); + + assertEquals(2, fallbacks.size()); + assertEquals(PLIST, fallbacks.get("paired-1")); + assertNull("a tag with no plist keeps its key and a null value", fallbacks.get("oh-1")); + assertTrue("the key must be there, or that tag is simply never fetched", + fallbacks.containsKey("oh-1")); + } + + /** And an import of nothing but self-generated tags is still a map of tags. */ + @Test + public void anImportOfOnlySelfGeneratedTagsCollectsFine() { + final Map fallbacks = BeaconRepository.plistFallbacks(List.of( + OwnedBeacon.builder().id("oh-1").content(null).accessoryJson(CUSTOM_JSON).build())); + + assertEquals(1, fallbacks.size()); + assertTrue(fallbacks.containsKey("oh-1")); + } + /** * The alignment record is what stops the first fetch searching the tag's whole * history, so the backfill has to hand it to the converter rather than dropping it. diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/ChaquopyHardwareDescriberTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/ChaquopyHardwareDescriberTest.java new file mode 100644 index 00000000..4839a935 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/ChaquopyHardwareDescriberTest.java @@ -0,0 +1,105 @@ +package dev.wander.android.opentagviewer.python; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import com.chaquo.python.Python; +import com.chaquo.python.android.AndroidPlatform; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Java asking the shared heuristic what an accessory is. + * + *

The heuristic itself is tested in {@code python/opentagviewer_export/tests/} and reachable + * from the APK per {@code PythonPackagingTest}. What is tested here is the Java side of the + * bridge: that the wrapper hands over what the function expects, returns the string rather + * than a {@code PyObject}'s {@code toString} of something else, and - the part that matters most + * - never throws, because it is called from a screen that must render either way. + */ +@RunWith(AndroidJUnit4.class) +public class ChaquopyHardwareDescriberTest { + + private static final String AIRTAG_PLIST = + "\n" + + "\n" + + "\n" + + "\n" + + " identifier725A989D-D871-49A7-B2FE-948C24F356AB\n" + + " model\n" + + " productId21760\n" + + " vendorId76\n" + + " stableIdentifier" + + "2001~#001234a12345aaac~#A02BCDEFG1AB\n" + + "\n" + + "\n"; + + private final HardwareDescriber describer = new ChaquopyHardwareDescriber(); + + @BeforeClass + public static void startPython() { + if (!Python.isStarted()) { + Python.start(new AndroidPlatform( + getInstrumentation().getTargetContext().getApplicationContext())); + } + } + + /** The whole point: a real answer, crossing the bridge, from the shared module. */ + @Test + public void anairTagIsNamedRatherThanNumbered() { + assertEquals("AirTag", this.describer.describe(AIRTAG_PLIST)); + } + + /** + * A tag with no plist is a null answer rather than a crash. + * + *

A self-generated tag has none, and this is the screen's most common non-Apple case. + * + *

Deliberately not named for the short-circuit. The implementation returns before + * crossing the bridge, which saves starting an interpreter - but nothing here can observe + * that, and Python would answer None for a null plist anyway. Asserting the contract this + * test can actually see beats a name implying one it cannot. + */ + @Test + public void atagWithoutAPlistIsANullAnswer() { + assertNull(this.describer.describe(null)); + assertNull(this.describer.describe("")); + assertNull(this.describer.whereToLookUp(null)); + } + + /** + * Nonsense is a null answer, not an exception. + * + *

The screen calls this after it has already drawn a label. Throwing would replace a + * correct-if-vague answer with a crash, which is a strictly worse trade - so the failure + * mode has to be "no improvement", and that is worth pinning rather than trusting. + */ + @Test + public void garbageIsRefusedQuietly() { + assertNull(this.describer.describe("not a plist at all")); + assertNull(this.describer.whereToLookUp("not a plist at all")); + } + + /** + * And the second question answers too, so the wrapper is not accidentally one function. + * + *

Only asserts that the call completes and is consistent with itself: what it says for an + * AirTag - a name it recognises - is the shared module's business, and pinning the sentence + * here would be the copy this design exists to avoid. + */ + @Test + public void thelookupHintIsReachableToo() { + // A recognised accessory needs no lookup hint; the contract is that asking is safe. + this.describer.whereToLookUp(AIRTAG_PLIST); + + assertNotNull("a recognised accessory should still describe", + this.describer.describe(AIRTAG_PLIST)); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/FakeAppleAuthService.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/FakeAppleAuthService.java index b379129e..113791f0 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/FakeAppleAuthService.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/FakeAppleAuthService.java @@ -85,6 +85,22 @@ public static FakeAppleAuthService rejectsTheSignIn(final String message) { return fake; } + /** + * Apple could not be reached at all - the failure that produced an empty error message. + * + *

Worth its own named state rather than {@code rejectsTheSignIn("")}, because the shape + * is what matters: a timeout carries no message, so the screen has to build the + * sentence from the reason instead of echoing what it was handed. + */ + public static FakeAppleAuthService cannotReachApple() { + final FakeAppleAuthService fake = + new FakeAppleAuthService(LOGIN_STATE.LOGGED_OUT, null); + // Empty, exactly as str(TimeoutError()) arrives from Python. + fake.loginFailsWith = new PythonAccountLoginException( + "", PythonAccountLoginException.REASON_NETWORK); + return fake; + } + /** Signing in works, but the code that gets typed is refused. */ public FakeAppleAuthService thatRejectsTheCode(final String message) { this.codeFailsWith = new PythonAccountLoginException(message); diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/BeaconIconTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/BeaconIconTest.java new file mode 100644 index 00000000..92a3f5d7 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/BeaconIconTest.java @@ -0,0 +1,257 @@ +package dev.wander.android.opentagviewer.ui; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; + +import androidx.appcompat.content.res.AppCompatResources; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.data.model.BeaconInformation; + +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +/** + * Which icon a tag with no emoji gets, and whether that icon actually draws anything. + * + *

Both halves matter, and the second is the one that has gone wrong here before. A vector that + * resolves, measures and reports no error can still paint nothing - the history timeline shipped + * blank exactly that way, with a screenshot test staying green because it loaded the drawables + * with a theme and the app did not. So these load them the way the app does, and then look at the + * pixels. + */ +@RunWith(AndroidJUnit4.class) +public class BeaconIconTest { + + private static final int APPLE_VENDOR_ID = 76; + + /** Where AGP wants rendered images, so they come back to the host after the run. */ + private static File outputDir; + + @BeforeClass + public static void resolveOutputDir() { + final String fromAgp = InstrumentationRegistry.getArguments() + .getString("additionalTestOutputDir"); + + outputDir = fromAgp != null + ? new File(fromAgp) + : getInstrumentation().getTargetContext().getExternalFilesDir("icon-shots"); + + if (outputDir != null && !outputDir.exists()) { + //noinspection ResultOfMethodCallIgnored + outputDir.mkdirs(); + } + } + + private Context context() { + return getInstrumentation().getTargetContext(); + } + + private static BeaconInformation beacon(final boolean custom, final int vendorId) { + return BeaconInformation.builder() + .beaconId("b-1") + .customAccessory(custom) + .vendorId(vendorId) + .build(); + } + + // ---------------------------------------------------------------- which icon + + /** The case the whole change exists for: it is not Apple's logo any more. */ + @Test + public void aselfGeneratedTagDoesNotBorrowApplesLogo() { + assertEquals(R.drawable.tag_self_generated, + BeaconIcon.forBeacon(beacon(true, 0))); + } + + @Test + public void applesOwnHardwareStillGetsApplesLogo() { + assertEquals(R.drawable.apple, + BeaconIcon.forBeacon(beacon(false, APPLE_VENDOR_ID))); + } + + /** A Chipolo, a Pebblebee - findable, paired, and not made by Apple. */ + @Test + public void athirdPartyTagGetsTheFindableIcon() { + assertEquals(R.drawable.tag_third_party, + BeaconIcon.forBeacon(beacon(false, 0x009E))); + } + + /** + * An unknown vendor is third-party, not Apple. + * + *

The honest way round. Claiming Apple for something unidentified is precisely the wrong + * answer this replaces, and it is the case a plist with no vendor id lands in. + */ + @Test + public void anunknownVendorIsNotAssumedToBeApple() { + assertNotEquals(R.drawable.apple, BeaconIcon.forBeacon(beacon(false, 0))); + } + + /** + * A self-generated tag stays self-generated even if something put a vendor id on it. + * + *

Order matters: it has no Apple provenance whatever its fields say, and the checks are + * not mutually exclusive by construction. + */ + @Test + public void beingSelfGeneratedWinsOverAnyVendorId() { + assertEquals(R.drawable.tag_self_generated, + BeaconIcon.forBeacon(beacon(true, APPLE_VENDOR_ID))); + } + + @Test + public void thethreeIconsAreActuallyDifferent() { + assertNotEquals(R.drawable.apple, R.drawable.tag_self_generated); + assertNotEquals(R.drawable.apple, R.drawable.tag_third_party); + assertNotEquals(R.drawable.tag_self_generated, R.drawable.tag_third_party); + } + + // ---------------------------------------------------------------- does it draw + + /** + * Every icon paints something, in both themes. + * + *

Loaded through {@link AppCompatResources}, which is what the screens use - not + * {@code ResourcesCompat.getDrawable(res, id, null)}, whose null theme is what rendered the + * timeline invisible while its test passed. + */ + @Test + public void everyIconDrawsSomethingInBothThemes() { + for (final int mode : new int[]{ + Configuration.UI_MODE_NIGHT_NO, Configuration.UI_MODE_NIGHT_YES}) { + final Context themed = themedContext(mode); + + for (final int icon : new int[]{ + R.drawable.apple, + R.drawable.tag_self_generated, + R.drawable.tag_third_party}) { + final Drawable drawable = AppCompatResources.getDrawable(themed, icon); + + assertNotNull("icon " + icon + " did not load", drawable); + assertTrue("icon " + icon + " has no intrinsic width", + drawable.getIntrinsicWidth() > 0); + assertTrue("icon " + icon + " painted nothing in mode " + mode, + paintedPixels(drawable) > 0); + } + } + } + + /** + * And they are visibly different from one another once drawn. + * + *

Three ids pointing at three files proves nothing about what a person sees; two vectors + * could easily be near-identical shapes. Comparing the painted coverage is a cheap way to + * say they are actually distinguishable rather than merely distinct resources. + */ + @Test + public void thenewIconsLookDifferentFromApples() { + final Context themed = themedContext(Configuration.UI_MODE_NIGHT_NO); + + final int apple = paintedPixels( + AppCompatResources.getDrawable(themed, R.drawable.apple)); + final int haystack = paintedPixels( + AppCompatResources.getDrawable(themed, R.drawable.tag_self_generated)); + final int findable = paintedPixels( + AppCompatResources.getDrawable(themed, R.drawable.tag_third_party)); + + assertNotEquals("the haystack draws the same coverage as Apple's logo", apple, haystack); + assertNotEquals("the findable icon draws the same coverage as Apple's logo", + apple, findable); + assertNotEquals("the two new icons draw the same coverage", haystack, findable); + } + + /** + * Draw each icon large, in both themes, so a person can see what they actually look like. + * + *

**Not an assertion**, and it is not pretending to be one - the tests above are what + * fails the build. These are hand-authored vector paths, and "covers some pixels" is a long + * way from "reads as a haystack", which is a judgement only an eye can make. + */ + @Test + public void renderTheIconsToLookAt() throws IOException { + for (final int mode : new int[]{ + Configuration.UI_MODE_NIGHT_NO, Configuration.UI_MODE_NIGHT_YES}) { + final Context themed = themedContext(mode); + final String variant = mode == Configuration.UI_MODE_NIGHT_YES ? "dark" : "light"; + + write("apple-" + variant, + AppCompatResources.getDrawable(themed, R.drawable.apple)); + write("selfgenerated-" + variant, + AppCompatResources.getDrawable(themed, R.drawable.tag_self_generated)); + write("thirdparty-" + variant, + AppCompatResources.getDrawable(themed, R.drawable.tag_third_party)); + } + } + + /** At 8x, because a 24dp vector says nothing about its shape at 24 pixels. */ + private static void write(final String name, final Drawable drawable) throws IOException { + assertNotNull(drawable); + if (outputDir == null) { + return; + } + + final int size = Math.max(1, drawable.getIntrinsicWidth()) * 8; + final Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(bitmap); + + drawable.setBounds(0, 0, size, size); + drawable.draw(canvas); + + try (FileOutputStream out = new FileOutputStream(new File(outputDir, name + ".png"))) { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); + } + bitmap.recycle(); + } + + // ---------------------------------------------------------------- helpers + + private Context themedContext(final int nightMode) { + final Configuration configuration = + new Configuration(context().getResources().getConfiguration()); + configuration.uiMode = + (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) | nightMode; + return context().createConfigurationContext(configuration); + } + + /** How many pixels the drawable actually covers, rendered at its natural size. */ + private static int paintedPixels(final Drawable drawable) { + assertNotNull(drawable); + + final int size = Math.max(1, drawable.getIntrinsicWidth()); + final Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(bitmap); + + drawable.setBounds(0, 0, size, size); + drawable.draw(canvas); + + int painted = 0; + for (int x = 0; x < size; x++) { + for (int y = 0; y < size; y++) { + if (android.graphics.Color.alpha(bitmap.getPixel(x, y)) > 0) { + painted++; + } + } + } + + bitmap.recycle(); + return painted; + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/FakeMapProvider.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/FakeMapProvider.java new file mode 100644 index 00000000..0f42c86b --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/FakeMapProvider.java @@ -0,0 +1,203 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import android.app.Activity; +import android.view.View; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A map that draws nothing and remembers everything. + * + *

Why this exists. The instrumented suite runs on the {@code aosp-atd} managed device, + * which has no Play Services - so a real map cannot initialise and {@code MapsActivity} had never + * been started by any test. The map, the tag carousel, history and delete are the most-used parts + * of the app and had no coverage at all; a change to the carousel could compile, pass and crash on + * launch. + * + *

Rule 7 is what makes this cheap. Providers are already behind {@link IMapProvider} - a third + * party added MapLibre in about eighty lines - so this is one more implementation rather than a + * change to any screen. + * + *

It records rather than renders, which is the more useful half anyway. "Is there a + * marker for each tag, at the right place" is a better assertion than a screenshot of a map: it + * says what the app decided, not what Google drew. + */ +public class FakeMapProvider implements IMapProvider { + + /** A marker as the screen asked for it, kept so a test can ask what was placed where. */ + public static final class PlacedMarker { + public final String id; + public final MapMarker marker; + + PlacedMarker(final String id, final MapMarker marker) { + this.id = id; + this.marker = marker; + } + } + + private final Map markers = new LinkedHashMap<>(); + private final Map polylines = new LinkedHashMap<>(); + private final List cameraMoves = new ArrayList<>(); + + private View mapView; + private MapStyle style; + private int nextId = 0; + + /** Set once {@link #initialize} has called its callback back, as a real provider would. */ + private boolean ready = false; + + // ------------------------------------------------------------------ what a test asks + + public List markers() { + return new ArrayList<>(this.markers.values()); + } + + public int markerCount() { + return this.markers.size(); + } + + public List polylines() { + return new ArrayList<>(this.polylines.values()); + } + + public List cameraMoves() { + return new ArrayList<>(this.cameraMoves); + } + + public boolean isReady() { + return this.ready; + } + + public MapStyle style() { + return this.style; + } + + // ------------------------------------------------------------------ IMapProvider + + /** + * Ready immediately, on the caller's thread. + * + *

A real provider calls back asynchronously once the map surface exists. Doing it + * synchronously here removes a wait the test would otherwise have to guess at, and the + * screen's own code path is identical either way - it only ever reacts to the callback. + */ + @Override + public void initialize( + final Activity activity, final int containerViewId, final OnMapReadyCallback callback) { + this.mapView = new View(activity); + this.ready = true; + + if (callback != null) { + callback.onMapReady(this); + } + } + + @Override + public void setMapStyle(final MapStyle mapStyle) { + this.style = mapStyle; + } + + @Override + public String addMarker(final MapMarker marker) { + final String id = "marker-" + (this.nextId++); + this.markers.put(id, new PlacedMarker(id, marker)); + return id; + } + + @Override + public void removeMarker(final String markerId) { + this.markers.remove(markerId); + } + + @Override + public void setMarkerZIndex(final String markerId, final float zIndex) { + // Recorded nowhere: nothing asserts stacking order, and pretending to model it would be + // a fake with opinions of its own. + } + + @Override + public void clearMarkers() { + this.markers.clear(); + } + + @Override + public String addPolyline(final MapPolyline polyline) { + final String id = "polyline-" + (this.nextId++); + this.polylines.put(id, polyline); + return id; + } + + @Override + public void removePolyline(final String polylineId) { + this.polylines.remove(polylineId); + } + + @Override + public void clearPolylines() { + this.polylines.clear(); + } + + @Override + public void moveCamera(final double latitude, final double longitude, final float zoom) { + this.cameraMoves.add(new CameraPosition(latitude, longitude, zoom)); + } + + @Override + public void animateCamera( + final double latitude, final double longitude, final float zoom, + final Runnable callback) { + this.cameraMoves.add(new CameraPosition(latitude, longitude, zoom)); + if (callback != null) { + callback.run(); + } + } + + @Override + public void setOnMapClickListener(final OnMapClickListener listener) { + } + + @Override + public void setOnMarkerClickListener(final OnMarkerClickListener listener) { + } + + @Override + public void setPadding(final int left, final int top, final int right, final int bottom) { + } + + @Override + public CameraPosition getCameraPosition() { + return this.cameraMoves.isEmpty() + ? new CameraPosition(0, 0, 0) + : this.cameraMoves.get(this.cameraMoves.size() - 1); + } + + @Override + public void setMyLocationButtonEnabled(final boolean enabled) { + } + + @Override + public void setRotateGesturesEnabled(final boolean enabled) { + } + + @Override + public void setCompassEnabled(final boolean enabled) { + } + + @Override + public void setMapToolbarEnabled(final boolean enabled) { + } + + @Override + public void clear() { + this.markers.clear(); + this.polylines.clear(); + } + + @Override + public View getMapView() { + return this.mapView; + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/MapProviderSubstitutionTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/MapProviderSubstitutionTest.java new file mode 100644 index 00000000..e3b804cb --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/MapProviderSubstitutionTest.java @@ -0,0 +1,94 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * The seam that lets a test put a map on a device that cannot have one. + * + *

The instrumented suite runs on {@code aosp-atd}, which has no Play Services, so a real + * provider cannot initialise there. Rule 7 already put providers behind {@link IMapProvider} - + * this adds the one thing missing, a way to hand a different one to the screens. + * + *

What this does not yet do is launch {@code MapsActivity}. Probing that established + * something worth writing down: Play Services is not the blocker - the screen reaches map + * initialisation happily without it. What stops it is that the screen requires a usable + * signed-in session, and restoring one goes through {@code PythonAuthService.restoreAccount}, + * which is static and has no seam. A stored blob that is not a real encrypted session fails to + * restore, and the screen then redirects to login before drawing anything. + * + *

So the end-to-end journey needs an account seam next, not a map one. That is the useful + * result of building this, and it is recorded here rather than in a test that only passes when + * run on its own. + */ +@RunWith(AndroidJUnit4.class) +public class MapProviderSubstitutionTest { + + @After + public void putTheRealOneBack() { + MapProviderFactory.reset(); + } + + /** Without the hook, production behaviour is untouched. */ + @Test + public void bydefaultAReadProviderIsBuilt() { + MapProviderFactory.reset(); + + assertNotNull(MapProviderFactory.create(MapProviderFactory.PROVIDER_GOOGLE)); + } + + /** With it, every screen gets the substitute regardless of the configured provider. */ + @Test + public void asubstituteIsHandedOutInsteadOfAnyRealProvider() { + final FakeMapProvider fake = new FakeMapProvider(); + MapProviderFactory.replaceWith(() -> fake); + + assertSame(fake, MapProviderFactory.create(MapProviderFactory.PROVIDER_GOOGLE)); + assertSame(fake, MapProviderFactory.create(MapProviderFactory.PROVIDER_AMAP)); + assertSame(fake, MapProviderFactory.create(null)); + } + + /** And resetting genuinely restores it, or the next test inherits a fake map. */ + @Test + public void resettingRestoresTheRealFactory() { + MapProviderFactory.replaceWith(FakeMapProvider::new); + MapProviderFactory.reset(); + + assertTrue("reset must hand back a real provider again", + MapProviderFactory.create(MapProviderFactory.PROVIDER_GOOGLE) + instanceof GoogleMapProvider); + } + + /** + * The fake records what it is asked for, which is the half a test actually asserts on. + * + *

"Is there a marker for each tag, in the right place" says what the app decided; a + * screenshot of a map says what Google drew. + */ + @Test + public void thefakeRecordsWhatTheScreenAsksOfIt() { + final FakeMapProvider fake = new FakeMapProvider(); + + final String id = fake.addMarker(MapMarker.builder() + .position(52.37, 4.90) + .title("Bike") + .build()); + fake.moveCamera(52.37, 4.90, 15f); + + assertEquals(1, fake.markerCount()); + assertEquals("Bike", fake.markers().get(0).marker.getTitle()); + assertEquals(1, fake.cameraMoves().size()); + assertEquals(15f, fake.cameraMoves().get(0).getZoom(), 0.001f); + + fake.removeMarker(id); + assertEquals(0, fake.markerCount()); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/theme/ThemeAttributesMatchTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/theme/ThemeAttributesMatchTest.java new file mode 100644 index 00000000..a5e6c8ae --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/theme/ThemeAttributesMatchTest.java @@ -0,0 +1,99 @@ +package dev.wander.android.opentagviewer.ui.theme; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.TypedArray; +import android.view.ContextThemeWrapper; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import dev.wander.android.opentagviewer.R; + +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * The day and night themes have to agree about everything that is not a colour. + * + *

They are two independent declarations, not one inheriting the other. + * {@code values-night/themes.xml} redeclares {@code Theme.OpenTagViewer} from scratch, so an + * attribute listed in one file and not the other does not fall back to the app's value - it + * falls back to the platform's, silently, in one mode only. + * + *

That is how the dropdown menu came to have rounded corners in light mode and square ones in + * dark: {@code android:popupMenuStyle} was set in the day theme alone. Nothing failed, nothing + * logged, and it is invisible to anyone who does not switch themes. + */ +@RunWith(AndroidJUnit4.class) +public class ThemeAttributesMatchTest { + + /** + * Attributes that must resolve to the same thing in both modes. + * + *

Shape, typeface and elevation describe the app's identity rather than its palette, so a + * difference here is a mistake by definition. Colours are deliberately absent - those are + * supposed to differ, and that is the whole point of having two files. + */ + private static final int[] SAME_IN_BOTH_MODES = { + android.R.attr.popupMenuStyle, + android.R.attr.fontFamily, + android.R.attr.buttonStyle, + }; + + private static Context themed(final int nightMode) { + final Context base = getInstrumentation().getTargetContext(); + final Configuration configuration = new Configuration(base.getResources().getConfiguration()); + configuration.uiMode = + (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) | nightMode; + + return new ContextThemeWrapper( + base.createConfigurationContext(configuration), R.style.Theme_OpenTagViewer); + } + + private static int resolve(final Context context, final int attribute) { + final TypedArray values = context.obtainStyledAttributes(new int[]{attribute}); + try { + return values.getResourceId(0, 0); + } finally { + values.recycle(); + } + } + + /** The headline: whatever the day theme says, the night theme says too. */ + @Test + public void bothThemesResolveTheSameNonColourAttributes() { + final Context light = themed(Configuration.UI_MODE_NIGHT_NO); + final Context dark = themed(Configuration.UI_MODE_NIGHT_YES); + + for (final int attribute : SAME_IN_BOTH_MODES) { + final int inLight = resolve(light, attribute); + final int inDark = resolve(dark, attribute); + + assertTrue("neither theme sets attribute " + attribute + + ", so it is not being checked at all", + inLight != 0 || inDark != 0); + assertEquals("attribute " + attribute + " differs between light and dark, so one of " + + "them is falling back to the platform default", + inLight, inDark); + } + } + + /** + * And specifically, the popup is ours in both - which is the one that was wrong. + * + *

Named separately from the loop above so a failure says what the user would see rather + * than an attribute id. + */ + @Test + public void thedropdownMenuIsTheAppsInBothThemes() { + for (final int mode : new int[]{ + Configuration.UI_MODE_NIGHT_NO, Configuration.UI_MODE_NIGHT_YES}) { + assertEquals("the dropdown falls back to the platform's square-cornered popup", + R.style.PopupMenu, resolve(themed(mode), android.R.attr.popupMenuStyle)); + } + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryImportTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryImportTest.java index 3620523b..85d77b18 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryImportTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryImportTest.java @@ -2,6 +2,7 @@ import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -11,7 +12,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; +import dev.wander.android.opentagviewer.R; import dev.wander.android.opentagviewer.data.model.BeaconInformation; +import dev.wander.android.opentagviewer.ui.BeaconIcon; import dev.wander.android.opentagviewer.db.repo.model.BeaconData; import dev.wander.android.opentagviewer.db.repo.model.ImportData; import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; @@ -153,8 +156,10 @@ public void itDescribesItselfOnTheDeviceScreen() { assertEquals(IDENTIFIER, info.getBeaconId()); assertEquals(NAME, info.getName()); assertEquals(KEY_COUNT, info.getCustomAccessoryKeyCount()); - assertTrue("it needs some emoji, or it renders as a gap where every other row has one", + assertFalse("no emoji: nobody has ever named this tag, and the icon covers it", info.isEmojiFilled()); + assertEquals("so it must fall to the self-generated icon, not Apple's logo", + R.drawable.tag_self_generated, BeaconIcon.forBeacon(info)); } /** diff --git a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java index f1eeed95..002a8177 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java @@ -51,6 +51,7 @@ import dev.wander.android.opentagviewer.db.repo.model.UserSettings; import dev.wander.android.opentagviewer.python.AppleAuthService; import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.PythonAccountLoginException; import dev.wander.android.opentagviewer.python.PythonAuthService; import dev.wander.android.opentagviewer.python.PythonAuthService.AuthMethodPhone; import dev.wander.android.opentagviewer.python.PythonAuthService.PythonAuthResponse; @@ -522,10 +523,38 @@ public void onClickLoginButton(View view) { loginErrorMessage.setVisibility(VISIBLE); TextView loginErrorText = this.findViewById(R.id.login_error_message_text); - loginErrorText.setText(this.getString(R.string.login_failed_x, error.getLocalizedMessage())); + loginErrorText.setText(this.describeLoginFailure(error)); }); } + /** + * What to put on screen when a sign-in fails. + * + *

Never an empty message. This used to be {@code login_failed_x} with + * {@code getLocalizedMessage()}, and the most common real failure - a connection timeout - + * carries no message at all, so the screen showed "Login failed:" and stopped. A person + * cannot tell from that whether they typed their password wrong, whether Apple is down, or + * whether the app is broken. + * + *

A recognised reason gets a translated sentence that says what to do. Anything else + * falls back to the detail, which at least names the exception - unhelpful, but honest, + * and better than a guess at a cause we have not established. + */ + private String describeLoginFailure(final Throwable error) { + final String reason = error instanceof PythonAccountLoginException + ? ((PythonAccountLoginException) error).getReason() + : PythonAccountLoginException.REASON_UNKNOWN; + + if (PythonAccountLoginException.REASON_NETWORK.equals(reason)) { + return this.getString(R.string.login_failed_network); + } + + final String detail = error.getLocalizedMessage(); + return detail == null || detail.isBlank() + ? this.getString(R.string.login_failed_x, error.getClass().getSimpleName()) + : this.getString(R.string.login_failed_x, detail); + } + private void handleLoginResponse(PythonAuthResponse authResponse) { final PythonAuthService.LOGIN_STATE loginState = authResponse.getLoginState(); Log.d(TAG, "Login state was " + loginState); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java index 8f83442b..293a5332 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java @@ -59,7 +59,13 @@ import dev.wander.android.opentagviewer.db.room.entity.UserBeaconOptions; import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; import dev.wander.android.opentagviewer.util.parse.BeaconDataParser; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.ui.BeaconIcon; +import dev.wander.android.opentagviewer.python.HardwareDescriber; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.annotations.NonNull; public class DeviceInfoActivity extends AppCompatActivity { @@ -81,6 +87,15 @@ public class DeviceInfoActivity extends AppCompatActivity { private Button currentIconButton; private ActivityDeviceInfoBinding binding; + /** + * The in-flight call to the shared heuristic, so it can be cancelled. + * + *

It hops back to the main thread to set a label. If the screen is gone by then, that is + * an update to a binding whose views are detached - held here so {@link #onDestroy()} can + * stop it rather than letting it land wherever it lands. + */ + private Disposable hardwareLookup; + private boolean hasNameChanges = false; @Override @@ -125,15 +140,11 @@ protected void onCreate(Bundle savedInstanceState) { binding.setImportedAt(timestampFormat.format(new Date(this.importData.importedAt))); binding.setExportedBy(this.importData.sourceUser); - // Checked first, and not as another guess. The other two read a plist field, and a - // self-generated tag has no plist at all - so without this it falls through both and - // reports "Unknown", which is the one answer that is definitely wrong: this is the kind - // of tag we know the most about, not the least. - binding.setDeviceType(this.beaconInformation.isCustomAccessory() - ? this.getString(R.string.custom_tag) - : this.beaconInformation.isIpad() ? this.getString(R.string.ipad) - : this.beaconInformation.isAirTag() ? this.getString(R.string.airtag) - : this.getString(R.string.unknown)); + // What is known without asking anything, drawn immediately. The shared heuristic can + // improve on it, but it costs a Python interpreter, so this screen must be readable + // before that answers rather than flashing "Unknown" and correcting itself. + binding.setDeviceType(this.knownDeviceType()); + this.describeHardwareInTheBackground(); // debug info binding.setDeviceNameOriginal(this.beaconInformation.getOriginalName()); @@ -246,7 +257,8 @@ private void visualiseDeviceEmoji() { ((MaterialButton)currentIconButton).setIcon(null); } else { currentIconButton.setText(null); - ((MaterialButton)currentIconButton).setIcon(AppCompatResources.getDrawable(this, R.drawable.apple)); + ((MaterialButton)currentIconButton).setIcon(AppCompatResources.getDrawable( + this, BeaconIcon.forBeacon(this.beaconInformation))); } } @@ -366,6 +378,70 @@ private void hideEmojiMenu() { .start(); } + @Override + protected void onDestroy() { + if (this.hardwareLookup != null && !this.hardwareLookup.isDisposed()) { + this.hardwareLookup.dispose(); + } + super.onDestroy(); + } + + /** + * The best description available without asking Python. + * + *

A self-generated tag is checked first, and not as another guess: the other two read a + * plist field and it has no plist at all, so without this it falls through both and reports + * "Unknown" - the one answer that is definitely wrong, since it is the kind of tag the app + * knows the most about. + */ + private String knownDeviceType() { + if (this.beaconInformation.isCustomAccessory()) { + return this.getString(R.string.custom_tag); + } + if (this.beaconInformation.isIpad()) { + return this.getString(R.string.ipad); + } + if (this.beaconInformation.isAirTag()) { + return this.getString(R.string.airtag); + } + return this.getString(R.string.unknown); + } + + /** + * Ask the shared heuristic what this actually is, and improve the label if it knows. + * + *

Why bother, when {@link #knownDeviceType()} already answered. That answer is the + * older, narrower version of the same question: it recognises an AirTag and an iPad and + * nothing else, so a pair of AirPods, a Tile or a Chipolo all arrive as "Unknown". The shared + * heuristic names them, knows which AirPod it is, and falls back to the vendor and product + * ids with somewhere to look them up. It lives in {@code opentagviewer_export/hardware.py} + * and the desktop exporter uses the same module - see AGENTS.md rule on not porting the + * table, because the vendor list grows and two copies means one goes stale. + * + *

Off the main thread, and only ever an improvement. The call starts a Python + * interpreter and parses a plist. A null answer means nothing recognised the record, and + * then the label already on screen stands - a wrong name is believed, where a hex number + * gets looked up. + */ + private void describeHardwareInTheBackground() { + final String plist = this.beaconInformation.getOwnedBeaconPlistRaw(); + if (plist == null || plist.isEmpty()) { + // A self-generated tag, which describes itself and has no plist to read. + return; + } + + final HardwareDescriber describer = AppDependencies.hardwareDescriber(); + + this.hardwareLookup = Observable + .fromCallable(() -> Optional.ofNullable(describer.describe(plist))) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + described -> described.ifPresent(this.binding::setDeviceType), + error -> Log.w(TAG, "Could not describe this accessory; " + + "keeping the label already shown", error)); + } + private String getDeviceNameForTitle() { if (this.beaconInformation.isEmojiFilled()) { return String.format("%s %s", this.beaconInformation.getEmoji(), this.beaconInformation.getName()); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java index 86ef3b27..9f11dc73 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -66,6 +66,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; import java.util.Locale; import java.util.HashMap; import java.util.Map; @@ -92,6 +93,7 @@ import dev.wander.android.opentagviewer.db.util.BeaconCombinerUtil; import dev.wander.android.opentagviewer.python.AccessoryRequest; import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.ui.BeaconIcon; import dev.wander.android.opentagviewer.python.PythonAppleService; import dev.wander.android.opentagviewer.python.PythonAccountLoginException; import dev.wander.android.opentagviewer.python.PythonAuthService; @@ -645,8 +647,8 @@ private void onImportFilePicked(Intent data, final String passcode) { .flatMapCompletable(storedBeacons -> RxFlows.allThen( // Once, after every accessory has landed, rather than per accessory. this.updateBeaconGeocodings(), - this.fetchLastReports(storedBeacons.getOwnedBeacons().stream() - .collect(Collectors.toMap(b -> b.id, b -> b.content)), HOURS_TO_GO_BACK_24H) + this.fetchLastReports( + BeaconRepository.plistFallbacks(storedBeacons.getOwnedBeacons()), HOURS_TO_GO_BACK_24H) .doOnNext(this::addBeaconLocationsToCurrent), BeaconDataParser.parseAsync(BeaconCombinerUtil.combine(storedBeacons)) .doOnNext(this::addBeaconToCurrent) @@ -1325,15 +1327,19 @@ private synchronized void updateBeaconCards() { deviceNameView.setText(beacon.getName()); // icon + TextView emojiContainer = v.findViewById(R.id.device_icon_emoji); + ImageView iconContainer = v.findViewById(R.id.device_icon_img); if (beacon.isEmojiFilled()) { - // use emoji - TextView emojiContainer = v.findViewById(R.id.device_icon_emoji); - ImageView iconContainer = v.findViewById(R.id.device_icon_img); + // Whatever the user or their Apple device set always wins. emojiContainer.setText(beacon.getEmoji()); emojiContainer.setVisibility(VISIBLE); iconContainer.setVisibility(GONE); + } else { + // Was always Apple's logo, for a Chipolo and an OpenHaystack tag alike. + iconContainer.setImageResource(BeaconIcon.forBeacon(beacon)); + iconContainer.setVisibility(VISIBLE); + emojiContainer.setVisibility(GONE); } - // ^ ELSE: show default apple icon // the location @@ -1400,10 +1406,12 @@ private void fetchAndUpdateCurrentBeacons() { // **Not Collectors.toMap**, which throws on a null value. This is the periodic refresh // for every tag at once, so a single self-generated tag - which has no plist - took // down the refresh for all of them, not just for itself. + // Same null-tolerant shape as the import path - see BeaconRepository.plistFallbacks. final Map beacons = new HashMap<>(); this.beacons.values().forEach(b -> beacons.put(b.getInfo().getBeaconId(), b.getInfo().getOwnedBeaconPlistRaw())); + TagCardHelper.toggleRefreshLoadingAll(this.dynamicCardsForTag, true); var async = this.fetchLastReports(beacons) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentity.java b/app/src/main/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentity.java index 4443abd5..0f430100 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/anisette/AdiDeviceIdentity.java @@ -49,10 +49,27 @@ public AdiDeviceIdentity(String uniqueDeviceIdentifier, String adiIdentifier, this.hardware = hardware; } - /** A fresh identity, claiming to be an iPhone. Call once, persist, never call again. */ + /** + * A fresh identity. Call once, persist, never call again. + * + *

The Mac, not the iPhone, and that is a retreat from evidence rather than a + * preference. Claiming an iPhone provisioned fine and signed in fine, and then Apple + * answered 401 to the very next request - {@code get_2fa_methods}, asking which phone + * numbers could receive a code. The desktop exporter makes the same call against the same + * account and is answered, and the largest remaining difference between them was this. + * + *

An iPhone is a trusted device, so a client claiming to be one asking where to + * send an SMS code is a question a real iPhone would not ask. That is a guess at the + * mechanism; what is not a guess is that this profile works and that one did not get past + * sign-in, and a nicer icon is not worth an app nobody can log into. + * + *

These values are byte-identical to what the {@code anisette} package provisions with, + * which is what the exporter uses - so the app and the working program now introduce + * themselves to Apple as the same machine. + */ public static AdiDeviceIdentity generate() { final SecureRandom random = new SecureRandom(); - final Hardware hardware = Hardware.IPHONE; + final Hardware hardware = Hardware.LEGACY_MAC; return new AdiDeviceIdentity( UUID.randomUUID().toString().toUpperCase(Locale.ROOT), @@ -78,7 +95,8 @@ private static String hex(SecureRandom random, int bytes) { *

Two profiles, and which one an install has is not a preference: it is part of * what Apple binds a session to, so moving an install from one to the other costs that user * a sign-in and leaves a second entry in their device list. An install that already has an - * ADI identity keeps {@link #LEGACY_MAC} forever; only a fresh one gets {@link #IPHONE}. + * ADI identity keeps {@link #LEGACY_MAC}, and so, for now, does a fresh one - see + * {@link #generate()} for why {@link #IPHONE} is built but not chosen. * *

Each carries all six parts because they describe one real release and move * together - model, OS, build, CFNetwork and Darwin. FindMy.py's {@code DeviceIdentity} @@ -122,7 +140,12 @@ public String localUserHeader(String localUserUuid) { }, /** - * What a fresh install claims: an iPhone 14 Pro. + * An iPhone 14 Pro. Built, tested, and not currently used. + * + *

It was what a fresh install claimed until Apple started answering 401 to + * {@code get_2fa_methods} for clients presenting it - see {@link #generate()}. Kept + * rather than deleted because the values are right and the reasoning below still holds + * if the 2FA question is ever answered; deleting it would mean rediscovering all of it. * *

For the icon, and for the words next to it. Apple synthesises the device-list * entry from the claimed model, so {@code iPhone15,2} renders as "iPhone 14 Pro" with a diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java index 37867712..75063d34 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java @@ -148,6 +148,21 @@ public static Map plistFallback(final String beaconId, final Str return fallback; } + /** + * The same, for a whole list of stored rows. + * + *

This exists because fixing the three call sites I found was not the same as fixing + * all of them. A fourth - the import path - kept {@code Collectors.toMap} and crashed + * with a {@link NullPointerException} the moment somebody imported a self-generated tag, + * which is the only kind that reaches it with no plist. One helper is harder to miss than a + * rule about which collectors happen to be null-safe. + */ + public static Map plistFallbacks(final List beacons) { + final Map fallbacks = new HashMap<>(); + beacons.forEach(beacon -> fallbacks.put(beacon.id, beacon.content)); + return fallbacks; + } + /** * Build the FindMy 0.9.x fetch input for the given beacons. For each beacon we use * the persisted {@code accessory_json} if present, otherwise lazily backfill it 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 a8496684..b04f7505 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 @@ -58,10 +58,23 @@ public interface AnisetteFactory { private static Function serverTesterFactory = AnisetteServerTesterService::new; + /** + * Names an accessory from its plist, through the shared Python heuristic. + * + *

Here for the same reason as the rest: the real one starts Chaquopy and imports a + * package, so a screen that used it directly could not be launched in a test. It also makes + * "an accessory nothing recognises" renderable on demand, rather than needing such a tag. + */ + private static HardwareDescriber hardwareDescriber = new ChaquopyHardwareDescriber(); + public static AppleAuthService authService() { return authService; } + public static HardwareDescriber hardwareDescriber() { + return hardwareDescriber; + } + public static AnisetteServerTesterService serverTester(final CronetEngine engine) { return serverTesterFactory.apply(engine); } @@ -81,6 +94,11 @@ public static void replaceAuthService(final AppleAuthService replacement) { authService = replacement; } + @VisibleForTesting + public static void replaceHardwareDescriber(final HardwareDescriber replacement) { + hardwareDescriber = replacement; + } + @VisibleForTesting public static void replaceAnisette(final Function replacement) { anisetteFactory = (context, settings, hasSession) -> replacement.apply(settings); @@ -92,5 +110,6 @@ public static void reset() { authService = new PythonAppleAuthService(); anisetteFactory = LocalAnisette::new; serverTesterFactory = AnisetteServerTesterService::new; + hardwareDescriber = new ChaquopyHardwareDescriber(); } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyHardwareDescriber.java b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyHardwareDescriber.java new file mode 100644 index 00000000..5168f164 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyHardwareDescriber.java @@ -0,0 +1,53 @@ +package dev.wander.android.opentagviewer.python; + +import android.util.Log; + +import com.chaquo.python.Python; + +/** + * The real describer: calls {@code main.py:identifyHardware} and + * {@code main.py:whereToLookUpHardware}, which delegate to + * {@code opentagviewer_export.hardware} - the same module the desktop exporter uses. + * + *

The Python runtime is resolved lazily per call rather than held as a field, so constructing + * this does not require Chaquopy to have started. + * + *

Both calls are blocking and start an interpreter. Never call them on the main thread; + * the screen that uses this does so on an Rx scheduler and renders what it already knows first. + */ +public class ChaquopyHardwareDescriber implements HardwareDescriber { + private static final String TAG = ChaquopyHardwareDescriber.class.getSimpleName(); + private static final String MODULE_MAIN = "main"; + + @Override + public String describe(final String plistXml) { + return call("identifyHardware", plistXml); + } + + @Override + public String whereToLookUp(final String plistXml) { + return call("whereToLookUpHardware", plistXml); + } + + /** + *

A null or empty plist short-circuits rather than crossing the bridge. A self-generated + * tag has no plist at all, and the Python side would only decode the empty string and return + * None anyway - so this saves starting an interpreter to be told what is already known. + */ + private static String call(final String function, final String plistXml) { + if (plistXml == null || plistXml.isEmpty()) { + return null; + } + + try { + final var module = Python.getInstance().getModule(MODULE_MAIN); + final var described = module.callAttr(function, plistXml); + return described == null ? null : described.toString(); + } catch (final Exception e) { + // Either Python has not started, or the record is not one the heuristic can read. + // Neither is worth failing a screen over: the caller keeps what it already had. + Log.w(TAG, function + " failed", e); + return null; + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/HardwareDescriber.java b/app/src/main/java/dev/wander/android/opentagviewer/python/HardwareDescriber.java new file mode 100644 index 00000000..b49d3aba --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/HardwareDescriber.java @@ -0,0 +1,39 @@ +package dev.wander.android.opentagviewer.python; + +/** + * What an accessory is, in words, according to the shared heuristic. + * + *

The heuristic itself is not here, and must not be copied here. It lives in + * {@code opentagviewer_export/hardware.py}, where the desktop exporter also uses it when it asks + * which accessories to export. It is guesswork over half a dozen fields - product and vendor ids, + * the model, the shape of {@code stableIdentifier} - and the vendor list came out of the + * Bluetooth SIG registry, so it grows as accessories turn up. Two copies means two things to + * update and one of them will be forgotten; the symptom of that is one tag described as an AirTag + * on one screen and as a hex number on the other. + * + *

Behind an interface because the real one is Chaquopy: it starts the interpreter, imports the + * package and parses a plist. A screen that called it directly could not be launched in a test + * without all of that working, and "an accessory nothing recognises" is a state worth being able + * to render on demand rather than by finding such a tag. + * + *

Null is a real answer, not a failure. It means nothing recognised the record, and the + * caller should then show what it already knows rather than a guess - the costs are asymmetric, + * because a wrong name is believed and a hex number gets looked up. + */ +public interface HardwareDescriber { + + /** + * A human-readable name for the accessory, or null if nothing recognises it. + * + * @param plistXml the {@code OwnedBeacons} plist, as the app stores it. Null for a tag that + * never had one - a self-generated tag - which returns null rather than + * throwing, because that kind already describes itself. + */ + String describe(String plistXml); + + /** + * One line on how the user could find out what an unrecognised accessory is, or null when + * there is nothing worth saying - which is the common case. + */ + String whereToLookUp(String plistXml); +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAccountLoginException.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAccountLoginException.java index bdb58a97..b40160e1 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAccountLoginException.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAccountLoginException.java @@ -1,15 +1,48 @@ package dev.wander.android.opentagviewer.python; +/** + * A sign-in that did not work, with enough about it to tell the user something useful. + * + *

The reason exists because the message was not enough. It used to carry + * {@code str(e)} from Python and nothing else - and the failure people actually hit is a + * connection timeout, whose {@code str()} is the empty string. The screen dutifully rendered + * "Login failed:" followed by nothing at all, which tells somebody neither what went wrong nor + * what to do. + * + *

The reason is a code, not prose: the sentence the user reads is chosen on this side, so it + * can be translated. The message stays as the detail for logs and for anything unclassified. + */ public class PythonAccountLoginException extends RuntimeException { + + /** Nothing answered - Apple was not reached at all. Matches {@code REASON_NETWORK}. */ + public static final String REASON_NETWORK = "network"; + + /** Anything not recognised. The detail is shown as-is rather than guessed at. */ + public static final String REASON_UNKNOWN = "unknown"; + + private final String reason; + public PythonAccountLoginException(String message) { + this(message, REASON_UNKNOWN); + } + + public PythonAccountLoginException(String message, String reason) { super(message); + this.reason = reason == null || reason.isBlank() ? REASON_UNKNOWN : reason; } public PythonAccountLoginException(String message, Throwable cause) { super(message, cause); + this.reason = REASON_UNKNOWN; } public PythonAccountLoginException(Throwable cause) { super(cause); + this.reason = REASON_UNKNOWN; + } + + /** Which kind of failure this was, for choosing what to show. Never null. */ + public String getReason() { + return this.reason; } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java index e0c32de6..9f98df6b 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java @@ -57,7 +57,11 @@ public static Observable pythonLogin( if (resultMap.containsKey("error")) { Log.e(TAG, "Failed to log in to account! (check python for errors)"); final String errorMessage = resultMap.get("error").toString(); - throw new PythonAccountLoginException(errorMessage); + // The reason decides what the user is told; the message is the detail behind it. + // Absent from anything that raised before main.py classified failures. + final var reason = resultMap.get("reason"); + throw new PythonAccountLoginException( + errorMessage, reason == null ? null : reason.toString()); } // need to do an annoying conversion here... diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/BeaconIcon.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/BeaconIcon.java new file mode 100644 index 00000000..bc652591 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/BeaconIcon.java @@ -0,0 +1,56 @@ +package dev.wander.android.opentagviewer.ui; + +import androidx.annotation.DrawableRes; + +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.data.model.BeaconInformation; + +/** + * Which icon stands in for a tag that has no emoji. + * + *

Every tag without an emoji used to show Apple's logo, including a Chipolo and + * including an OpenHaystack-style tag whose keys have never been near an Apple account. That is + * not a bland default, it is a wrong one - the icon is the only place the app says anything about + * where a tag came from, and it was saying the same thing about all of them. + * + *

One place, because there are three surfaces. The map carousel, the device list and + * the device screen each render this, and each used to name {@code R.drawable.apple} itself. + * Three copies of a default is how two of them end up stale. + * + *

Only reached when {@link BeaconInformation#isEmojiFilled()} is false - anything the user or + * their Apple device has set wins, always. This is the fallback, not a category label. + */ +public final class BeaconIcon { + + private BeaconIcon() { + } + + /** + * Apple's Bluetooth SIG company identifier, which is what an {@code OwnedBeacons} plist + * records for hardware Apple made. + */ + private static final int APPLE_VENDOR_ID = 76; + + /** + * The icon for a tag with no emoji of its own. + * + *

Decided from stored fields, not from the shared heuristic. The heuristic gives a + * better name, but it costs a Python interpreter and answers asynchronously - and an + * icon that arrives late is an icon that visibly changes under the user. The vendor id is on + * the row already and answers the only question this needs: who made it. + * + *

An unknown vendor is treated as third-party rather than as Apple. That is the honest + * way round: claiming Apple for something we cannot identify is exactly the wrong answer + * this replaces, and the arcs read as "a findable tag" for anything in the network. + */ + @DrawableRes + public static int forBeacon(final BeaconInformation beacon) { + if (beacon.isCustomAccessory()) { + return R.drawable.tag_self_generated; + } + if (beacon.getVendorId() == APPLE_VENDOR_ID) { + return R.drawable.apple; + } + return R.drawable.tag_third_party; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapProviderFactory.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapProviderFactory.java index a4599be1..0bccf806 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapProviderFactory.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapProviderFactory.java @@ -1,8 +1,11 @@ package dev.wander.android.opentagviewer.ui.maps; -import android.app.Activity; import android.util.Log; +import androidx.annotation.VisibleForTesting; + +import java.util.function.Supplier; + /** * 地图提供商工厂类 * 根据用户设置创建对应的地图提供商实例 @@ -18,7 +21,37 @@ public class MapProviderFactory { * @param providerType 提供商类型 ("google" 或 "amap") * @return 地图提供商实例 */ + /** + * A provider to hand out instead of a real one, or null in production. + * + *

The reason this hook exists. The instrumented tests run on the {@code aosp-atd} + * managed device, which carries no Play Services - so a screen that builds a real map cannot + * start there at all, and {@code MapsActivity} has therefore never been launched by a test. + * The map, the tag carousel, history and delete are the most-used parts of the app and the + * least covered. + * + *

Rule 7 is what makes this cheap: providers are already behind {@link IMapProvider}, so a + * fake is another implementation rather than a change to the screens. + */ + private static Supplier replacement = null; + + @VisibleForTesting + public static void replaceWith(final Supplier factory) { + replacement = factory; + } + + /** Put the real ones back. Call from a teardown, or the next test inherits the fake. */ + @VisibleForTesting + public static void reset() { + replacement = null; + } + public static IMapProvider create(String providerType) { + if (replacement != null) { + Log.d(TAG, "Creating a substituted map provider"); + return replacement.get(); + } + if (providerType == null || providerType.isEmpty() || PROVIDER_GOOGLE.equals(providerType)) { Log.d(TAG, "Creating Google Maps provider"); return new GoogleMapProvider(); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/DeviceListAdaptor.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/DeviceListAdaptor.java index dadddb13..131bc6aa 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/DeviceListAdaptor.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/DeviceListAdaptor.java @@ -28,6 +28,7 @@ import dev.wander.android.opentagviewer.R; import dev.wander.android.opentagviewer.data.model.BeaconInformation; +import dev.wander.android.opentagviewer.ui.BeaconIcon; import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; import lombok.Getter; @@ -140,10 +141,16 @@ public void onBindViewHolder(ViewHolder viewHolder, final int position) { final String beaconId = beacon.getBeaconId(); viewHolder.getDeviceName().setText(beacon.getName()); + // **Both branches set both views, because these are recycled.** With only the emoji + // branch, a row reused from a tag that had one kept showing that tag's emoji. if (beacon.isEmojiFilled()) { viewHolder.getItemEmoji().setText(beacon.getEmoji()); viewHolder.getItemEmoji().setVisibility(VISIBLE); viewHolder.getItemImage().setVisibility(GONE); + } else { + viewHolder.getItemImage().setImageResource(BeaconIcon.forBeacon(beacon)); + viewHolder.getItemImage().setVisibility(VISIBLE); + viewHolder.getItemEmoji().setVisibility(GONE); } // locations? diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryParser.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryParser.java index 97af9927..35552c55 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryParser.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryParser.java @@ -33,18 +33,6 @@ final class CustomAccessoryParser { /** FindMy.py's tag for the mapping. The same string {@code main.py} dispatches on. */ private static final String CUSTOM_TYPE = "custom_rolling_key_accessory"; - /** - * The emoji a self-generated tag gets when nothing has named it. - * - *

Every other tag arrives with whatever its owner set on an Apple device. These have - * nobody to have set one, so without a default they render as a blank where every other row - * has a picture - which reads as something failing to load rather than as a kind of tag. - * - *

A haystack, for OpenHaystack, which is the family of tools these come from. It is a - * default and not a label: the user can change it, and {@link UserBeaconOptions} stores that - * exactly as it does for any other tag. - */ - static final String DEFAULT_EMOJI = "🌾"; private CustomAccessoryParser() { } @@ -100,7 +88,11 @@ static BeaconInformation parse( // nothing that was ever "modified by" a device. Left null rather than invented. .namingRecordId(null) .originalName(name) - .originalEmoji(DEFAULT_EMOJI) + // **No emoji, deliberately.** It used to default to a haystack so the row was + // not blank, but that sent it down the emoji path and past the icon. There is a + // drawable for exactly this kind now - see BeaconIcon - and leaving this null is + // what lets it through. + .originalEmoji(null) .customAccessory(true) .customAccessoryKeyCount(keyCount) // Not an Apple product, so it has no product or vendor id to report and no diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 5f32b8b6..f7acf96a 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -17,7 +17,11 @@ SmsSecondFactorMethod, TrustedDeviceSecondFactorMethod, ) -from findmy.reports.anisette import BaseAnisetteProvider +from findmy.reports.anisette import ( + CLIENT_IDENTITY, + CLIENT_SERIAL, + BaseAnisetteProvider, +) from findmy.util import files as util_files from findmy.reports.twofactor import ( SyncSecondFactorMethod @@ -149,6 +153,25 @@ def _convertToJavaDictWrapper(method: SyncSecondFactorMethod) -> dict[str, Any]: return return_obj +LOGIN_TIMEOUT_SECONDS = 30 +""" +How long a single request to Apple may take. + +FindMy.py defaults to five seconds total per request, which suits a desktop on a good connection +and does not suit a phone. Signing in is several round trips measured separately, and there may +be an Anisette server in the middle generating its data on demand. + +**Thirty, to match the other half of the same sign-in.** `AdiProvisioning` already uses thirty +second connect and read timeouts for the exchange it makes with Apple directly, and it is the +same network at the same moment - so the two halves having different patience only meant that +whichever ran second was the one that failed. + +Measured rather than guessed: a login on an emulator with roughly 500ms round trips to Apple, and +a site-local IPv6 address that routes nowhere, spent its whole five second budget inside +happy-eyeballs and arrived as a bare `TimeoutError`. Provisioning survived the identical network. +""" + + class LocalAnisetteProvider(BaseAnisetteProvider): """Anisette produced on this device, rather than by somebody else's server. @@ -187,14 +210,36 @@ def machine(self) -> str: return str(self._bridge.machine()) def to_json(self, dst=None, /): - # Deliberately the remote mapping - see the class docstring. - return util_files.save_and_return_json( - { - "type": "aniRemote", - "url": self._fallbackServerUrl, - }, - dst, - ) + """Deliberately the remote mapping - see the class docstring. + + **Everything the session was established with has to be in here.** This mapping is the + whole of what a restored session is rebuilt from, so a field left out is not "defaulted", + it is *reverted* - and silently, on a session Apple has already bound to the value that + was dropped. + + That is not hypothetical: writing only the type and the URL meant a session established + as `0PENTAGVIEWR` came back as FindMy.py's `0FINDMYPY001` on the next launch, and one + established as a MacBookPro13,2 came back as a MacBookPro18,3. Two names and two machines + for one session, which is exactly what rule 11 exists to prevent. + + Written only when it differs from the library's own default, matching what + `RemoteAnisetteProvider.to_json` does - so a bundle from a version that imposed nothing + stays byte-identical. + """ + state: dict[str, Any] = { + "type": "aniRemote", + "url": self._fallbackServerUrl, + # Carried even though nothing here spends it: a restored session is rebuilt from + # this mapping, and omitting it would hand it back the five second default. + "timeout": LOGIN_TIMEOUT_SECONDS, + } + + if self.serial != CLIENT_SERIAL: + state["serial"] = self.serial + if self.identity != CLIENT_IDENTITY: + state["identity"] = self.identity.to_json() + + return util_files.save_and_return_json(state, dst) @classmethod def from_json(cls, val): @@ -233,7 +278,10 @@ def _anisetteProvider(anisetteServerUrl: str, localAnisette: Any = None, **ident except Exception: print(f"Local Anisette failed, using the remote server: {traceback.format_exc()}") - return RemoteAnisetteProvider(anisetteServerUrl, **identityKwargs) + # Passed here rather than through identityKwargs, which also reach LocalAnisetteProvider - + # BaseAnisetteProvider takes no timeout, and there is no HTTP in the local one to spend it on. + return RemoteAnisetteProvider( + anisetteServerUrl, timeout=LOGIN_TIMEOUT_SECONDS, **identityKwargs) def loginSync(email: str, password: str, anisetteServerUrl: str, @@ -255,7 +303,13 @@ def loginSync(email: str, password: str, anisetteServerUrl: str, # And the two ids the same install already used when it provisioned ADI, so this is one # device rather than two that happen to share a serial. Empty when Java cannot say, in # which case FindMy.py mints its own pair exactly as it always did. - acc = AppleAccount(anisette, **app_identity.deviceIdsForNewSession(localAnisette)) + # The account and the Anisette provider hold separate sessions, so both need this: + # the Anisette fetch happens inside the login but from the provider's own client. + acc = AppleAccount( + anisette, + timeout=LOGIN_TIMEOUT_SECONDS, + **app_identity.deviceIdsForNewSession(localAnisette), + ) state = acc.login(email, password) @@ -294,7 +348,8 @@ def loginSync(email: str, password: str, anisetteServerUrl: str, except Exception as e: print(f"Failed to log in due to error: {traceback.format_exc()}") return { - "error": str(e) + "error": describeLoginFailure(e), + "reason": classifyLoginFailure(e), } @@ -359,6 +414,64 @@ def assertAnisetteIsSupported(serializedAccountData: str) -> str | None: return "This saved login could not be read." +# What went wrong at sign-in, in a form the screen can act on. +# +# **`str(e)` is not enough, and that is not a nitpick.** The failure people actually hit is a +# connection timeout, and `str(TimeoutError())` is the empty string - so the screen said +# "Login failed:" with nothing after the colon. Several of the exceptions that reach here carry +# no message at all: TimeoutError, CancelledError and most of asyncio's. + +REASON_NETWORK = "network" +"""Could not reach Apple. Nothing was refused - nothing answered.""" + +REASON_UNKNOWN = "unknown" +"""Anything else. The detail is shown as-is, because a wrong guess is worse than raw text.""" + +# Matched by type rather than by message, because the messages are empty or English prose from +# three libraries deep. aiohttp's errors all derive from ClientError, and the asyncio ones are +# what a stalled connection raises. +_NETWORK_ERRORS = ( + TimeoutError, + ConnectionError, + OSError, +) + + +def classifyLoginFailure(error: BaseException) -> str: + """Which kind of failure this is, as a code the Java side maps to a localised sentence.""" + import asyncio + + if isinstance(error, (asyncio.TimeoutError, asyncio.CancelledError)): + return REASON_NETWORK + if isinstance(error, _NETWORK_ERRORS): + return REASON_NETWORK + + # aiohttp is not imported here directly - matching on the module keeps this working + # whether or not the library is present, and without importing it for a failure path. + module = type(error).__module__ or "" + if module.startswith("aiohttp") or module.startswith("aiohappyeyeballs"): + return REASON_NETWORK + + return REASON_UNKNOWN + + +def describeLoginFailure(error: BaseException) -> str: + """ + A detail string that is **never empty**. + + Falls back to the exception's type name, which is the whole point: an empty message is how + the screen came to show a colon and nothing at all. Kept as a detail rather than a sentence + because it is untranslatable Python text - the sentence the user reads is chosen on the Java + side from the reason code. + """ + detail = str(error).strip() + name = type(error).__name__ + + if not detail: + return name + return f"{name}: {detail}" + + def _preferLocalAnisette(acc: AppleAccount, localAnisette: Any) -> None: """Swap a restored account's anisette provider for the local one, if it is usable. diff --git a/app/src/main/res/drawable/tag_self_generated.xml b/app/src/main/res/drawable/tag_self_generated.xml new file mode 100644 index 00000000..3b39db01 --- /dev/null +++ b/app/src/main/res/drawable/tag_self_generated.xml @@ -0,0 +1,40 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/tag_third_party.xml b/app/src/main/res/drawable/tag_third_party.xml new file mode 100644 index 00000000..f040b522 --- /dev/null +++ b/app/src/main/res/drawable/tag_third_party.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ca67004b..6bcfa6a8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -167,4 +167,5 @@ Ihre Tags und deren Standortverlauf bleiben auf diesem Telefon – es ist nichts Selbst erzeugter Tag Ein Tag im OpenHaystack-Stil. Seine Schlüssel wurden von seinem Ersteller erzeugt und waren nie in einem Apple-Konto, daher gibt es von Apple weder Namen noch Emoji oder Akkustand – legen Sie unten Ihre eigenen fest. %1$d vorab erzeugte Schlüssel + Apple konnte nicht erreicht werden. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut. \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index b978cb46..d346dc5e 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -167,4 +167,5 @@ Your tags and their location history stay on this phone — nothing has been los Self-generated tag An OpenHaystack-style tag. Its keys were generated by whoever made it and were never in an Apple account, so it has no name, emoji or battery level from Apple — set your own below. %1$d pre-generated keys + Could not reach Apple. Check your connection and try again. \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index fefd4927..6d26a2d1 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -167,4 +167,5 @@ Vos balises et leur historique de position restent sur ce téléphone — rien n Balise auto-générée Une balise de type OpenHaystack. Ses clés ont été générées par la personne qui l’a créée et n’ont jamais été dans un compte Apple : elle n’a donc ni nom, ni emoji, ni niveau de batterie venant d’Apple — définissez les vôtres ci-dessous. %1$d clés pré-générées + Impossible de joindre Apple. Vérifiez votre connexion et réessayez. \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 671ee26e..d6704185 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -167,4 +167,5 @@ 自作タグ OpenHaystack 方式のタグです。鍵は作成者が生成したもので、Apple アカウントに登録されたことがありません。そのため Apple 由来の名前・絵文字・バッテリー残量はありません。以下でご自身で設定してください。 事前生成された鍵 %1$d 個 + Apple に接続できませんでした。通信環境を確認して、もう一度お試しください。 \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 8ea3a602..f02f572e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -167,4 +167,5 @@ 직접 만든 태그 OpenHaystack 방식의 태그입니다. 키는 만든 사람이 직접 생성했고 Apple 계정에 등록된 적이 없어, Apple에서 받은 이름·이모지·배터리 정보가 없습니다. 아래에서 직접 설정하세요. 미리 생성된 키 %1$d개 + Apple에 연결할 수 없습니다. 연결 상태를 확인한 후 다시 시도해 주세요. \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 3df4e2fa..3a9b4120 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -13,6 +13,16 @@ ?attr/colorPrimary + + @style/PopupMenu + + @font/nunito_regular + @style/Theme.OpenTagViewer.Button diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index dab28648..9c6c6342 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -167,4 +167,5 @@ Je tags en hun locatiegeschiedenis blijven op deze telefoon staan — er is niet Zelfgemaakte tag Een tag in OpenHaystack-stijl. De sleutels zijn gemaakt door wie de tag bouwde en hebben nooit in een Apple-account gestaan, dus er is geen naam, emoji of batterijniveau van Apple — stel hieronder je eigen in. %1$d vooraf gegenereerde sleutels + Kan Apple niet bereiken. Controleer je verbinding en probeer het opnieuw. \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b44bb2a4..913a63a5 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -167,4 +167,5 @@ Самодельная метка Метка в стиле OpenHaystack. Её ключи создал тот, кто её сделал, и они никогда не были в учётной записи Apple, поэтому у неё нет ни имени, ни эмодзи, ни уровня заряда от Apple — задайте свои ниже. %1$d заранее созданных ключей + Не удалось связаться с Apple. Проверьте подключение и повторите попытку. \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 41e182ad..f76e803f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -167,4 +167,5 @@ 自制标签 OpenHaystack 类型的标签。它的密钥由制作者生成,从未存在于 Apple 账户中,因此没有来自 Apple 的名称、表情或电量信息 — 请在下方自行设置。 %1$d 个预生成密钥 + 无法连接 Apple。请检查网络连接后重试。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 16ba647c..6f9f5b1f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -167,4 +167,5 @@ 自製標籤 OpenHaystack 類型的標籤。它的金鑰由製作者產生,從未存在於 Apple 帳戶中,因此沒有來自 Apple 的名稱、表情符號或電量資訊 — 請在下方自行設定。 %1$d 個預先產生的金鑰 + 無法連線至 Apple。請檢查網路連線後再試一次。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7fc1f8f1..c1ea815d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -202,4 +202,5 @@ Your tags and their location history stay on this phone — nothing has been los Self-generated tag An OpenHaystack-style tag. Its keys were generated by whoever made it and were never in an Apple account, so it has no name, emoji or battery level from Apple — set your own below. %1$d pre-generated keys + Could not reach Apple. Check your connection and try again. diff --git a/app/src/test/python/requirements.txt b/app/src/test/python/requirements.txt index fee8d425..cb6d1553 100644 --- a/app/src/test/python/requirements.txt +++ b/app/src/test/python/requirements.txt @@ -9,7 +9,7 @@ # `FindMy==0.9.8` here for as long as the app built the fork, so every bridge test # ran against a library the app does not ship - which is not a small difference: # the fork's Anisette providers take `serial=` and PyPI's do not. -git+https://github.com/parawanderer/FindMy.py@102dd8ea14767d2a2aa745186ac23276f32689f1 +git+https://github.com/parawanderer/FindMy.py@23a9b8d7109b405f8362ea1e69ebe51f9ca82fca NSKeyedUnArchiver==1.5 pytest>=8.0 diff --git a/app/src/test/python/test_identity.py b/app/src/test/python/test_identity.py index b4fa44db..f9adaf21 100644 --- a/app/src/test/python/test_identity.py +++ b/app/src/test/python/test_identity.py @@ -407,3 +407,109 @@ def test_the_default_identity_has_not_moved(self): # Deliberately a character short of a real macOS build (13.4.1 is 22F82). Kept wrong # because every existing session is bound to it; correcting it would cost a re-login. assert CLIENT_IDENTITY.os_build == "22F8" + + +class TestHowLongASignInMayTake: + """ + FindMy.py allows five seconds per request by default, which is a desktop assumption. + + A phone signing in makes several round trips, each measured separately, possibly through an + Anisette server generating its data on demand. Five seconds is how a working login on a slow + network arrives as a bare `TimeoutError`. + """ + + def test_the_remote_provider_is_given_longer(self): + # Asserted through what it serializes rather than through `_timeout`: the library + # exposes `serial` and `identity` as properties but not this one, and a test that + # reaches into a private attribute breaks on a rename that changed nothing real. + provider = main._anisetteProvider("https://example.invalid") + + assert provider.to_json()["timeout"] == main.LOGIN_TIMEOUT_SECONDS + + def test_it_is_longer_than_the_librarys_default(self): + from findmy.util.http import DEFAULT_TIMEOUT + + assert main.LOGIN_TIMEOUT_SECONDS > DEFAULT_TIMEOUT + + def test_it_matches_what_the_java_side_allows_itself(self): + """ + AdiProvisioning uses 30s connect and read timeouts for the exchange it makes with Apple + directly. Same network, same moment - so different patience only decided which half + failed first. + """ + assert main.LOGIN_TIMEOUT_SECONDS == 30 + + def test_a_local_provider_carries_it_into_what_it_serializes_as(self): + """ + The one that is easy to miss. `LocalAnisetteProvider` serializes as the *remote* + provider, and that mapping is what a restored session is rebuilt from - so omitting the + timeout would quietly hand every restored session back the five second default. + """ + stored = main.LocalAnisetteProvider(Bridge(), "https://example.invalid").to_json() + + assert stored["timeout"] == main.LOGIN_TIMEOUT_SECONDS + + def test_a_restored_provider_keeps_it(self): + from findmy.reports import RemoteAnisetteProvider + + stored = main.LocalAnisetteProvider(Bridge(), "https://example.invalid").to_json() + restored = RemoteAnisetteProvider.from_json(stored) + + assert restored.to_json()["timeout"] == main.LOGIN_TIMEOUT_SECONDS + + +class TestWhatSurvivesBeingStored: + """ + The mapping a local provider writes is the whole of what a restored session is rebuilt from. + + A field left out of it is not defaulted, it is **reverted** - silently, on a session Apple + has already bound to the value that was dropped. Both of these shipped: a session established + as `0PENTAGVIEWR` came back as `0FINDMYPY001`, and one established as a MacBookPro13,2 came + back as FindMy.py's MacBookPro18,3. + """ + + MAC = DeviceIdentity(**LEGACY_MAC) + + def _stored(self): + return main.LocalAnisetteProvider( + Bridge(), "https://example.invalid", + serial=identity.APP_SERIAL, identity=self.MAC, + ).to_json() + + def test_the_serial_the_session_was_established_with_survives(self): + assert self._stored()["serial"] == identity.APP_SERIAL + + def test_the_machine_it_was_established_as_survives(self): + assert self._stored()["identity"] == self.MAC.to_json() + + def test_a_restored_provider_presents_both_again(self): + restored = RemoteAnisetteProvider.from_json(self._stored()) + + assert restored.serial == identity.APP_SERIAL + assert restored.identity == self.MAC + + def test_and_identityForRestore_then_carries_them_onward(self): + """ + The end of the loop. `_preferLocalAnisette` swaps the transport on a restored account and + reads the identity off the rebuilt provider - so if the mapping had dropped it, the swap + would hand the session a third identity again. + """ + restored = RemoteAnisetteProvider.from_json(self._stored()) + + carried = identity.identityForRestore(restored) + + assert carried["serial"] == identity.APP_SERIAL + assert carried["identity"] == self.MAC + + def test_a_provider_that_imposed_nothing_writes_nothing(self): + """ + A session established before any of this stays byte-identical. + + Writing the library's own defaults explicitly would be harmless today and a trap later: + it pins a value the library promises never to move, into files it would then have to keep + honouring. + """ + stored = main.LocalAnisetteProvider(Bridge(), "https://example.invalid").to_json() + + assert "serial" not in stored + assert "identity" not in stored diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py index 89f94f72..88777343 100644 --- a/app/src/test/python/test_main.py +++ b/app/src/test/python/test_main.py @@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone from pathlib import Path +import asyncio import json import re @@ -705,3 +706,58 @@ def test_pinned_versions_match_the_app_build(): assert f"{package}=={version}" in required_lines, ( f"{package} is {version} in build.gradle.kts but not in requirements.txt" ) + + +# -------------------------------------------------------------------------- +# Describing a failed sign-in +# +# The screen showed "Login failed:" and nothing after the colon, because the failure +# people actually hit is a connection timeout and `str(TimeoutError())` is "". +# -------------------------------------------------------------------------- + +class TestDescribingAFailedLogin: + def test_an_exception_with_no_message_still_says_something(self): + """The bug exactly: several asyncio errors carry no message at all.""" + assert main.describeLoginFailure(TimeoutError()) == "TimeoutError" + assert main.describeLoginFailure(asyncio.CancelledError()) == "CancelledError" + + def test_a_message_is_kept_and_named(self): + described = main.describeLoginFailure(ValueError("that password is wrong")) + + assert "that password is wrong" in described + assert "ValueError" in described + + @pytest.mark.parametrize("blank", ["", " ", "\n"]) + def test_a_whitespace_only_message_counts_as_none(self, blank): + assert main.describeLoginFailure(RuntimeError(blank)) == "RuntimeError" + + def test_nothing_ever_describes_itself_as_empty(self): + for error in (TimeoutError(), asyncio.CancelledError(), OSError(), Exception()): + assert main.describeLoginFailure(error).strip() + + +class TestClassifyingAFailedLogin: + @pytest.mark.parametrize("error", [ + TimeoutError(), + asyncio.TimeoutError(), + asyncio.CancelledError(), + ConnectionRefusedError(), + OSError("network is unreachable"), + ]) + def test_not_reaching_apple_is_a_network_failure(self, error): + assert main.classifyLoginFailure(error) == main.REASON_NETWORK + + def test_anything_else_is_left_unclassified(self): + """ + Deliberately not guessed at. Telling somebody to check their connection when their + password was wrong sends them to fix the wrong thing. + """ + assert main.classifyLoginFailure(ValueError("bad password")) == main.REASON_UNKNOWN + + def test_a_library_error_is_recognised_by_its_module(self): + class ClientConnectorError(Exception): + pass + + ClientConnectorError.__module__ = "aiohttp.client_exceptions" + + assert main.classifyLoginFailure(ClientConnectorError()) == main.REASON_NETWORK diff --git a/python/pyproject.toml b/python/pyproject.toml index d4c968f5..70c6dc33 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -69,7 +69,7 @@ constraint-dependencies = [ ] [tool.uv.sources] -FindMy = { git = "https://github.com/parawanderer/FindMy.py", rev = "102dd8ea14767d2a2aa745186ac23276f32689f1" } +FindMy = { git = "https://github.com/parawanderer/FindMy.py", rev = "23a9b8d7109b405f8362ea1e69ebe51f9ca82fca" } [dependency-groups] # Only the release build installs this, with `uv sync --no-default-groups --group build`. It is diff --git a/python/uv.lock b/python/uv.lock index 81ce8919..4f521d18 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -659,7 +659,7 @@ wheels = [ [[package]] name = "findmy" version = "0.10.1" -source = { git = "https://github.com/parawanderer/FindMy.py?rev=102dd8ea14767d2a2aa745186ac23276f32689f1#102dd8ea14767d2a2aa745186ac23276f32689f1" } +source = { git = "https://github.com/parawanderer/FindMy.py?rev=23a9b8d7109b405f8362ea1e69ebe51f9ca82fca#23a9b8d7109b405f8362ea1e69ebe51f9ca82fca" } dependencies = [ { name = "aiohttp" }, { name = "anisette" }, @@ -1032,7 +1032,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "findmy", git = "https://github.com/parawanderer/FindMy.py?rev=102dd8ea14767d2a2aa745186ac23276f32689f1" }, + { name = "findmy", git = "https://github.com/parawanderer/FindMy.py?rev=23a9b8d7109b405f8362ea1e69ebe51f9ca82fca" }, { name = "pycryptodome", specifier = "==3.22.0" }, { name = "pyyaml", specifier = "==6.0.2" }, { name = "pyzipper", specifier = "==0.4.0" },