From 4eb7c6a4ae8f74a3415eee5008471ee04283a015 Mon Sep 17 00:00:00 2001 From: "alex.so.3" Date: Mon, 28 Sep 2026 01:39:25 +0200 Subject: [PATCH 1/3] Send DULT Sound_Stop when continuous ringing is stopped Stopping only ended the re-triggering, so a DULT accessory kept sounding until its own timeout. AirTags have no stop command and stop by themselves; unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xeHfDLMuUeusX9PEgNEmY --- .../android/opentagviewer/MapsActivity.java | 13 +++- .../ble/AccessorySoundTrigger.java | 9 +++ .../ble/BleAccessorySoundTrigger.java | 13 ++++ .../ble/BleGattSoundTrigger.java | 62 +++++++++++++++++++ 4 files changed, 96 insertions(+), 1 deletion(-) 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 0d740daf..3396a13f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -1719,9 +1719,20 @@ private void handleContinuousPingUpdate(final String beaconId, final BleSoundTri } private void stopContinuousPing() { - if (this.continuousPingDisposable != null && !this.continuousPingDisposable.isDisposed()) { + final boolean wasRinging = this.continuousPingDisposable != null + && !this.continuousPingDisposable.isDisposed(); + if (wasRinging) { this.continuousPingDisposable.dispose(); } + // The last trigger may still be sounding (a DULT device plays until told otherwise or + // its own timeout), so tell it to stop rather than only not asking again + final BeaconData ringing = this.continuousPingBeaconId == null ? null + : this.beacons.get(this.continuousPingBeaconId); + if (wasRinging && ringing != null) { + final var async = AppDependencies.accessorySoundTrigger() + .stopSound(this.getApplicationContext(), ringing.getInfo().getOwnedBeaconAccessoryJson()) + .subscribe(() -> Log.d(TAG, "Stop sent (or nothing to stop)")); + } this.continuousPingDisposable = null; if (this.continuousPingBeaconId != null) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java index c7446537..3ac3b713 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/AccessorySoundTrigger.java @@ -37,4 +37,13 @@ public interface AccessorySoundTrigger { * end the loop. Dispose the subscription to stop. */ Observable playSoundContinuously(Context context, String accessoryJson); + + /** + * Asks a sounding accessory to stop, where its protocol has a stop command (DULT's + * Sound_Stop). AirTags stop by themselves and have none, so the default does nothing. + * Never errors; best effort. + */ + default io.reactivex.rxjava3.core.Completable stopSound(Context context, String accessoryJson) { + return io.reactivex.rxjava3.core.Completable.complete(); + } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java index 3c3df140..ab655efb 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTrigger.java @@ -195,6 +195,19 @@ public Observable playSound(final Context context, final }).subscribeOn(Schedulers.io()); } + @Override + public io.reactivex.rxjava3.core.Completable stopSound(final Context context, final String accessoryJson) { + return io.reactivex.rxjava3.core.Completable.defer(() -> { + if (!this.permissionCheck.granted(context)) return io.reactivex.rxjava3.core.Completable.complete(); + final Map candidates = this.macResolver.currentMacAddresses(accessoryJson); + if (candidates == null || candidates.isEmpty()) return io.reactivex.rxjava3.core.Completable.complete(); + return this.scanner.findNearby(context, candidates.keySet(), SCAN_TIMEOUT_MS) + .flatMapCompletable(device -> device instanceof android.bluetooth.BluetoothDevice + ? BleGattSoundTrigger.stopDult(context, (android.bluetooth.BluetoothDevice) device) + : io.reactivex.rxjava3.core.Completable.complete()); + }).onErrorComplete().subscribeOn(Schedulers.io()); + } + /** * {@link GattTrigger#trigger}, retried up to {@code attemptsLeft} times as long as each * failure is {@link BleSoundTriggerStatus#FAILED} - see {@link #GATT_ATTEMPTS}. Only the 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 975fc046..74915df1 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 @@ -239,4 +239,66 @@ private static void writeDescriptorCompat( gatt.writeDescriptor(descriptor); } } + + /** DULT Sound_Stop, little-endian like the start opcode. */ + private static final byte[] DULT_STOP_OPCODE = {0x01, 0x03}; + + /** + * Connects and writes DULT's Sound_Stop, if the device has the DULT service; completes + * either way (and on any failure) within about ten seconds. + */ + @SuppressLint("MissingPermission") + public static io.reactivex.rxjava3.core.Completable stopDult( + final Context context, final BluetoothDevice device) { + return io.reactivex.rxjava3.core.Completable.create(emitter -> { + final BluetoothGatt[] gattRef = new BluetoothGatt[1]; + final BluetoothGattCallback callback = new BluetoothGattCallback() { + private BluetoothGattCharacteristic dult; + + @Override + public void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + gatt.discoverServices(); + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + gatt.close(); + emitter.onComplete(); + } + } + + @Override + public void onServicesDiscovered(final BluetoothGatt gatt, final int status) { + this.dult = characteristicOf(gatt, DULT_SERVICE, DULT_CHARACTERISTIC); + if (this.dult == null) { + gatt.disconnect(); + return; + } + gatt.setCharacteristicNotification(this.dult, true); + final BluetoothGattDescriptor descriptor = this.dult.getDescriptor(CCCD); + if (descriptor == null) { + writeCharacteristicCompat(gatt, this.dult, DULT_STOP_OPCODE); + } else { + writeDescriptorCompat(gatt, descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); + } + } + + @Override + public void onDescriptorWrite(final BluetoothGatt gatt, final BluetoothGattDescriptor d, final int status) { + if (this.dult != null) writeCharacteristicCompat(gatt, this.dult, DULT_STOP_OPCODE); + } + + @Override + public void onCharacteristicWrite(final BluetoothGatt gatt, final BluetoothGattCharacteristic c, final int status) { + Log.i(TAG, "DULT stop written to " + device.getAddress() + " (status=" + status + ")"); + gatt.disconnect(); + } + }; + gattRef[0] = device.connectGatt(context, false, callback); + emitter.setCancellable(() -> { + if (gattRef[0] != null) { + gattRef[0].disconnect(); + gattRef[0].close(); + } + }); + }).timeout(10, java.util.concurrent.TimeUnit.SECONDS).onErrorComplete(); + } } From b23bc8deb13d1172ba1663b64b937877d9a90978 Mon Sep 17 00:00:00 2001 From: "alex.so.3" Date: Mon, 28 Sep 2026 02:57:48 +0200 Subject: [PATCH 2/3] Extra location sources: Google Find Hub trackers, FMDN ringing, multi-key .keys import Adds Google Find Hub as a second location network next to Apple's, through a self-hosted service built on GoogleFindMyTools (the "googlefind" service). - Google trackers of the account can be added as devices (menu: "Add from Google Find Hub"), or linked to any existing device. A tag that advertises on both networks is then one device in the app, with the reports of both merged into its location and history. Reports carry provenance "google"; the map card says which network a position came from. Fresh locations are requested from Google only on a manual refresh; a tracker with no reports yet is located once. - OpenHaystack keys can be imported from a .keys file ("Add OpenHaystack key"), including files holding several keys for tags that rotate through them; they are ordinary custom_rolling_key_accessory devices fetched through the Apple account. - Ringing Google trackers: owner ring over the FMDN Beacon Actions characteristic, with the ring key and current EIDs from the googlefind service. Google trackers also show as "Nearby", recognised by EID (their battery flags are encrypted, so signal only). - Ring failures on the map card are shown instead of being retried silently. - Settings > Extra location sources: service URL, API token, optional HTTP Basic Auth. - No Room schema change; strings in all locales. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xeHfDLMuUeusX9PEgNEmY --- app/src/main/AndroidManifest.xml | 4 + .../opentagviewer/DeviceInfoActivity.java | 18 ++ .../ExtraSourcesSettingsActivity.java | 100 ++++++ .../opentagviewer/HistoryViewActivity.java | 9 +- .../android/opentagviewer/MapsActivity.java | 152 ++++++++-- .../opentagviewer/MyDevicesListActivity.java | 18 ++ .../opentagviewer/SettingsActivity.java | 3 + .../opentagviewer/ble/FmdnNearbyWatcher.java | 99 ++++++ .../opentagviewer/ble/FmdnRingCommand.java | 153 ++++++++++ .../opentagviewer/ble/FmdnRingTrigger.java | 286 ++++++++++++++++++ .../ble/RoutingSoundTrigger.java | 40 +++ .../db/repo/BeaconRepository.java | 10 +- .../db/room/entity/LocationReport.java | 6 + .../opentagviewer/python/AppDependencies.java | 6 +- .../python/ChaquopyAccessoryMacResolver.java | 15 +- .../source/ExternalAccessory.java | 119 ++++++++ .../opentagviewer/source/ExtraSourcesApi.java | 180 +++++++++++ .../source/ExtraSourcesSettings.java | 36 +++ .../opentagviewer/source/ReportSources.java | 167 ++++++++++ .../opentagviewer/source/SourceLinks.java | 35 +++ .../ui/importing/ExternalDeviceImporter.java | 202 +++++++++++++ .../res/layout/activity_extra_sources.xml | 195 ++++++++++++ app/src/main/res/layout/activity_settings.xml | 10 + app/src/main/res/menu/device_info_menu.xml | 4 + .../main/res/menu/global_map_more_menu.xml | 6 + app/src/main/res/menu/my_devices_menu.xml | 7 + app/src/main/res/values-de/strings.xml | 27 ++ app/src/main/res/values-en/strings.xml | 27 ++ app/src/main/res/values-fr/strings.xml | 27 ++ app/src/main/res/values-ja/strings.xml | 27 ++ app/src/main/res/values-ko/strings.xml | 27 ++ app/src/main/res/values-nl/strings.xml | 27 ++ app/src/main/res/values-ru/strings.xml | 27 ++ app/src/main/res/values-zh-rCN/strings.xml | 27 ++ app/src/main/res/values-zh-rTW/strings.xml | 27 ++ app/src/main/res/values/strings.xml | 30 ++ .../ble/FmdnRingCommandTest.java | 51 ++++ .../opentagviewer/source/KeysFileTest.java | 65 ++++ .../source/ReportSourcesTest.java | 57 ++++ 39 files changed, 2288 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ExtraSourcesSettingsActivity.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnNearbyWatcher.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingCommand.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingTrigger.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ble/RoutingSoundTrigger.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/source/ExternalAccessory.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesApi.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesSettings.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/source/ReportSources.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/source/SourceLinks.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/ui/importing/ExternalDeviceImporter.java create mode 100644 app/src/main/res/layout/activity_extra_sources.xml create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/ble/FmdnRingCommandTest.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/source/KeysFileTest.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/source/ReportSourcesTest.java 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" /> + { 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..fdb93255 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ExtraSourcesSettingsActivity.java @@ -0,0 +1,100 @@ +package dev.wander.android.opentagviewer; + +import android.os.Bundle; +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; + +/** + * 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()); + } + + 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 f078b0d4..4ddf3287 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 3396a13f..2f02fa3d 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -50,6 +50,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; @@ -75,6 +76,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; @@ -104,7 +106,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; @@ -145,6 +151,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; @@ -218,7 +225,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; @@ -251,6 +266,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 @@ -520,6 +539,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 @@ -738,27 +759,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()) @@ -793,6 +830,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(); @@ -850,9 +891,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. @@ -1007,7 +1052,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(), @@ -1035,6 +1080,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) { @@ -1566,7 +1615,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()) @@ -1629,6 +1679,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()) @@ -1685,6 +1736,14 @@ 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. + final String reason = update.getResult().getMessage(); + if ((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); @@ -1821,7 +1880,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. @@ -1838,6 +1897,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) @@ -2261,11 +2328,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(); } }); @@ -2604,7 +2673,14 @@ private synchronized void updateBeaconCards() { now, DateUtils.MINUTE_IN_MILLIS ).toString(); - deviceLastUpdate.setText(this.getString(R.string.last_updated_x, timeAgo)); + // Which network it came from, once there is more than one to come from + final String source = LocationReport.PROVENANCE_GOOGLE.equals(lastLocation.getProvenance()) + ? this.getString(R.string.source_google) + : LocationReport.PROVENANCE_LOCAL.equals(lastLocation.getProvenance()) + ? null + : this.getString(R.string.source_apple); + 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); } @@ -2746,7 +2822,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); } @@ -2756,7 +2832,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); } @@ -2780,18 +2856,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) -> { @@ -2808,17 +2884,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/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..576b6faf --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingTrigger.java @@ -0,0 +1,286 @@ +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(); + private 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); + gatt.writeDescriptor(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)); + } + + @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)); + gatt.writeCharacteristic(this.actions, request, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT); + } 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..9dca38d5 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesApi.java @@ -0,0 +1,180 @@ +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 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 { + private final ExtraSourcesSettings settings; + + public ExtraSourcesApi(final ExtraSourcesSettings settings) { + this.settings = settings; + } + + 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 static String basic(final String user, final String password) { + if (user.isEmpty()) return null; + final String raw = user + ":" + password; + return "Basic " + Base64.encodeToString(raw.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); + } + + 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.settings.get(ExtraSourcesSettings.GOOGLE_URL), path), + "POST".equals(method) ? "{}" : null, + basic(this.settings.get(ExtraSourcesSettings.GOOGLE_USER), + this.settings.get(ExtraSourcesSettings.GOOGLE_PASSWORD)), + this.settings.get(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(java.util.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..24b1bfcf --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesSettings.java @@ -0,0 +1,36 @@ +package dev.wander.android.opentagviewer.source; + +import android.content.Context; +import android.content.SharedPreferences; + +/** + * 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. + */ +public final class ExtraSourcesSettings { + 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 final SharedPreferences prefs; + + public ExtraSourcesSettings(final Context context) { + this.prefs = context.getApplicationContext().getSharedPreferences("extra_sources", Context.MODE_PRIVATE); + } + + public String get(final String key) { + return this.prefs.getString(key, ""); + } + + public void put(final String key, final String value) { + this.prefs.edit().putString(key, value == null ? "" : value.trim()).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..d6dcd5b3 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/source/ReportSources.java @@ -0,0 +1,167 @@ +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 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; + private static final Map lastFirstLocate = new java.util.concurrent.ConcurrentHashMap<>(); + + private final ExtraSourcesSettings settings; + private final SourceLinks links; + private final ExtraSourcesApi api; + + public ReportSources(final ExtraSourcesSettings settings, final SourceLinks links, final ExtraSourcesApi api) { + this.settings = settings; + this.links = links; + this.api = api; + } + + public static ReportSources create(final Context context) { + final ExtraSourcesSettings settings = new ExtraSourcesSettings(context); + return new ReportSources(settings, new SourceLinks(context), new ExtraSourcesApi(settings)); + } + + /** + * 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.links.googleId(beaconId); + if (googleId == null || !this.settings.isGoogleConfigured()) { + return apple; + } + + final Observable> google = Observable.fromCallable(() -> { + if (askGoogleNow) { + try { + this.api.googleLocate(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, System.currentTimeMillis() - 7 * 86_400_000L); + List fetched = this.api.googleReports(googleId, googleSince); + if (!askGoogleNow && fetched.isEmpty() && this.api.googleReports(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 = System.currentTimeMillis(); + final Long last = lastFirstLocate.get(googleId); + if (last == null || now - last > GOOGLE_FIRST_LOCATE_EVERY_MS) { + lastFirstLocate.put(googleId, now); + try { + this.api.googleLocate(googleId, GOOGLE_LOCATE_WAIT_S); + fetched = this.api.googleReports(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..79942526 --- /dev/null +++ b/app/src/main/res/layout/activity_extra_sources.xml @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +