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 0d740daf..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); @@ -1719,9 +1778,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) { @@ -1810,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. @@ -1827,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) @@ -2250,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(); } }); @@ -2593,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); } @@ -2735,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); } @@ -2745,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); } @@ -2769,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) -> { @@ -2797,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/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(); + } } 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/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryParser.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryParser.java index 259f060b..5bdcc5c7 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryParser.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/CustomAccessoryParser.java @@ -107,7 +107,11 @@ static BeaconInformation parse( .productId(0) .vendorId(0) .batteryLevel(0) - .stableIdentifier(List.of()); + .stableIdentifier(List.of()) + // The map card's Ring reads this: without it a self-generated tag had no + // accessory to derive addresses from ("Could not compute this accessory's + // current address"), while the device screen - which reads the row - worked. + .ownedBeaconAccessoryJson(beaconData.getOwnedBeaconInfo().accessoryJson); if (userOverrides != null) { built.userOverrideName(userOverrides.uiName) 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +