diff --git a/README.md b/README.md index eee7ccb3..3dd67076 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ This project is a relatively polished looking Android/Java UI-wrapper around the | **Self-made tags** ([OpenHaystack](https://github.com/seemoo-lab/openhaystack), [Macless Haystack](https://github.com/dchristl/macless-haystack)) | ✅ | Works; requires creating an export by [CLI](https://github.com/parawanderer/OpenTagViewer/wiki/How-To:-Export-AirTags-With-The-CLI) or [Wizard](https://github.com/parawanderer/OpenTagViewer/wiki/How-To:-Export-AirTags) | | **Your own iPhone, iPad or Mac** | ⚠️ | Partial coverage (see issue [#131](https://github.com/parawanderer/OpenTagViewer/issues/131)) | | **A tag someone shared with you** through Apple's own sharing | ❌ | Only the account that *owns* a tag can export it. Ask the owner to export it and send you the zip | -| **Tile, Samsung SmartTag, Google Find My Device trackers** | ❌ | Different networks entirely, with nothing in common with Apple's. This is an open feature request that can be contributed to the app if wanted. | +| **Google Find Hub trackers** | 🧪 | Experimental. Needs a self-hosted service, see the [setup guide](https://github.com/parawanderer/OpenTagViewer/wiki/%5BGoogle%5D-How-To:-Find-Hub-Trackers). An on-device version is [#257](https://github.com/parawanderer/OpenTagViewer/issues/257) | +| **Tile, Samsung SmartTag** | ❌ | Different networks entirely, with nothing in common with Apple's. | > [!NOTE] > This app (currently) reads Apple's Find My network. Anything not on that network cannot be tracked with it, no matter how similar the device looks. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 57bb5c8b..513ed154 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -567,6 +567,9 @@ dependencies { implementation(libs.commons.csv) testImplementation(libs.junit) + // The real org.json for JVM tests that parse the googlefind service's answers - + // android.jar's is a stub there. The app itself uses the platform's. + testImplementation(libs.org.json) testImplementation(libs.android.room.testing) testCompileOnly(libs.projectlombok) diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/source/ExtraSourcesSettingsTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/source/ExtraSourcesSettingsTest.java new file mode 100644 index 00000000..7366063b --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/source/ExtraSourcesSettingsTest.java @@ -0,0 +1,96 @@ +package dev.wander.android.opentagviewer.source; + +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.assertTrue; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * The googlefind service's settings, against the real keystore. + * + *

The API token reads every location the service holds, so it is encrypted like the Apple + * session. Plain text in SharedPreferences would still pass every test that only reads it back, + * which is why these look at what is actually on disk. + */ +@RunWith(AndroidJUnit4.class) +public class ExtraSourcesSettingsTest { + + private Context context; + private SharedPreferences raw; + + @Before + public void startEmpty() { + this.context = getInstrumentation().getTargetContext(); + this.raw = this.context.getSharedPreferences("extra_sources", Context.MODE_PRIVATE); + this.raw.edit().clear().commit(); + } + + @After + public void leaveNothingBehind() { + this.raw.edit().clear().commit(); + } + + @Test + public void theTokenReadsBackButIsNotStoredInPlainText() { + new ExtraSourcesSettings(this.context).put(ExtraSourcesSettings.GOOGLE_TOKEN, "s3cret-token"); + + assertEquals("s3cret-token", + new ExtraSourcesSettings(this.context).get(ExtraSourcesSettings.GOOGLE_TOKEN)); + for (final Object stored : this.raw.getAll().values()) { + assertFalse("the token must not be on disk as written: " + stored, + String.valueOf(stored).contains("s3cret-token")); + } + } + + @Test + public void thePasswordIsEncryptedToo() { + new ExtraSourcesSettings(this.context).put(ExtraSourcesSettings.GOOGLE_PASSWORD, "hunter2-pass"); + + for (final Object stored : this.raw.getAll().values()) { + assertFalse(String.valueOf(stored).contains("hunter2-pass")); + } + } + + /** Builds of the fork stored the token in plain text; it is moved over on first read. */ + @Test + public void aPlainTextTokenFromAnEarlierBuildIsMovedOver() { + this.raw.edit().putString(ExtraSourcesSettings.GOOGLE_TOKEN, "old-token").commit(); + + assertEquals("old-token", new ExtraSourcesSettings(this.context).get(ExtraSourcesSettings.GOOGLE_TOKEN)); + + assertFalse("the plain copy should be gone", this.raw.contains(ExtraSourcesSettings.GOOGLE_TOKEN)); + assertEquals("old-token", new ExtraSourcesSettings(this.context).get(ExtraSourcesSettings.GOOGLE_TOKEN)); + } + + @Test + public void clearingTheTokenRemovesIt() { + final ExtraSourcesSettings settings = new ExtraSourcesSettings(this.context); + settings.put(ExtraSourcesSettings.GOOGLE_TOKEN, "s3cret-token"); + + settings.put(ExtraSourcesSettings.GOOGLE_TOKEN, ""); + + assertTrue("nothing should be stored for an empty token", this.raw.getAll().isEmpty()); + assertEquals("", settings.get(ExtraSourcesSettings.GOOGLE_TOKEN)); + } + + @Test + public void theAddressIsTrimmedAndIsWhatTurnsTheFeatureOn() { + final ExtraSourcesSettings settings = new ExtraSourcesSettings(this.context); + assertFalse(settings.isGoogleConfigured()); + + settings.put(ExtraSourcesSettings.GOOGLE_URL, " https://find.example "); + + assertEquals("https://find.example", settings.get(ExtraSourcesSettings.GOOGLE_URL)); + assertTrue(settings.isGoogleConfigured()); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/ExtraSourcesLayoutTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/ExtraSourcesLayoutTest.java new file mode 100644 index 00000000..4f20b934 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/ExtraSourcesLayoutTest.java @@ -0,0 +1,70 @@ +package dev.wander.android.opentagviewer.ui.settings; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.res.Configuration; +import android.view.LayoutInflater; +import android.view.View; + +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.databinding.ActivityExtraSourcesBinding; + +/** + * The Google Find Hub settings screen, inflated in the theme its activity uses, in both modes. + * + *

Every view the activity reads has to resolve, and the screen has to measure to something: + * a missing attribute or a renamed id fails only at runtime, on a screen few people open. + */ +@RunWith(AndroidJUnit4.class) +public class ExtraSourcesLayoutTest { + + private static final int[] VIEWS_THE_ACTIVITY_READS = { + R.id.extra_sources_google_url, R.id.extra_sources_google_url_layout, + R.id.extra_sources_google_token, R.id.extra_sources_google_user, + R.id.extra_sources_google_password, R.id.extra_sources_test, + R.id.extra_sources_test_progress, R.id.extra_sources_test_result, + R.id.extra_sources_google_guide, + }; + + @Test + public void itInflatesInLightMode() { + this.assertItInflates(Configuration.UI_MODE_NIGHT_NO); + } + + /** Half of what breaks only breaks in one mode. */ + @Test + public void itInflatesInDarkMode() { + this.assertItInflates(Configuration.UI_MODE_NIGHT_YES); + } + + private void assertItInflates(final int nightMode) { + final Context target = getInstrumentation().getTargetContext(); + final Configuration config = new Configuration(target.getResources().getConfiguration()); + config.uiMode = nightMode | Configuration.UI_MODE_TYPE_NORMAL; + final Context themed = new ContextThemeWrapper(target.createConfigurationContext(config), + R.style.Theme_OpenTagViewer_GenericGreyishActivity); + + final View[] root = new View[1]; + // On the main thread: Material text fields start animators, which need a Looper. + getInstrumentation().runOnMainSync(() -> { + root[0] = ActivityExtraSourcesBinding.inflate(LayoutInflater.from(themed)).getRoot(); + root[0].measure(View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + }); + + for (final int id : VIEWS_THE_ACTIVITY_READS) { + assertNotNull("a view the activity reads is missing: " + + target.getResources().getResourceEntryName(id), root[0].findViewById(id)); + } + assertTrue("the screen measured to nothing", root[0].getMeasuredHeight() > 0); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/ExtraSourcesSettingsScreenTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/ExtraSourcesSettingsScreenTest.java new file mode 100644 index 00000000..f928f030 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/ExtraSourcesSettingsScreenTest.java @@ -0,0 +1,102 @@ +package dev.wander.android.opentagviewer.ui.settings; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Context; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.ExtraSourcesSettingsActivity; +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.source.ExtraSourcesSettings; + +/** + * The Google Find Hub settings screen, driven. + * + *

The warning is the one that matters: the app permits cleartext HTTP, so an http:// address + * sends the API token, which reads every location the service holds, unencrypted. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class ExtraSourcesSettingsScreenTest { + + private Context context; + private ActivityScenario scenario; + + @Before + public void openItEmpty() { + this.context = getInstrumentation().getTargetContext(); + this.clearSettings(); + this.scenario = ActivityScenario.launch(ExtraSourcesSettingsActivity.class); + } + + @After + public void closeIt() { + if (this.scenario != null) { + this.scenario.close(); + } + this.clearSettings(); + } + + private void clearSettings() { + this.context.getSharedPreferences("extra_sources", Context.MODE_PRIVATE).edit().clear().commit(); + } + + @Test + public void anHttpAddressWarnsThatTheTokenTravelsUnencrypted() { + onView(withId(R.id.extra_sources_google_url)).perform(replaceText("http://192.168.1.2:8090")); + + Eventually.check(() -> onView(withText(R.string.server_url_not_https)).check(matches(isDisplayed()))); + } + + @Test + public void anHttpsAddressDoesNot() { + onView(withId(R.id.extra_sources_google_url)).perform(replaceText("http://192.168.1.2:8090")); + Eventually.check(() -> onView(withText(R.string.server_url_not_https)).check(matches(isDisplayed()))); + + onView(withId(R.id.extra_sources_google_url)).perform(replaceText("https://find.example")); + + // Gone or hidden, whichever TextInputLayout does with a null helper text + Eventually.check(() -> onView(withText(R.string.server_url_not_https)).check((view, noMatch) -> + assertTrue("the warning is still showing", view == null || !view.isShown()))); + } + + @Test + public void testingWithNoAddressSaysToEnterOne() { + onView(withId(R.id.extra_sources_test)).perform(click()); + + Eventually.check(() -> onView(withId(R.id.extra_sources_test_result)) + .check(matches(withText(R.string.test_nothing_configured)))); + } + + /** Saved when the screen is left, like the rest of Settings. */ + @Test + public void whatWasTypedSurvivesLeavingTheScreen() { + onView(withId(R.id.extra_sources_google_url)).perform(replaceText("https://find.example")); + onView(withId(R.id.extra_sources_google_token)).perform(replaceText("s3cret-token"), closeSoftKeyboard()); + + this.scenario.recreate(); + + Eventually.check(() -> onView(withId(R.id.extra_sources_google_url)) + .check(matches(withText("https://find.example")))); + assertEquals("s3cret-token", new ExtraSourcesSettings(this.context).get(ExtraSourcesSettings.GOOGLE_TOKEN)); + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c3a3a7b3..ac5c7b20 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -180,6 +180,10 @@ android:exported="false" android:label="@string/information" android:theme="@style/Theme.OpenTagViewer.GenericGreyishActivity" /> + Extra location sources: the Google Find Hub feature needs a self-hosted +# service, and nothing else in the app says how to set one up +googleFindHubWikiPage=https://github.com/parawanderer/OpenTagViewer/wiki/%5BGoogle%5D-How-To:-Find-Hub-Trackers bluetoothSigAssignedNumbers=https://www.bluetooth.com/specifications/assigned-numbers/ diff --git a/app/src/main/java/dev/wander/android/opentagviewer/AppKeyStoreConstants.java b/app/src/main/java/dev/wander/android/opentagviewer/AppKeyStoreConstants.java index 8c090daa..b7813c8e 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/AppKeyStoreConstants.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/AppKeyStoreConstants.java @@ -15,4 +15,7 @@ public final class AppKeyStoreConstants { * a peer is left on the account that this app can no longer use or clean up. */ public static final String KEYSTORE_ALIAS_KEYCHAIN = "keychain_membership"; + + /** The Google Find Hub service's API token and HTTP password - see ExtraSourcesSettings. */ + public static final String KEYSTORE_ALIAS_EXTRA_SOURCES = "extra_sources"; } 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 af1545c5..300fa80d 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java @@ -78,6 +78,8 @@ import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; import dev.wander.android.opentagviewer.db.room.entity.Import; import dev.wander.android.opentagviewer.db.room.entity.UserBeaconOptions; +import dev.wander.android.opentagviewer.source.ExternalAccessory; +import dev.wander.android.opentagviewer.ui.importing.ExternalDeviceImporter; import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; import dev.wander.android.opentagviewer.util.android.CachedPhoneLocation; import dev.wander.android.opentagviewer.util.android.FusedPhoneLocation; @@ -1097,6 +1099,9 @@ private void showPlaySoundStatus(final int messageRes, final int duration) { * knows the most about. */ private String knownDeviceType() { + if (ExternalAccessory.isGoogle(this.accessoryJson())) { + return this.getString(R.string.google_find_hub_tracker); + } if (this.beaconInformation.isCustomAccessory()) { return this.getString(R.string.custom_tag); } @@ -1208,6 +1213,11 @@ private String getDeviceNameForTitle() { return this.beaconInformation.getName(); } + private String accessoryJson() { + return this.beaconData == null || this.beaconData.getOwnedBeaconInfo() == null + ? null : this.beaconData.getOwnedBeaconInfo().accessoryJson; + } + private void handleClickMenu() { Log.d(TAG, "Device more button clicked"); @@ -1216,6 +1226,10 @@ private void handleClickMenu() { var popupMenu = new PopupMenu(this, button); popupMenu.getMenuInflater().inflate(R.menu.device_info_menu, popupMenu.getMenu()); + // A Google tracker rings through FMDN (see RoutingSoundTrigger) and has nothing to link + popupMenu.getMenu().findItem(R.id.device_link_google) + .setVisible(!ExternalAccessory.isGoogle(this.accessoryJson())); + popupMenu.setOnMenuItemClickListener(menuItem -> { Log.d(TAG, "Device menu option " + menuItem.getTitle() + " was selected"); @@ -1223,6 +1237,10 @@ private void handleClickMenu() { this.redirectToDeviceHistory(); } else if (menuItem.getItemId() == R.id.device_play_sound_nearby) { this.onClickPlaySoundNearby(); + } else if (menuItem.getItemId() == R.id.device_link_google) { + new ExternalDeviceImporter(this, () -> + Toast.makeText(this, R.string.google_link_saved, Toast.LENGTH_SHORT).show()) + .linkGoogleTracker(this.beaconId); } else if (menuItem.getItemId() == R.id.device_delete) { this.onClickDeviceDelete(); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ExtraSourcesSettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/ExtraSourcesSettingsActivity.java new file mode 100644 index 00000000..60fb9632 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ExtraSourcesSettingsActivity.java @@ -0,0 +1,136 @@ +package dev.wander.android.opentagviewer; + +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.View; +import android.widget.EditText; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.databinding.DataBindingUtil; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import dev.wander.android.opentagviewer.databinding.ActivityExtraSourcesBinding; +import dev.wander.android.opentagviewer.source.ExtraSourcesApi; +import dev.wander.android.opentagviewer.source.ExtraSourcesSettings; +import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; +import dev.wander.android.opentagviewer.util.android.PropertiesUtil; +import dev.wander.android.opentagviewer.util.android.WebLink; + +/** + * Settings for the extra location source: googlefind (Google Find Hub trackers). Values are + * saved when the screen is left, like the rest of Settings. + */ +public class ExtraSourcesSettingsActivity extends AppCompatActivity { + private final ExecutorService io = Executors.newSingleThreadExecutor(); + private ActivityExtraSourcesBinding binding; + private ExtraSourcesSettings settings; + + private static final String[] KEYS = { + ExtraSourcesSettings.GOOGLE_URL, ExtraSourcesSettings.GOOGLE_TOKEN, + ExtraSourcesSettings.GOOGLE_USER, ExtraSourcesSettings.GOOGLE_PASSWORD, + }; + + private EditText[] fields() { + return new EditText[]{ + binding.extraSourcesGoogleUrl, binding.extraSourcesGoogleToken, + binding.extraSourcesGoogleUser, binding.extraSourcesGooglePassword, + }; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + this.settings = new ExtraSourcesSettings(this); + + this.binding = DataBindingUtil.setContentView(this, R.layout.activity_extra_sources); + WindowPaddingUtil.insetForSystemBarsAndKeyboard(this.binding.getRoot()); + this.binding.setHandleClickBack(this::finish); + if (this.getSupportActionBar() != null) { + this.getSupportActionBar().hide(); + } + + final EditText[] fields = this.fields(); + for (int i = 0; i < KEYS.length; i++) fields[i].setText(this.settings.get(KEYS[i])); + + this.binding.extraSourcesTest.setOnClickListener(v -> this.test()); + this.binding.extraSourcesGoogleGuide.setOnClickListener(v -> { + final var properties = PropertiesUtil.getProperties(this.getAssets(), "app.properties"); + if (properties != null) { + WebLink.open(this, properties.getProperty("googleFindHubWikiPage")); + } + }); + + this.warnIfUnencrypted(); + this.binding.extraSourcesGoogleUrl.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) { + } + + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { + } + + @Override + public void afterTextChanged(final Editable s) { + ExtraSourcesSettingsActivity.this.warnIfUnencrypted(); + } + }); + } + + /** + * The app permits cleartext HTTP (network_security_config.xml), so an http:// address sends + * the API token, which reads every location the service holds, unencrypted. + */ + private void warnIfUnencrypted() { + final String url = String.valueOf(this.binding.extraSourcesGoogleUrl.getText()).trim(); + this.binding.extraSourcesGoogleUrlLayout.setHelperText( + url.regionMatches(true, 0, "http://", 0, 7) ? this.getString(R.string.server_url_not_https) : null); + } + + private void save() { + final EditText[] fields = this.fields(); + for (int i = 0; i < KEYS.length; i++) this.settings.put(KEYS[i], fields[i].getText().toString()); + } + + @Override + protected void onPause() { + this.save(); + super.onPause(); + } + + @Override + protected void onDestroy() { + this.io.shutdownNow(); + super.onDestroy(); + } + + /** Asks the service something cheap and says whether it answered. */ + private void test() { + this.save(); + this.binding.extraSourcesTest.setEnabled(false); + this.binding.extraSourcesTestProgress.setVisibility(View.VISIBLE); + this.binding.extraSourcesTestResult.setText(""); + this.io.execute(() -> { + final ExtraSourcesApi api = new ExtraSourcesApi(this.settings); + final StringBuilder result = new StringBuilder(); + if (this.settings.isGoogleConfigured()) { + try { + final int count = api.googleDevices().size(); + result.append(getString(R.string.test_google_ok, count)); + } catch (final Exception e) { + result.append(getString(R.string.test_google_failed, e.getMessage())); + } + } + final String text = result.toString().trim(); + runOnUiThread(() -> { + this.binding.extraSourcesTest.setEnabled(true); + this.binding.extraSourcesTestProgress.setVisibility(View.GONE); + this.binding.extraSourcesTestResult.setText( + text.isEmpty() ? getString(R.string.test_nothing_configured) : text); + }); + }); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java index 26e998ef..883bcc3b 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java @@ -82,6 +82,7 @@ import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; import dev.wander.android.opentagviewer.db.room.entity.DailyHistoryFetchRecord; import dev.wander.android.opentagviewer.db.util.BeaconCombinerUtil; +import dev.wander.android.opentagviewer.source.ReportSources; import dev.wander.android.opentagviewer.python.AppDependencies; import dev.wander.android.opentagviewer.python.PythonAppleService; import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; @@ -386,8 +387,14 @@ private Observable> fetchReports(final long beginning var reqData = BeaconRepository.plistFallback( this.beaconId, this.beaconInformation.getOwnedBeaconPlistRaw()); // asyncReq emits Observable (reports + updated accessory state per beacon) + // Each device through its own sources: the Apple account and Google Find Hub - see + // ReportSources. var asyncReq = this.beaconRepo.toAccessoryRequests(reqData) - .flatMap(requests -> this.appleService.getReportsBetween(requests, beginningOfDay, endOfDay)); + .flatMap(requests -> Observable.fromIterable(requests) + .concatMap(request -> ReportSources.create(this).fetch( + request, beginningOfDay, endOfDay, false, + this.appleService == null ? null + : this.appleService.getReportsBetween(List.of(request), beginningOfDay, endOfDay)))); // **What Apple returns is merged with what we already hold, for every day.** // 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 f65580fe..05d02607 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -48,6 +48,7 @@ import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.LatLng; +import dev.wander.android.opentagviewer.ui.importing.ExternalDeviceImporter; import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; import dev.wander.android.opentagviewer.ui.importing.BundlePasscodeDialog; import dev.wander.android.opentagviewer.ui.importing.ImportOutcome; @@ -73,6 +74,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import dev.wander.android.opentagviewer.db.room.entity.LocationReport; import dev.wander.android.opentagviewer.db.room.entity.Import; import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity; import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; @@ -102,7 +104,11 @@ import dev.wander.android.opentagviewer.db.repo.BeaconRepository; import dev.wander.android.opentagviewer.db.repo.model.ImportData; import dev.wander.android.opentagviewer.db.util.BeaconCombinerUtil; +import dev.wander.android.opentagviewer.source.ExternalAccessory; +import dev.wander.android.opentagviewer.source.ExtraSourcesSettings; +import dev.wander.android.opentagviewer.source.ReportSources; import dev.wander.android.opentagviewer.python.AccessoryRequest; +import dev.wander.android.opentagviewer.python.FetchResult; import dev.wander.android.opentagviewer.python.icloud.ICloudFailures; import dev.wander.android.opentagviewer.python.AppDependencies; import dev.wander.android.opentagviewer.python.PythonDiagnostics; @@ -143,6 +149,7 @@ import dev.wander.android.opentagviewer.ble.NearbyTagSightings; import dev.wander.android.opentagviewer.ble.NearbyTagIndex; import dev.wander.android.opentagviewer.ble.NearbyTagWatcher; +import dev.wander.android.opentagviewer.ble.FmdnNearbyWatcher; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Observable; @@ -216,7 +223,15 @@ private float initialZoom() { private UserDataRepository userDataRepository; + /** OpenHaystack keys and Google trackers - see ExternalDeviceImporter. */ + private final ExternalDeviceImporter externalImporter = + new ExternalDeviceImporter(this, this::handleDeviceListChanged); + private final ActivityResultLauncher openKeysFileLauncher = registerForActivityResult( + new ActivityResultContracts.OpenDocument(), this.externalImporter::importKeysFile); + private PythonAppleService appleService = null; + /** Google Find Hub, beside the Apple account. */ + private ReportSources reportSources; private UserSettings userSettings; @@ -258,6 +273,10 @@ private float initialZoom() { /** The in-flight nearby scan, disposed in {@link #onPause()} so the radio stops with the screen. */ private Disposable nearbyWatchDisposable; + /** Last ring failure shown as a toast, so a retrying loop reports it once. */ + private String lastRingFailureShown; + /** Google Find Hub trackers, recognised by EID - see FmdnNearbyWatcher. */ + private Disposable fmdnNearbyWatchDisposable; /** * Redraws the cards once a second while the nearby scan is running, so a nearby card's @@ -526,6 +545,8 @@ protected void onCreate(Bundle savedInstanceState) { UserAuthDataStore.getInstance(getApplicationContext()), new AppCryptographyUtil()); + this.reportSources = ReportSources.create(this); + this.beaconRepo = new BeaconRepository( OpenTagViewerDatabase.getInstance(getApplicationContext())); // The fetch this screen is about to run is what produces the drift measurement, so @@ -747,27 +768,43 @@ private void startWatchingForNearbyTags() { } final Map accessoryJsonByBeaconId = new HashMap<>(); + final Map googleIdByBeaconId = new HashMap<>(); for (final var entry : this.beacons.entrySet()) { final String accessoryJson = entry.getValue().getInfo().getOwnedBeaconAccessoryJson(); - if (accessoryJson != null && !accessoryJson.isEmpty()) { + if (accessoryJson == null || accessoryJson.isEmpty()) continue; + if (ExternalAccessory.isGoogle(accessoryJson)) { + googleIdByBeaconId.put(entry.getKey(), ExternalAccessory.googleId(accessoryJson)); + } else { accessoryJsonByBeaconId.put(entry.getKey(), accessoryJson); } } - if (accessoryJsonByBeaconId.isEmpty()) { + if (accessoryJsonByBeaconId.isEmpty() && googleIdByBeaconId.isEmpty()) { // Ordinary on a cold start: the beacons load asynchronously and are not here yet. // addBeaconToCurrent starts the watch once they arrive - see there. return; } - this.nearbyWatchDisposable = new NearbyTagWatcher( - AppDependencies.accessoryMacResolver(), this.sightingPersister::onSighting, - ScanSettings.SCAN_MODE_LOW_LATENCY) - .watch(this.getApplicationContext(), accessoryJsonByBeaconId) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - this::onTagHeardNearby, - error -> Log.w(TAG, "Nearby tag watch ended unexpectedly", error), - this::onNearbyWatchEnded); + if (!googleIdByBeaconId.isEmpty() && new ExtraSourcesSettings(this).isGoogleConfigured()) { + this.fmdnNearbyWatchDisposable = new FmdnNearbyWatcher() + .watch(this.getApplicationContext(), googleIdByBeaconId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::onTagHeardNearby, + error -> Log.w(TAG, "Google tracker nearby watch ended", error)); + } + + // Only with Apple-keyed tags to watch: an empty watch completes at once, which reads as + // a dead scan and would clear the Google sightings every 30 s + if (!accessoryJsonByBeaconId.isEmpty()) { + this.nearbyWatchDisposable = new NearbyTagWatcher( + AppDependencies.accessoryMacResolver(), this.sightingPersister::onSighting, + ScanSettings.SCAN_MODE_LOW_LATENCY) + .watch(this.getApplicationContext(), accessoryJsonByBeaconId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::onTagHeardNearby, + error -> Log.w(TAG, "Nearby tag watch ended unexpectedly", error), + this::onNearbyWatchEnded); + } this.nearbyStatusTickerDisposable = Observable .interval(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) @@ -802,6 +839,10 @@ private void stopWatchingForNearbyTags() { this.nearbyWatchDisposable.dispose(); } this.nearbyWatchDisposable = null; + if (this.fmdnNearbyWatchDisposable != null && !this.fmdnNearbyWatchDisposable.isDisposed()) { + this.fmdnNearbyWatchDisposable.dispose(); + } + this.fmdnNearbyWatchDisposable = null; if (this.nearbyStatusTickerDisposable != null && !this.nearbyStatusTickerDisposable.isDisposed()) { this.nearbyStatusTickerDisposable.dispose(); @@ -859,9 +900,13 @@ private void onTagHeardNearby(final NearbyTagSighting sighting) { private void showNearbyStatusOn( final FrameLayout card, final NearbyTagSighting sighting, final long nowMs) { final TextView line = card.findViewById(R.id.device_last_update); - line.setText(this.getString(R.string.nearby_now_with_battery_and_signal, - this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())), - NearbyTagLabel.signalStrengthBars(sighting.getRssi()))); + // Google trackers encrypt their battery flags, so their sightings carry none + line.setText(sighting.getBatteryLevel() == null + ? this.getString(R.string.nearby_now_with_signal, + NearbyTagLabel.signalStrengthBars(sighting.getRssi())) + : this.getString(R.string.nearby_now_with_battery_and_signal, + this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())), + NearbyTagLabel.signalStrengthBars(sighting.getRssi()))); // Never negative: nowMs can be a hair behind seenAtMs when this runs right off the scan // callback, before the clock the caller reads has ticked past it. @@ -1016,7 +1061,7 @@ private void lookForAnIgnoredTagAgain(final String beaconId) { // same thing they were told before, having done the one thing the screen offered them. var async = this.fetchLastReportsFor( beaconId, beacon.getInfo().getOwnedBeaconPlistRaw(), - HOURS_TO_GO_BACK_FIRST_TIME) + HOURS_TO_GO_BACK_FIRST_TIME, true) .observeOn(AndroidSchedulers.mainThread()) .subscribe( reports -> this.handleDeviceListChanged(), @@ -1044,6 +1089,10 @@ public void onClickMoreSettings(View view) if (itemId == R.id.do_import) { this.handleImport(); + } else if (itemId == R.id.add_openhaystack) { + this.openKeysFileLauncher.launch(new String[]{"*/*"}); + } else if (itemId == R.id.add_google) { + this.externalImporter.importFromGoogle(); } else if (itemId == R.id.settings) { this.showSettingsPage(); } else if (itemId == R.id.information) { @@ -1575,7 +1624,8 @@ public void onClickRefresh(View view) { var async = this.fetchLastReportsFor( beaconId, Objects.requireNonNull(this.beacons.get(beaconId)).getInfo().getOwnedBeaconPlistRaw(), - 1) + 1, + true) .doOnNext(this::addBeaconLocationsToCurrent) .flatMapCompletable((__) -> this.updateBeaconGeocodings()) .observeOn(AndroidSchedulers.mainThread()) @@ -1644,6 +1694,7 @@ private void startContinuousPing(final String beaconId) { TagCardHelper.toggleRingActive(container, true); } + this.lastRingFailureShown = null; this.continuousPingDisposable = AppDependencies.accessorySoundTrigger() .playSoundContinuously(this.getApplicationContext(), accessoryJson) .observeOn(AndroidSchedulers.mainThread()) @@ -1704,6 +1755,19 @@ private void handleContinuousPingUpdate(final String beaconId, final BleSoundTri LENGTH_LONG).show(); return; } + // The loop retries quietly, which used to hide why a ring never happens (a tracker + // refusing the request, a missing sound service). Say so - once per distinct reason. + // Google trackers only, while that path is experimental: the message is diagnostic + // English, and the Find My path already reports through the card's label + final String reason = update.getResult().getMessage(); + final BeaconData ringing = this.beacons.get(beaconId); + final boolean google = ringing != null + && ExternalAccessory.isGoogle(ringing.getInfo().getOwnedBeaconAccessoryJson()); + if (google && (status == BleSoundTriggerStatus.FAILED || status == BleSoundTriggerStatus.NO_SOUND_SERVICE) + && reason != null && !reason.equals(this.lastRingFailureShown)) { + this.lastRingFailureShown = reason; + Toast.makeText(this, this.getString(R.string.ring_failed_reason, reason), LENGTH_LONG).show(); + } } final FrameLayout container = this.dynamicCardsForTag.get(beaconId); @@ -1827,7 +1891,7 @@ private void getAppleUser(Optional userAuth) { // so we no longer need to read userSettings.getAnisetteServerUrl() here. // Produce Anisette on this device where possible, rather than relaying every refresh // through a public Anisette server. Falls back to the configured server by itself. - var asyncAppleService = PythonAuthService.restoreAccount( + final Observable asyncAppleService = PythonAuthService.restoreAccount( userAuth.get(), // Somebody is signed in here, so an unchosen mode keeps them on whatever // already works: switching would present Apple with a different machine. @@ -1844,6 +1908,14 @@ private void getAppleUser(Optional userAuth) { return this.appleService; }); + this.showDevicesAndFetch(asyncAppleService); + } + + /** + * Shows the stored devices and their cached locations, then fetches fresh reports once + * {@code asyncAppleService} (the restored account) is ready. + */ + private void showDevicesAndFetch(final Observable asyncAppleService) { // get list of Beacons var asyncAllBeacons = this.beaconRepo.getAllBeacons() .flatMap(BeaconDataParser::parseAsync) @@ -2282,11 +2354,13 @@ private synchronized void addBeaconToCurrent(final List allBe // Guarded on the disposable so the periodic account refresh, which also lands here, // does not bounce a running scan - Android silently blocks an app that starts scans // too often. - if (!newBeaconInformation.isEmpty() && this.nearbyWatchDisposable == null) { + if (!newBeaconInformation.isEmpty() && this.nearbyWatchDisposable == null + && this.fmdnNearbyWatchDisposable == null) { // Re-checked on the main thread: this load finishes on a background thread, and by // the time the post runs, onResume may have started the watch already. this.runOnUiThread(() -> { - if (this.nearbyWatchDisposable == null && !this.isFinishing()) { + if (this.nearbyWatchDisposable == null && this.fmdnNearbyWatchDisposable == null + && !this.isFinishing()) { this.startWatchingForNearbyTags(); } }); @@ -2625,7 +2699,13 @@ private synchronized void updateBeaconCards() { now, DateUtils.MINUTE_IN_MILLIS ).toString(); - deviceLastUpdate.setText(this.getString(R.string.last_updated_x, timeAgo)); + // Only Google is named: Apple is every other card's source, and labelling all of + // them would change the row for everyone who never set Google up + final String source = LocationReport.PROVENANCE_GOOGLE.equals(lastLocation.getProvenance()) + ? this.getString(R.string.source_google) + : null; + final String updated = this.getString(R.string.last_updated_x, timeAgo); + deviceLastUpdate.setText(source == null ? updated : updated + " · " + source); // Nothing live to pulse for once the sighting has aged out. ((ImageView) v.findViewById(R.id.device_nearby_pulse)).setVisibility(GONE); } @@ -2767,7 +2847,7 @@ private Observable>> fetchLastReports(fin return this.beaconRepo.toScheduledAccessoryRequests(beaconIdToPlist) .doOnSubscribe(__ -> this.markFetchStarted()) .doOnNext(this::armLongFetchBannerIfSlow) - .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack)) + .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack, false)) .doOnNext(reports -> this.refreshPolicy.markFetched(now)) // on success, update this time. .doFinally(this::markFetchFinished); } @@ -2777,7 +2857,7 @@ private Observable>> fetchLastReports(fin return this.beaconRepo.toAccessoryRequests(beaconIdToPlist) .doOnSubscribe(__ -> this.markFetchStarted()) .doOnNext(this::armLongFetchBannerIfSlow) - .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack)) + .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack, false)) .doFinally(this::markFetchFinished); } @@ -2801,18 +2881,18 @@ private Observable>> fetchLastReports(fin * an Activity - see {@code RxFlowsTest}. */ private Observable>> fetchOneAccessoryAtATime( - final List requests, final int hoursToGoBack) { + final List requests, final int hoursToGoBack, final boolean askGoogleNow) { // **A tag nobody has searched for yet gets the whole week.** Read once for the batch, // then applied per accessory - see HOURS_TO_GO_BACK_FIRST_TIME. Everything else keeps // the window it asked for. return this.beaconRepo.neverScanned().flatMap(neverScanned -> RxFlows.oneAtATime( requests, - request -> this.appleService.getLastReports( - List.of(request), + request -> this.fetchFromSources(request, neverScanned.contains(request.getBeaconId()) ? HOURS_TO_GO_BACK_FIRST_TIME - : hoursToGoBack) + : hoursToGoBack, + askGoogleNow) .flatMap(this.beaconRepo::storeFetchResult), this::setLongFetchProgress, (request, error) -> { @@ -2829,17 +2909,37 @@ private Observable>> fetchOneAccessoryAtA })); } - private Observable>> fetchLastReportsFor(final String beaconId, final String pList, final int hoursToGoBack) { + private Observable>> fetchLastReportsFor( + final String beaconId, final String pList, final int hoursToGoBack, final boolean askGoogleNow) { Log.i(TAG, "Preparing to fetch location reports for the last " + hoursToGoBack + " hours!"); // Not Map.of - see BeaconRepository.plistFallback. A self-generated tag has no plist. return this.beaconRepo.toAccessoryRequests(BeaconRepository.plistFallback(beaconId, pList)) .doOnSubscribe(__ -> this.markFetchStarted()) .doOnNext(this::armLongFetchBannerIfSlow) - .flatMap(requests -> this.appleService.getLastReports(requests, hoursToGoBack)) + .flatMap(requests -> Observable.fromIterable(requests) + .concatMap(request -> this.fetchFromSources(request, hoursToGoBack, askGoogleNow))) .flatMap(this.beaconRepo::storeFetchResult) .doFinally(this::markFetchFinished); } + /** + * One device's reports from wherever they come from: the Apple account and Google Find Hub - + * see {@link ReportSources}. + * + * @param askGoogleNow a person asked (refresh button), so Google is asked for fresh + * locations rather than only reading what the service already holds + */ + private Observable fetchFromSources( + final AccessoryRequest request, final int hoursToGoBack, final boolean askGoogleNow) { + final long now = System.currentTimeMillis(); + return this.reportSources.fetch( + request, + now - hoursToGoBack * 3_600_000L, + now, + askGoogleNow, + ReportSources.appleFetchOrNull(this.appleService, request, hoursToGoBack)); + } + /** * Starts the clock on the "still working" banner. *
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java index e89a06ef..6db01368 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java @@ -47,6 +47,7 @@ import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore; import dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore; import dev.wander.android.opentagviewer.db.repo.BeaconRepository; +import dev.wander.android.opentagviewer.ui.importing.ExternalDeviceImporter; import dev.wander.android.opentagviewer.ui.login.SignInAgain; import dev.wander.android.opentagviewer.python.icloud.ICloudFailures; import dev.wander.android.opentagviewer.python.icloud.AccountRefresher; @@ -201,6 +202,15 @@ public class MyDevicesListActivity extends AppCompatActivity { } ); + /** OpenHaystack keys and Google trackers; the list is rebuilt once one is added. */ + private final ExternalDeviceImporter externalImporter = new ExternalDeviceImporter(this, () -> { + this.devicesListChanged = true; + this.recreate(); + }); + + private final ActivityResultLauncher openKeysFileLauncher = registerForActivityResult( + new ActivityResultContracts.OpenDocument(), this.externalImporter::importKeysFile); + private final ActivityResultLauncher deviceInfoActivityLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), (ActivityResult result) -> { @@ -650,6 +660,14 @@ private void showPageMenu() { this.handleStartImport(); return true; } + if (id == R.id.action_add_openhaystack) { + this.openKeysFileLauncher.launch(new String[]{"*/*"}); + return true; + } + if (id == R.id.action_add_google) { + this.externalImporter.importFromGoogle(); + return true; + } if (id == R.id.action_import_history) { this.openHistoryZipLauncher.launch(new String[]{"application/zip"}); return true; diff --git a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java index e2363efd..62231536 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java @@ -228,6 +228,9 @@ protected void onCreate(Bundle savedInstanceState) { this.binding.setHandleClickBack(this::handleEndActivity); this.binding.setOnClickFetchFromAccount(this::onClickFetchFromAccount); this.binding.setOnClickUnlinkAccount(this::onClickUnlinkAccount); + this.binding.setOnClickExtraSources(() -> + startActivity(new Intent(this, ExtraSourcesSettingsActivity.class))); + this.sayWhetherTheAccountIsLinked(); this.binding.setOnClickTheme(this::onClickEditTheme); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java index e7f2a65e..5d74adc4 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java @@ -223,7 +223,7 @@ private static BluetoothGattCharacteristic findMyCharacteristic(final BluetoothG } @SuppressLint("MissingPermission") - private static void writeCharacteristicCompat( + static void writeCharacteristicCompat( final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, final byte[] value) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { gatt.writeCharacteristic(characteristic, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT); @@ -235,7 +235,7 @@ private static void writeCharacteristicCompat( } @SuppressLint("MissingPermission") - private static void writeDescriptorCompat( + static void writeDescriptorCompat( final BluetoothGatt gatt, final BluetoothGattDescriptor descriptor, final byte[] value) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { gatt.writeDescriptor(descriptor, value); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java index eedd46d7..9541e2ff 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java @@ -52,11 +52,12 @@ public BleSoundTriggerResult( } /** - * Whether this attempt left something sounding that can be told to stop. Only DULT has a + * Whether this attempt left something sounding that can be told to stop. DULT and FMDN have a * stop command; an AirTag stops by itself, so asking it costs a connection for nothing. */ public boolean hasStopCommand() { return this.status == BleSoundTriggerStatus.SUCCESS - && BleGattSoundTrigger.PROTOCOL_DULT.equals(this.protocol); + && (BleGattSoundTrigger.PROTOCOL_DULT.equals(this.protocol) + || FmdnRingTrigger.PROTOCOL.equals(this.protocol)); } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnNearbyWatcher.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnNearbyWatcher.java new file mode 100644 index 00000000..138e2d6c --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnNearbyWatcher.java @@ -0,0 +1,99 @@ +package dev.wander.android.opentagviewer.ble; + +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothManager; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanFilter; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.ScanSettings; +import android.content.Context; +import android.os.ParcelUuid; +import android.util.Log; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import dev.wander.android.opentagviewer.source.ExtraSourcesApi; +import dev.wander.android.opentagviewer.source.ExtraSourcesSettings; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * "Nearby" for Google Find Hub trackers. They carry no Apple keys, so {@link NearbyTagWatcher} + * cannot derive their addresses; instead they are recognised by their current EID in the FMDN + * advertisement (service data 0xFEAA), with the EIDs from the googlefind service - the same + * list the ring uses. The FMDN flags byte (battery) is encrypted, so no battery is reported. + */ +@SuppressLint("MissingPermission") // callers check BlePermissions first +public final class FmdnNearbyWatcher { + private static final String TAG = FmdnNearbyWatcher.class.getSimpleName(); + private static final ParcelUuid FMDN_SERVICE_DATA = ParcelUuid.fromString("0000feaa-0000-1000-8000-00805f9b34fb"); + + /** + * Emits a sighting each time one of the trackers advertises nearby. + * + * @param googleIdByBeaconId the Google trackers to watch for (beacon id -> googlefind id) + */ + public Observable watch(final Context context, final Map googleIdByBeaconId) { + return Observable.>fromCallable(() -> { + // EID (hex) -> beacon id. The service returns a +-6 h window, so one fetch + // covers a long map session; the watch is restarted on resume anyway. + final ExtraSourcesApi api = new ExtraSourcesApi(new ExtraSourcesSettings(context)); + final Map beaconByEid = new HashMap<>(); + for (final Map.Entry e : googleIdByBeaconId.entrySet()) { + try { + for (final String eid : api.ringMaterial(e.getValue()).eids) beaconByEid.put(eid, e.getKey()); + } catch (final Exception ex) { + Log.w(TAG, "No EIDs for " + e.getKey(), ex); + } + } + return beaconByEid; + }) + .subscribeOn(Schedulers.io()) + .flatMap(beaconByEid -> beaconByEid.isEmpty() ? Observable.empty() + : scan(context, beaconByEid)); + } + + private static Observable scan(final Context context, final Map beaconByEid) { + return Observable.create(emitter -> { + final BluetoothManager manager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE); + final BluetoothAdapter adapter = manager == null ? null : manager.getAdapter(); + final BluetoothLeScanner scanner = adapter == null || !adapter.isEnabled() ? null : adapter.getBluetoothLeScanner(); + if (scanner == null) { + emitter.onComplete(); + return; + } + final ScanCallback callback = new ScanCallback() { + @Override + public void onScanResult(final int callbackType, final ScanResult result) { + final byte[] data = result.getScanRecord() == null ? null + : result.getScanRecord().getServiceData(FMDN_SERVICE_DATA); + if (data == null || data.length < 21) return; + final int frame = data[0] & 0xFF; + if (frame != 0x40 && frame != 0x41) return; + final String eid = FmdnRingCommand.hex(java.util.Arrays.copyOfRange(data, 1, 21)); + final String beaconId = beaconByEid.get(eid); + if (beaconId != null && !emitter.isDisposed()) { + emitter.onNext(new NearbyTagSighting(beaconId, null, result.getRssi(), null, + data.length > 21 ? data[21] & 0xFF : 0, null, System.currentTimeMillis())); + } + } + + @Override + public void onScanFailed(final int errorCode) { + Log.w(TAG, "FMDN nearby scan failed: " + errorCode); + emitter.onComplete(); + } + }; + final List filters = Collections.singletonList( + new ScanFilter.Builder().setServiceData(FMDN_SERVICE_DATA, new byte[0]).build()); + scanner.startScan(filters, new ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), callback); + emitter.setCancellable(() -> scanner.stopScan(callback)); + }); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingCommand.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingCommand.java new file mode 100644 index 00000000..310d5326 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingCommand.java @@ -0,0 +1,153 @@ +package dev.wander.android.opentagviewer.ble; + +import java.security.GeneralSecurityException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Locale; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * The bytes of a Google Find Hub (FMDN) owner "ring" request, per the FMDN accessory + * specification's Beacon Actions characteristic. + * + *

Write layout: {@code dataId(0x05) | len(0x0C) | auth(8) | components | timeout(u16 BE, + * deciseconds) | volume}. The one-time authentication key binds the nonce the tracker handed out + * on the preceding read: {@code HMAC-SHA256(ringKey, version | nonce | dataId | len | additional)[:8]}, where {@code ringKey = SHA256(EIK | 0x02)[:8]} is derived by the googlefind + * service, which holds the identity key. + * + *

Pure Java so it can be tested on the JVM. + */ +public final class FmdnRingCommand { + public static final byte DATA_ID_RING = 0x05; + /** Ring every component the tracker has. */ + public static final byte COMPONENTS_ALL = (byte) 0xFF; + /** Components 0 means stop. */ + public static final byte COMPONENTS_STOP = 0x00; + public static final byte VOLUME_DEFAULT = 0x00; + // Data length counts the 8-byte authentication key plus the additional data (a length of 4 + // made a real tracker drop the link) + private static final byte LENGTH = 12; + /** Direction byte some firmware expects appended to the HMAC input (fallback variants only). */ + private static final byte FROM_SEEKER = 0x01; + + private FmdnRingCommand() { + } + + static byte[] additionalData(final byte components, final int timeoutDeciseconds, final byte volume) { + return new byte[]{components, (byte) (timeoutDeciseconds >> 8), (byte) timeoutDeciseconds, volume}; + } + + /** The 8-byte one-time authentication key. */ + public static byte[] authKey(final byte[] ringKey, final int version, final byte[] nonce, + final byte dataId, final byte length, final byte[] additional) { + try { + final Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(ringKey, "HmacSHA256")); + mac.update((byte) version); + mac.update(nonce); + mac.update(dataId); + mac.update(length); + mac.update(additional); + return Arrays.copyOf(mac.doFinal(), 8); + } catch (final GeneralSecurityException e) { + throw new IllegalStateException(e); + } + } + + /** + * The value to write, given what the tracker's Beacon Actions characteristic returned on read + * ({@code version | nonce(8)}). + */ + public static byte[] ringWrite(final byte[] ringKey, final byte[] readValue, final byte components, + final int timeoutDeciseconds, final byte volume) { + return ringWrite(ringKey, readValue, components, timeoutDeciseconds, volume, VARIANTS[0]); + } + + /** + * One way of phrasing the ring request. Trackers differ: some predate the volume byte, and + * implementations disagree on the trailing direction byte in the HMAC input. + */ + public static final class Variant { + public final boolean withVolume; + public final boolean trailingDirection; + + public Variant(final boolean withVolume, final boolean trailingDirection) { + this.withVolume = withVolume; + this.trailingDirection = trailingDirection; + } + + @Override + public String toString() { + return (withVolume ? "4-byte" : "3-byte") + (trailingDirection ? "+dir" : ""); + } + } + + /** + * Tried in this order. The first is what a real tracker accepted: four bytes of additional + * data and no trailing direction byte (that byte belongs to the tracker's own notification + * authentication, not to the seeker's request). The rest are fallbacks for other firmware. + */ + public static final Variant[] VARIANTS = { + new Variant(true, false), new Variant(false, false), + new Variant(true, true), new Variant(false, true), + }; + + public static byte[] ringWrite(final byte[] ringKey, final byte[] readValue, final byte components, + final int timeoutDeciseconds, final byte volume, final Variant variant) { + if (readValue == null || readValue.length < 9) { + throw new IllegalArgumentException("Beacon Actions read returned no nonce"); + } + final int version = readValue[0] & 0xFF; + final byte[] nonce = Arrays.copyOfRange(readValue, 1, 9); + final byte[] full = additionalData(components, timeoutDeciseconds, volume); + final byte[] additional = variant.withVolume ? full : Arrays.copyOf(full, 3); + final byte length = (byte) (8 + additional.length); + final byte[] auth; + try { + final Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(ringKey, "HmacSHA256")); + mac.update((byte) version); + mac.update(nonce); + mac.update(DATA_ID_RING); + mac.update(length); + mac.update(additional); + if (variant.trailingDirection) mac.update(FROM_SEEKER); + auth = Arrays.copyOf(mac.doFinal(), 8); + } catch (final GeneralSecurityException e) { + throw new IllegalStateException(e); + } + final byte[] out = new byte[2 + 8 + additional.length]; + out[0] = DATA_ID_RING; + out[1] = length; + System.arraycopy(auth, 0, out, 2, 8); + System.arraycopy(additional, 0, out, 10, additional.length); + return out; + } + + /** + * Whether FMDN service data (UUID 0xFEAA: frame type, then the 20-byte EID) carries one of + * the given EIDs, as lowercase hex. + */ + public static boolean matchesEid(final byte[] serviceData, final Collection eidsHex) { + if (serviceData == null || serviceData.length < 21) return false; + final int frame = serviceData[0] & 0xFF; + if (frame != 0x40 && frame != 0x41) return false; + return eidsHex.contains(hex(Arrays.copyOfRange(serviceData, 1, 21))); + } + + public static String hex(final byte[] bytes) { + final StringBuilder sb = new StringBuilder(bytes.length * 2); + for (final byte b : bytes) sb.append(String.format(Locale.ROOT, "%02x", b)); + return sb.toString(); + } + + public static byte[] unhex(final String hex) { + final byte[] out = new byte[hex.length() / 2]; + for (int i = 0; i < out.length; i++) { + out[i] = (byte) Integer.parseInt(hex.substring(2 * i, 2 * i + 2), 16); + } + return out; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingTrigger.java new file mode 100644 index 00000000..9ae131d2 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingTrigger.java @@ -0,0 +1,304 @@ +package dev.wander.android.opentagviewer.ble; + +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothGattService; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanFilter; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.ScanSettings; +import android.content.Context; +import android.os.ParcelUuid; +import android.util.Log; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import dev.wander.android.opentagviewer.source.ExternalAccessory; +import dev.wander.android.opentagviewer.source.ExtraSourcesApi; +import dev.wander.android.opentagviewer.source.ExtraSourcesSettings; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * Rings a Google Find Hub tracker the way its owner's Find Hub app does: the tracker is + * recognised by its current EID in the FMDN advertisement (service data 0xFEAA), then its Beacon + * Actions characteristic is read for a nonce and written a ring request authenticated with the + * ring key (see {@link FmdnRingCommand}). The ring key and EIDs come from the googlefind service, + * which holds the tracker's identity key; the ring key rings and nothing else. + * + *

Same contract as {@link BleAccessorySoundTrigger}: progress updates, exactly one DONE, never + * an error. + */ +@SuppressLint("MissingPermission") // checked through BlePermissions first +public final class FmdnRingTrigger implements AccessorySoundTrigger { + private static final String TAG = FmdnRingTrigger.class.getSimpleName(); + /** {@link BleSoundTriggerResult#getProtocol()} for a ring sent over FMDN. */ + static final String PROTOCOL = "FMDN"; + + private static final ParcelUuid FMDN_SERVICE_DATA = ParcelUuid.fromString("0000feaa-0000-1000-8000-00805f9b34fb"); + private static final UUID FAST_PAIR_SERVICE = UUID.fromString("0000fe2c-0000-1000-8000-00805f9b34fb"); + private static final UUID BEACON_ACTIONS = UUID.fromString("fe2c1238-8366-4814-8eb0-01de32100bea"); + private static final UUID CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); + + private static final long SCAN_TIMEOUT_S = 30; + private static final long GATT_TIMEOUT_S = 20; + /** One ring lasts this long (deciseconds); continuous ringing re-rings before it ends. */ + private static final int RING_DECISECONDS = 300; + private static final long CONTINUOUS_PAUSE_S = 20; + + @Override + public Observable playSound(final Context context, final String accessoryJson) { + return this.run(context, accessoryJson, FmdnRingCommand.COMPONENTS_ALL); + } + + @Override + public Observable playSoundContinuously(final Context context, final String accessoryJson) { + return this.playSound(context, accessoryJson) + .repeatWhen(completed -> completed.delay(CONTINUOUS_PAUSE_S, TimeUnit.SECONDS)); + } + + @Override + public Completable stopSound(final Context context, final String accessoryJson) { + return this.run(context, accessoryJson, FmdnRingCommand.COMPONENTS_STOP).ignoreElements().onErrorComplete(); + } + + private static BleSoundTriggerUpdate done(final BleSoundTriggerStatus status, final String message) { + return BleSoundTriggerUpdate.done(new BleSoundTriggerResult( + status, status == BleSoundTriggerStatus.SUCCESS ? PROTOCOL : null, message)); + } + + private Observable run(final Context context, final String accessoryJson, + final byte components) { + return Observable.defer(() -> { + if (!BlePermissions.granted(context)) { + return Observable.just(done(BleSoundTriggerStatus.MISSING_PERMISSION, + "Bluetooth scan/connect permission not granted")); + } + final String googleId = ExternalAccessory.googleId(accessoryJson); + if (googleId == null) { + return Observable.just(done(BleSoundTriggerStatus.NO_CANDIDATE_MACS, "Not a Google Find Hub tracker")); + } + + final ExtraSourcesApi.RingMaterial material; + try { + material = new ExtraSourcesApi(new ExtraSourcesSettings(context)).ringMaterial(googleId); + } catch (final Exception e) { + Log.w(TAG, "Could not get ring material for " + googleId, e); + return Observable.just(done(BleSoundTriggerStatus.NO_CANDIDATE_MACS, + "Could not get the ring key from the Google Find Hub service: " + e.getMessage())); + } + + return Observable.just(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.SCANNING)) + .concatWith(findByEid(context, material.eids).toObservable() + .concatMap(device -> Observable.just(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.CONNECTING)) + .concatWith(ring(context, device, material.ringKey, components) + .map(update -> update.withMatchedMac(device.getAddress()))))) + .onErrorReturn(e -> e instanceof NotNearby + ? done(BleSoundTriggerStatus.NOT_NEARBY, e.getMessage()) + : done(BleSoundTriggerStatus.FAILED, String.valueOf(e.getMessage()))); + }).subscribeOn(Schedulers.io()); + } + + private static final class NotNearby extends Exception { + NotNearby(final String message) { + super(message); + } + } + + /** Scans for an FMDN advertisement carrying one of the EIDs. */ + private static Single findByEid(final Context context, final List eids) { + return Single.create(emitter -> { + final BluetoothManager manager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE); + final BluetoothAdapter adapter = manager == null ? null : manager.getAdapter(); + final BluetoothLeScanner scanner = adapter == null || !adapter.isEnabled() ? null : adapter.getBluetoothLeScanner(); + if (scanner == null) { + emitter.tryOnError(new IllegalStateException("Bluetooth is off")); + return; + } + final AtomicBoolean found = new AtomicBoolean(false); + final java.util.concurrent.atomic.AtomicInteger seen = new java.util.concurrent.atomic.AtomicInteger(); + Log.i(TAG, "Scanning for an FMDN tracker matching one of " + eids.size() + " EIDs"); + final ScanCallback callback = new ScanCallback() { + @Override + public void onScanResult(final int callbackType, final ScanResult result) { + final byte[] data = result.getScanRecord() == null ? null + : result.getScanRecord().getServiceData(FMDN_SERVICE_DATA); + if (data != null && seen.incrementAndGet() <= 40) { + Log.d(TAG, "FEAA " + FmdnRingCommand.hex(data) + " from " + result.getDevice().getAddress() + + (FmdnRingCommand.matchesEid(data, eids) ? " MATCH" : "")); + } + if (FmdnRingCommand.matchesEid(data, eids) && found.compareAndSet(false, true)) { + emitter.onSuccess(result.getDevice()); + } + } + + @Override + public void onScanFailed(final int errorCode) { + emitter.tryOnError(new IllegalStateException("Bluetooth scan failed (" + errorCode + ")")); + } + }; + // Any FMDN advertisement; the EID is compared in the callback + final ScanFilter filter = new ScanFilter.Builder().setServiceData(FMDN_SERVICE_DATA, new byte[0]).build(); + scanner.startScan(Collections.singletonList(filter), + new ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), callback); + emitter.setCancellable(() -> scanner.stopScan(callback)); + }).timeout(SCAN_TIMEOUT_S, TimeUnit.SECONDS, Single.error(new NotNearby("Tracker not found nearby"))); + } + + /** Connect, read the nonce, write the ring (or stop) request. */ + private static Observable ring(final Context context, final BluetoothDevice device, + final byte[] ringKey, final byte components) { + return Observable.create(emitter -> { + final AtomicBoolean finished = new AtomicBoolean(false); + final BluetoothGatt[] gattRef = new BluetoothGatt[1]; + + final BluetoothGattCallback callback = new BluetoothGattCallback() { + private BluetoothGattCharacteristic actions; + private int variant = 0; + + private void finish(final BleSoundTriggerUpdate update, final BluetoothGatt gatt) { + if (!finished.compareAndSet(false, true)) return; + if (!emitter.isDisposed()) { + emitter.onNext(update); + emitter.onComplete(); + } + gatt.disconnect(); + } + + @Override + public void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) { + Log.i(TAG, "Connection state " + newState + " (status=" + status + ") " + device.getAddress()); + if (newState == BluetoothProfile.STATE_CONNECTED) { + gatt.discoverServices(); + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + gatt.close(); + this.finish(done(BleSoundTriggerStatus.FAILED, + "Connection closed before the ring request was sent"), gatt); + } + } + + @Override + public void onServicesDiscovered(final BluetoothGatt gatt, final int status) { + final BluetoothGattService fastPair = gatt.getService(FAST_PAIR_SERVICE); + this.actions = fastPair == null ? null : fastPair.getCharacteristic(BEACON_ACTIONS); + if (this.actions == null) { + // Some trackers expose Beacon Actions outside the Fast Pair service + for (final BluetoothGattService svc : gatt.getServices()) { + final BluetoothGattCharacteristic c = svc.getCharacteristic(BEACON_ACTIONS); + if (c != null) { this.actions = c; break; } + } + } + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.w(TAG, "Service discovery status=" + status); + } + if (this.actions == null) { + final StringBuilder found = new StringBuilder(); + for (final BluetoothGattService svc : gatt.getServices()) found.append(svc.getUuid()).append(' '); + Log.w(TAG, "No Beacon Actions char on " + device.getAddress() + "; services: " + found); + this.finish(done(BleSoundTriggerStatus.NO_SOUND_SERVICE, + "No FMDN Beacon Actions characteristic"), gatt); + return; + } + if (!emitter.isDisposed()) { + emitter.onNext(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.TRIGGERING)); + } + final BluetoothGattDescriptor cccd = this.actions.getDescriptor(CCCD); + if (cccd != null) { + gatt.setCharacteristicNotification(this.actions, true); + BleGattSoundTrigger.writeDescriptorCompat( + gatt, cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); + } else { + gatt.readCharacteristic(this.actions); + } + } + + @Override + public void onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic c, final byte[] value) { + Log.i(TAG, "Beacon Actions notification: " + FmdnRingCommand.hex(value)); + } + + // Below API 33 the platform calls these older overloads instead, with the value + // on the characteristic; without them a ring on Android 12 and below waits out + // the timeout + @Override + @SuppressWarnings("deprecation") + public void onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic c) { + this.onCharacteristicChanged(gatt, c, c.getValue()); + } + + @Override + @SuppressWarnings("deprecation") + public void onCharacteristicRead(final BluetoothGatt gatt, final BluetoothGattCharacteristic c, + final int status) { + this.onCharacteristicRead(gatt, c, c.getValue(), status); + } + + @Override + public void onDescriptorWrite(final BluetoothGatt gatt, final BluetoothGattDescriptor d, final int status) { + Log.i(TAG, "CCCD write status=" + status); + gatt.readCharacteristic(this.actions); + } + + @Override + public void onCharacteristicRead(final BluetoothGatt gatt, final BluetoothGattCharacteristic c, + final byte[] value, final int status) { + if (status != BluetoothGatt.GATT_SUCCESS) { + this.finish(done(BleSoundTriggerStatus.FAILED, "Reading the nonce failed (status=" + status + ")"), gatt); + return; + } + Log.i(TAG, "Beacon Actions read: " + FmdnRingCommand.hex(value == null ? new byte[0] : value)); + try { + final byte[] request = FmdnRingCommand.ringWrite(ringKey, value, components, + RING_DECISECONDS, FmdnRingCommand.VOLUME_DEFAULT, + FmdnRingCommand.VARIANTS[this.variant]); + Log.i(TAG, "Writing: " + FmdnRingCommand.hex(request)); + BleGattSoundTrigger.writeCharacteristicCompat(gatt, this.actions, request); + } catch (final IllegalArgumentException e) { + this.finish(done(BleSoundTriggerStatus.FAILED, e.getMessage()), gatt); + } + } + + @Override + public void onCharacteristicWrite(final BluetoothGatt gatt, final BluetoothGattCharacteristic c, final int status) { + Log.i(TAG, "Ring request (components=" + components + ", variant " + + FmdnRingCommand.VARIANTS[this.variant] + ") written to " + + device.getAddress() + ", status=" + status); + // 0x80 is the spec's "unauthenticated": the tracker is still connected, so try + // the next phrasing with a fresh nonce + if (status == 0x80 && this.variant + 1 < FmdnRingCommand.VARIANTS.length) { + this.variant++; + gatt.readCharacteristic(this.actions); + return; + } + this.finish(status == BluetoothGatt.GATT_SUCCESS + ? done(BleSoundTriggerStatus.SUCCESS, null) + // The tracker rejects a bad one-time key with an ATT error + : done(BleSoundTriggerStatus.FAILED, "The tracker refused the ring request (status=" + status + ")"), gatt); + } + }; + + gattRef[0] = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE); + emitter.setCancellable(() -> { + if (gattRef[0] != null) { + gattRef[0].disconnect(); + gattRef[0].close(); + } + }); + }).timeout(GATT_TIMEOUT_S, TimeUnit.SECONDS, + Observable.just(done(BleSoundTriggerStatus.FAILED, "The tracker did not answer in time"))); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/RoutingSoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/RoutingSoundTrigger.java new file mode 100644 index 00000000..c24accf7 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/RoutingSoundTrigger.java @@ -0,0 +1,40 @@ +package dev.wander.android.opentagviewer.ble; + +import android.content.Context; + +import dev.wander.android.opentagviewer.source.ExternalAccessory; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Observable; + +/** + * Picks the sound protocol by the kind of device: Google Find Hub trackers ring through FMDN + * ({@link FmdnRingTrigger}), everything else through the Find My / DULT path. + */ +public final class RoutingSoundTrigger implements AccessorySoundTrigger { + private final AccessorySoundTrigger findMy; + private final AccessorySoundTrigger fmdn; + + public RoutingSoundTrigger(final AccessorySoundTrigger findMy, final AccessorySoundTrigger fmdn) { + this.findMy = findMy; + this.fmdn = fmdn; + } + + private AccessorySoundTrigger forDevice(final String accessoryJson) { + return ExternalAccessory.isGoogle(accessoryJson) ? this.fmdn : this.findMy; + } + + @Override + public Observable playSound(final Context context, final String accessoryJson) { + return this.forDevice(accessoryJson).playSound(context, accessoryJson); + } + + @Override + public Observable playSoundContinuously(final Context context, final String accessoryJson) { + return this.forDevice(accessoryJson).playSoundContinuously(context, accessoryJson); + } + + @Override + public Completable stopSound(final Context context, final String accessoryJson) { + return this.forDevice(accessoryJson).stopSound(context, accessoryJson); + } +} 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 b6514156..3937427a 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 @@ -958,10 +958,12 @@ public Observable>> storeToLocationCache( .horizontalAccuracy(locationReport.getHorizontalAccuracy()) .status(locationReport.getStatus()) .lastUpdate(now) - // Everything arriving here was decrypted from Apple's - // network. The local path writes its own rows and sets this - // itself - see recordLocalSighting. - .provenance(LocationReport.PROVENANCE_APPLE) + // Apple's network unless the fetch said otherwise: the + // extra sources mark Google's reports (see ReportSources). + // The local path writes its own rows - see recordLocalSighting. + .provenance(locationReport.getProvenance() != null + ? locationReport.getProvenance() + : LocationReport.PROVENANCE_APPLE) .build() )) .toArray(LocationReport[]::new); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java index cfd31704..014ee427 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java @@ -109,4 +109,10 @@ public class LocationReport { /** Heard by this phone's own radio, positioned from this phone's own location. */ public static final String PROVENANCE_LOCAL = "local"; + + /** + * Reported by the Google Find Hub network, through the googlefind service + * (see {@code source/ExtraSourcesApi}). + */ + public static final String PROVENANCE_GOOGLE = "google"; } 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 bf7deb8a..e92fec1f 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 @@ -16,6 +16,8 @@ import dev.wander.android.opentagviewer.anisette.LocalAnisette; import dev.wander.android.opentagviewer.ble.AccessorySoundTrigger; import dev.wander.android.opentagviewer.ble.BleAccessorySoundTrigger; +import dev.wander.android.opentagviewer.ble.FmdnRingTrigger; +import dev.wander.android.opentagviewer.ble.RoutingSoundTrigger; import dev.wander.android.opentagviewer.python.icloud.ICloudService; import dev.wander.android.opentagviewer.python.icloud.PythonICloudService; import dev.wander.android.opentagviewer.db.repo.model.UserSettings; @@ -211,7 +213,9 @@ public static AccessoryMacResolver accessoryMacResolver() { public static AccessorySoundTrigger accessorySoundTrigger() { if (accessorySoundTrigger == null) { - accessorySoundTrigger = BleAccessorySoundTrigger.forRealBluetooth(accessoryMacResolver); + accessorySoundTrigger = new RoutingSoundTrigger( + BleAccessorySoundTrigger.forRealBluetooth(accessoryMacResolver), + new FmdnRingTrigger()); } return accessorySoundTrigger; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java index 93a06832..141eecbd 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java @@ -7,6 +7,7 @@ import com.chaquo.python.PyObject; import com.chaquo.python.Python; +import dev.wander.android.opentagviewer.source.ExternalAccessory; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -22,13 +23,17 @@ *

Blocking, and starts an interpreter. Never call this on the main thread; the {@code * ble} package that uses it does so on an Rx scheduler. */ +/* + * Google Find Hub trackers (ExternalAccessory.TYPE_GOOGLE) have no Apple keys and are never + * handed to Python here: there is no MAC to derive, and FindMy.py raises on the type. + */ public class ChaquopyAccessoryMacResolver implements AccessoryMacResolver { private static final String TAG = ChaquopyAccessoryMacResolver.class.getSimpleName(); private static final String MODULE_MAIN = "main"; @Override public Map currentMacAddresses(final String accessoryJson) { - if (accessoryJson == null || accessoryJson.isEmpty()) { + if (accessoryJson == null || accessoryJson.isEmpty() || ExternalAccessory.isGoogle(accessoryJson)) { // Not yet backfilled from the legacy plist - see OwnedBeacon.accessoryJson. A real // state, not a failure, so this reports it the same way Python does: nothing found. return Collections.emptyMap(); @@ -61,7 +66,7 @@ public Map currentMacAddresses(final String accessoryJson) { @Override @Nullable public IndexRange candidateWindow(final String accessoryJson) { - if (accessoryJson == null || accessoryJson.isEmpty()) { + if (accessoryJson == null || accessoryJson.isEmpty() || ExternalAccessory.isGoogle(accessoryJson)) { return null; } @@ -86,7 +91,8 @@ public IndexRange candidateWindow(final String accessoryJson) { @Override public Map addressesBetween( final String accessoryJson, final int lo, final int hi) { - if (accessoryJson == null || accessoryJson.isEmpty() || hi < lo) { + if (accessoryJson == null || accessoryJson.isEmpty() || hi < lo + || ExternalAccessory.isGoogle(accessoryJson)) { return Collections.emptyMap(); } @@ -120,7 +126,8 @@ public Map addressesBetween( public String recordSeen( final String accessoryJson, final String mac, final long seenAtUnixMs, final Integer hintIndex) { - if (accessoryJson == null || accessoryJson.isEmpty() || mac == null) { + if (accessoryJson == null || accessoryJson.isEmpty() || mac == null + || ExternalAccessory.isGoogle(accessoryJson)) { return null; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/source/ExternalAccessory.java b/app/src/main/java/dev/wander/android/opentagviewer/source/ExternalAccessory.java new file mode 100644 index 00000000..36e2e676 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/source/ExternalAccessory.java @@ -0,0 +1,119 @@ +package dev.wander.android.opentagviewer.source; + +import android.util.Base64; + +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Function; + +/** + * The accessory JSON of the two kinds of device the extra sources add. + * + *

    + *
  • OpenHaystack keys are FindMy.py's own {@code custom_rolling_key_accessory}, so everything + * that already works for self-generated tags (BLE matching, play sound, history) works for them + * unchanged. It takes every key of a tag that rotates through several (Everytag does), and + * searches all of them on each fetch.
  • + *
  • A Google Find Hub tracker is {@code google_find_hub}: it has no Apple keys at all, and + * the Apple paths must never be handed one (FindMy.py raises on an unknown type).
  • + *
+ */ +public final class ExternalAccessory { + public static final String TYPE_OPENHAYSTACK = "custom_rolling_key_accessory"; + public static final String TYPE_GOOGLE = "google_find_hub"; + + private ExternalAccessory() { + } + + @Nullable + private static JSONObject parse(@Nullable final String accessoryJson) { + if (accessoryJson == null) return null; + try { + return new JSONObject(accessoryJson); + } catch (final JSONException e) { + return null; + } + } + + public static boolean isGoogle(@Nullable final String accessoryJson) { + final JSONObject o = parse(accessoryJson); + return o != null && TYPE_GOOGLE.equals(o.optString("type")); + } + + public static boolean isOpenHaystack(@Nullable final String accessoryJson) { + final JSONObject o = parse(accessoryJson); + return o != null && TYPE_OPENHAYSTACK.equals(o.optString("type")); + } + + /** The canonic id of a Google tracker. */ + @Nullable + public static String googleId(@Nullable final String accessoryJson) { + final JSONObject o = parse(accessoryJson); + return o == null ? null : o.optString("google_id", null); + } + + /** + * Reads the private keys out of a Macless-Haystack / OpenHaystack {@code .keys} file + * ("Private key: <base64>", once per key), as hex, in file order and without repeats. + * + * @throws IllegalArgumentException when there is none, or one is not a 28-byte key + */ + public static List privateKeysFromKeysFile(final String text) { + return privateKeysFromKeysFile(text, b64 -> Base64.decode(b64, Base64.DEFAULT)); + } + + /** As above, with the Base64 decoder passed in: {@code android.util.Base64} is a stub on the JVM. */ + static List privateKeysFromKeysFile(final String text, final Function base64) { + final List keys = new ArrayList<>(); + for (final String line : text.split("\n")) { + final int colon = line.indexOf(':'); + if (colon > 0 && line.substring(0, colon).trim().equalsIgnoreCase("Private key")) { + final byte[] key = base64.apply(line.substring(colon + 1).trim()); + if (key.length != 28) throw new IllegalArgumentException("A private key is not 28 bytes long"); + final String hex = toHex(key); + if (!keys.contains(hex)) keys.add(hex); + } + } + if (keys.isEmpty()) throw new IllegalArgumentException("No \"Private key\" line in this file"); + return keys; + } + + static String toHex(final byte[] bytes) { + final StringBuilder sb = new StringBuilder(bytes.length * 2); + for (final byte b : bytes) sb.append(String.format(Locale.ROOT, "%02x", b)); + return sb.toString(); + } + + public static String newOpenHaystack(final String id, final String name, final List privateKeysHex) { + try { + return new JSONObject() + .put("type", TYPE_OPENHAYSTACK) + .put("identifier", id) + .put("name", name) + .put("private_keys", new JSONArray(privateKeysHex)) + .toString(); + } catch (final JSONException e) { + throw new IllegalStateException(e); + } + } + + public static String newGoogle(final String googleId, final String name) { + try { + return new JSONObject() + .put("type", TYPE_GOOGLE) + .put("identifier", "google-" + googleId) + .put("name", name) + .put("google_id", googleId) + .toString(); + } catch (final JSONException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesApi.java b/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesApi.java new file mode 100644 index 00000000..22cb6483 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesApi.java @@ -0,0 +1,193 @@ +package dev.wander.android.opentagviewer.source; + +import android.util.Base64; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Function; + +import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import dev.wander.android.opentagviewer.db.room.entity.LocationReport; + +/** + * Blocking HTTP client for the googlefind service (Google Find Hub). Call from a background + * thread. + * + *

{@code GET /api/devices}, {@code GET /api/devices/{id}/reports}, + * {@code POST /api/devices/{id}/locate}, {@code GET /api/devices/{id}/ring}; reports arrive + * decrypted. The service may sit behind HTTP Basic Auth on a reverse proxy, so its token also + * travels in {@code X-Api-Token} rather than only in {@code Authorization}. + */ +public final class ExtraSourcesApi { + /** A stored setting by key. Real: {@link ExtraSourcesSettings#get}. */ + private final Function setting; + /** Base64 without line breaks. Real: {@code android.util.Base64}, a stub on the JVM. */ + private final Function base64; + + public ExtraSourcesApi(final ExtraSourcesSettings settings) { + this(settings::get, bytes -> Base64.encodeToString(bytes, Base64.NO_WRAP)); + } + + /** For {@code ExtraSourcesApiTest}, which runs on the JVM against a local socket. */ + ExtraSourcesApi(final Function setting, final Function base64) { + this.setting = setting; + this.base64 = base64; + } + + public static final class ApiException extends IOException { + public ApiException(final String message) { + super(message); + } + } + + /** A tracker of the Google account. */ + public static final class GoogleDevice { + public final String id; + public final String name; + public final boolean custom; + + GoogleDevice(final String id, final String name, final boolean custom) { + this.id = id; + this.name = name; + this.custom = custom; + } + } + + private String basic(final String user, final String password) { + if (user.isEmpty()) { + return null; + } + final String raw = user + ":" + password; + return "Basic " + this.base64.apply(raw.getBytes(StandardCharsets.UTF_8)); + } + + private static String join(String base, final String path) { + while (base.endsWith("/")) base = base.substring(0, base.length() - 1); + return base + path; + } + + private static String request(final String method, final String url, final String body, + final String authorization, final String token, + final int timeoutMs) throws IOException { + final HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection(); + try { + c.setRequestMethod(method); + c.setConnectTimeout(15000); + c.setReadTimeout(timeoutMs); + if (authorization != null) c.setRequestProperty("Authorization", authorization); + if (token != null && !token.isEmpty()) { + if (authorization == null) c.setRequestProperty("Authorization", "Bearer " + token); + c.setRequestProperty("X-Api-Token", token); + } + if (body != null) { + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/json"); + try (OutputStream os = c.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + } + final int code = c.getResponseCode(); + final InputStream in = code < 400 ? c.getInputStream() : c.getErrorStream(); + String text = ""; + if (in != null) { + final ByteArrayOutputStream buf = new ByteArrayOutputStream(); + final byte[] chunk = new byte[8192]; + int n; + while ((n = in.read(chunk)) > 0) buf.write(chunk, 0, n); + in.close(); + text = buf.toString("UTF-8"); + } + if (code >= 400) throw new ApiException("HTTP " + code + " from " + url); + return text; + } finally { + c.disconnect(); + } + } + + // ---- Google Find Hub, through googlefind ---- + + private String google(final String method, final String path, final int timeoutMs) throws IOException { + return request(method, join(this.setting.apply(ExtraSourcesSettings.GOOGLE_URL), path), + "POST".equals(method) ? "{}" : null, + this.basic(this.setting.apply(ExtraSourcesSettings.GOOGLE_USER), + this.setting.apply(ExtraSourcesSettings.GOOGLE_PASSWORD)), + this.setting.apply(ExtraSourcesSettings.GOOGLE_TOKEN), timeoutMs); + } + + public List googleDevices() throws Exception { + final JSONArray arr = new JSONArray(this.google("GET", "/api/devices", 30000)); + final List out = new ArrayList<>(); + for (int i = 0; i < arr.length(); i++) { + final JSONObject d = arr.getJSONObject(i); + out.add(new GoogleDevice(d.getString("id"), d.getString("name"), d.optBoolean("custom"))); + } + return out; + } + + /** What is needed to ring a Google tracker nearby: its ring key and current EIDs. */ + public static final class RingMaterial { + /** 8-byte ring key: rings the tracker, cannot read its locations. */ + public final byte[] ringKey; + /** EIDs it may be advertising around now, lowercase hex (20 bytes each). */ + public final List eids; + + RingMaterial(final byte[] ringKey, final List eids) { + this.ringKey = ringKey; + this.eids = eids; + } + } + + public RingMaterial ringMaterial(final String googleId) throws Exception { + final JSONObject o = new JSONObject(this.google("GET", "/api/devices/" + googleId + "/ring", 30000)); + final String keyHex = o.getString("ring_key"); + final byte[] key = new byte[keyHex.length() / 2]; + for (int i = 0; i < key.length; i++) key[i] = (byte) Integer.parseInt(keyHex.substring(2 * i, 2 * i + 2), 16); + final JSONArray arr = o.getJSONArray("eids"); + final List eids = new ArrayList<>(); + for (int i = 0; i < arr.length(); i++) eids.add(arr.getString(i).toLowerCase(Locale.ROOT)); + return new RingMaterial(key, eids); + } + + /** Asks Google for fresh locations and waits up to {@code waitS} for the answer. */ + public boolean googleLocate(final String googleId, final int waitS) throws Exception { + final String text = this.google("POST", "/api/devices/" + googleId + "/locate?wait=" + waitS, + (waitS + 30) * 1000); + return new JSONObject(text).optBoolean("answered"); + } + + /** Reports recorded at or after {@code sinceMs} (the service keeps what Google sent it). */ + public List googleReports(final String googleId, final long sinceMs) throws Exception { + final JSONArray arr = new JSONArray(this.google("GET", + "/api/devices/" + googleId + "/reports?limit=5000&since=" + (sinceMs / 1000), 30000)); + final List out = new ArrayList<>(); + for (int i = 0; i < arr.length(); i++) { + final JSONObject r = arr.getJSONObject(i); + // A "semantic" report ("home") has no position to draw + if (r.isNull("latitude") || r.isNull("longitude")) continue; + final long timeMs = r.getLong("time") * 1000L; + out.add(BeaconLocationReport.builder() + .publishedAt(r.optLong("received", r.getLong("time")) * 1000L) + .description(r.optBoolean("own") ? "Google Find Hub (own device)" : "Google Find Hub") + .timestamp(timeMs) + .confidence(0) + .latitude(r.getDouble("latitude")) + .longitude(r.getDouble("longitude")) + .horizontalAccuracy(Math.round(r.optDouble("accuracy", 0))) + .status(r.optLong("status")) + .provenance(LocationReport.PROVENANCE_GOOGLE) + .build()); + } + return out; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesSettings.java b/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesSettings.java new file mode 100644 index 00000000..a63a1561 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesSettings.java @@ -0,0 +1,87 @@ +package dev.wander.android.opentagviewer.source; + +import static dev.wander.android.opentagviewer.AppKeyStoreConstants.KEYSTORE_ALIAS_EXTRA_SOURCES; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Base64; +import android.util.Log; + +import java.nio.charset.StandardCharsets; +import java.util.Set; + +import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; + +/** + * Where the extra location source lives: a Google Find Hub service (googlefind) for Google + * trackers. + * + *

SharedPreferences rather than the app's DataStore: these are read synchronously from the + * fetch path, which runs on Rx worker threads and has no reason to wait on a Flowable. + * + *

The API token and the HTTP password are encrypted with a keystore key, like the Apple + * session: the token reads every location the service holds. + */ +public final class ExtraSourcesSettings { + private static final String TAG = ExtraSourcesSettings.class.getSimpleName(); + + public static final String GOOGLE_URL = "google_url"; + public static final String GOOGLE_TOKEN = "google_token"; + public static final String GOOGLE_USER = "google_user"; + public static final String GOOGLE_PASSWORD = "google_password"; + + private static final Set SECRETS = Set.of(GOOGLE_TOKEN, GOOGLE_PASSWORD); + private static final String ENCRYPTED_SUFFIX = "_encrypted"; + + private final SharedPreferences prefs; + private final AppCryptographyUtil crypto = new AppCryptographyUtil(); + + public ExtraSourcesSettings(final Context context) { + this.prefs = context.getApplicationContext().getSharedPreferences("extra_sources", Context.MODE_PRIVATE); + } + + public String get(final String key) { + if (!SECRETS.contains(key)) { + return this.prefs.getString(key, ""); + } + final String plain = this.prefs.getString(key, null); + if (plain != null) { + // Written in plain text by a build before encryption: move it over + this.put(key, plain); + return plain.trim(); + } + final String stored = this.prefs.getString(key + ENCRYPTED_SUFFIX, null); + if (stored == null) { + return ""; + } + try { + final byte[] decrypted = this.crypto.decrypt(AppCryptographyUtil.AppEncryptedData.fromFlattened( + Base64.decode(stored, Base64.NO_WRAP)), KEYSTORE_ALIAS_EXTRA_SOURCES); + return new String(decrypted, StandardCharsets.UTF_8); + } catch (final RuntimeException e) { + Log.w(TAG, "Could not decrypt the stored " + key + "; it has to be entered again", e); + return ""; + } + } + + public void put(final String key, final String value) { + final String trimmed = value == null ? "" : value.trim(); + if (!SECRETS.contains(key)) { + this.prefs.edit().putString(key, trimmed).apply(); + return; + } + final SharedPreferences.Editor edit = this.prefs.edit().remove(key); + if (trimmed.isEmpty()) { + edit.remove(key + ENCRYPTED_SUFFIX); + } else { + final byte[] flattened = this.crypto.encrypt( + trimmed.getBytes(StandardCharsets.UTF_8), KEYSTORE_ALIAS_EXTRA_SOURCES).flatten(); + edit.putString(key + ENCRYPTED_SUFFIX, Base64.encodeToString(flattened, Base64.NO_WRAP)); + } + edit.apply(); + } + + public boolean isGoogleConfigured() { + return !this.get(GOOGLE_URL).isEmpty(); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/source/ReportSources.java b/app/src/main/java/dev/wander/android/opentagviewer/source/ReportSources.java new file mode 100644 index 00000000..c272e9ba --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/source/ReportSources.java @@ -0,0 +1,201 @@ +package dev.wander.android.opentagviewer.source; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BooleanSupplier; +import java.util.function.Function; +import java.util.function.LongSupplier; + +import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import dev.wander.android.opentagviewer.python.AccessoryRequest; +import dev.wander.android.opentagviewer.python.FetchResult; +import dev.wander.android.opentagviewer.python.PythonAppleService; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * Decides, per device, where its location reports come from, and hands them back in the same + * {@link FetchResult} the Apple path produces - so storing, drawing, the scan backoff and the + * history all work unchanged. + * + *

    + *
  • Apple-paired accessories and OpenHaystack keys: the signed-in Apple account, as before.
  • + *
  • Google trackers, and any device linked to one: googlefind.
  • + *
+ */ +public final class ReportSources { + private static final String TAG = ReportSources.class.getSimpleName(); + /** How long a manual refresh waits for Google to answer a fresh locate request. */ + private static final int GOOGLE_LOCATE_WAIT_S = 25; + /** + * A tracker with no reports at all has no card, so nothing to press refresh on: it is asked + * for by itself instead, but not more often than this. + */ + private static final long GOOGLE_FIRST_LOCATE_EVERY_MS = 30 * 60_000L; + /** Shared by every instance: a screen creates its own, and the throttle must outlive it. */ + private static final Map LAST_FIRST_LOCATE = new ConcurrentHashMap<>(); + + /** The googlefind calls this class makes. Real: {@link ExtraSourcesApi}. */ + interface Google { + void locate(String googleId, int waitS) throws Exception; + + List reports(String googleId, long sinceMs) throws Exception; + } + + private final BooleanSupplier googleConfigured; + private final Function linkedGoogleId; + private final Google google; + private final LongSupplier clock; + private final Map lastFirstLocate; + + /** + * Seams rather than the settings, links and HTTP client themselves, which all need a + * {@code Context}: this class decides which network a device's reports come from, and that + * decision is tested on the JVM - see {@code ReportSourcesTest}. + */ + ReportSources(final BooleanSupplier googleConfigured, final Function linkedGoogleId, + final Google google, final LongSupplier clock, final Map lastFirstLocate) { + this.googleConfigured = googleConfigured; + this.linkedGoogleId = linkedGoogleId; + this.google = google; + this.clock = clock; + this.lastFirstLocate = lastFirstLocate; + } + + public static ReportSources create(final Context context) { + final ExtraSourcesSettings settings = new ExtraSourcesSettings(context); + final SourceLinks links = new SourceLinks(context); + final ExtraSourcesApi api = new ExtraSourcesApi(settings); + return new ReportSources(settings::isGoogleConfigured, links::googleId, new Google() { + @Override + public void locate(final String googleId, final int waitS) throws Exception { + api.googleLocate(googleId, waitS); + } + + @Override + public List reports(final String googleId, final long sinceMs) throws Exception { + return api.googleReports(googleId, sinceMs); + } + }, System::currentTimeMillis, LAST_FIRST_LOCATE); + } + + /** + * Reports of one device. + * + * @param sinceMs oldest report wanted + * @param untilMs newest report wanted (history asks for one day) + * @param askGoogleNow ask Google for fresh locations first (a manual refresh), rather than + * only reading what the service already has + * @param appleFetch the Apple-account fetch for this request + */ + public Observable fetch(final AccessoryRequest request, + final long sinceMs, final long untilMs, + final boolean askGoogleNow, + @Nullable final Observable appleFetch) { + final String beaconId = request.getBeaconId(); + final String json = request.getAccessoryJson(); + + final Observable apple; + if (ExternalAccessory.isGoogle(json)) { + apple = Observable.just(empty(beaconId)); + } else if (appleFetch != null) { + apple = appleFetch; + } else { + apple = Observable.just(empty(beaconId)); + } + + final String googleId = ExternalAccessory.isGoogle(json) + ? ExternalAccessory.googleId(json) : this.linkedGoogleId.apply(beaconId); + if (googleId == null || !this.googleConfigured.getAsBoolean()) { + return apple; + } + + final Observable> google = Observable.fromCallable(() -> { + if (askGoogleNow) { + try { + this.google.locate(googleId, GOOGLE_LOCATE_WAIT_S); + } catch (final Exception e) { + // The stored reports are still worth showing + Log.w(TAG, "Google locate request failed for " + beaconId, e); + } + } + // Google's answer is the last known position, often hours or days old, so a scheduled + // one-hour window would hide it; the database drops what it already holds + final long googleSince = Math.min(sinceMs, this.clock.getAsLong() - 7 * 86_400_000L); + List fetched = this.google.reports(googleId, googleSince); + if (!askGoogleNow && fetched.isEmpty() && this.google.reports(googleId, 0).isEmpty()) { + // Never located: ask Google once, rather than waiting for a refresh button that + // a device without a location does not have + final long now = this.clock.getAsLong(); + final Long last = this.lastFirstLocate.get(googleId); + if (last == null || now - last > GOOGLE_FIRST_LOCATE_EVERY_MS) { + this.lastFirstLocate.put(googleId, now); + try { + this.google.locate(googleId, GOOGLE_LOCATE_WAIT_S); + fetched = this.google.reports(googleId, googleSince); + } catch (final Exception e) { + Log.w(TAG, "First Google locate failed for " + beaconId, e); + } + } + } + final List reports = new ArrayList<>(); + for (final BeaconLocationReport r : fetched) { + if (r.getTimestamp() <= untilMs) reports.add(r); + } + return reports; + }).subscribeOn(Schedulers.io()); + + // A failing Google service must not cost the device its Apple reports, and the reverse + if (ExternalAccessory.isGoogle(json)) { + return google.map(reports -> withReports(beaconId, reports)); + } + return Observable.zip( + apple.onErrorReturn(e -> { + Log.w(TAG, "Apple reports failed for " + beaconId, e); + return empty(beaconId); + }), + google.onErrorReturn(e -> { + Log.w(TAG, "Google reports failed for " + beaconId, e); + return new ArrayList<>(); + }), + (fromApple, fromGoogle) -> merge(beaconId, fromApple, fromGoogle)); + } + + private static FetchResult empty(final String beaconId) { + return withReports(beaconId, new ArrayList<>()); + } + + private static FetchResult withReports(final String beaconId, final List reports) { + final Map> map = new HashMap<>(); + map.put(beaconId, reports); + return new FetchResult(map, new HashMap<>(), new HashSet<>(), new HashSet<>()); + } + + static FetchResult merge(final String beaconId, final FetchResult apple, final List google) { + final Map> map = new HashMap<>(apple.getReports()); + final List all = new ArrayList<>(map.getOrDefault(beaconId, new ArrayList<>())); + all.addAll(google); + map.put(beaconId, all); + final Set exhausted = new HashSet<>(apple.getExhaustedWideSearch()); + // Google answering means the device is not silent, whatever Apple found + if (!google.isEmpty()) exhausted.remove(beaconId); + return new FetchResult(map, apple.getUpdatedAccessoryJson(), exhausted, apple.getWideSearch()); + } + + /** For callers that only hold an Apple service. */ + @Nullable + public static Observable appleFetchOrNull(@Nullable final PythonAppleService service, + final AccessoryRequest request, final int hours) { + return service == null ? null : service.getLastReports(List.of(request), hours); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/source/SourceLinks.java b/app/src/main/java/dev/wander/android/opentagviewer/source/SourceLinks.java new file mode 100644 index 00000000..2a9e2644 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/source/SourceLinks.java @@ -0,0 +1,35 @@ +package dev.wander.android.opentagviewer.source; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.Nullable; + +/** + * Per-device extras that do not belong in the accessory JSON: the Google tracker whose reports + * are shown on this device. + * + *

Kept apart because FindMy.py rewrites the accessory JSON on every fetch and sighting + * ({@code to_json}), dropping any key it does not know. + */ +public final class SourceLinks { + private final SharedPreferences prefs; + + public SourceLinks(final Context context) { + this.prefs = context.getApplicationContext().getSharedPreferences("source_links", Context.MODE_PRIVATE); + } + + @Nullable + public String googleId(final String beaconId) { + return this.prefs.getString("google:" + beaconId, null); + } + + public void setGoogleId(final String beaconId, @Nullable final String googleId) { + if (googleId == null) this.prefs.edit().remove("google:" + beaconId).apply(); + else this.prefs.edit().putString("google:" + beaconId, googleId).apply(); + } + + public void forget(final String beaconId) { + this.prefs.edit().remove("google:" + beaconId).apply(); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/importing/ExternalDeviceImporter.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/importing/ExternalDeviceImporter.java new file mode 100644 index 00000000..158596b0 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/importing/ExternalDeviceImporter.java @@ -0,0 +1,202 @@ +package dev.wander.android.opentagviewer.ui.importing; + +import android.app.Activity; +import android.net.Uri; +import android.util.Log; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.Toast; + +import androidx.annotation.Nullable; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputEditText; +import com.google.android.material.textfield.TextInputLayout; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.security.MessageDigest; +import java.util.List; + +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.db.repo.BeaconRepository; +import dev.wander.android.opentagviewer.db.repo.model.ImportData; +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.Import; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.source.ExternalAccessory; +import dev.wander.android.opentagviewer.source.ExtraSourcesApi; +import dev.wander.android.opentagviewer.source.ExtraSourcesSettings; +import dev.wander.android.opentagviewer.source.SourceLinks; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * Adds devices that do not come from an Apple export: an OpenHaystack key from a + * Macless-Haystack style {@code .keys} file, or a tracker of the Google Find Hub account; and + * links a Google tracker to an existing device so both networks' reports land on one marker. + * + *

Stored as ordinary {@code OwnedBeacons} rows through {@link BeaconRepository#addNewImport}, + * so the list, the map and the history treat them like any imported tag. + */ +public final class ExternalDeviceImporter { + private static final String TAG = ExternalDeviceImporter.class.getSimpleName(); + private static final String VERSION = "extra-sources-1"; + + private final Activity activity; + private final Runnable onChanged; + + public ExternalDeviceImporter(final Activity activity, final Runnable onChanged) { + this.activity = activity; + this.onChanged = onChanged; + } + + // ---- OpenHaystack key ---- + + /** Call with the document the user picked (a .keys file). */ + public void importKeysFile(@Nullable final Uri uri) { + if (uri == null) return; + final List privateKeys; + try (InputStream in = this.activity.getContentResolver().openInputStream(uri)) { + final ByteArrayOutputStream buf = new ByteArrayOutputStream(); + final byte[] chunk = new byte[4096]; + int n; + while (in != null && (n = in.read(chunk)) > 0 && buf.size() < 65536) buf.write(chunk, 0, n); + privateKeys = ExternalAccessory.privateKeysFromKeysFile(buf.toString("UTF-8")); + } catch (final Exception e) { + Log.w(TAG, "Not a usable .keys file", e); + Toast.makeText(this.activity, R.string.keys_file_invalid, Toast.LENGTH_LONG).show(); + return; + } + + this.askName(this.activity.getString(R.string.openhaystack_default_name), name -> { + // The id is derived from the first key, so importing the same file twice updates the row + final String id = "openhaystack-" + sha256Hex(privateKeys.get(0)).substring(0, 16); + this.insert(id, ExternalAccessory.newOpenHaystack(id, name, privateKeys), "openhaystack-keys"); + }); + } + + // ---- Google Find Hub ---- + + /** Lists the trackers of the Google account and adds the chosen one as a device. */ + public void importFromGoogle() { + this.chooseGoogleTracker(R.string.add_from_google, device -> + this.insert("google-" + device.id, ExternalAccessory.newGoogle(device.id, device.name), "google-find-hub")); + } + + /** Shows this device's reports from a Google tracker as well (or stops, with "None"). */ + public void linkGoogleTracker(final String beaconId) { + this.chooseGoogleTracker(R.string.link_google_tracker, device -> { + new SourceLinks(this.activity).setGoogleId(beaconId, device == null ? null : device.id); + this.onChanged.run(); + }, true); + } + + private interface Chosen { + void accept(T value); + } + + private void chooseGoogleTracker(final int titleRes, final Chosen chosen) { + this.chooseGoogleTracker(titleRes, chosen, false); + } + + private void chooseGoogleTracker(final int titleRes, final Chosen chosen, + final boolean offerNone) { + final ExtraSourcesSettings settings = new ExtraSourcesSettings(this.activity); + if (!settings.isGoogleConfigured()) { + Toast.makeText(this.activity, R.string.google_not_configured, Toast.LENGTH_LONG).show(); + return; + } + final var async = Observable.fromCallable(() -> new ExtraSourcesApi(settings).googleDevices()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(devices -> { + final int extra = offerNone ? 1 : 0; + final String[] names = new String[devices.size() + extra]; + if (offerNone) names[0] = this.activity.getString(R.string.google_tracker_none); + for (int i = 0; i < devices.size(); i++) names[i + extra] = devices.get(i).name; + new MaterialAlertDialogBuilder(this.activity) + .setTitle(titleRes) + .setItems(names, (dialog, which) -> + chosen.accept(which < extra ? null : devices.get(which - extra))) + .setNegativeButton(android.R.string.cancel, null) + .show(); + }, error -> { + Log.w(TAG, "Could not list Google trackers", error); + Toast.makeText(this.activity, + this.activity.getString(R.string.google_list_failed, error.getMessage()), + Toast.LENGTH_LONG).show(); + }); + } + + // ---- shared ---- + + private void askName(final String suggestion, final Chosen chosen) { + final TextInputLayout layout = new TextInputLayout(this.activity); + layout.setHint(this.activity.getString(R.string.device_name)); + final EditText input = new TextInputEditText(layout.getContext()); + input.setSingleLine(true); + input.setText(suggestion); + layout.addView(input); + final FrameLayout frame = new FrameLayout(this.activity); + final int pad = Math.round(22 * this.activity.getResources().getDisplayMetrics().density); + frame.setPadding(pad, pad / 2, pad, 0); + frame.addView(layout); + + new MaterialAlertDialogBuilder(this.activity) + .setTitle(R.string.add_openhaystack_key) + .setView(frame) + .setPositiveButton(R.string.add, (d, w) -> { + final String name = input.getText().toString().trim(); + chosen.accept(name.isEmpty() ? suggestion : name); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void insert(final String id, final String accessoryJson, final String via) { + final long now = System.currentTimeMillis(); + final Import anImport = Import.builder() + .version(VERSION) + .importedAt(now) + .exportedAt(now) + .sourceUser(null) + .exportedVia(via) + .build(); + final OwnedBeacon beacon = OwnedBeacon.builder() + .id(id) + .version(VERSION) + .content(null) + .alignmentPlist(null) + .accessoryJson(accessoryJson) + .build(); + try { + final var async = new BeaconRepository(OpenTagViewerDatabase.getInstance(this.activity.getApplicationContext())) + .addNewImport(new ImportData(anImport, List.of(beacon), List.of())) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(done -> { + Toast.makeText(this.activity, R.string.device_added, Toast.LENGTH_SHORT).show(); + this.onChanged.run(); + }, error -> { + Log.e(TAG, "Could not store " + id, error); + Toast.makeText(this.activity, R.string.device_add_failed, Toast.LENGTH_LONG).show(); + }); + } catch (final Exception e) { + Log.e(TAG, "Could not store " + id, e); + Toast.makeText(this.activity, R.string.device_add_failed, Toast.LENGTH_LONG).show(); + } + } + + private static String sha256Hex(final String text) { + try { + final byte[] digest = MessageDigest.getInstance("SHA-256").digest(text.getBytes("UTF-8")); + final StringBuilder sb = new StringBuilder(); + for (final byte b : digest) sb.append(String.format("%02x", b)); + return sb.toString(); + } catch (final Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/app/src/main/res/layout/activity_extra_sources.xml b/app/src/main/res/layout/activity_extra_sources.xml new file mode 100644 index 00000000..1483818c --- /dev/null +++ b/app/src/main/res/layout/activity_extra_sources.xml @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +