diff --git a/README.md b/README.md
index eee7ccb3..3dd67076 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,8 @@ This project is a relatively polished looking Android/Java UI-wrapper around the
| **Self-made tags** ([OpenHaystack](https://github.com/seemoo-lab/openhaystack), [Macless Haystack](https://github.com/dchristl/macless-haystack)) | ✅ | Works; requires creating an export by [CLI](https://github.com/parawanderer/OpenTagViewer/wiki/How-To:-Export-AirTags-With-The-CLI) or [Wizard](https://github.com/parawanderer/OpenTagViewer/wiki/How-To:-Export-AirTags) |
| **Your own iPhone, iPad or Mac** | ⚠️ | Partial coverage (see issue [#131](https://github.com/parawanderer/OpenTagViewer/issues/131)) |
| **A tag someone shared with you** through Apple's own sharing | ❌ | Only the account that *owns* a tag can export it. Ask the owner to export it and send you the zip |
-| **Tile, Samsung SmartTag, Google Find My Device trackers** | ❌ | Different networks entirely, with nothing in common with Apple's. This is an open feature request that can be contributed to the app if wanted. |
+| **Google Find Hub trackers** | 🧪 | Experimental. Needs a self-hosted service, see the [setup guide](https://github.com/parawanderer/OpenTagViewer/wiki/%5BGoogle%5D-How-To:-Find-Hub-Trackers). An on-device version is [#257](https://github.com/parawanderer/OpenTagViewer/issues/257) |
+| **Tile, Samsung SmartTag** | ❌ | Different networks entirely, with nothing in common with Apple's. |
> [!NOTE]
> This app (currently) reads Apple's Find My network. Anything not on that network cannot be tracked with it, no matter how similar the device looks.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 57bb5c8b..513ed154 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -567,6 +567,9 @@ dependencies {
implementation(libs.commons.csv)
testImplementation(libs.junit)
+ // The real org.json for JVM tests that parse the googlefind service's answers -
+ // android.jar's is a stub there. The app itself uses the platform's.
+ testImplementation(libs.org.json)
testImplementation(libs.android.room.testing)
testCompileOnly(libs.projectlombok)
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/source/ExtraSourcesSettingsTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/source/ExtraSourcesSettingsTest.java
new file mode 100644
index 00000000..7366063b
--- /dev/null
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/source/ExtraSourcesSettingsTest.java
@@ -0,0 +1,96 @@
+package dev.wander.android.opentagviewer.source;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * The googlefind service's settings, against the real keystore.
+ *
+ *
The API token reads every location the service holds, so it is encrypted like the Apple
+ * session. Plain text in SharedPreferences would still pass every test that only reads it back,
+ * which is why these look at what is actually on disk.
+ */
+@RunWith(AndroidJUnit4.class)
+public class ExtraSourcesSettingsTest {
+
+ private Context context;
+ private SharedPreferences raw;
+
+ @Before
+ public void startEmpty() {
+ this.context = getInstrumentation().getTargetContext();
+ this.raw = this.context.getSharedPreferences("extra_sources", Context.MODE_PRIVATE);
+ this.raw.edit().clear().commit();
+ }
+
+ @After
+ public void leaveNothingBehind() {
+ this.raw.edit().clear().commit();
+ }
+
+ @Test
+ public void theTokenReadsBackButIsNotStoredInPlainText() {
+ new ExtraSourcesSettings(this.context).put(ExtraSourcesSettings.GOOGLE_TOKEN, "s3cret-token");
+
+ assertEquals("s3cret-token",
+ new ExtraSourcesSettings(this.context).get(ExtraSourcesSettings.GOOGLE_TOKEN));
+ for (final Object stored : this.raw.getAll().values()) {
+ assertFalse("the token must not be on disk as written: " + stored,
+ String.valueOf(stored).contains("s3cret-token"));
+ }
+ }
+
+ @Test
+ public void thePasswordIsEncryptedToo() {
+ new ExtraSourcesSettings(this.context).put(ExtraSourcesSettings.GOOGLE_PASSWORD, "hunter2-pass");
+
+ for (final Object stored : this.raw.getAll().values()) {
+ assertFalse(String.valueOf(stored).contains("hunter2-pass"));
+ }
+ }
+
+ /** Builds of the fork stored the token in plain text; it is moved over on first read. */
+ @Test
+ public void aPlainTextTokenFromAnEarlierBuildIsMovedOver() {
+ this.raw.edit().putString(ExtraSourcesSettings.GOOGLE_TOKEN, "old-token").commit();
+
+ assertEquals("old-token", new ExtraSourcesSettings(this.context).get(ExtraSourcesSettings.GOOGLE_TOKEN));
+
+ assertFalse("the plain copy should be gone", this.raw.contains(ExtraSourcesSettings.GOOGLE_TOKEN));
+ assertEquals("old-token", new ExtraSourcesSettings(this.context).get(ExtraSourcesSettings.GOOGLE_TOKEN));
+ }
+
+ @Test
+ public void clearingTheTokenRemovesIt() {
+ final ExtraSourcesSettings settings = new ExtraSourcesSettings(this.context);
+ settings.put(ExtraSourcesSettings.GOOGLE_TOKEN, "s3cret-token");
+
+ settings.put(ExtraSourcesSettings.GOOGLE_TOKEN, "");
+
+ assertTrue("nothing should be stored for an empty token", this.raw.getAll().isEmpty());
+ assertEquals("", settings.get(ExtraSourcesSettings.GOOGLE_TOKEN));
+ }
+
+ @Test
+ public void theAddressIsTrimmedAndIsWhatTurnsTheFeatureOn() {
+ final ExtraSourcesSettings settings = new ExtraSourcesSettings(this.context);
+ assertFalse(settings.isGoogleConfigured());
+
+ settings.put(ExtraSourcesSettings.GOOGLE_URL, " https://find.example ");
+
+ assertEquals("https://find.example", settings.get(ExtraSourcesSettings.GOOGLE_URL));
+ assertTrue(settings.isGoogleConfigured());
+ }
+}
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/ExtraSourcesLayoutTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/ExtraSourcesLayoutTest.java
new file mode 100644
index 00000000..4f20b934
--- /dev/null
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/ExtraSourcesLayoutTest.java
@@ -0,0 +1,70 @@
+package dev.wander.android.opentagviewer.ui.settings;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.view.LayoutInflater;
+import android.view.View;
+
+import androidx.appcompat.view.ContextThemeWrapper;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import dev.wander.android.opentagviewer.R;
+import dev.wander.android.opentagviewer.databinding.ActivityExtraSourcesBinding;
+
+/**
+ * The Google Find Hub settings screen, inflated in the theme its activity uses, in both modes.
+ *
+ *
Every view the activity reads has to resolve, and the screen has to measure to something:
+ * a missing attribute or a renamed id fails only at runtime, on a screen few people open.
+ */
+@RunWith(AndroidJUnit4.class)
+public class ExtraSourcesLayoutTest {
+
+ private static final int[] VIEWS_THE_ACTIVITY_READS = {
+ R.id.extra_sources_google_url, R.id.extra_sources_google_url_layout,
+ R.id.extra_sources_google_token, R.id.extra_sources_google_user,
+ R.id.extra_sources_google_password, R.id.extra_sources_test,
+ R.id.extra_sources_test_progress, R.id.extra_sources_test_result,
+ R.id.extra_sources_google_guide,
+ };
+
+ @Test
+ public void itInflatesInLightMode() {
+ this.assertItInflates(Configuration.UI_MODE_NIGHT_NO);
+ }
+
+ /** Half of what breaks only breaks in one mode. */
+ @Test
+ public void itInflatesInDarkMode() {
+ this.assertItInflates(Configuration.UI_MODE_NIGHT_YES);
+ }
+
+ private void assertItInflates(final int nightMode) {
+ final Context target = getInstrumentation().getTargetContext();
+ final Configuration config = new Configuration(target.getResources().getConfiguration());
+ config.uiMode = nightMode | Configuration.UI_MODE_TYPE_NORMAL;
+ final Context themed = new ContextThemeWrapper(target.createConfigurationContext(config),
+ R.style.Theme_OpenTagViewer_GenericGreyishActivity);
+
+ final View[] root = new View[1];
+ // On the main thread: Material text fields start animators, which need a Looper.
+ getInstrumentation().runOnMainSync(() -> {
+ root[0] = ActivityExtraSourcesBinding.inflate(LayoutInflater.from(themed)).getRoot();
+ root[0].measure(View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
+ });
+
+ for (final int id : VIEWS_THE_ACTIVITY_READS) {
+ assertNotNull("a view the activity reads is missing: "
+ + target.getResources().getResourceEntryName(id), root[0].findViewById(id));
+ }
+ assertTrue("the screen measured to nothing", root[0].getMeasuredHeight() > 0);
+ }
+}
diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/ExtraSourcesSettingsScreenTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/ExtraSourcesSettingsScreenTest.java
new file mode 100644
index 00000000..f928f030
--- /dev/null
+++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/settings/ExtraSourcesSettingsScreenTest.java
@@ -0,0 +1,102 @@
+package dev.wander.android.opentagviewer.ui.settings;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
+import static androidx.test.espresso.action.ViewActions.replaceText;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import dev.wander.android.opentagviewer.Eventually;
+import dev.wander.android.opentagviewer.ExtraSourcesSettingsActivity;
+import dev.wander.android.opentagviewer.R;
+import dev.wander.android.opentagviewer.source.ExtraSourcesSettings;
+
+/**
+ * The Google Find Hub settings screen, driven.
+ *
+ *
The warning is the one that matters: the app permits cleartext HTTP, so an http:// address
+ * sends the API token, which reads every location the service holds, unencrypted.
+ */
+@LargeTest
+@RunWith(AndroidJUnit4.class)
+public class ExtraSourcesSettingsScreenTest {
+
+ private Context context;
+ private ActivityScenario scenario;
+
+ @Before
+ public void openItEmpty() {
+ this.context = getInstrumentation().getTargetContext();
+ this.clearSettings();
+ this.scenario = ActivityScenario.launch(ExtraSourcesSettingsActivity.class);
+ }
+
+ @After
+ public void closeIt() {
+ if (this.scenario != null) {
+ this.scenario.close();
+ }
+ this.clearSettings();
+ }
+
+ private void clearSettings() {
+ this.context.getSharedPreferences("extra_sources", Context.MODE_PRIVATE).edit().clear().commit();
+ }
+
+ @Test
+ public void anHttpAddressWarnsThatTheTokenTravelsUnencrypted() {
+ onView(withId(R.id.extra_sources_google_url)).perform(replaceText("http://192.168.1.2:8090"));
+
+ Eventually.check(() -> onView(withText(R.string.server_url_not_https)).check(matches(isDisplayed())));
+ }
+
+ @Test
+ public void anHttpsAddressDoesNot() {
+ onView(withId(R.id.extra_sources_google_url)).perform(replaceText("http://192.168.1.2:8090"));
+ Eventually.check(() -> onView(withText(R.string.server_url_not_https)).check(matches(isDisplayed())));
+
+ onView(withId(R.id.extra_sources_google_url)).perform(replaceText("https://find.example"));
+
+ // Gone or hidden, whichever TextInputLayout does with a null helper text
+ Eventually.check(() -> onView(withText(R.string.server_url_not_https)).check((view, noMatch) ->
+ assertTrue("the warning is still showing", view == null || !view.isShown())));
+ }
+
+ @Test
+ public void testingWithNoAddressSaysToEnterOne() {
+ onView(withId(R.id.extra_sources_test)).perform(click());
+
+ Eventually.check(() -> onView(withId(R.id.extra_sources_test_result))
+ .check(matches(withText(R.string.test_nothing_configured))));
+ }
+
+ /** Saved when the screen is left, like the rest of Settings. */
+ @Test
+ public void whatWasTypedSurvivesLeavingTheScreen() {
+ onView(withId(R.id.extra_sources_google_url)).perform(replaceText("https://find.example"));
+ onView(withId(R.id.extra_sources_google_token)).perform(replaceText("s3cret-token"), closeSoftKeyboard());
+
+ this.scenario.recreate();
+
+ Eventually.check(() -> onView(withId(R.id.extra_sources_google_url))
+ .check(matches(withText("https://find.example"))));
+ assertEquals("s3cret-token", new ExtraSourcesSettings(this.context).get(ExtraSourcesSettings.GOOGLE_TOKEN));
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c3a3a7b3..ac5c7b20 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -180,6 +180,10 @@
android:exported="false"
android:label="@string/information"
android:theme="@style/Theme.OpenTagViewer.GenericGreyishActivity" />
+
Extra location sources: the Google Find Hub feature needs a self-hosted
+# service, and nothing else in the app says how to set one up
+googleFindHubWikiPage=https://github.com/parawanderer/OpenTagViewer/wiki/%5BGoogle%5D-How-To:-Find-Hub-Trackers
bluetoothSigAssignedNumbers=https://www.bluetooth.com/specifications/assigned-numbers/
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/AppKeyStoreConstants.java b/app/src/main/java/dev/wander/android/opentagviewer/AppKeyStoreConstants.java
index 8c090daa..b7813c8e 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/AppKeyStoreConstants.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/AppKeyStoreConstants.java
@@ -15,4 +15,7 @@ public final class AppKeyStoreConstants {
* a peer is left on the account that this app can no longer use or clean up.
*/
public static final String KEYSTORE_ALIAS_KEYCHAIN = "keychain_membership";
+
+ /** The Google Find Hub service's API token and HTTP password - see ExtraSourcesSettings. */
+ public static final String KEYSTORE_ALIAS_EXTRA_SOURCES = "extra_sources";
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
index af1545c5..300fa80d 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/DeviceInfoActivity.java
@@ -78,6 +78,8 @@
import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase;
import dev.wander.android.opentagviewer.db.room.entity.Import;
import dev.wander.android.opentagviewer.db.room.entity.UserBeaconOptions;
+import dev.wander.android.opentagviewer.source.ExternalAccessory;
+import dev.wander.android.opentagviewer.ui.importing.ExternalDeviceImporter;
import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil;
import dev.wander.android.opentagviewer.util.android.CachedPhoneLocation;
import dev.wander.android.opentagviewer.util.android.FusedPhoneLocation;
@@ -1097,6 +1099,9 @@ private void showPlaySoundStatus(final int messageRes, final int duration) {
* knows the most about.
*/
private String knownDeviceType() {
+ if (ExternalAccessory.isGoogle(this.accessoryJson())) {
+ return this.getString(R.string.google_find_hub_tracker);
+ }
if (this.beaconInformation.isCustomAccessory()) {
return this.getString(R.string.custom_tag);
}
@@ -1208,6 +1213,11 @@ private String getDeviceNameForTitle() {
return this.beaconInformation.getName();
}
+ private String accessoryJson() {
+ return this.beaconData == null || this.beaconData.getOwnedBeaconInfo() == null
+ ? null : this.beaconData.getOwnedBeaconInfo().accessoryJson;
+ }
+
private void handleClickMenu() {
Log.d(TAG, "Device more button clicked");
@@ -1216,6 +1226,10 @@ private void handleClickMenu() {
var popupMenu = new PopupMenu(this, button);
popupMenu.getMenuInflater().inflate(R.menu.device_info_menu, popupMenu.getMenu());
+ // A Google tracker rings through FMDN (see RoutingSoundTrigger) and has nothing to link
+ popupMenu.getMenu().findItem(R.id.device_link_google)
+ .setVisible(!ExternalAccessory.isGoogle(this.accessoryJson()));
+
popupMenu.setOnMenuItemClickListener(menuItem -> {
Log.d(TAG, "Device menu option " + menuItem.getTitle() + " was selected");
@@ -1223,6 +1237,10 @@ private void handleClickMenu() {
this.redirectToDeviceHistory();
} else if (menuItem.getItemId() == R.id.device_play_sound_nearby) {
this.onClickPlaySoundNearby();
+ } else if (menuItem.getItemId() == R.id.device_link_google) {
+ new ExternalDeviceImporter(this, () ->
+ Toast.makeText(this, R.string.google_link_saved, Toast.LENGTH_SHORT).show())
+ .linkGoogleTracker(this.beaconId);
} else if (menuItem.getItemId() == R.id.device_delete) {
this.onClickDeviceDelete();
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ExtraSourcesSettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/ExtraSourcesSettingsActivity.java
new file mode 100644
index 00000000..60fb9632
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ExtraSourcesSettingsActivity.java
@@ -0,0 +1,136 @@
+package dev.wander.android.opentagviewer;
+
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.View;
+import android.widget.EditText;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.databinding.DataBindingUtil;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import dev.wander.android.opentagviewer.databinding.ActivityExtraSourcesBinding;
+import dev.wander.android.opentagviewer.source.ExtraSourcesApi;
+import dev.wander.android.opentagviewer.source.ExtraSourcesSettings;
+import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil;
+import dev.wander.android.opentagviewer.util.android.PropertiesUtil;
+import dev.wander.android.opentagviewer.util.android.WebLink;
+
+/**
+ * Settings for the extra location source: googlefind (Google Find Hub trackers). Values are
+ * saved when the screen is left, like the rest of Settings.
+ */
+public class ExtraSourcesSettingsActivity extends AppCompatActivity {
+ private final ExecutorService io = Executors.newSingleThreadExecutor();
+ private ActivityExtraSourcesBinding binding;
+ private ExtraSourcesSettings settings;
+
+ private static final String[] KEYS = {
+ ExtraSourcesSettings.GOOGLE_URL, ExtraSourcesSettings.GOOGLE_TOKEN,
+ ExtraSourcesSettings.GOOGLE_USER, ExtraSourcesSettings.GOOGLE_PASSWORD,
+ };
+
+ private EditText[] fields() {
+ return new EditText[]{
+ binding.extraSourcesGoogleUrl, binding.extraSourcesGoogleToken,
+ binding.extraSourcesGoogleUser, binding.extraSourcesGooglePassword,
+ };
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ this.settings = new ExtraSourcesSettings(this);
+
+ this.binding = DataBindingUtil.setContentView(this, R.layout.activity_extra_sources);
+ WindowPaddingUtil.insetForSystemBarsAndKeyboard(this.binding.getRoot());
+ this.binding.setHandleClickBack(this::finish);
+ if (this.getSupportActionBar() != null) {
+ this.getSupportActionBar().hide();
+ }
+
+ final EditText[] fields = this.fields();
+ for (int i = 0; i < KEYS.length; i++) fields[i].setText(this.settings.get(KEYS[i]));
+
+ this.binding.extraSourcesTest.setOnClickListener(v -> this.test());
+ this.binding.extraSourcesGoogleGuide.setOnClickListener(v -> {
+ final var properties = PropertiesUtil.getProperties(this.getAssets(), "app.properties");
+ if (properties != null) {
+ WebLink.open(this, properties.getProperty("googleFindHubWikiPage"));
+ }
+ });
+
+ this.warnIfUnencrypted();
+ this.binding.extraSourcesGoogleUrl.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) {
+ }
+
+ @Override
+ public void onTextChanged(final CharSequence s, final int start, final int before, final int count) {
+ }
+
+ @Override
+ public void afterTextChanged(final Editable s) {
+ ExtraSourcesSettingsActivity.this.warnIfUnencrypted();
+ }
+ });
+ }
+
+ /**
+ * The app permits cleartext HTTP (network_security_config.xml), so an http:// address sends
+ * the API token, which reads every location the service holds, unencrypted.
+ */
+ private void warnIfUnencrypted() {
+ final String url = String.valueOf(this.binding.extraSourcesGoogleUrl.getText()).trim();
+ this.binding.extraSourcesGoogleUrlLayout.setHelperText(
+ url.regionMatches(true, 0, "http://", 0, 7) ? this.getString(R.string.server_url_not_https) : null);
+ }
+
+ private void save() {
+ final EditText[] fields = this.fields();
+ for (int i = 0; i < KEYS.length; i++) this.settings.put(KEYS[i], fields[i].getText().toString());
+ }
+
+ @Override
+ protected void onPause() {
+ this.save();
+ super.onPause();
+ }
+
+ @Override
+ protected void onDestroy() {
+ this.io.shutdownNow();
+ super.onDestroy();
+ }
+
+ /** Asks the service something cheap and says whether it answered. */
+ private void test() {
+ this.save();
+ this.binding.extraSourcesTest.setEnabled(false);
+ this.binding.extraSourcesTestProgress.setVisibility(View.VISIBLE);
+ this.binding.extraSourcesTestResult.setText("");
+ this.io.execute(() -> {
+ final ExtraSourcesApi api = new ExtraSourcesApi(this.settings);
+ final StringBuilder result = new StringBuilder();
+ if (this.settings.isGoogleConfigured()) {
+ try {
+ final int count = api.googleDevices().size();
+ result.append(getString(R.string.test_google_ok, count));
+ } catch (final Exception e) {
+ result.append(getString(R.string.test_google_failed, e.getMessage()));
+ }
+ }
+ final String text = result.toString().trim();
+ runOnUiThread(() -> {
+ this.binding.extraSourcesTest.setEnabled(true);
+ this.binding.extraSourcesTestProgress.setVisibility(View.GONE);
+ this.binding.extraSourcesTestResult.setText(
+ text.isEmpty() ? getString(R.string.test_nothing_configured) : text);
+ });
+ });
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java
index 26e998ef..883bcc3b 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java
@@ -82,6 +82,7 @@
import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase;
import dev.wander.android.opentagviewer.db.room.entity.DailyHistoryFetchRecord;
import dev.wander.android.opentagviewer.db.util.BeaconCombinerUtil;
+import dev.wander.android.opentagviewer.source.ReportSources;
import dev.wander.android.opentagviewer.python.AppDependencies;
import dev.wander.android.opentagviewer.python.PythonAppleService;
import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil;
@@ -386,8 +387,14 @@ private Observable> fetchReports(final long beginning
var reqData = BeaconRepository.plistFallback(
this.beaconId, this.beaconInformation.getOwnedBeaconPlistRaw());
// asyncReq emits Observable (reports + updated accessory state per beacon)
+ // Each device through its own sources: the Apple account and Google Find Hub - see
+ // ReportSources.
var asyncReq = this.beaconRepo.toAccessoryRequests(reqData)
- .flatMap(requests -> this.appleService.getReportsBetween(requests, beginningOfDay, endOfDay));
+ .flatMap(requests -> Observable.fromIterable(requests)
+ .concatMap(request -> ReportSources.create(this).fetch(
+ request, beginningOfDay, endOfDay, false,
+ this.appleService == null ? null
+ : this.appleService.getReportsBetween(List.of(request), beginningOfDay, endOfDay))));
// **What Apple returns is merged with what we already hold, for every day.**
//
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
index f65580fe..05d02607 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java
@@ -48,6 +48,7 @@
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.model.LatLng;
+import dev.wander.android.opentagviewer.ui.importing.ExternalDeviceImporter;
import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil;
import dev.wander.android.opentagviewer.ui.importing.BundlePasscodeDialog;
import dev.wander.android.opentagviewer.ui.importing.ImportOutcome;
@@ -73,6 +74,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import dev.wander.android.opentagviewer.db.room.entity.LocationReport;
import dev.wander.android.opentagviewer.db.room.entity.Import;
import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity;
import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon;
@@ -102,7 +104,11 @@
import dev.wander.android.opentagviewer.db.repo.BeaconRepository;
import dev.wander.android.opentagviewer.db.repo.model.ImportData;
import dev.wander.android.opentagviewer.db.util.BeaconCombinerUtil;
+import dev.wander.android.opentagviewer.source.ExternalAccessory;
+import dev.wander.android.opentagviewer.source.ExtraSourcesSettings;
+import dev.wander.android.opentagviewer.source.ReportSources;
import dev.wander.android.opentagviewer.python.AccessoryRequest;
+import dev.wander.android.opentagviewer.python.FetchResult;
import dev.wander.android.opentagviewer.python.icloud.ICloudFailures;
import dev.wander.android.opentagviewer.python.AppDependencies;
import dev.wander.android.opentagviewer.python.PythonDiagnostics;
@@ -143,6 +149,7 @@
import dev.wander.android.opentagviewer.ble.NearbyTagSightings;
import dev.wander.android.opentagviewer.ble.NearbyTagIndex;
import dev.wander.android.opentagviewer.ble.NearbyTagWatcher;
+import dev.wander.android.opentagviewer.ble.FmdnNearbyWatcher;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Observable;
@@ -216,7 +223,15 @@ private float initialZoom() {
private UserDataRepository userDataRepository;
+ /** OpenHaystack keys and Google trackers - see ExternalDeviceImporter. */
+ private final ExternalDeviceImporter externalImporter =
+ new ExternalDeviceImporter(this, this::handleDeviceListChanged);
+ private final ActivityResultLauncher openKeysFileLauncher = registerForActivityResult(
+ new ActivityResultContracts.OpenDocument(), this.externalImporter::importKeysFile);
+
private PythonAppleService appleService = null;
+ /** Google Find Hub, beside the Apple account. */
+ private ReportSources reportSources;
private UserSettings userSettings;
@@ -258,6 +273,10 @@ private float initialZoom() {
/** The in-flight nearby scan, disposed in {@link #onPause()} so the radio stops with the screen. */
private Disposable nearbyWatchDisposable;
+ /** Last ring failure shown as a toast, so a retrying loop reports it once. */
+ private String lastRingFailureShown;
+ /** Google Find Hub trackers, recognised by EID - see FmdnNearbyWatcher. */
+ private Disposable fmdnNearbyWatchDisposable;
/**
* Redraws the cards once a second while the nearby scan is running, so a nearby card's
@@ -526,6 +545,8 @@ protected void onCreate(Bundle savedInstanceState) {
UserAuthDataStore.getInstance(getApplicationContext()),
new AppCryptographyUtil());
+ this.reportSources = ReportSources.create(this);
+
this.beaconRepo = new BeaconRepository(
OpenTagViewerDatabase.getInstance(getApplicationContext()));
// The fetch this screen is about to run is what produces the drift measurement, so
@@ -747,27 +768,43 @@ private void startWatchingForNearbyTags() {
}
final Map accessoryJsonByBeaconId = new HashMap<>();
+ final Map googleIdByBeaconId = new HashMap<>();
for (final var entry : this.beacons.entrySet()) {
final String accessoryJson = entry.getValue().getInfo().getOwnedBeaconAccessoryJson();
- if (accessoryJson != null && !accessoryJson.isEmpty()) {
+ if (accessoryJson == null || accessoryJson.isEmpty()) continue;
+ if (ExternalAccessory.isGoogle(accessoryJson)) {
+ googleIdByBeaconId.put(entry.getKey(), ExternalAccessory.googleId(accessoryJson));
+ } else {
accessoryJsonByBeaconId.put(entry.getKey(), accessoryJson);
}
}
- if (accessoryJsonByBeaconId.isEmpty()) {
+ if (accessoryJsonByBeaconId.isEmpty() && googleIdByBeaconId.isEmpty()) {
// Ordinary on a cold start: the beacons load asynchronously and are not here yet.
// addBeaconToCurrent starts the watch once they arrive - see there.
return;
}
- this.nearbyWatchDisposable = new NearbyTagWatcher(
- AppDependencies.accessoryMacResolver(), this.sightingPersister::onSighting,
- ScanSettings.SCAN_MODE_LOW_LATENCY)
- .watch(this.getApplicationContext(), accessoryJsonByBeaconId)
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(
- this::onTagHeardNearby,
- error -> Log.w(TAG, "Nearby tag watch ended unexpectedly", error),
- this::onNearbyWatchEnded);
+ if (!googleIdByBeaconId.isEmpty() && new ExtraSourcesSettings(this).isGoogleConfigured()) {
+ this.fmdnNearbyWatchDisposable = new FmdnNearbyWatcher()
+ .watch(this.getApplicationContext(), googleIdByBeaconId)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(this::onTagHeardNearby,
+ error -> Log.w(TAG, "Google tracker nearby watch ended", error));
+ }
+
+ // Only with Apple-keyed tags to watch: an empty watch completes at once, which reads as
+ // a dead scan and would clear the Google sightings every 30 s
+ if (!accessoryJsonByBeaconId.isEmpty()) {
+ this.nearbyWatchDisposable = new NearbyTagWatcher(
+ AppDependencies.accessoryMacResolver(), this.sightingPersister::onSighting,
+ ScanSettings.SCAN_MODE_LOW_LATENCY)
+ .watch(this.getApplicationContext(), accessoryJsonByBeaconId)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ this::onTagHeardNearby,
+ error -> Log.w(TAG, "Nearby tag watch ended unexpectedly", error),
+ this::onNearbyWatchEnded);
+ }
this.nearbyStatusTickerDisposable = Observable
.interval(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread())
@@ -802,6 +839,10 @@ private void stopWatchingForNearbyTags() {
this.nearbyWatchDisposable.dispose();
}
this.nearbyWatchDisposable = null;
+ if (this.fmdnNearbyWatchDisposable != null && !this.fmdnNearbyWatchDisposable.isDisposed()) {
+ this.fmdnNearbyWatchDisposable.dispose();
+ }
+ this.fmdnNearbyWatchDisposable = null;
if (this.nearbyStatusTickerDisposable != null
&& !this.nearbyStatusTickerDisposable.isDisposed()) {
this.nearbyStatusTickerDisposable.dispose();
@@ -859,9 +900,13 @@ private void onTagHeardNearby(final NearbyTagSighting sighting) {
private void showNearbyStatusOn(
final FrameLayout card, final NearbyTagSighting sighting, final long nowMs) {
final TextView line = card.findViewById(R.id.device_last_update);
- line.setText(this.getString(R.string.nearby_now_with_battery_and_signal,
- this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())),
- NearbyTagLabel.signalStrengthBars(sighting.getRssi())));
+ // Google trackers encrypt their battery flags, so their sightings carry none
+ line.setText(sighting.getBatteryLevel() == null
+ ? this.getString(R.string.nearby_now_with_signal,
+ NearbyTagLabel.signalStrengthBars(sighting.getRssi()))
+ : this.getString(R.string.nearby_now_with_battery_and_signal,
+ this.getString(NearbyTagLabel.shortBatteryLabel(sighting.getBatteryLevel())),
+ NearbyTagLabel.signalStrengthBars(sighting.getRssi())));
// Never negative: nowMs can be a hair behind seenAtMs when this runs right off the scan
// callback, before the clock the caller reads has ticked past it.
@@ -1016,7 +1061,7 @@ private void lookForAnIgnoredTagAgain(final String beaconId) {
// same thing they were told before, having done the one thing the screen offered them.
var async = this.fetchLastReportsFor(
beaconId, beacon.getInfo().getOwnedBeaconPlistRaw(),
- HOURS_TO_GO_BACK_FIRST_TIME)
+ HOURS_TO_GO_BACK_FIRST_TIME, true)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
reports -> this.handleDeviceListChanged(),
@@ -1044,6 +1089,10 @@ public void onClickMoreSettings(View view)
if (itemId == R.id.do_import) {
this.handleImport();
+ } else if (itemId == R.id.add_openhaystack) {
+ this.openKeysFileLauncher.launch(new String[]{"*/*"});
+ } else if (itemId == R.id.add_google) {
+ this.externalImporter.importFromGoogle();
} else if (itemId == R.id.settings) {
this.showSettingsPage();
} else if (itemId == R.id.information) {
@@ -1575,7 +1624,8 @@ public void onClickRefresh(View view) {
var async = this.fetchLastReportsFor(
beaconId,
Objects.requireNonNull(this.beacons.get(beaconId)).getInfo().getOwnedBeaconPlistRaw(),
- 1)
+ 1,
+ true)
.doOnNext(this::addBeaconLocationsToCurrent)
.flatMapCompletable((__) -> this.updateBeaconGeocodings())
.observeOn(AndroidSchedulers.mainThread())
@@ -1644,6 +1694,7 @@ private void startContinuousPing(final String beaconId) {
TagCardHelper.toggleRingActive(container, true);
}
+ this.lastRingFailureShown = null;
this.continuousPingDisposable = AppDependencies.accessorySoundTrigger()
.playSoundContinuously(this.getApplicationContext(), accessoryJson)
.observeOn(AndroidSchedulers.mainThread())
@@ -1704,6 +1755,19 @@ private void handleContinuousPingUpdate(final String beaconId, final BleSoundTri
LENGTH_LONG).show();
return;
}
+ // The loop retries quietly, which used to hide why a ring never happens (a tracker
+ // refusing the request, a missing sound service). Say so - once per distinct reason.
+ // Google trackers only, while that path is experimental: the message is diagnostic
+ // English, and the Find My path already reports through the card's label
+ final String reason = update.getResult().getMessage();
+ final BeaconData ringing = this.beacons.get(beaconId);
+ final boolean google = ringing != null
+ && ExternalAccessory.isGoogle(ringing.getInfo().getOwnedBeaconAccessoryJson());
+ if (google && (status == BleSoundTriggerStatus.FAILED || status == BleSoundTriggerStatus.NO_SOUND_SERVICE)
+ && reason != null && !reason.equals(this.lastRingFailureShown)) {
+ this.lastRingFailureShown = reason;
+ Toast.makeText(this, this.getString(R.string.ring_failed_reason, reason), LENGTH_LONG).show();
+ }
}
final FrameLayout container = this.dynamicCardsForTag.get(beaconId);
@@ -1827,7 +1891,7 @@ private void getAppleUser(Optional userAuth) {
// so we no longer need to read userSettings.getAnisetteServerUrl() here.
// Produce Anisette on this device where possible, rather than relaying every refresh
// through a public Anisette server. Falls back to the configured server by itself.
- var asyncAppleService = PythonAuthService.restoreAccount(
+ final Observable asyncAppleService = PythonAuthService.restoreAccount(
userAuth.get(),
// Somebody is signed in here, so an unchosen mode keeps them on whatever
// already works: switching would present Apple with a different machine.
@@ -1844,6 +1908,14 @@ private void getAppleUser(Optional userAuth) {
return this.appleService;
});
+ this.showDevicesAndFetch(asyncAppleService);
+ }
+
+ /**
+ * Shows the stored devices and their cached locations, then fetches fresh reports once
+ * {@code asyncAppleService} (the restored account) is ready.
+ */
+ private void showDevicesAndFetch(final Observable asyncAppleService) {
// get list of Beacons
var asyncAllBeacons = this.beaconRepo.getAllBeacons()
.flatMap(BeaconDataParser::parseAsync)
@@ -2282,11 +2354,13 @@ private synchronized void addBeaconToCurrent(final List allBe
// Guarded on the disposable so the periodic account refresh, which also lands here,
// does not bounce a running scan - Android silently blocks an app that starts scans
// too often.
- if (!newBeaconInformation.isEmpty() && this.nearbyWatchDisposable == null) {
+ if (!newBeaconInformation.isEmpty() && this.nearbyWatchDisposable == null
+ && this.fmdnNearbyWatchDisposable == null) {
// Re-checked on the main thread: this load finishes on a background thread, and by
// the time the post runs, onResume may have started the watch already.
this.runOnUiThread(() -> {
- if (this.nearbyWatchDisposable == null && !this.isFinishing()) {
+ if (this.nearbyWatchDisposable == null && this.fmdnNearbyWatchDisposable == null
+ && !this.isFinishing()) {
this.startWatchingForNearbyTags();
}
});
@@ -2625,7 +2699,13 @@ private synchronized void updateBeaconCards() {
now,
DateUtils.MINUTE_IN_MILLIS
).toString();
- deviceLastUpdate.setText(this.getString(R.string.last_updated_x, timeAgo));
+ // Only Google is named: Apple is every other card's source, and labelling all of
+ // them would change the row for everyone who never set Google up
+ final String source = LocationReport.PROVENANCE_GOOGLE.equals(lastLocation.getProvenance())
+ ? this.getString(R.string.source_google)
+ : null;
+ final String updated = this.getString(R.string.last_updated_x, timeAgo);
+ deviceLastUpdate.setText(source == null ? updated : updated + " · " + source);
// Nothing live to pulse for once the sighting has aged out.
((ImageView) v.findViewById(R.id.device_nearby_pulse)).setVisibility(GONE);
}
@@ -2767,7 +2847,7 @@ private Observable>> fetchLastReports(fin
return this.beaconRepo.toScheduledAccessoryRequests(beaconIdToPlist)
.doOnSubscribe(__ -> this.markFetchStarted())
.doOnNext(this::armLongFetchBannerIfSlow)
- .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack))
+ .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack, false))
.doOnNext(reports -> this.refreshPolicy.markFetched(now)) // on success, update this time.
.doFinally(this::markFetchFinished);
}
@@ -2777,7 +2857,7 @@ private Observable>> fetchLastReports(fin
return this.beaconRepo.toAccessoryRequests(beaconIdToPlist)
.doOnSubscribe(__ -> this.markFetchStarted())
.doOnNext(this::armLongFetchBannerIfSlow)
- .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack))
+ .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack, false))
.doFinally(this::markFetchFinished);
}
@@ -2801,18 +2881,18 @@ private Observable>> fetchLastReports(fin
* an Activity - see {@code RxFlowsTest}.
*/
private Observable>> fetchOneAccessoryAtATime(
- final List requests, final int hoursToGoBack) {
+ final List requests, final int hoursToGoBack, final boolean askGoogleNow) {
// **A tag nobody has searched for yet gets the whole week.** Read once for the batch,
// then applied per accessory - see HOURS_TO_GO_BACK_FIRST_TIME. Everything else keeps
// the window it asked for.
return this.beaconRepo.neverScanned().flatMap(neverScanned -> RxFlows.oneAtATime(
requests,
- request -> this.appleService.getLastReports(
- List.of(request),
+ request -> this.fetchFromSources(request,
neverScanned.contains(request.getBeaconId())
? HOURS_TO_GO_BACK_FIRST_TIME
- : hoursToGoBack)
+ : hoursToGoBack,
+ askGoogleNow)
.flatMap(this.beaconRepo::storeFetchResult),
this::setLongFetchProgress,
(request, error) -> {
@@ -2829,17 +2909,37 @@ private Observable>> fetchOneAccessoryAtA
}));
}
- private Observable>> fetchLastReportsFor(final String beaconId, final String pList, final int hoursToGoBack) {
+ private Observable>> fetchLastReportsFor(
+ final String beaconId, final String pList, final int hoursToGoBack, final boolean askGoogleNow) {
Log.i(TAG, "Preparing to fetch location reports for the last " + hoursToGoBack + " hours!");
// Not Map.of - see BeaconRepository.plistFallback. A self-generated tag has no plist.
return this.beaconRepo.toAccessoryRequests(BeaconRepository.plistFallback(beaconId, pList))
.doOnSubscribe(__ -> this.markFetchStarted())
.doOnNext(this::armLongFetchBannerIfSlow)
- .flatMap(requests -> this.appleService.getLastReports(requests, hoursToGoBack))
+ .flatMap(requests -> Observable.fromIterable(requests)
+ .concatMap(request -> this.fetchFromSources(request, hoursToGoBack, askGoogleNow)))
.flatMap(this.beaconRepo::storeFetchResult)
.doFinally(this::markFetchFinished);
}
+ /**
+ * One device's reports from wherever they come from: the Apple account and Google Find Hub -
+ * see {@link ReportSources}.
+ *
+ * @param askGoogleNow a person asked (refresh button), so Google is asked for fresh
+ * locations rather than only reading what the service already holds
+ */
+ private Observable fetchFromSources(
+ final AccessoryRequest request, final int hoursToGoBack, final boolean askGoogleNow) {
+ final long now = System.currentTimeMillis();
+ return this.reportSources.fetch(
+ request,
+ now - hoursToGoBack * 3_600_000L,
+ now,
+ askGoogleNow,
+ ReportSources.appleFetchOrNull(this.appleService, request, hoursToGoBack));
+ }
+
/**
* Starts the clock on the "still working" banner.
*
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java
index e89a06ef..6db01368 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java
@@ -47,6 +47,7 @@
import dev.wander.android.opentagviewer.db.datastore.UserAuthDataStore;
import dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore;
import dev.wander.android.opentagviewer.db.repo.BeaconRepository;
+import dev.wander.android.opentagviewer.ui.importing.ExternalDeviceImporter;
import dev.wander.android.opentagviewer.ui.login.SignInAgain;
import dev.wander.android.opentagviewer.python.icloud.ICloudFailures;
import dev.wander.android.opentagviewer.python.icloud.AccountRefresher;
@@ -201,6 +202,15 @@ public class MyDevicesListActivity extends AppCompatActivity {
}
);
+ /** OpenHaystack keys and Google trackers; the list is rebuilt once one is added. */
+ private final ExternalDeviceImporter externalImporter = new ExternalDeviceImporter(this, () -> {
+ this.devicesListChanged = true;
+ this.recreate();
+ });
+
+ private final ActivityResultLauncher openKeysFileLauncher = registerForActivityResult(
+ new ActivityResultContracts.OpenDocument(), this.externalImporter::importKeysFile);
+
private final ActivityResultLauncher deviceInfoActivityLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
(ActivityResult result) -> {
@@ -650,6 +660,14 @@ private void showPageMenu() {
this.handleStartImport();
return true;
}
+ if (id == R.id.action_add_openhaystack) {
+ this.openKeysFileLauncher.launch(new String[]{"*/*"});
+ return true;
+ }
+ if (id == R.id.action_add_google) {
+ this.externalImporter.importFromGoogle();
+ return true;
+ }
if (id == R.id.action_import_history) {
this.openHistoryZipLauncher.launch(new String[]{"application/zip"});
return true;
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java
index e2363efd..62231536 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java
@@ -228,6 +228,9 @@ protected void onCreate(Bundle savedInstanceState) {
this.binding.setHandleClickBack(this::handleEndActivity);
this.binding.setOnClickFetchFromAccount(this::onClickFetchFromAccount);
this.binding.setOnClickUnlinkAccount(this::onClickUnlinkAccount);
+ this.binding.setOnClickExtraSources(() ->
+ startActivity(new Intent(this, ExtraSourcesSettingsActivity.class)));
+
this.sayWhetherTheAccountIsLinked();
this.binding.setOnClickTheme(this::onClickEditTheme);
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java
index e7f2a65e..5d74adc4 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleGattSoundTrigger.java
@@ -223,7 +223,7 @@ private static BluetoothGattCharacteristic findMyCharacteristic(final BluetoothG
}
@SuppressLint("MissingPermission")
- private static void writeCharacteristicCompat(
+ static void writeCharacteristicCompat(
final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, final byte[] value) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeCharacteristic(characteristic, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT);
@@ -235,7 +235,7 @@ private static void writeCharacteristicCompat(
}
@SuppressLint("MissingPermission")
- private static void writeDescriptorCompat(
+ static void writeDescriptorCompat(
final BluetoothGatt gatt, final BluetoothGattDescriptor descriptor, final byte[] value) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(descriptor, value);
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java
index eedd46d7..9541e2ff 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/BleSoundTriggerResult.java
@@ -52,11 +52,12 @@ public BleSoundTriggerResult(
}
/**
- * Whether this attempt left something sounding that can be told to stop. Only DULT has a
+ * Whether this attempt left something sounding that can be told to stop. DULT and FMDN have a
* stop command; an AirTag stops by itself, so asking it costs a connection for nothing.
*/
public boolean hasStopCommand() {
return this.status == BleSoundTriggerStatus.SUCCESS
- && BleGattSoundTrigger.PROTOCOL_DULT.equals(this.protocol);
+ && (BleGattSoundTrigger.PROTOCOL_DULT.equals(this.protocol)
+ || FmdnRingTrigger.PROTOCOL.equals(this.protocol));
}
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnNearbyWatcher.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnNearbyWatcher.java
new file mode 100644
index 00000000..138e2d6c
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnNearbyWatcher.java
@@ -0,0 +1,99 @@
+package dev.wander.android.opentagviewer.ble;
+
+import android.annotation.SuppressLint;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.content.Context;
+import android.os.ParcelUuid;
+import android.util.Log;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import dev.wander.android.opentagviewer.source.ExtraSourcesApi;
+import dev.wander.android.opentagviewer.source.ExtraSourcesSettings;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+/**
+ * "Nearby" for Google Find Hub trackers. They carry no Apple keys, so {@link NearbyTagWatcher}
+ * cannot derive their addresses; instead they are recognised by their current EID in the FMDN
+ * advertisement (service data 0xFEAA), with the EIDs from the googlefind service - the same
+ * list the ring uses. The FMDN flags byte (battery) is encrypted, so no battery is reported.
+ */
+@SuppressLint("MissingPermission") // callers check BlePermissions first
+public final class FmdnNearbyWatcher {
+ private static final String TAG = FmdnNearbyWatcher.class.getSimpleName();
+ private static final ParcelUuid FMDN_SERVICE_DATA = ParcelUuid.fromString("0000feaa-0000-1000-8000-00805f9b34fb");
+
+ /**
+ * Emits a sighting each time one of the trackers advertises nearby.
+ *
+ * @param googleIdByBeaconId the Google trackers to watch for (beacon id -> googlefind id)
+ */
+ public Observable watch(final Context context, final Map googleIdByBeaconId) {
+ return Observable.>fromCallable(() -> {
+ // EID (hex) -> beacon id. The service returns a +-6 h window, so one fetch
+ // covers a long map session; the watch is restarted on resume anyway.
+ final ExtraSourcesApi api = new ExtraSourcesApi(new ExtraSourcesSettings(context));
+ final Map beaconByEid = new HashMap<>();
+ for (final Map.Entry e : googleIdByBeaconId.entrySet()) {
+ try {
+ for (final String eid : api.ringMaterial(e.getValue()).eids) beaconByEid.put(eid, e.getKey());
+ } catch (final Exception ex) {
+ Log.w(TAG, "No EIDs for " + e.getKey(), ex);
+ }
+ }
+ return beaconByEid;
+ })
+ .subscribeOn(Schedulers.io())
+ .flatMap(beaconByEid -> beaconByEid.isEmpty() ? Observable.empty()
+ : scan(context, beaconByEid));
+ }
+
+ private static Observable scan(final Context context, final Map beaconByEid) {
+ return Observable.create(emitter -> {
+ final BluetoothManager manager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
+ final BluetoothAdapter adapter = manager == null ? null : manager.getAdapter();
+ final BluetoothLeScanner scanner = adapter == null || !adapter.isEnabled() ? null : adapter.getBluetoothLeScanner();
+ if (scanner == null) {
+ emitter.onComplete();
+ return;
+ }
+ final ScanCallback callback = new ScanCallback() {
+ @Override
+ public void onScanResult(final int callbackType, final ScanResult result) {
+ final byte[] data = result.getScanRecord() == null ? null
+ : result.getScanRecord().getServiceData(FMDN_SERVICE_DATA);
+ if (data == null || data.length < 21) return;
+ final int frame = data[0] & 0xFF;
+ if (frame != 0x40 && frame != 0x41) return;
+ final String eid = FmdnRingCommand.hex(java.util.Arrays.copyOfRange(data, 1, 21));
+ final String beaconId = beaconByEid.get(eid);
+ if (beaconId != null && !emitter.isDisposed()) {
+ emitter.onNext(new NearbyTagSighting(beaconId, null, result.getRssi(), null,
+ data.length > 21 ? data[21] & 0xFF : 0, null, System.currentTimeMillis()));
+ }
+ }
+
+ @Override
+ public void onScanFailed(final int errorCode) {
+ Log.w(TAG, "FMDN nearby scan failed: " + errorCode);
+ emitter.onComplete();
+ }
+ };
+ final List filters = Collections.singletonList(
+ new ScanFilter.Builder().setServiceData(FMDN_SERVICE_DATA, new byte[0]).build());
+ scanner.startScan(filters, new ScanSettings.Builder()
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), callback);
+ emitter.setCancellable(() -> scanner.stopScan(callback));
+ });
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingCommand.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingCommand.java
new file mode 100644
index 00000000..310d5326
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingCommand.java
@@ -0,0 +1,153 @@
+package dev.wander.android.opentagviewer.ble;
+
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Locale;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * The bytes of a Google Find Hub (FMDN) owner "ring" request, per the FMDN accessory
+ * specification's Beacon Actions characteristic.
+ *
+ * Write layout: {@code dataId(0x05) | len(0x0C) | auth(8) | components | timeout(u16 BE,
+ * deciseconds) | volume}. The one-time authentication key binds the nonce the tracker handed out
+ * on the preceding read: {@code HMAC-SHA256(ringKey, version | nonce | dataId | len | additional)[:8]}, where {@code ringKey = SHA256(EIK | 0x02)[:8]} is derived by the googlefind
+ * service, which holds the identity key.
+ *
+ *
Pure Java so it can be tested on the JVM.
+ */
+public final class FmdnRingCommand {
+ public static final byte DATA_ID_RING = 0x05;
+ /** Ring every component the tracker has. */
+ public static final byte COMPONENTS_ALL = (byte) 0xFF;
+ /** Components 0 means stop. */
+ public static final byte COMPONENTS_STOP = 0x00;
+ public static final byte VOLUME_DEFAULT = 0x00;
+ // Data length counts the 8-byte authentication key plus the additional data (a length of 4
+ // made a real tracker drop the link)
+ private static final byte LENGTH = 12;
+ /** Direction byte some firmware expects appended to the HMAC input (fallback variants only). */
+ private static final byte FROM_SEEKER = 0x01;
+
+ private FmdnRingCommand() {
+ }
+
+ static byte[] additionalData(final byte components, final int timeoutDeciseconds, final byte volume) {
+ return new byte[]{components, (byte) (timeoutDeciseconds >> 8), (byte) timeoutDeciseconds, volume};
+ }
+
+ /** The 8-byte one-time authentication key. */
+ public static byte[] authKey(final byte[] ringKey, final int version, final byte[] nonce,
+ final byte dataId, final byte length, final byte[] additional) {
+ try {
+ final Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(ringKey, "HmacSHA256"));
+ mac.update((byte) version);
+ mac.update(nonce);
+ mac.update(dataId);
+ mac.update(length);
+ mac.update(additional);
+ return Arrays.copyOf(mac.doFinal(), 8);
+ } catch (final GeneralSecurityException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ /**
+ * The value to write, given what the tracker's Beacon Actions characteristic returned on read
+ * ({@code version | nonce(8)}).
+ */
+ public static byte[] ringWrite(final byte[] ringKey, final byte[] readValue, final byte components,
+ final int timeoutDeciseconds, final byte volume) {
+ return ringWrite(ringKey, readValue, components, timeoutDeciseconds, volume, VARIANTS[0]);
+ }
+
+ /**
+ * One way of phrasing the ring request. Trackers differ: some predate the volume byte, and
+ * implementations disagree on the trailing direction byte in the HMAC input.
+ */
+ public static final class Variant {
+ public final boolean withVolume;
+ public final boolean trailingDirection;
+
+ public Variant(final boolean withVolume, final boolean trailingDirection) {
+ this.withVolume = withVolume;
+ this.trailingDirection = trailingDirection;
+ }
+
+ @Override
+ public String toString() {
+ return (withVolume ? "4-byte" : "3-byte") + (trailingDirection ? "+dir" : "");
+ }
+ }
+
+ /**
+ * Tried in this order. The first is what a real tracker accepted: four bytes of additional
+ * data and no trailing direction byte (that byte belongs to the tracker's own notification
+ * authentication, not to the seeker's request). The rest are fallbacks for other firmware.
+ */
+ public static final Variant[] VARIANTS = {
+ new Variant(true, false), new Variant(false, false),
+ new Variant(true, true), new Variant(false, true),
+ };
+
+ public static byte[] ringWrite(final byte[] ringKey, final byte[] readValue, final byte components,
+ final int timeoutDeciseconds, final byte volume, final Variant variant) {
+ if (readValue == null || readValue.length < 9) {
+ throw new IllegalArgumentException("Beacon Actions read returned no nonce");
+ }
+ final int version = readValue[0] & 0xFF;
+ final byte[] nonce = Arrays.copyOfRange(readValue, 1, 9);
+ final byte[] full = additionalData(components, timeoutDeciseconds, volume);
+ final byte[] additional = variant.withVolume ? full : Arrays.copyOf(full, 3);
+ final byte length = (byte) (8 + additional.length);
+ final byte[] auth;
+ try {
+ final Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(ringKey, "HmacSHA256"));
+ mac.update((byte) version);
+ mac.update(nonce);
+ mac.update(DATA_ID_RING);
+ mac.update(length);
+ mac.update(additional);
+ if (variant.trailingDirection) mac.update(FROM_SEEKER);
+ auth = Arrays.copyOf(mac.doFinal(), 8);
+ } catch (final GeneralSecurityException e) {
+ throw new IllegalStateException(e);
+ }
+ final byte[] out = new byte[2 + 8 + additional.length];
+ out[0] = DATA_ID_RING;
+ out[1] = length;
+ System.arraycopy(auth, 0, out, 2, 8);
+ System.arraycopy(additional, 0, out, 10, additional.length);
+ return out;
+ }
+
+ /**
+ * Whether FMDN service data (UUID 0xFEAA: frame type, then the 20-byte EID) carries one of
+ * the given EIDs, as lowercase hex.
+ */
+ public static boolean matchesEid(final byte[] serviceData, final Collection eidsHex) {
+ if (serviceData == null || serviceData.length < 21) return false;
+ final int frame = serviceData[0] & 0xFF;
+ if (frame != 0x40 && frame != 0x41) return false;
+ return eidsHex.contains(hex(Arrays.copyOfRange(serviceData, 1, 21)));
+ }
+
+ public static String hex(final byte[] bytes) {
+ final StringBuilder sb = new StringBuilder(bytes.length * 2);
+ for (final byte b : bytes) sb.append(String.format(Locale.ROOT, "%02x", b));
+ return sb.toString();
+ }
+
+ public static byte[] unhex(final String hex) {
+ final byte[] out = new byte[hex.length() / 2];
+ for (int i = 0; i < out.length; i++) {
+ out[i] = (byte) Integer.parseInt(hex.substring(2 * i, 2 * i + 2), 16);
+ }
+ return out;
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingTrigger.java
new file mode 100644
index 00000000..9ae131d2
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/FmdnRingTrigger.java
@@ -0,0 +1,304 @@
+package dev.wander.android.opentagviewer.ble;
+
+import android.annotation.SuppressLint;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.content.Context;
+import android.os.ParcelUuid;
+import android.util.Log;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import dev.wander.android.opentagviewer.source.ExternalAccessory;
+import dev.wander.android.opentagviewer.source.ExtraSourcesApi;
+import dev.wander.android.opentagviewer.source.ExtraSourcesSettings;
+import io.reactivex.rxjava3.core.Completable;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.core.Single;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+/**
+ * Rings a Google Find Hub tracker the way its owner's Find Hub app does: the tracker is
+ * recognised by its current EID in the FMDN advertisement (service data 0xFEAA), then its Beacon
+ * Actions characteristic is read for a nonce and written a ring request authenticated with the
+ * ring key (see {@link FmdnRingCommand}). The ring key and EIDs come from the googlefind service,
+ * which holds the tracker's identity key; the ring key rings and nothing else.
+ *
+ * Same contract as {@link BleAccessorySoundTrigger}: progress updates, exactly one DONE, never
+ * an error.
+ */
+@SuppressLint("MissingPermission") // checked through BlePermissions first
+public final class FmdnRingTrigger implements AccessorySoundTrigger {
+ private static final String TAG = FmdnRingTrigger.class.getSimpleName();
+ /** {@link BleSoundTriggerResult#getProtocol()} for a ring sent over FMDN. */
+ static final String PROTOCOL = "FMDN";
+
+ private static final ParcelUuid FMDN_SERVICE_DATA = ParcelUuid.fromString("0000feaa-0000-1000-8000-00805f9b34fb");
+ private static final UUID FAST_PAIR_SERVICE = UUID.fromString("0000fe2c-0000-1000-8000-00805f9b34fb");
+ private static final UUID BEACON_ACTIONS = UUID.fromString("fe2c1238-8366-4814-8eb0-01de32100bea");
+ private static final UUID CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
+
+ private static final long SCAN_TIMEOUT_S = 30;
+ private static final long GATT_TIMEOUT_S = 20;
+ /** One ring lasts this long (deciseconds); continuous ringing re-rings before it ends. */
+ private static final int RING_DECISECONDS = 300;
+ private static final long CONTINUOUS_PAUSE_S = 20;
+
+ @Override
+ public Observable playSound(final Context context, final String accessoryJson) {
+ return this.run(context, accessoryJson, FmdnRingCommand.COMPONENTS_ALL);
+ }
+
+ @Override
+ public Observable playSoundContinuously(final Context context, final String accessoryJson) {
+ return this.playSound(context, accessoryJson)
+ .repeatWhen(completed -> completed.delay(CONTINUOUS_PAUSE_S, TimeUnit.SECONDS));
+ }
+
+ @Override
+ public Completable stopSound(final Context context, final String accessoryJson) {
+ return this.run(context, accessoryJson, FmdnRingCommand.COMPONENTS_STOP).ignoreElements().onErrorComplete();
+ }
+
+ private static BleSoundTriggerUpdate done(final BleSoundTriggerStatus status, final String message) {
+ return BleSoundTriggerUpdate.done(new BleSoundTriggerResult(
+ status, status == BleSoundTriggerStatus.SUCCESS ? PROTOCOL : null, message));
+ }
+
+ private Observable run(final Context context, final String accessoryJson,
+ final byte components) {
+ return Observable.defer(() -> {
+ if (!BlePermissions.granted(context)) {
+ return Observable.just(done(BleSoundTriggerStatus.MISSING_PERMISSION,
+ "Bluetooth scan/connect permission not granted"));
+ }
+ final String googleId = ExternalAccessory.googleId(accessoryJson);
+ if (googleId == null) {
+ return Observable.just(done(BleSoundTriggerStatus.NO_CANDIDATE_MACS, "Not a Google Find Hub tracker"));
+ }
+
+ final ExtraSourcesApi.RingMaterial material;
+ try {
+ material = new ExtraSourcesApi(new ExtraSourcesSettings(context)).ringMaterial(googleId);
+ } catch (final Exception e) {
+ Log.w(TAG, "Could not get ring material for " + googleId, e);
+ return Observable.just(done(BleSoundTriggerStatus.NO_CANDIDATE_MACS,
+ "Could not get the ring key from the Google Find Hub service: " + e.getMessage()));
+ }
+
+ return Observable.just(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.SCANNING))
+ .concatWith(findByEid(context, material.eids).toObservable()
+ .concatMap(device -> Observable.just(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.CONNECTING))
+ .concatWith(ring(context, device, material.ringKey, components)
+ .map(update -> update.withMatchedMac(device.getAddress())))))
+ .onErrorReturn(e -> e instanceof NotNearby
+ ? done(BleSoundTriggerStatus.NOT_NEARBY, e.getMessage())
+ : done(BleSoundTriggerStatus.FAILED, String.valueOf(e.getMessage())));
+ }).subscribeOn(Schedulers.io());
+ }
+
+ private static final class NotNearby extends Exception {
+ NotNearby(final String message) {
+ super(message);
+ }
+ }
+
+ /** Scans for an FMDN advertisement carrying one of the EIDs. */
+ private static Single findByEid(final Context context, final List eids) {
+ return Single.create(emitter -> {
+ final BluetoothManager manager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
+ final BluetoothAdapter adapter = manager == null ? null : manager.getAdapter();
+ final BluetoothLeScanner scanner = adapter == null || !adapter.isEnabled() ? null : adapter.getBluetoothLeScanner();
+ if (scanner == null) {
+ emitter.tryOnError(new IllegalStateException("Bluetooth is off"));
+ return;
+ }
+ final AtomicBoolean found = new AtomicBoolean(false);
+ final java.util.concurrent.atomic.AtomicInteger seen = new java.util.concurrent.atomic.AtomicInteger();
+ Log.i(TAG, "Scanning for an FMDN tracker matching one of " + eids.size() + " EIDs");
+ final ScanCallback callback = new ScanCallback() {
+ @Override
+ public void onScanResult(final int callbackType, final ScanResult result) {
+ final byte[] data = result.getScanRecord() == null ? null
+ : result.getScanRecord().getServiceData(FMDN_SERVICE_DATA);
+ if (data != null && seen.incrementAndGet() <= 40) {
+ Log.d(TAG, "FEAA " + FmdnRingCommand.hex(data) + " from " + result.getDevice().getAddress()
+ + (FmdnRingCommand.matchesEid(data, eids) ? " MATCH" : ""));
+ }
+ if (FmdnRingCommand.matchesEid(data, eids) && found.compareAndSet(false, true)) {
+ emitter.onSuccess(result.getDevice());
+ }
+ }
+
+ @Override
+ public void onScanFailed(final int errorCode) {
+ emitter.tryOnError(new IllegalStateException("Bluetooth scan failed (" + errorCode + ")"));
+ }
+ };
+ // Any FMDN advertisement; the EID is compared in the callback
+ final ScanFilter filter = new ScanFilter.Builder().setServiceData(FMDN_SERVICE_DATA, new byte[0]).build();
+ scanner.startScan(Collections.singletonList(filter),
+ new ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), callback);
+ emitter.setCancellable(() -> scanner.stopScan(callback));
+ }).timeout(SCAN_TIMEOUT_S, TimeUnit.SECONDS, Single.error(new NotNearby("Tracker not found nearby")));
+ }
+
+ /** Connect, read the nonce, write the ring (or stop) request. */
+ private static Observable ring(final Context context, final BluetoothDevice device,
+ final byte[] ringKey, final byte components) {
+ return Observable.create(emitter -> {
+ final AtomicBoolean finished = new AtomicBoolean(false);
+ final BluetoothGatt[] gattRef = new BluetoothGatt[1];
+
+ final BluetoothGattCallback callback = new BluetoothGattCallback() {
+ private BluetoothGattCharacteristic actions;
+ private int variant = 0;
+
+ private void finish(final BleSoundTriggerUpdate update, final BluetoothGatt gatt) {
+ if (!finished.compareAndSet(false, true)) return;
+ if (!emitter.isDisposed()) {
+ emitter.onNext(update);
+ emitter.onComplete();
+ }
+ gatt.disconnect();
+ }
+
+ @Override
+ public void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) {
+ Log.i(TAG, "Connection state " + newState + " (status=" + status + ") " + device.getAddress());
+ if (newState == BluetoothProfile.STATE_CONNECTED) {
+ gatt.discoverServices();
+ } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
+ gatt.close();
+ this.finish(done(BleSoundTriggerStatus.FAILED,
+ "Connection closed before the ring request was sent"), gatt);
+ }
+ }
+
+ @Override
+ public void onServicesDiscovered(final BluetoothGatt gatt, final int status) {
+ final BluetoothGattService fastPair = gatt.getService(FAST_PAIR_SERVICE);
+ this.actions = fastPair == null ? null : fastPair.getCharacteristic(BEACON_ACTIONS);
+ if (this.actions == null) {
+ // Some trackers expose Beacon Actions outside the Fast Pair service
+ for (final BluetoothGattService svc : gatt.getServices()) {
+ final BluetoothGattCharacteristic c = svc.getCharacteristic(BEACON_ACTIONS);
+ if (c != null) { this.actions = c; break; }
+ }
+ }
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ Log.w(TAG, "Service discovery status=" + status);
+ }
+ if (this.actions == null) {
+ final StringBuilder found = new StringBuilder();
+ for (final BluetoothGattService svc : gatt.getServices()) found.append(svc.getUuid()).append(' ');
+ Log.w(TAG, "No Beacon Actions char on " + device.getAddress() + "; services: " + found);
+ this.finish(done(BleSoundTriggerStatus.NO_SOUND_SERVICE,
+ "No FMDN Beacon Actions characteristic"), gatt);
+ return;
+ }
+ if (!emitter.isDisposed()) {
+ emitter.onNext(BleSoundTriggerUpdate.progress(BleSoundTriggerPhase.TRIGGERING));
+ }
+ final BluetoothGattDescriptor cccd = this.actions.getDescriptor(CCCD);
+ if (cccd != null) {
+ gatt.setCharacteristicNotification(this.actions, true);
+ BleGattSoundTrigger.writeDescriptorCompat(
+ gatt, cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
+ } else {
+ gatt.readCharacteristic(this.actions);
+ }
+ }
+
+ @Override
+ public void onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic c, final byte[] value) {
+ Log.i(TAG, "Beacon Actions notification: " + FmdnRingCommand.hex(value));
+ }
+
+ // Below API 33 the platform calls these older overloads instead, with the value
+ // on the characteristic; without them a ring on Android 12 and below waits out
+ // the timeout
+ @Override
+ @SuppressWarnings("deprecation")
+ public void onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic c) {
+ this.onCharacteristicChanged(gatt, c, c.getValue());
+ }
+
+ @Override
+ @SuppressWarnings("deprecation")
+ public void onCharacteristicRead(final BluetoothGatt gatt, final BluetoothGattCharacteristic c,
+ final int status) {
+ this.onCharacteristicRead(gatt, c, c.getValue(), status);
+ }
+
+ @Override
+ public void onDescriptorWrite(final BluetoothGatt gatt, final BluetoothGattDescriptor d, final int status) {
+ Log.i(TAG, "CCCD write status=" + status);
+ gatt.readCharacteristic(this.actions);
+ }
+
+ @Override
+ public void onCharacteristicRead(final BluetoothGatt gatt, final BluetoothGattCharacteristic c,
+ final byte[] value, final int status) {
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ this.finish(done(BleSoundTriggerStatus.FAILED, "Reading the nonce failed (status=" + status + ")"), gatt);
+ return;
+ }
+ Log.i(TAG, "Beacon Actions read: " + FmdnRingCommand.hex(value == null ? new byte[0] : value));
+ try {
+ final byte[] request = FmdnRingCommand.ringWrite(ringKey, value, components,
+ RING_DECISECONDS, FmdnRingCommand.VOLUME_DEFAULT,
+ FmdnRingCommand.VARIANTS[this.variant]);
+ Log.i(TAG, "Writing: " + FmdnRingCommand.hex(request));
+ BleGattSoundTrigger.writeCharacteristicCompat(gatt, this.actions, request);
+ } catch (final IllegalArgumentException e) {
+ this.finish(done(BleSoundTriggerStatus.FAILED, e.getMessage()), gatt);
+ }
+ }
+
+ @Override
+ public void onCharacteristicWrite(final BluetoothGatt gatt, final BluetoothGattCharacteristic c, final int status) {
+ Log.i(TAG, "Ring request (components=" + components + ", variant "
+ + FmdnRingCommand.VARIANTS[this.variant] + ") written to "
+ + device.getAddress() + ", status=" + status);
+ // 0x80 is the spec's "unauthenticated": the tracker is still connected, so try
+ // the next phrasing with a fresh nonce
+ if (status == 0x80 && this.variant + 1 < FmdnRingCommand.VARIANTS.length) {
+ this.variant++;
+ gatt.readCharacteristic(this.actions);
+ return;
+ }
+ this.finish(status == BluetoothGatt.GATT_SUCCESS
+ ? done(BleSoundTriggerStatus.SUCCESS, null)
+ // The tracker rejects a bad one-time key with an ATT error
+ : done(BleSoundTriggerStatus.FAILED, "The tracker refused the ring request (status=" + status + ")"), gatt);
+ }
+ };
+
+ gattRef[0] = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE);
+ emitter.setCancellable(() -> {
+ if (gattRef[0] != null) {
+ gattRef[0].disconnect();
+ gattRef[0].close();
+ }
+ });
+ }).timeout(GATT_TIMEOUT_S, TimeUnit.SECONDS,
+ Observable.just(done(BleSoundTriggerStatus.FAILED, "The tracker did not answer in time")));
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ble/RoutingSoundTrigger.java b/app/src/main/java/dev/wander/android/opentagviewer/ble/RoutingSoundTrigger.java
new file mode 100644
index 00000000..c24accf7
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ble/RoutingSoundTrigger.java
@@ -0,0 +1,40 @@
+package dev.wander.android.opentagviewer.ble;
+
+import android.content.Context;
+
+import dev.wander.android.opentagviewer.source.ExternalAccessory;
+import io.reactivex.rxjava3.core.Completable;
+import io.reactivex.rxjava3.core.Observable;
+
+/**
+ * Picks the sound protocol by the kind of device: Google Find Hub trackers ring through FMDN
+ * ({@link FmdnRingTrigger}), everything else through the Find My / DULT path.
+ */
+public final class RoutingSoundTrigger implements AccessorySoundTrigger {
+ private final AccessorySoundTrigger findMy;
+ private final AccessorySoundTrigger fmdn;
+
+ public RoutingSoundTrigger(final AccessorySoundTrigger findMy, final AccessorySoundTrigger fmdn) {
+ this.findMy = findMy;
+ this.fmdn = fmdn;
+ }
+
+ private AccessorySoundTrigger forDevice(final String accessoryJson) {
+ return ExternalAccessory.isGoogle(accessoryJson) ? this.fmdn : this.findMy;
+ }
+
+ @Override
+ public Observable playSound(final Context context, final String accessoryJson) {
+ return this.forDevice(accessoryJson).playSound(context, accessoryJson);
+ }
+
+ @Override
+ public Observable playSoundContinuously(final Context context, final String accessoryJson) {
+ return this.forDevice(accessoryJson).playSoundContinuously(context, accessoryJson);
+ }
+
+ @Override
+ public Completable stopSound(final Context context, final String accessoryJson) {
+ return this.forDevice(accessoryJson).stopSound(context, accessoryJson);
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
index b6514156..3937427a 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java
@@ -958,10 +958,12 @@ public Observable>> storeToLocationCache(
.horizontalAccuracy(locationReport.getHorizontalAccuracy())
.status(locationReport.getStatus())
.lastUpdate(now)
- // Everything arriving here was decrypted from Apple's
- // network. The local path writes its own rows and sets this
- // itself - see recordLocalSighting.
- .provenance(LocationReport.PROVENANCE_APPLE)
+ // Apple's network unless the fetch said otherwise: the
+ // extra sources mark Google's reports (see ReportSources).
+ // The local path writes its own rows - see recordLocalSighting.
+ .provenance(locationReport.getProvenance() != null
+ ? locationReport.getProvenance()
+ : LocationReport.PROVENANCE_APPLE)
.build()
))
.toArray(LocationReport[]::new);
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java
index cfd31704..014ee427 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/LocationReport.java
@@ -109,4 +109,10 @@ public class LocationReport {
/** Heard by this phone's own radio, positioned from this phone's own location. */
public static final String PROVENANCE_LOCAL = "local";
+
+ /**
+ * Reported by the Google Find Hub network, through the googlefind service
+ * (see {@code source/ExtraSourcesApi}).
+ */
+ public static final String PROVENANCE_GOOGLE = "google";
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java
index bf7deb8a..e92fec1f 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java
@@ -16,6 +16,8 @@
import dev.wander.android.opentagviewer.anisette.LocalAnisette;
import dev.wander.android.opentagviewer.ble.AccessorySoundTrigger;
import dev.wander.android.opentagviewer.ble.BleAccessorySoundTrigger;
+import dev.wander.android.opentagviewer.ble.FmdnRingTrigger;
+import dev.wander.android.opentagviewer.ble.RoutingSoundTrigger;
import dev.wander.android.opentagviewer.python.icloud.ICloudService;
import dev.wander.android.opentagviewer.python.icloud.PythonICloudService;
import dev.wander.android.opentagviewer.db.repo.model.UserSettings;
@@ -211,7 +213,9 @@ public static AccessoryMacResolver accessoryMacResolver() {
public static AccessorySoundTrigger accessorySoundTrigger() {
if (accessorySoundTrigger == null) {
- accessorySoundTrigger = BleAccessorySoundTrigger.forRealBluetooth(accessoryMacResolver);
+ accessorySoundTrigger = new RoutingSoundTrigger(
+ BleAccessorySoundTrigger.forRealBluetooth(accessoryMacResolver),
+ new FmdnRingTrigger());
}
return accessorySoundTrigger;
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
index 93a06832..141eecbd 100644
--- a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
+++ b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyAccessoryMacResolver.java
@@ -7,6 +7,7 @@
import com.chaquo.python.PyObject;
import com.chaquo.python.Python;
+import dev.wander.android.opentagviewer.source.ExternalAccessory;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
@@ -22,13 +23,17 @@
* Blocking, and starts an interpreter. Never call this on the main thread; the {@code
* ble} package that uses it does so on an Rx scheduler.
*/
+/*
+ * Google Find Hub trackers (ExternalAccessory.TYPE_GOOGLE) have no Apple keys and are never
+ * handed to Python here: there is no MAC to derive, and FindMy.py raises on the type.
+ */
public class ChaquopyAccessoryMacResolver implements AccessoryMacResolver {
private static final String TAG = ChaquopyAccessoryMacResolver.class.getSimpleName();
private static final String MODULE_MAIN = "main";
@Override
public Map currentMacAddresses(final String accessoryJson) {
- if (accessoryJson == null || accessoryJson.isEmpty()) {
+ if (accessoryJson == null || accessoryJson.isEmpty() || ExternalAccessory.isGoogle(accessoryJson)) {
// Not yet backfilled from the legacy plist - see OwnedBeacon.accessoryJson. A real
// state, not a failure, so this reports it the same way Python does: nothing found.
return Collections.emptyMap();
@@ -61,7 +66,7 @@ public Map currentMacAddresses(final String accessoryJson) {
@Override
@Nullable
public IndexRange candidateWindow(final String accessoryJson) {
- if (accessoryJson == null || accessoryJson.isEmpty()) {
+ if (accessoryJson == null || accessoryJson.isEmpty() || ExternalAccessory.isGoogle(accessoryJson)) {
return null;
}
@@ -86,7 +91,8 @@ public IndexRange candidateWindow(final String accessoryJson) {
@Override
public Map addressesBetween(
final String accessoryJson, final int lo, final int hi) {
- if (accessoryJson == null || accessoryJson.isEmpty() || hi < lo) {
+ if (accessoryJson == null || accessoryJson.isEmpty() || hi < lo
+ || ExternalAccessory.isGoogle(accessoryJson)) {
return Collections.emptyMap();
}
@@ -120,7 +126,8 @@ public Map addressesBetween(
public String recordSeen(
final String accessoryJson, final String mac, final long seenAtUnixMs,
final Integer hintIndex) {
- if (accessoryJson == null || accessoryJson.isEmpty() || mac == null) {
+ if (accessoryJson == null || accessoryJson.isEmpty() || mac == null
+ || ExternalAccessory.isGoogle(accessoryJson)) {
return null;
}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/source/ExternalAccessory.java b/app/src/main/java/dev/wander/android/opentagviewer/source/ExternalAccessory.java
new file mode 100644
index 00000000..36e2e676
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/source/ExternalAccessory.java
@@ -0,0 +1,119 @@
+package dev.wander.android.opentagviewer.source;
+
+import android.util.Base64;
+
+import androidx.annotation.Nullable;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Function;
+
+/**
+ * The accessory JSON of the two kinds of device the extra sources add.
+ *
+ *
+ * OpenHaystack keys are FindMy.py's own {@code custom_rolling_key_accessory}, so everything
+ * that already works for self-generated tags (BLE matching, play sound, history) works for them
+ * unchanged. It takes every key of a tag that rotates through several (Everytag does), and
+ * searches all of them on each fetch.
+ * A Google Find Hub tracker is {@code google_find_hub}: it has no Apple keys at all, and
+ * the Apple paths must never be handed one (FindMy.py raises on an unknown type).
+ *
+ */
+public final class ExternalAccessory {
+ public static final String TYPE_OPENHAYSTACK = "custom_rolling_key_accessory";
+ public static final String TYPE_GOOGLE = "google_find_hub";
+
+ private ExternalAccessory() {
+ }
+
+ @Nullable
+ private static JSONObject parse(@Nullable final String accessoryJson) {
+ if (accessoryJson == null) return null;
+ try {
+ return new JSONObject(accessoryJson);
+ } catch (final JSONException e) {
+ return null;
+ }
+ }
+
+ public static boolean isGoogle(@Nullable final String accessoryJson) {
+ final JSONObject o = parse(accessoryJson);
+ return o != null && TYPE_GOOGLE.equals(o.optString("type"));
+ }
+
+ public static boolean isOpenHaystack(@Nullable final String accessoryJson) {
+ final JSONObject o = parse(accessoryJson);
+ return o != null && TYPE_OPENHAYSTACK.equals(o.optString("type"));
+ }
+
+ /** The canonic id of a Google tracker. */
+ @Nullable
+ public static String googleId(@Nullable final String accessoryJson) {
+ final JSONObject o = parse(accessoryJson);
+ return o == null ? null : o.optString("google_id", null);
+ }
+
+ /**
+ * Reads the private keys out of a Macless-Haystack / OpenHaystack {@code .keys} file
+ * ("Private key: <base64>", once per key), as hex, in file order and without repeats.
+ *
+ * @throws IllegalArgumentException when there is none, or one is not a 28-byte key
+ */
+ public static List privateKeysFromKeysFile(final String text) {
+ return privateKeysFromKeysFile(text, b64 -> Base64.decode(b64, Base64.DEFAULT));
+ }
+
+ /** As above, with the Base64 decoder passed in: {@code android.util.Base64} is a stub on the JVM. */
+ static List privateKeysFromKeysFile(final String text, final Function base64) {
+ final List keys = new ArrayList<>();
+ for (final String line : text.split("\n")) {
+ final int colon = line.indexOf(':');
+ if (colon > 0 && line.substring(0, colon).trim().equalsIgnoreCase("Private key")) {
+ final byte[] key = base64.apply(line.substring(colon + 1).trim());
+ if (key.length != 28) throw new IllegalArgumentException("A private key is not 28 bytes long");
+ final String hex = toHex(key);
+ if (!keys.contains(hex)) keys.add(hex);
+ }
+ }
+ if (keys.isEmpty()) throw new IllegalArgumentException("No \"Private key\" line in this file");
+ return keys;
+ }
+
+ static String toHex(final byte[] bytes) {
+ final StringBuilder sb = new StringBuilder(bytes.length * 2);
+ for (final byte b : bytes) sb.append(String.format(Locale.ROOT, "%02x", b));
+ return sb.toString();
+ }
+
+ public static String newOpenHaystack(final String id, final String name, final List privateKeysHex) {
+ try {
+ return new JSONObject()
+ .put("type", TYPE_OPENHAYSTACK)
+ .put("identifier", id)
+ .put("name", name)
+ .put("private_keys", new JSONArray(privateKeysHex))
+ .toString();
+ } catch (final JSONException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ public static String newGoogle(final String googleId, final String name) {
+ try {
+ return new JSONObject()
+ .put("type", TYPE_GOOGLE)
+ .put("identifier", "google-" + googleId)
+ .put("name", name)
+ .put("google_id", googleId)
+ .toString();
+ } catch (final JSONException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesApi.java b/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesApi.java
new file mode 100644
index 00000000..22cb6483
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesApi.java
@@ -0,0 +1,193 @@
+package dev.wander.android.opentagviewer.source;
+
+import android.util.Base64;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Function;
+
+import dev.wander.android.opentagviewer.data.model.BeaconLocationReport;
+import dev.wander.android.opentagviewer.db.room.entity.LocationReport;
+
+/**
+ * Blocking HTTP client for the googlefind service (Google Find Hub). Call from a background
+ * thread.
+ *
+ * {@code GET /api/devices}, {@code GET /api/devices/{id}/reports},
+ * {@code POST /api/devices/{id}/locate}, {@code GET /api/devices/{id}/ring}; reports arrive
+ * decrypted. The service may sit behind HTTP Basic Auth on a reverse proxy, so its token also
+ * travels in {@code X-Api-Token} rather than only in {@code Authorization}.
+ */
+public final class ExtraSourcesApi {
+ /** A stored setting by key. Real: {@link ExtraSourcesSettings#get}. */
+ private final Function setting;
+ /** Base64 without line breaks. Real: {@code android.util.Base64}, a stub on the JVM. */
+ private final Function base64;
+
+ public ExtraSourcesApi(final ExtraSourcesSettings settings) {
+ this(settings::get, bytes -> Base64.encodeToString(bytes, Base64.NO_WRAP));
+ }
+
+ /** For {@code ExtraSourcesApiTest}, which runs on the JVM against a local socket. */
+ ExtraSourcesApi(final Function setting, final Function base64) {
+ this.setting = setting;
+ this.base64 = base64;
+ }
+
+ public static final class ApiException extends IOException {
+ public ApiException(final String message) {
+ super(message);
+ }
+ }
+
+ /** A tracker of the Google account. */
+ public static final class GoogleDevice {
+ public final String id;
+ public final String name;
+ public final boolean custom;
+
+ GoogleDevice(final String id, final String name, final boolean custom) {
+ this.id = id;
+ this.name = name;
+ this.custom = custom;
+ }
+ }
+
+ private String basic(final String user, final String password) {
+ if (user.isEmpty()) {
+ return null;
+ }
+ final String raw = user + ":" + password;
+ return "Basic " + this.base64.apply(raw.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String join(String base, final String path) {
+ while (base.endsWith("/")) base = base.substring(0, base.length() - 1);
+ return base + path;
+ }
+
+ private static String request(final String method, final String url, final String body,
+ final String authorization, final String token,
+ final int timeoutMs) throws IOException {
+ final HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
+ try {
+ c.setRequestMethod(method);
+ c.setConnectTimeout(15000);
+ c.setReadTimeout(timeoutMs);
+ if (authorization != null) c.setRequestProperty("Authorization", authorization);
+ if (token != null && !token.isEmpty()) {
+ if (authorization == null) c.setRequestProperty("Authorization", "Bearer " + token);
+ c.setRequestProperty("X-Api-Token", token);
+ }
+ if (body != null) {
+ c.setDoOutput(true);
+ c.setRequestProperty("Content-Type", "application/json");
+ try (OutputStream os = c.getOutputStream()) {
+ os.write(body.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+ final int code = c.getResponseCode();
+ final InputStream in = code < 400 ? c.getInputStream() : c.getErrorStream();
+ String text = "";
+ if (in != null) {
+ final ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ final byte[] chunk = new byte[8192];
+ int n;
+ while ((n = in.read(chunk)) > 0) buf.write(chunk, 0, n);
+ in.close();
+ text = buf.toString("UTF-8");
+ }
+ if (code >= 400) throw new ApiException("HTTP " + code + " from " + url);
+ return text;
+ } finally {
+ c.disconnect();
+ }
+ }
+
+ // ---- Google Find Hub, through googlefind ----
+
+ private String google(final String method, final String path, final int timeoutMs) throws IOException {
+ return request(method, join(this.setting.apply(ExtraSourcesSettings.GOOGLE_URL), path),
+ "POST".equals(method) ? "{}" : null,
+ this.basic(this.setting.apply(ExtraSourcesSettings.GOOGLE_USER),
+ this.setting.apply(ExtraSourcesSettings.GOOGLE_PASSWORD)),
+ this.setting.apply(ExtraSourcesSettings.GOOGLE_TOKEN), timeoutMs);
+ }
+
+ public List googleDevices() throws Exception {
+ final JSONArray arr = new JSONArray(this.google("GET", "/api/devices", 30000));
+ final List out = new ArrayList<>();
+ for (int i = 0; i < arr.length(); i++) {
+ final JSONObject d = arr.getJSONObject(i);
+ out.add(new GoogleDevice(d.getString("id"), d.getString("name"), d.optBoolean("custom")));
+ }
+ return out;
+ }
+
+ /** What is needed to ring a Google tracker nearby: its ring key and current EIDs. */
+ public static final class RingMaterial {
+ /** 8-byte ring key: rings the tracker, cannot read its locations. */
+ public final byte[] ringKey;
+ /** EIDs it may be advertising around now, lowercase hex (20 bytes each). */
+ public final List eids;
+
+ RingMaterial(final byte[] ringKey, final List eids) {
+ this.ringKey = ringKey;
+ this.eids = eids;
+ }
+ }
+
+ public RingMaterial ringMaterial(final String googleId) throws Exception {
+ final JSONObject o = new JSONObject(this.google("GET", "/api/devices/" + googleId + "/ring", 30000));
+ final String keyHex = o.getString("ring_key");
+ final byte[] key = new byte[keyHex.length() / 2];
+ for (int i = 0; i < key.length; i++) key[i] = (byte) Integer.parseInt(keyHex.substring(2 * i, 2 * i + 2), 16);
+ final JSONArray arr = o.getJSONArray("eids");
+ final List eids = new ArrayList<>();
+ for (int i = 0; i < arr.length(); i++) eids.add(arr.getString(i).toLowerCase(Locale.ROOT));
+ return new RingMaterial(key, eids);
+ }
+
+ /** Asks Google for fresh locations and waits up to {@code waitS} for the answer. */
+ public boolean googleLocate(final String googleId, final int waitS) throws Exception {
+ final String text = this.google("POST", "/api/devices/" + googleId + "/locate?wait=" + waitS,
+ (waitS + 30) * 1000);
+ return new JSONObject(text).optBoolean("answered");
+ }
+
+ /** Reports recorded at or after {@code sinceMs} (the service keeps what Google sent it). */
+ public List googleReports(final String googleId, final long sinceMs) throws Exception {
+ final JSONArray arr = new JSONArray(this.google("GET",
+ "/api/devices/" + googleId + "/reports?limit=5000&since=" + (sinceMs / 1000), 30000));
+ final List out = new ArrayList<>();
+ for (int i = 0; i < arr.length(); i++) {
+ final JSONObject r = arr.getJSONObject(i);
+ // A "semantic" report ("home") has no position to draw
+ if (r.isNull("latitude") || r.isNull("longitude")) continue;
+ final long timeMs = r.getLong("time") * 1000L;
+ out.add(BeaconLocationReport.builder()
+ .publishedAt(r.optLong("received", r.getLong("time")) * 1000L)
+ .description(r.optBoolean("own") ? "Google Find Hub (own device)" : "Google Find Hub")
+ .timestamp(timeMs)
+ .confidence(0)
+ .latitude(r.getDouble("latitude"))
+ .longitude(r.getDouble("longitude"))
+ .horizontalAccuracy(Math.round(r.optDouble("accuracy", 0)))
+ .status(r.optLong("status"))
+ .provenance(LocationReport.PROVENANCE_GOOGLE)
+ .build());
+ }
+ return out;
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesSettings.java b/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesSettings.java
new file mode 100644
index 00000000..a63a1561
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesSettings.java
@@ -0,0 +1,87 @@
+package dev.wander.android.opentagviewer.source;
+
+import static dev.wander.android.opentagviewer.AppKeyStoreConstants.KEYSTORE_ALIAS_EXTRA_SOURCES;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Base64;
+import android.util.Log;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Set;
+
+import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil;
+
+/**
+ * Where the extra location source lives: a Google Find Hub service (googlefind) for Google
+ * trackers.
+ *
+ * SharedPreferences rather than the app's DataStore: these are read synchronously from the
+ * fetch path, which runs on Rx worker threads and has no reason to wait on a Flowable.
+ *
+ *
The API token and the HTTP password are encrypted with a keystore key, like the Apple
+ * session: the token reads every location the service holds.
+ */
+public final class ExtraSourcesSettings {
+ private static final String TAG = ExtraSourcesSettings.class.getSimpleName();
+
+ public static final String GOOGLE_URL = "google_url";
+ public static final String GOOGLE_TOKEN = "google_token";
+ public static final String GOOGLE_USER = "google_user";
+ public static final String GOOGLE_PASSWORD = "google_password";
+
+ private static final Set SECRETS = Set.of(GOOGLE_TOKEN, GOOGLE_PASSWORD);
+ private static final String ENCRYPTED_SUFFIX = "_encrypted";
+
+ private final SharedPreferences prefs;
+ private final AppCryptographyUtil crypto = new AppCryptographyUtil();
+
+ public ExtraSourcesSettings(final Context context) {
+ this.prefs = context.getApplicationContext().getSharedPreferences("extra_sources", Context.MODE_PRIVATE);
+ }
+
+ public String get(final String key) {
+ if (!SECRETS.contains(key)) {
+ return this.prefs.getString(key, "");
+ }
+ final String plain = this.prefs.getString(key, null);
+ if (plain != null) {
+ // Written in plain text by a build before encryption: move it over
+ this.put(key, plain);
+ return plain.trim();
+ }
+ final String stored = this.prefs.getString(key + ENCRYPTED_SUFFIX, null);
+ if (stored == null) {
+ return "";
+ }
+ try {
+ final byte[] decrypted = this.crypto.decrypt(AppCryptographyUtil.AppEncryptedData.fromFlattened(
+ Base64.decode(stored, Base64.NO_WRAP)), KEYSTORE_ALIAS_EXTRA_SOURCES);
+ return new String(decrypted, StandardCharsets.UTF_8);
+ } catch (final RuntimeException e) {
+ Log.w(TAG, "Could not decrypt the stored " + key + "; it has to be entered again", e);
+ return "";
+ }
+ }
+
+ public void put(final String key, final String value) {
+ final String trimmed = value == null ? "" : value.trim();
+ if (!SECRETS.contains(key)) {
+ this.prefs.edit().putString(key, trimmed).apply();
+ return;
+ }
+ final SharedPreferences.Editor edit = this.prefs.edit().remove(key);
+ if (trimmed.isEmpty()) {
+ edit.remove(key + ENCRYPTED_SUFFIX);
+ } else {
+ final byte[] flattened = this.crypto.encrypt(
+ trimmed.getBytes(StandardCharsets.UTF_8), KEYSTORE_ALIAS_EXTRA_SOURCES).flatten();
+ edit.putString(key + ENCRYPTED_SUFFIX, Base64.encodeToString(flattened, Base64.NO_WRAP));
+ }
+ edit.apply();
+ }
+
+ public boolean isGoogleConfigured() {
+ return !this.get(GOOGLE_URL).isEmpty();
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/source/ReportSources.java b/app/src/main/java/dev/wander/android/opentagviewer/source/ReportSources.java
new file mode 100644
index 00000000..c272e9ba
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/source/ReportSources.java
@@ -0,0 +1,201 @@
+package dev.wander.android.opentagviewer.source;
+
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.BooleanSupplier;
+import java.util.function.Function;
+import java.util.function.LongSupplier;
+
+import dev.wander.android.opentagviewer.data.model.BeaconLocationReport;
+import dev.wander.android.opentagviewer.python.AccessoryRequest;
+import dev.wander.android.opentagviewer.python.FetchResult;
+import dev.wander.android.opentagviewer.python.PythonAppleService;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+/**
+ * Decides, per device, where its location reports come from, and hands them back in the same
+ * {@link FetchResult} the Apple path produces - so storing, drawing, the scan backoff and the
+ * history all work unchanged.
+ *
+ *
+ * Apple-paired accessories and OpenHaystack keys: the signed-in Apple account, as before.
+ * Google trackers, and any device linked to one: googlefind.
+ *
+ */
+public final class ReportSources {
+ private static final String TAG = ReportSources.class.getSimpleName();
+ /** How long a manual refresh waits for Google to answer a fresh locate request. */
+ private static final int GOOGLE_LOCATE_WAIT_S = 25;
+ /**
+ * A tracker with no reports at all has no card, so nothing to press refresh on: it is asked
+ * for by itself instead, but not more often than this.
+ */
+ private static final long GOOGLE_FIRST_LOCATE_EVERY_MS = 30 * 60_000L;
+ /** Shared by every instance: a screen creates its own, and the throttle must outlive it. */
+ private static final Map LAST_FIRST_LOCATE = new ConcurrentHashMap<>();
+
+ /** The googlefind calls this class makes. Real: {@link ExtraSourcesApi}. */
+ interface Google {
+ void locate(String googleId, int waitS) throws Exception;
+
+ List reports(String googleId, long sinceMs) throws Exception;
+ }
+
+ private final BooleanSupplier googleConfigured;
+ private final Function linkedGoogleId;
+ private final Google google;
+ private final LongSupplier clock;
+ private final Map lastFirstLocate;
+
+ /**
+ * Seams rather than the settings, links and HTTP client themselves, which all need a
+ * {@code Context}: this class decides which network a device's reports come from, and that
+ * decision is tested on the JVM - see {@code ReportSourcesTest}.
+ */
+ ReportSources(final BooleanSupplier googleConfigured, final Function linkedGoogleId,
+ final Google google, final LongSupplier clock, final Map lastFirstLocate) {
+ this.googleConfigured = googleConfigured;
+ this.linkedGoogleId = linkedGoogleId;
+ this.google = google;
+ this.clock = clock;
+ this.lastFirstLocate = lastFirstLocate;
+ }
+
+ public static ReportSources create(final Context context) {
+ final ExtraSourcesSettings settings = new ExtraSourcesSettings(context);
+ final SourceLinks links = new SourceLinks(context);
+ final ExtraSourcesApi api = new ExtraSourcesApi(settings);
+ return new ReportSources(settings::isGoogleConfigured, links::googleId, new Google() {
+ @Override
+ public void locate(final String googleId, final int waitS) throws Exception {
+ api.googleLocate(googleId, waitS);
+ }
+
+ @Override
+ public List reports(final String googleId, final long sinceMs) throws Exception {
+ return api.googleReports(googleId, sinceMs);
+ }
+ }, System::currentTimeMillis, LAST_FIRST_LOCATE);
+ }
+
+ /**
+ * Reports of one device.
+ *
+ * @param sinceMs oldest report wanted
+ * @param untilMs newest report wanted (history asks for one day)
+ * @param askGoogleNow ask Google for fresh locations first (a manual refresh), rather than
+ * only reading what the service already has
+ * @param appleFetch the Apple-account fetch for this request
+ */
+ public Observable fetch(final AccessoryRequest request,
+ final long sinceMs, final long untilMs,
+ final boolean askGoogleNow,
+ @Nullable final Observable appleFetch) {
+ final String beaconId = request.getBeaconId();
+ final String json = request.getAccessoryJson();
+
+ final Observable apple;
+ if (ExternalAccessory.isGoogle(json)) {
+ apple = Observable.just(empty(beaconId));
+ } else if (appleFetch != null) {
+ apple = appleFetch;
+ } else {
+ apple = Observable.just(empty(beaconId));
+ }
+
+ final String googleId = ExternalAccessory.isGoogle(json)
+ ? ExternalAccessory.googleId(json) : this.linkedGoogleId.apply(beaconId);
+ if (googleId == null || !this.googleConfigured.getAsBoolean()) {
+ return apple;
+ }
+
+ final Observable> google = Observable.fromCallable(() -> {
+ if (askGoogleNow) {
+ try {
+ this.google.locate(googleId, GOOGLE_LOCATE_WAIT_S);
+ } catch (final Exception e) {
+ // The stored reports are still worth showing
+ Log.w(TAG, "Google locate request failed for " + beaconId, e);
+ }
+ }
+ // Google's answer is the last known position, often hours or days old, so a scheduled
+ // one-hour window would hide it; the database drops what it already holds
+ final long googleSince = Math.min(sinceMs, this.clock.getAsLong() - 7 * 86_400_000L);
+ List fetched = this.google.reports(googleId, googleSince);
+ if (!askGoogleNow && fetched.isEmpty() && this.google.reports(googleId, 0).isEmpty()) {
+ // Never located: ask Google once, rather than waiting for a refresh button that
+ // a device without a location does not have
+ final long now = this.clock.getAsLong();
+ final Long last = this.lastFirstLocate.get(googleId);
+ if (last == null || now - last > GOOGLE_FIRST_LOCATE_EVERY_MS) {
+ this.lastFirstLocate.put(googleId, now);
+ try {
+ this.google.locate(googleId, GOOGLE_LOCATE_WAIT_S);
+ fetched = this.google.reports(googleId, googleSince);
+ } catch (final Exception e) {
+ Log.w(TAG, "First Google locate failed for " + beaconId, e);
+ }
+ }
+ }
+ final List reports = new ArrayList<>();
+ for (final BeaconLocationReport r : fetched) {
+ if (r.getTimestamp() <= untilMs) reports.add(r);
+ }
+ return reports;
+ }).subscribeOn(Schedulers.io());
+
+ // A failing Google service must not cost the device its Apple reports, and the reverse
+ if (ExternalAccessory.isGoogle(json)) {
+ return google.map(reports -> withReports(beaconId, reports));
+ }
+ return Observable.zip(
+ apple.onErrorReturn(e -> {
+ Log.w(TAG, "Apple reports failed for " + beaconId, e);
+ return empty(beaconId);
+ }),
+ google.onErrorReturn(e -> {
+ Log.w(TAG, "Google reports failed for " + beaconId, e);
+ return new ArrayList<>();
+ }),
+ (fromApple, fromGoogle) -> merge(beaconId, fromApple, fromGoogle));
+ }
+
+ private static FetchResult empty(final String beaconId) {
+ return withReports(beaconId, new ArrayList<>());
+ }
+
+ private static FetchResult withReports(final String beaconId, final List reports) {
+ final Map> map = new HashMap<>();
+ map.put(beaconId, reports);
+ return new FetchResult(map, new HashMap<>(), new HashSet<>(), new HashSet<>());
+ }
+
+ static FetchResult merge(final String beaconId, final FetchResult apple, final List google) {
+ final Map> map = new HashMap<>(apple.getReports());
+ final List all = new ArrayList<>(map.getOrDefault(beaconId, new ArrayList<>()));
+ all.addAll(google);
+ map.put(beaconId, all);
+ final Set exhausted = new HashSet<>(apple.getExhaustedWideSearch());
+ // Google answering means the device is not silent, whatever Apple found
+ if (!google.isEmpty()) exhausted.remove(beaconId);
+ return new FetchResult(map, apple.getUpdatedAccessoryJson(), exhausted, apple.getWideSearch());
+ }
+
+ /** For callers that only hold an Apple service. */
+ @Nullable
+ public static Observable appleFetchOrNull(@Nullable final PythonAppleService service,
+ final AccessoryRequest request, final int hours) {
+ return service == null ? null : service.getLastReports(List.of(request), hours);
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/source/SourceLinks.java b/app/src/main/java/dev/wander/android/opentagviewer/source/SourceLinks.java
new file mode 100644
index 00000000..2a9e2644
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/source/SourceLinks.java
@@ -0,0 +1,35 @@
+package dev.wander.android.opentagviewer.source;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Per-device extras that do not belong in the accessory JSON: the Google tracker whose reports
+ * are shown on this device.
+ *
+ * Kept apart because FindMy.py rewrites the accessory JSON on every fetch and sighting
+ * ({@code to_json}), dropping any key it does not know.
+ */
+public final class SourceLinks {
+ private final SharedPreferences prefs;
+
+ public SourceLinks(final Context context) {
+ this.prefs = context.getApplicationContext().getSharedPreferences("source_links", Context.MODE_PRIVATE);
+ }
+
+ @Nullable
+ public String googleId(final String beaconId) {
+ return this.prefs.getString("google:" + beaconId, null);
+ }
+
+ public void setGoogleId(final String beaconId, @Nullable final String googleId) {
+ if (googleId == null) this.prefs.edit().remove("google:" + beaconId).apply();
+ else this.prefs.edit().putString("google:" + beaconId, googleId).apply();
+ }
+
+ public void forget(final String beaconId) {
+ this.prefs.edit().remove("google:" + beaconId).apply();
+ }
+}
diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/importing/ExternalDeviceImporter.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/importing/ExternalDeviceImporter.java
new file mode 100644
index 00000000..158596b0
--- /dev/null
+++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/importing/ExternalDeviceImporter.java
@@ -0,0 +1,202 @@
+package dev.wander.android.opentagviewer.ui.importing;
+
+import android.app.Activity;
+import android.net.Uri;
+import android.util.Log;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.textfield.TextInputEditText;
+import com.google.android.material.textfield.TextInputLayout;
+
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.security.MessageDigest;
+import java.util.List;
+
+import dev.wander.android.opentagviewer.R;
+import dev.wander.android.opentagviewer.db.repo.BeaconRepository;
+import dev.wander.android.opentagviewer.db.repo.model.ImportData;
+import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase;
+import dev.wander.android.opentagviewer.db.room.entity.Import;
+import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon;
+import dev.wander.android.opentagviewer.source.ExternalAccessory;
+import dev.wander.android.opentagviewer.source.ExtraSourcesApi;
+import dev.wander.android.opentagviewer.source.ExtraSourcesSettings;
+import dev.wander.android.opentagviewer.source.SourceLinks;
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+/**
+ * Adds devices that do not come from an Apple export: an OpenHaystack key from a
+ * Macless-Haystack style {@code .keys} file, or a tracker of the Google Find Hub account; and
+ * links a Google tracker to an existing device so both networks' reports land on one marker.
+ *
+ *
Stored as ordinary {@code OwnedBeacons} rows through {@link BeaconRepository#addNewImport},
+ * so the list, the map and the history treat them like any imported tag.
+ */
+public final class ExternalDeviceImporter {
+ private static final String TAG = ExternalDeviceImporter.class.getSimpleName();
+ private static final String VERSION = "extra-sources-1";
+
+ private final Activity activity;
+ private final Runnable onChanged;
+
+ public ExternalDeviceImporter(final Activity activity, final Runnable onChanged) {
+ this.activity = activity;
+ this.onChanged = onChanged;
+ }
+
+ // ---- OpenHaystack key ----
+
+ /** Call with the document the user picked (a .keys file). */
+ public void importKeysFile(@Nullable final Uri uri) {
+ if (uri == null) return;
+ final List privateKeys;
+ try (InputStream in = this.activity.getContentResolver().openInputStream(uri)) {
+ final ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ final byte[] chunk = new byte[4096];
+ int n;
+ while (in != null && (n = in.read(chunk)) > 0 && buf.size() < 65536) buf.write(chunk, 0, n);
+ privateKeys = ExternalAccessory.privateKeysFromKeysFile(buf.toString("UTF-8"));
+ } catch (final Exception e) {
+ Log.w(TAG, "Not a usable .keys file", e);
+ Toast.makeText(this.activity, R.string.keys_file_invalid, Toast.LENGTH_LONG).show();
+ return;
+ }
+
+ this.askName(this.activity.getString(R.string.openhaystack_default_name), name -> {
+ // The id is derived from the first key, so importing the same file twice updates the row
+ final String id = "openhaystack-" + sha256Hex(privateKeys.get(0)).substring(0, 16);
+ this.insert(id, ExternalAccessory.newOpenHaystack(id, name, privateKeys), "openhaystack-keys");
+ });
+ }
+
+ // ---- Google Find Hub ----
+
+ /** Lists the trackers of the Google account and adds the chosen one as a device. */
+ public void importFromGoogle() {
+ this.chooseGoogleTracker(R.string.add_from_google, device ->
+ this.insert("google-" + device.id, ExternalAccessory.newGoogle(device.id, device.name), "google-find-hub"));
+ }
+
+ /** Shows this device's reports from a Google tracker as well (or stops, with "None"). */
+ public void linkGoogleTracker(final String beaconId) {
+ this.chooseGoogleTracker(R.string.link_google_tracker, device -> {
+ new SourceLinks(this.activity).setGoogleId(beaconId, device == null ? null : device.id);
+ this.onChanged.run();
+ }, true);
+ }
+
+ private interface Chosen {
+ void accept(T value);
+ }
+
+ private void chooseGoogleTracker(final int titleRes, final Chosen chosen) {
+ this.chooseGoogleTracker(titleRes, chosen, false);
+ }
+
+ private void chooseGoogleTracker(final int titleRes, final Chosen chosen,
+ final boolean offerNone) {
+ final ExtraSourcesSettings settings = new ExtraSourcesSettings(this.activity);
+ if (!settings.isGoogleConfigured()) {
+ Toast.makeText(this.activity, R.string.google_not_configured, Toast.LENGTH_LONG).show();
+ return;
+ }
+ final var async = Observable.fromCallable(() -> new ExtraSourcesApi(settings).googleDevices())
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(devices -> {
+ final int extra = offerNone ? 1 : 0;
+ final String[] names = new String[devices.size() + extra];
+ if (offerNone) names[0] = this.activity.getString(R.string.google_tracker_none);
+ for (int i = 0; i < devices.size(); i++) names[i + extra] = devices.get(i).name;
+ new MaterialAlertDialogBuilder(this.activity)
+ .setTitle(titleRes)
+ .setItems(names, (dialog, which) ->
+ chosen.accept(which < extra ? null : devices.get(which - extra)))
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }, error -> {
+ Log.w(TAG, "Could not list Google trackers", error);
+ Toast.makeText(this.activity,
+ this.activity.getString(R.string.google_list_failed, error.getMessage()),
+ Toast.LENGTH_LONG).show();
+ });
+ }
+
+ // ---- shared ----
+
+ private void askName(final String suggestion, final Chosen chosen) {
+ final TextInputLayout layout = new TextInputLayout(this.activity);
+ layout.setHint(this.activity.getString(R.string.device_name));
+ final EditText input = new TextInputEditText(layout.getContext());
+ input.setSingleLine(true);
+ input.setText(suggestion);
+ layout.addView(input);
+ final FrameLayout frame = new FrameLayout(this.activity);
+ final int pad = Math.round(22 * this.activity.getResources().getDisplayMetrics().density);
+ frame.setPadding(pad, pad / 2, pad, 0);
+ frame.addView(layout);
+
+ new MaterialAlertDialogBuilder(this.activity)
+ .setTitle(R.string.add_openhaystack_key)
+ .setView(frame)
+ .setPositiveButton(R.string.add, (d, w) -> {
+ final String name = input.getText().toString().trim();
+ chosen.accept(name.isEmpty() ? suggestion : name);
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }
+
+ private void insert(final String id, final String accessoryJson, final String via) {
+ final long now = System.currentTimeMillis();
+ final Import anImport = Import.builder()
+ .version(VERSION)
+ .importedAt(now)
+ .exportedAt(now)
+ .sourceUser(null)
+ .exportedVia(via)
+ .build();
+ final OwnedBeacon beacon = OwnedBeacon.builder()
+ .id(id)
+ .version(VERSION)
+ .content(null)
+ .alignmentPlist(null)
+ .accessoryJson(accessoryJson)
+ .build();
+ try {
+ final var async = new BeaconRepository(OpenTagViewerDatabase.getInstance(this.activity.getApplicationContext()))
+ .addNewImport(new ImportData(anImport, List.of(beacon), List.of()))
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(done -> {
+ Toast.makeText(this.activity, R.string.device_added, Toast.LENGTH_SHORT).show();
+ this.onChanged.run();
+ }, error -> {
+ Log.e(TAG, "Could not store " + id, error);
+ Toast.makeText(this.activity, R.string.device_add_failed, Toast.LENGTH_LONG).show();
+ });
+ } catch (final Exception e) {
+ Log.e(TAG, "Could not store " + id, e);
+ Toast.makeText(this.activity, R.string.device_add_failed, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private static String sha256Hex(final String text) {
+ try {
+ final byte[] digest = MessageDigest.getInstance("SHA-256").digest(text.getBytes("UTF-8"));
+ final StringBuilder sb = new StringBuilder();
+ for (final byte b : digest) sb.append(String.format("%02x", b));
+ return sb.toString();
+ } catch (final Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/app/src/main/res/layout/activity_extra_sources.xml b/app/src/main/res/layout/activity_extra_sources.xml
new file mode 100644
index 00000000..1483818c
--- /dev/null
+++ b/app/src/main/res/layout/activity_extra_sources.xml
@@ -0,0 +1,210 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
index 97c7a73c..cdb58d01 100644
--- a/app/src/main/res/layout/activity_settings.xml
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -8,6 +8,7 @@
+
@@ -122,6 +123,15 @@
app:sectionSubtitle="@{@string/icloud_unlink_subtitle}"
app:title="@{@string/icloud_unlink_title}" />
+
+
+
diff --git a/app/src/main/res/menu/device_info_menu.xml b/app/src/main/res/menu/device_info_menu.xml
index d0bd9774..681a6c2a 100644
--- a/app/src/main/res/menu/device_info_menu.xml
+++ b/app/src/main/res/menu/device_info_menu.xml
@@ -9,6 +9,10 @@
android:id="@+id/device_play_sound_nearby"
android:title="@string/play_sound_nearby" />
+
+
diff --git a/app/src/main/res/menu/global_map_more_menu.xml b/app/src/main/res/menu/global_map_more_menu.xml
index 91166a31..eb977d4d 100644
--- a/app/src/main/res/menu/global_map_more_menu.xml
+++ b/app/src/main/res/menu/global_map_more_menu.xml
@@ -9,6 +9,12 @@
android:id="@+id/do_import"
android:title="@string/do_import" />
+
+
diff --git a/app/src/main/res/menu/my_devices_menu.xml b/app/src/main/res/menu/my_devices_menu.xml
index e5046ceb..cbe5f167 100644
--- a/app/src/main/res/menu/my_devices_menu.xml
+++ b/app/src/main/res/menu/my_devices_menu.xml
@@ -42,6 +42,13 @@
android:id="@+id/action_import_from_file"
android:title="@string/icloud_import_from_file" />
+
+
+
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 772071e5..6bda6f93 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -394,4 +394,33 @@ Wenn Apple die Anfrage lediglich abgelehnt hat, lohnt es sich, zu warten und es
Dieses Handy melden
Apples Anmeldebibliothek stürzt auf diesem Handy ab, sobald sie geladen wird. Auf den meisten Handys funktioniert sie, und wir wissen noch nicht, was die betroffenen gemeinsam haben.\n\nOpenTagViewer nutzt für diesen Schritt jetzt einen Server, du kannst dich also anmelden und die App normal verwenden. Ein Bericht mit den Angaben unten hilft uns, die Ursache zu finden.
Apple hat die Anmeldung mit dieser Apple-ID abgelehnt, bevor dein Passwort geprüft wurde. Es erneut einzugeben hilft also nicht. Prüfe, ob du dich auf account.apple.com anmelden kannst und ob Apple dort etwas von dir verlangt. Ein Remote-Server stellt sich Apple als anderes Gerät vor, einen Versuch ist das wert. Apple meldete: %1$s
+ Weitere Standortquellen (experimentell)
+ Google-Find-Hub-Tracker
+ Experimentell. Orte die Tracker deines Google-Kontos über deinen eigenen googlefind-Dienst. Sie lassen sich als Geräte hinzufügen oder mit einem Gerät verknüpfen, sodass die Berichte beider Netzwerke auf einer Markierung erscheinen.
+ Berichte der Tracker in deinem Google-Konto über einen googlefind-Dienst. Neue Standorte werden nur beim Aktualisieren eines Tags angefordert.
+ Serveradresse
+ HTTP-Benutzername (optional)
+ HTTP-Passwort (optional)
+ API-Token
+ Verbindung testen
+ Google Find Hub: OK, %1$d Tracker
+ Google Find Hub: %1$s
+ Zuerst eine Serveradresse eingeben
+ Das ist keine .keys-Datei mit privatem Schlüssel
+ OpenHaystack-Tag
+ OpenHaystack-Schlüssel hinzufügen (.keys)
+ Aus Google Find Hub hinzufügen (experimentell)
+ Google-Tracker verknüpfen (experimentell)
+ Google-Tracker-Verknüpfung gespeichert
+ Richte zuerst Google Find Hub unter Einstellungen → Weitere Standortquellen ein
+ Keiner
+ Google-Tracker konnten nicht geladen werden: %1$s
+ Hinzufügen
+ Gerät hinzugefügt
+ Das Gerät konnte nicht hinzugefügt werden
+ Google-Find-Hub-Tracker
+ In der Nähe (%1$s)
+ Klingeln fehlgeschlagen: %1$s
+ Kein https: Das API-Token wird unverschlüsselt gesendet.
+ So richtest du das ein
\ No newline at end of file
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 2b758df6..9bd596cf 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -394,4 +394,33 @@ If Apple simply refused the request, waiting and trying again is worth doing fir
Report this phone
Apple\'s sign-in library crashes as soon as it loads on this phone. It works on most phones, and we do not know yet what the affected ones have in common.\n\nOpenTagViewer now uses a server for that step, so you can sign in and use the app normally. A report with the details below is what helps us find the cause.
Apple refused to start signing in to this Apple ID before your password was checked, so retyping it will not help. Check that you can sign in at account.apple.com and whether Apple asks anything of you there. Trying a remote server presents Apple with a different device, which is worth a try. Apple said: %1$s
+ Extra location sources (experimental)
+ Google Find Hub trackers
+ Experimental. Locate the trackers in your Google account through your own googlefind service. They can be added as devices, or linked to a device so both networks\' reports show on one marker.
+ Reports of the trackers in your Google account, from a googlefind service. Fresh locations are requested only when you refresh a tag.
+ Server address
+ HTTP username (optional)
+ HTTP password (optional)
+ API token
+ Test connection
+ Google Find Hub: OK, %1$d trackers
+ Google Find Hub: %1$s
+ Enter a server address first
+ This is not a .keys file with a private key
+ OpenHaystack tag
+ Add OpenHaystack key (.keys)
+ Add from Google Find Hub (experimental)
+ Link Google tracker (experimental)
+ Google tracker link saved
+ Set up Google Find Hub in Settings → Extra location sources first
+ None
+ Could not load Google trackers: %1$s
+ Add
+ Device added
+ The device could not be added
+ Google Find Hub tracker
+ Nearby (%1$s)
+ Ring failed: %1$s
+ Not https: the API token will be sent unencrypted.
+ How to set this up
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index eeafea73..86ce02ed 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -394,4 +394,33 @@ Si Apple a simplement refusé la requête, il vaut mieux attendre et réessayer.
Signaler ce téléphone
La bibliothèque de connexion d\'Apple plante dès son chargement sur ce téléphone. Elle fonctionne sur la plupart des téléphones, et nous ne savons pas encore ce que les appareils concernés ont en commun.\n\nOpenTagViewer utilise désormais un serveur pour cette étape : vous pouvez vous connecter et utiliser l\'app normalement. Un rapport avec les informations ci-dessous nous aide à trouver la cause.
Apple a refusé de commencer la connexion à cet identifiant Apple avant même de vérifier votre mot de passe : le saisir à nouveau ne changera rien. Vérifiez que vous pouvez vous connecter sur account.apple.com et si Apple vous y demande quelque chose. Un serveur distant présente un autre appareil à Apple, cela vaut la peine d\'essayer. Apple a répondu : %1$s
+ Sources de localisation supplémentaires (expérimental)
+ Traceurs Google Find Hub
+ Expérimental. Localisez les traceurs de votre compte Google via votre propre service googlefind. Ajoutez-les comme appareils, ou associez-les à un appareil pour afficher les rapports des deux réseaux sur un seul repère.
+ Rapports des traceurs de votre compte Google, via un service googlefind. Les nouvelles positions ne sont demandées que lorsque vous actualisez une balise.
+ Adresse du serveur
+ Nom d\'utilisateur HTTP (facultatif)
+ Mot de passe HTTP (facultatif)
+ Jeton d\'API
+ Tester la connexion
+ Google Find Hub : OK, %1$d traceurs
+ Google Find Hub : %1$s
+ Saisissez d\'abord une adresse de serveur
+ Ce n\'est pas un fichier .keys contenant une clé privée
+ Balise OpenHaystack
+ Ajouter une clé OpenHaystack (.keys)
+ Ajouter depuis Google Find Hub (expérimental)
+ Associer un traceur Google (expérimental)
+ Association au traceur Google enregistrée
+ Configurez d\'abord Google Find Hub dans Paramètres → Sources de localisation supplémentaires
+ Aucun
+ Impossible de charger les traceurs Google : %1$s
+ Ajouter
+ Appareil ajouté
+ Impossible d\'ajouter l\'appareil
+ Traceur Google Find Hub
+ À proximité (%1$s)
+ Échec de la sonnerie : %1$s
+ Pas en https : le jeton d\'API sera envoyé sans chiffrement.
+ Comment le configurer
\ No newline at end of file
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 70de482a..89f8366e 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -394,4 +394,33 @@ Appleが要求を拒否しただけなら、少し待ってからもう一度お
この端末を報告
この端末では、Apple のサインイン用ライブラリが読み込み直後にクラッシュします。ほとんどの端末では問題なく動作しており、影響を受ける端末の共通点はまだ分かっていません。\n\nOpenTagViewer はこの手順にサーバーを使うようになったため、サインインもアプリの利用も通常どおりできます。下の情報を添えて報告していただくと、原因の特定に役立ちます。
Apple はパスワードを確認する前に、この Apple ID へのサインインの開始を拒否しました。パスワードを入力し直しても解決しません。account.apple.com にサインインできるか、そこで Apple から何か求められていないかを確認してください。リモートサーバーを使うと Apple には別のデバイスとして見えるので、試す価値があります。Apple の応答: %1$s
+ 追加の位置情報ソース(試験運用)
+ Google Find Hub トラッカー
+ 試験運用中の機能です。自分の googlefind サービス経由で Google アカウントのトラッカーを探します。デバイスとして追加するか、デバイスにリンクして両方のネットワークのレポートを 1 つのマーカーに表示できます。
+ googlefind サービス経由の、Google アカウントのトラッカーのレポート。新しい位置はタグを更新したときだけ要求します。
+ サーバーのアドレス
+ HTTP ユーザー名(任意)
+ HTTP パスワード(任意)
+ API トークン
+ 接続をテスト
+ Google Find Hub:OK、トラッカー %1$d 台
+ Google Find Hub:%1$s
+ 先にサーバーのアドレスを入力してください
+ 秘密鍵を含む .keys ファイルではありません
+ OpenHaystack タグ
+ OpenHaystack キーを追加(.keys)
+ Google Find Hub から追加(試験運用)
+ Google トラッカーをリンク(試験運用)
+ Google トラッカーのリンクを保存しました
+ 先に「設定 → 追加の位置情報ソース」で Google Find Hub を設定してください
+ なし
+ Google トラッカーを読み込めませんでした:%1$s
+ 追加
+ デバイスを追加しました
+ デバイスを追加できませんでした
+ Google Find Hub トラッカー
+ 近くにあります (%1$s)
+ 鳴らせませんでした: %1$s
+ https ではありません。API トークンは暗号化されずに送信されます。
+ 設定方法
\ No newline at end of file
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 250e0c83..868a1313 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -394,4 +394,33 @@ Apple이 요청을 거부한 것뿐이라면 잠시 기다렸다가 다시 시
이 휴대폰 신고하기
이 휴대폰에서는 Apple의 로그인 라이브러리가 불러오자마자 충돌합니다. 대부분의 휴대폰에서는 정상 작동하며, 영향을 받는 기기들의 공통점은 아직 모릅니다.\n\n이제 OpenTagViewer가 이 단계에 서버를 사용하므로 로그인하고 앱을 정상적으로 쓸 수 있습니다. 아래 정보와 함께 신고해 주시면 원인을 찾는 데 도움이 됩니다.
Apple이 비밀번호를 확인하기 전에 이 Apple ID의 로그인 시작을 거부했습니다. 비밀번호를 다시 입력해도 해결되지 않습니다. account.apple.com에 로그인할 수 있는지, 그리고 Apple이 그곳에서 무언가를 요구하는지 확인하세요. 원격 서버를 사용하면 Apple에 다른 기기로 보이므로 시도해 볼 만합니다. Apple 응답: %1$s
+ 추가 위치 소스 (실험적)
+ Google Find Hub 트래커
+ 실험적 기능입니다. 자체 googlefind 서비스로 Google 계정의 트래커를 찾습니다. 기기로 추가하거나 기기에 연결해 두 네트워크의 보고서를 하나의 마커에 표시할 수 있습니다.
+ googlefind 서비스를 통한 Google 계정 트래커의 보고서. 새 위치는 태그를 새로 고칠 때만 요청합니다.
+ 서버 주소
+ HTTP 사용자 이름(선택)
+ HTTP 비밀번호(선택)
+ API 토큰
+ 연결 테스트
+ Google Find Hub: OK, 트래커 %1$d개
+ Google Find Hub: %1$s
+ 먼저 서버 주소를 입력하세요
+ 개인 키가 있는 .keys 파일이 아닙니다
+ OpenHaystack 태그
+ OpenHaystack 키 추가(.keys)
+ Google Find Hub에서 추가 (실험적)
+ Google 트래커 연결 (실험적)
+ Google 트래커 연결을 저장했습니다
+ 먼저 설정 → 추가 위치 소스에서 Google Find Hub를 설정하세요
+ 없음
+ Google 트래커를 불러올 수 없습니다: %1$s
+ 추가
+ 기기를 추가했습니다
+ 기기를 추가할 수 없습니다
+ Google Find Hub 트래커
+ 근처 (%1$s)
+ 소리 재생 실패: %1$s
+ https가 아닙니다. API 토큰이 암호화되지 않은 채 전송됩니다.
+ 설정 방법
\ No newline at end of file
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 8ee3a063..d2e116d2 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -394,4 +394,33 @@ Als Apple het verzoek simpelweg weigerde, is even wachten en opnieuw proberen he
Deze telefoon melden
Apples aanmeldbibliotheek crasht op deze telefoon zodra ze geladen wordt. Op de meeste telefoons werkt ze, en we weten nog niet wat de getroffen toestellen gemeen hebben.\n\nOpenTagViewer gebruikt voor die stap nu een server, dus je kunt gewoon aanmelden en de app normaal gebruiken. Een melding met de gegevens hieronder helpt ons de oorzaak te vinden.
Apple weigerde het inloggen met deze Apple ID te starten voordat je wachtwoord werd gecontroleerd, dus opnieuw intypen helpt niet. Controleer of je kunt inloggen op account.apple.com en of Apple daar iets van je vraagt. Een externe server stelt zich bij Apple voor als een ander apparaat, dat is het proberen waard. Apple meldde: %1$s
+ Extra locatiebronnen (experimenteel)
+ Google Find Hub-trackers
+ Experimenteel. Vind de trackers in je Google-account via je eigen googlefind-service. Voeg ze toe als apparaat, of koppel ze aan een apparaat zodat de meldingen van beide netwerken op één markering staan.
+ Meldingen van de trackers in je Google-account, via een googlefind-service. Nieuwe locaties worden alleen opgevraagd als je een tag vernieuwt.
+ Serveradres
+ HTTP-gebruikersnaam (optioneel)
+ HTTP-wachtwoord (optioneel)
+ API-token
+ Verbinding testen
+ Google Find Hub: OK, %1$d trackers
+ Google Find Hub: %1$s
+ Vul eerst een serveradres in
+ Dit is geen .keys-bestand met een privésleutel
+ OpenHaystack-tag
+ OpenHaystack-sleutel toevoegen (.keys)
+ Toevoegen uit Google Find Hub (experimenteel)
+ Google-tracker koppelen (experimenteel)
+ Koppeling met Google-tracker opgeslagen
+ Stel eerst Google Find Hub in via Instellingen → Extra locatiebronnen
+ Geen
+ Google-trackers konden niet worden geladen: %1$s
+ Toevoegen
+ Apparaat toegevoegd
+ Het apparaat kon niet worden toegevoegd
+ Google Find Hub-tracker
+ Dichtbij (%1$s)
+ Laten afgaan mislukt: %1$s
+ Geen https: het API-token wordt onversleuteld verstuurd.
+ Zo stel je dit in
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 61010ef2..96a992cb 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -394,4 +394,33 @@
Сообщить об этом телефоне
На этом телефоне библиотека входа Apple падает сразу после загрузки. На большинстве телефонов она работает, и мы пока не знаем, что общего у затронутых устройств.\n\nТеперь OpenTagViewer использует для этого шага сервер, так что вы можете войти и пользоваться приложением как обычно. Отчёт с данными ниже поможет нам найти причину.
Apple отказалась начинать вход с этим Apple ID ещё до проверки пароля, поэтому вводить его заново бесполезно. Проверьте, можете ли вы войти на account.apple.com и не требует ли Apple там чего-либо. Удалённый сервер представляется Apple другим устройством — это стоит попробовать. Ответ Apple: %1$s
+ Дополнительные источники местоположения (экспериментально)
+ Трекеры Google Find Hub
+ Экспериментальная функция. Определяйте местоположение трекеров вашего аккаунта Google через собственный сервис googlefind. Их можно добавить как устройства или связать с устройством, чтобы отчёты обеих сетей были на одной метке.
+ Отчёты трекеров вашего аккаунта Google через сервис googlefind. Новые местоположения запрашиваются только при обновлении метки.
+ Адрес сервера
+ Имя пользователя HTTP (необязательно)
+ Пароль HTTP (необязательно)
+ Токен API
+ Проверить соединение
+ Google Find Hub: OK, трекеров: %1$d
+ Google Find Hub: %1$s
+ Сначала введите адрес сервера
+ Это не файл .keys с закрытым ключом
+ Метка OpenHaystack
+ Добавить ключ OpenHaystack (.keys)
+ Добавить из Google Find Hub (экспериментально)
+ Связать трекер Google (экспериментально)
+ Связь с трекером Google сохранена
+ Сначала настройте Google Find Hub в разделе «Настройки → Дополнительные источники местоположения»
+ Нет
+ Не удалось загрузить трекеры Google: %1$s
+ Добавить
+ Устройство добавлено
+ Не удалось добавить устройство
+ Трекер Google Find Hub
+ Рядом (%1$s)
+ Не удалось подать сигнал: %1$s
+ Не https: токен API будет передан без шифрования.
+ Как это настроить
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index fd8239fb..5f1810a5 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -394,4 +394,33 @@
报告这部手机
Apple 的登录库在这部手机上一加载就会崩溃。它在大多数手机上都能正常运行,我们还不知道受影响的手机有什么共同点。\n\nOpenTagViewer 现在改用服务器完成这一步,因此你可以正常登录和使用应用。附上下方信息的报告能帮助我们找到原因。
Apple 在检查你的密码之前就拒绝开始登录此 Apple ID,所以重新输入密码没有用。请确认你能否在 account.apple.com 登录,以及 Apple 是否在那里要求你做什么。远程服务器会以另一台设备的身份出现在 Apple 面前,值得一试。Apple 的回复:%1$s
+ 其他位置来源(实验性)
+ Google Find Hub 追踪器
+ 实验性功能。通过你自己的 googlefind 服务定位 Google 帐号中的追踪器。可将其添加为设备,或关联到某个设备,让两个网络的报告显示在同一个标记上。
+ 通过 googlefind 服务获取你 Google 帐号中追踪器的报告。仅在刷新标签时请求新位置。
+ 服务器地址
+ HTTP 用户名(可选)
+ HTTP 密码(可选)
+ API 令牌
+ 测试连接
+ Google Find Hub:正常,%1$d 个追踪器
+ Google Find Hub:%1$s
+ 请先输入服务器地址
+ 这不是包含私钥的 .keys 文件
+ OpenHaystack 标签
+ 添加 OpenHaystack 密钥(.keys)
+ 从 Google Find Hub 添加(实验性)
+ 关联 Google 追踪器(实验性)
+ 已保存 Google 追踪器关联
+ 请先在“设置 → 其他位置来源”中设置 Google Find Hub
+ 无
+ 无法加载 Google 追踪器:%1$s
+ 添加
+ 已添加设备
+ 无法添加设备
+ Google Find Hub 追踪器
+ 在附近 (%1$s)
+ 响铃失败:%1$s
+ 不是 https:API 令牌将以未加密方式发送。
+ 如何设置
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 964ba039..9bf37589 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -394,4 +394,33 @@
回報這支手機
Apple 的登入函式庫在這支手機上一載入就會當機。它在大多數手機上都能正常運作,我們還不知道受影響的手機有什麼共通點。\n\nOpenTagViewer 現在改用伺服器完成這一步,因此你可以正常登入和使用 App。附上下方資訊的回報能幫助我們找到原因。
Apple 在檢查你的密碼之前就拒絕開始登入此 Apple ID,所以重新輸入密碼沒有用。請確認你能否在 account.apple.com 登入,以及 Apple 是否在那裡要求你做什麼。遠端伺服器會以另一台裝置的身分出現在 Apple 面前,值得一試。Apple 的回覆:%1$s
+ 其他位置來源(實驗性)
+ Google Find Hub 追蹤器
+ 實驗性功能。透過你自己的 googlefind 服務定位 Google 帳戶中的追蹤器。可將其新增為裝置,或連結到某個裝置,讓兩個網路的報告顯示在同一個標記上。
+ 透過 googlefind 服務取得你 Google 帳戶中追蹤器的報告。只在重新整理標籤時要求新位置。
+ 伺服器位址
+ HTTP 使用者名稱(選填)
+ HTTP 密碼(選填)
+ API 權杖
+ 測試連線
+ Google Find Hub:正常,%1$d 個追蹤器
+ Google Find Hub:%1$s
+ 請先輸入伺服器位址
+ 這不是包含私密金鑰的 .keys 檔案
+ OpenHaystack 標籤
+ 新增 OpenHaystack 金鑰(.keys)
+ 從 Google Find Hub 新增(實驗性)
+ 連結 Google 追蹤器(實驗性)
+ 已儲存 Google 追蹤器連結
+ 請先在「設定 → 其他位置來源」中設定 Google Find Hub
+ 無
+ 無法載入 Google 追蹤器:%1$s
+ 新增
+ 已新增裝置
+ 無法新增裝置
+ Google Find Hub 追蹤器
+ 在附近 (%1$s)
+ 響鈴失敗:%1$s
+ 不是 https:API 權杖將以未加密方式傳送。
+ 如何設定
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 85017a97..5f33053d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -427,4 +427,35 @@ If Apple simply refused the request, waiting and trying again is worth doing fir
Report this phone
Apple\'s sign-in library crashes as soon as it loads on this phone. It works on most phones, and we do not know yet what the affected ones have in common.\n\nOpenTagViewer now uses a server for that step, so you can sign in and use the app normally. A report with the details below is what helps us find the cause.
Apple refused to start signing in to this Apple ID before your password was checked, so retyping it will not help. Check that you can sign in at account.apple.com and whether Apple asks anything of you there. Trying a remote server presents Apple with a different device, which is worth a try. Apple said: %1$s
+ Extra location sources (experimental)
+ Google Find Hub trackers
+ Experimental. Locate the trackers in your Google account through your own googlefind service. They can be added as devices, or linked to a device so both networks\' reports show on one marker.
+ Google Find Hub
+ Reports of the trackers in your Google account, from a googlefind service. Fresh locations are requested only when you refresh a tag.
+ Server address
+ HTTP username (optional)
+ HTTP password (optional)
+ API token
+ Test connection
+ Google Find Hub: OK, %1$d trackers
+ Google Find Hub: %1$s
+ Enter a server address first
+ This is not a .keys file with a private key
+ OpenHaystack tag
+ Add OpenHaystack key (.keys)
+ Add from Google Find Hub (experimental)
+ Link Google tracker (experimental)
+ Google tracker link saved
+ Set up Google Find Hub in Settings → Extra location sources first
+ None
+ Could not load Google trackers: %1$s
+ Add
+ Device added
+ The device could not be added
+ Google
+ Google Find Hub tracker
+ Nearby (%1$s)
+ Ring failed: %1$s
+ Not https: the API token will be sent unencrypted.
+ How to set this up
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
index eaa023b7..8d667751 100644
--- a/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/BleAccessorySoundTriggerTest.java
@@ -490,11 +490,13 @@ public void aFailedStopStillCompletes() throws InterruptedException {
assertEquals(List.of(A_DEVICE), fakes.stopped);
}
- /** Only a sound started over DULT is worth a connection to stop. */
+ /** Only a sound started over DULT or FMDN is worth a connection to stop. */
@Test
- public void onlyASuccessfulDultSoundHasAStopCommand() {
+ public void onlyASuccessfulDultOrFmdnSoundHasAStopCommand() {
assertTrue(new BleSoundTriggerResult(BleSoundTriggerStatus.SUCCESS,
BleGattSoundTrigger.PROTOCOL_DULT, "test").hasStopCommand());
+ assertTrue(new BleSoundTriggerResult(BleSoundTriggerStatus.SUCCESS,
+ FmdnRingTrigger.PROTOCOL, "test").hasStopCommand());
assertFalse(new BleSoundTriggerResult(BleSoundTriggerStatus.SUCCESS,
"AirTag", "test").hasStopCommand());
assertFalse(new BleSoundTriggerResult(BleSoundTriggerStatus.SUCCESS,
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/ble/FmdnRingCommandTest.java b/app/src/test/java/dev/wander/android/opentagviewer/ble/FmdnRingCommandTest.java
new file mode 100644
index 00000000..917a486e
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/ble/FmdnRingCommandTest.java
@@ -0,0 +1,51 @@
+package dev.wander.android.opentagviewer.ble;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.List;
+
+public class FmdnRingCommandTest {
+ private static final byte[] RING_KEY = {1, 2, 3, 4, 5, 6, 7, 8};
+ /** Version 1, then the nonce a1..a8 - what a Beacon Actions read returns. */
+ private static final byte[] READ = FmdnRingCommand.unhex("01a1a2a3a4a5a6a7a8");
+
+ @Test
+ public void authKeyMatchesTheSpecifiedHmac() {
+ // Reference: hmac.new(ring_key, 01|nonce|05|0c|ff012c00 (no trailing direction byte), sha256).digest()[:8] in Python
+ final byte[] auth = FmdnRingCommand.authKey(RING_KEY, 1, FmdnRingCommand.unhex("a1a2a3a4a5a6a7a8"),
+ FmdnRingCommand.DATA_ID_RING, (byte) 12, new byte[]{(byte) 0xff, 0x01, 0x2c, 0x00});
+ assertEquals("43e3b0b455361e86", FmdnRingCommand.hex(auth));
+ }
+
+ @Test
+ public void ringWriteLayout() {
+ final byte[] write = FmdnRingCommand.ringWrite(RING_KEY, READ, FmdnRingCommand.COMPONENTS_ALL, 300,
+ FmdnRingCommand.VOLUME_DEFAULT);
+ assertEquals(14, write.length);
+ assertEquals(0x05, write[0]);
+ assertEquals(12, write[1]);
+ assertEquals("43e3b0b455361e86", FmdnRingCommand.hex(java.util.Arrays.copyOfRange(write, 2, 10)));
+ assertArrayEquals(new byte[]{(byte) 0xff, 0x01, 0x2c, 0x00}, java.util.Arrays.copyOfRange(write, 10, 14));
+ }
+
+ @Test
+ public void stopUsesNoComponents() {
+ final byte[] write = FmdnRingCommand.ringWrite(RING_KEY, READ, FmdnRingCommand.COMPONENTS_STOP, 300,
+ FmdnRingCommand.VOLUME_DEFAULT);
+ assertEquals(0x00, write[10]);
+ }
+
+ @Test
+ public void eidMatching() {
+ final String eid = "0123456789abcdef0123456789abcdef01234567";
+ final byte[] frame = FmdnRingCommand.unhex("41" + eid + "a0");
+ assertTrue(FmdnRingCommand.matchesEid(frame, List.of(eid)));
+ assertFalse(FmdnRingCommand.matchesEid(FmdnRingCommand.unhex("10" + eid + "a0"), List.of(eid)));
+ assertFalse(FmdnRingCommand.matchesEid(frame, List.of("00" + eid.substring(2))));
+ }
+}
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/source/ExtraSourcesApiTest.java b/app/src/test/java/dev/wander/android/opentagviewer/source/ExtraSourcesApiTest.java
new file mode 100644
index 00000000..d53f6de0
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/source/ExtraSourcesApiTest.java
@@ -0,0 +1,175 @@
+package dev.wander.android.opentagviewer.source;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import dev.wander.android.opentagviewer.data.model.BeaconLocationReport;
+import dev.wander.android.opentagviewer.db.room.entity.LocationReport;
+
+/**
+ * The HTTP client for the googlefind service, against a fake service on 127.0.0.1.
+ *
+ * The headers are the part worth pinning: the service sits behind a reverse proxy that may
+ * take {@code Authorization} for its own Basic Auth, so the token has to travel in
+ * {@code X-Api-Token} too, and a mistake there presents as "the service rejects my token" with
+ * nothing in the app to say why.
+ */
+public class ExtraSourcesApiTest {
+
+ private FakeGooglefindServer server;
+ private final Map settings = new HashMap<>();
+
+ @Before
+ public void startTheService() throws IOException {
+ this.server = new FakeGooglefindServer();
+ this.settings.put(ExtraSourcesSettings.GOOGLE_URL, this.server.url());
+ this.settings.put(ExtraSourcesSettings.GOOGLE_TOKEN, "s3cret");
+ }
+
+ @After
+ public void stopTheService() throws IOException {
+ this.server.close();
+ }
+
+ private ExtraSourcesApi api() {
+ return new ExtraSourcesApi(key -> this.settings.getOrDefault(key, ""),
+ bytes -> Base64.getEncoder().encodeToString(bytes));
+ }
+
+ // --- authentication -------------------------------------------------------------------------
+
+ @Test
+ public void theTokenIsSentAsABearerAndInItsOwnHeader() throws Exception {
+ this.server.answer("/api/devices", 200, "[]");
+
+ this.api().googleDevices();
+
+ final FakeGooglefindServer.Request request = this.server.lastRequest();
+ assertEquals("Bearer s3cret", request.headers.get("authorization"));
+ assertEquals("s3cret", request.headers.get("x-api-token"));
+ }
+
+ /** Basic Auth takes Authorization for the proxy, so the token must still reach the service. */
+ @Test
+ public void withBasicAuthTheTokenTravelsOnlyInItsOwnHeader() throws Exception {
+ this.server.answer("/api/devices", 200, "[]");
+ this.settings.put(ExtraSourcesSettings.GOOGLE_USER, "me");
+ this.settings.put(ExtraSourcesSettings.GOOGLE_PASSWORD, "pw");
+
+ this.api().googleDevices();
+
+ final FakeGooglefindServer.Request request = this.server.lastRequest();
+ assertEquals("Basic bWU6cHc=", request.headers.get("authorization"));
+ assertEquals("s3cret", request.headers.get("x-api-token"));
+ }
+
+ @Test
+ public void noTokenSendsNoCredentials() throws Exception {
+ this.server.answer("/api/devices", 200, "[]");
+ this.settings.put(ExtraSourcesSettings.GOOGLE_TOKEN, "");
+
+ this.api().googleDevices();
+
+ final FakeGooglefindServer.Request request = this.server.lastRequest();
+ assertNull(request.headers.get("authorization"));
+ assertNull(request.headers.get("x-api-token"));
+ }
+
+ @Test
+ public void aRefusalIsAnErrorThatSaysWhichStatus() {
+ this.server.answer("/api/devices", 401, "{\"detail\":\"bad token\"}");
+
+ final ExtraSourcesApi.ApiException error =
+ assertThrows(ExtraSourcesApi.ApiException.class, () -> this.api().googleDevices());
+ assertTrue(error.getMessage(), error.getMessage().contains("HTTP 401"));
+ }
+
+ @Test
+ public void aTrailingSlashOnTheAddressDoesNotDoubleThePath() throws Exception {
+ this.server.answer("/api/devices", 200, "[]");
+ this.settings.put(ExtraSourcesSettings.GOOGLE_URL, this.server.url() + "//");
+
+ this.api().googleDevices();
+
+ assertEquals("/api/devices", this.server.lastRequest().target);
+ }
+
+ // --- what comes back ------------------------------------------------------------------------
+
+ @Test
+ public void devicesAreRead() throws Exception {
+ this.server.answer("/api/devices", 200,
+ "[{\"id\":\"abc\",\"name\":\"Keys\",\"custom\":true},{\"id\":\"def\",\"name\":\"Bag\"}]");
+
+ final List devices = this.api().googleDevices();
+
+ assertEquals(2, devices.size());
+ assertEquals("abc", devices.get(0).id);
+ assertEquals("Keys", devices.get(0).name);
+ assertTrue(devices.get(0).custom);
+ assertFalse(devices.get(1).custom);
+ }
+
+ /**
+ * A report with no coordinates ("home", a semantic location) is skipped, since there is
+ * nowhere to draw it. Times arrive in seconds and are stored in milliseconds.
+ */
+ @Test
+ public void reportsAreConvertedAndTheOnesWithoutAPositionSkipped() throws Exception {
+ this.server.answer("/api/devices/abc/reports", 200, "["
+ + "{\"time\":1700000000,\"received\":1700000060,\"latitude\":52.4,\"longitude\":16.9,"
+ + "\"accuracy\":12.6,\"status\":3},"
+ + "{\"time\":1700000100,\"latitude\":null,\"longitude\":null}"
+ + "]");
+
+ final List reports = this.api().googleReports("abc", 1_699_999_000_123L);
+
+ assertEquals("abc's reports since that second, capped",
+ "/api/devices/abc/reports?limit=5000&since=1699999000", this.server.lastRequest().target);
+ assertEquals(1, reports.size());
+ final BeaconLocationReport report = reports.get(0);
+ assertEquals(1_700_000_000_000L, report.getTimestamp());
+ assertEquals(1_700_000_060_000L, report.getPublishedAt());
+ assertEquals(52.4, report.getLatitude(), 0);
+ assertEquals(16.9, report.getLongitude(), 0);
+ assertEquals(13, report.getHorizontalAccuracy());
+ assertEquals(LocationReport.PROVENANCE_GOOGLE, report.getProvenance());
+ }
+
+ @Test
+ public void ringMaterialIsParsedAndTheEidsLowercased() throws Exception {
+ this.server.answer("/api/devices/abc/ring", 200,
+ "{\"ring_key\":\"0102030405060708\",\"eids\":[\"ABCDEF\",\"012345\"]}");
+
+ final ExtraSourcesApi.RingMaterial material = this.api().ringMaterial("abc");
+
+ assertArrayEquals(new byte[]{1, 2, 3, 4, 5, 6, 7, 8}, material.ringKey);
+ assertEquals(List.of("abcdef", "012345"), material.eids);
+ }
+
+ @Test
+ public void locateIsAPostThatSaysHowLongToWait() throws Exception {
+ this.server.answer("/api/devices/abc/locate", 200, "{\"answered\":true}");
+
+ assertTrue(this.api().googleLocate("abc", 25));
+
+ final FakeGooglefindServer.Request request = this.server.lastRequest();
+ assertEquals("POST", request.method);
+ assertEquals("/api/devices/abc/locate?wait=25", request.target);
+ assertEquals("{}", request.body);
+ }
+}
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/source/FakeGooglefindServer.java b/app/src/test/java/dev/wander/android/opentagviewer/source/FakeGooglefindServer.java
new file mode 100644
index 00000000..da389890
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/source/FakeGooglefindServer.java
@@ -0,0 +1,151 @@
+package dev.wander.android.opentagviewer.source;
+
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A googlefind service on 127.0.0.1, for {@code ExtraSourcesApiTest}: answers each path with a
+ * canned status and body, and records what was asked.
+ *
+ * A plain {@link ServerSocket} rather than the JDK's HTTP server, which is not on the unit test
+ * compile classpath here - the same choice as {@code FakeApkServer}.
+ */
+final class FakeGooglefindServer implements Closeable {
+
+ /** One request as it arrived. Header names are lowercased. */
+ static final class Request {
+ final String method;
+ final String target;
+ final Map headers;
+ final String body;
+
+ Request(final String method, final String target, final Map headers, final String body) {
+ this.method = method;
+ this.target = target;
+ this.headers = headers;
+ this.body = body;
+ }
+ }
+
+ private static final class Answer {
+ final int status;
+ final String body;
+
+ Answer(final int status, final String body) {
+ this.status = status;
+ this.body = body;
+ }
+ }
+
+ private final ServerSocket socket;
+ private final AtomicBoolean stopped = new AtomicBoolean(false);
+ private final Map answers = new HashMap<>();
+ private final List requests = new ArrayList<>();
+
+ FakeGooglefindServer() throws IOException {
+ // Port 0: the OS picks a free one, so parallel tests cannot collide on a fixed number.
+ this.socket = new ServerSocket(0, 0, InetAddress.getByName("127.0.0.1"));
+ final Thread thread = new Thread(this::serveUntilStopped, "FakeGooglefindServer");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ String url() {
+ return "http://127.0.0.1:" + this.socket.getLocalPort();
+ }
+
+ /** Answer any request whose path (query excluded) is {@code path}. Unknown paths get 404. */
+ synchronized FakeGooglefindServer answer(final String path, final int status, final String body) {
+ this.answers.put(path, new Answer(status, body));
+ return this;
+ }
+
+ synchronized List requests() {
+ return new ArrayList<>(this.requests);
+ }
+
+ synchronized Request lastRequest() {
+ return this.requests.get(this.requests.size() - 1);
+ }
+
+ @Override
+ public void close() throws IOException {
+ this.stopped.set(true);
+ this.socket.close();
+ }
+
+ private void serveUntilStopped() {
+ while (!this.stopped.get()) {
+ try (Socket client = this.socket.accept()) {
+ this.handle(client);
+ } catch (final IOException e) {
+ if (!this.stopped.get()) {
+ // Printed: on its own thread an exception otherwise surfaces only as a test
+ // timing out on a read, with nothing to say why.
+ System.err.println("FakeGooglefindServer: failed to answer a request: " + e);
+ }
+ return;
+ }
+ }
+ }
+
+ private void handle(final Socket client) throws IOException {
+ final InputStream in = client.getInputStream();
+ final String[] head = readHead(in).split("\r\n");
+ final String[] requestLine = head[0].split(" ");
+ final Map headers = new HashMap<>();
+ for (int i = 1; i < head.length; i++) {
+ final int colon = head[i].indexOf(':');
+ if (colon > 0) {
+ headers.put(head[i].substring(0, colon).trim().toLowerCase(Locale.ROOT),
+ head[i].substring(colon + 1).trim());
+ }
+ }
+ final int length = headers.containsKey("content-length")
+ ? Integer.parseInt(headers.get("content-length")) : 0;
+ final String body = new String(in.readNBytes(length), StandardCharsets.UTF_8);
+
+ final String target = requestLine[1];
+ final String path = target.contains("?") ? target.substring(0, target.indexOf('?')) : target;
+ final Answer answer;
+ synchronized (this) {
+ this.requests.add(new Request(requestLine[0], target, headers, body));
+ answer = this.answers.getOrDefault(path, new Answer(404, "{\"detail\":\"Not Found\"}"));
+ }
+
+ final byte[] payload = answer.body.getBytes(StandardCharsets.UTF_8);
+ final OutputStream out = client.getOutputStream();
+ out.write(("HTTP/1.1 " + answer.status + " X\r\n"
+ + "Content-Type: application/json\r\n"
+ + "Content-Length: " + payload.length + "\r\n"
+ + "Connection: close\r\n\r\n").getBytes(StandardCharsets.UTF_8));
+ out.write(payload);
+ out.flush();
+ }
+
+ private static String readHead(final InputStream in) throws IOException {
+ final ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ int matched = 0;
+ final byte[] end = {'\r', '\n', '\r', '\n'};
+ int b;
+ while (matched < 4 && (b = in.read()) != -1) {
+ buf.write(b);
+ matched = b == end[matched] ? matched + 1 : (b == '\r' ? 1 : 0);
+ }
+ final String head = buf.toString(StandardCharsets.UTF_8.name());
+ return head.substring(0, Math.max(0, head.length() - 4));
+ }
+}
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/source/GoogleFindHubGuideLinkTest.java b/app/src/test/java/dev/wander/android/opentagviewer/source/GoogleFindHubGuideLinkTest.java
new file mode 100644
index 00000000..8ec90a6e
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/source/GoogleFindHubGuideLinkTest.java
@@ -0,0 +1,35 @@
+package dev.wander.android.opentagviewer.source;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Properties;
+
+/**
+ * The settings screen's "How to set this up" opens this page, and it is the only place the app
+ * points to for setting up the googlefind service. The page lives in the wiki, where nothing
+ * checks it, so the address is pinned here: a typo or a rename would otherwise surface as a
+ * GitHub 404 that only a user sees.
+ */
+public class GoogleFindHubGuideLinkTest {
+
+ @Test
+ public void theGuideLinkPointsAtTheWikiPage() throws IOException {
+ final Properties properties = new Properties();
+ // Unit tests run from the module directory
+ try (InputStream in = new FileInputStream("src/main/assets/app.properties")) {
+ properties.load(in);
+ }
+
+ final String page = properties.getProperty("googleFindHubWikiPage");
+
+ assertEquals("https://github.com/parawanderer/OpenTagViewer/wiki/%5BGoogle%5D-How-To:-Find-Hub-Trackers", page);
+ assertTrue("it should be a page of this project's wiki",
+ page.startsWith(properties.getProperty("projectWiki") + "/"));
+ }
+}
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/source/KeysFileTest.java b/app/src/test/java/dev/wander/android/opentagviewer/source/KeysFileTest.java
new file mode 100644
index 00000000..7833e563
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/source/KeysFileTest.java
@@ -0,0 +1,65 @@
+package dev.wander.android.opentagviewer.source;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.List;
+import java.util.function.Function;
+
+public class KeysFileTest {
+ private static final Function DECODE = s -> Base64.getDecoder().decode(s);
+
+ private static String key(final int fill) {
+ final byte[] k = new byte[28];
+ Arrays.fill(k, (byte) fill);
+ return Base64.getEncoder().encodeToString(k);
+ }
+
+ private static String hex(final int fill) {
+ return String.format("%02x", fill).repeat(28);
+ }
+
+ private static String entry(final int fill) {
+ return "Private key: " + key(fill) + "\nAdvertisement key: x\nHashed adv key: y\n";
+ }
+
+ @Test
+ public void aSingleKeyFileGivesOneKey() {
+ assertEquals(List.of(hex(1)), ExternalAccessory.privateKeysFromKeysFile(entry(1), DECODE));
+ }
+
+ @Test
+ public void aRotatingTagKeepsEveryKeyInFileOrder() {
+ final String text = entry(3) + entry(1) + entry(2);
+ assertEquals(List.of(hex(3), hex(1), hex(2)), ExternalAccessory.privateKeysFromKeysFile(text, DECODE));
+ }
+
+ @Test
+ public void repeatsAreDropped() {
+ assertEquals(List.of(hex(1), hex(2)),
+ ExternalAccessory.privateKeysFromKeysFile(entry(1) + entry(2) + entry(1), DECODE));
+ }
+
+ @Test
+ public void windowsLineEndingsAreFine() {
+ assertEquals(List.of(hex(1), hex(2)),
+ ExternalAccessory.privateKeysFromKeysFile((entry(1) + entry(2)).replace("\n", "\r\n"), DECODE));
+ }
+
+ @Test
+ public void aFileWithoutKeysIsRefused() {
+ assertThrows(IllegalArgumentException.class,
+ () -> ExternalAccessory.privateKeysFromKeysFile("Advertisement key: x\n", DECODE));
+ }
+
+ @Test
+ public void aKeyOfTheWrongLengthIsRefusedRatherThanSkipped() {
+ final String shortKey = "Private key: " + Base64.getEncoder().encodeToString(new byte[20]) + "\n";
+ assertThrows(IllegalArgumentException.class,
+ () -> ExternalAccessory.privateKeysFromKeysFile(entry(1) + shortKey, DECODE));
+ }
+}
diff --git a/app/src/test/java/dev/wander/android/opentagviewer/source/ReportSourcesTest.java b/app/src/test/java/dev/wander/android/opentagviewer/source/ReportSourcesTest.java
new file mode 100644
index 00000000..839e6b26
--- /dev/null
+++ b/app/src/test/java/dev/wander/android/opentagviewer/source/ReportSourcesTest.java
@@ -0,0 +1,231 @@
+package dev.wander.android.opentagviewer.source;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+
+import dev.wander.android.opentagviewer.data.model.BeaconLocationReport;
+import dev.wander.android.opentagviewer.db.room.entity.LocationReport;
+import dev.wander.android.opentagviewer.python.AccessoryRequest;
+import dev.wander.android.opentagviewer.python.FetchResult;
+import io.reactivex.rxjava3.core.Observable;
+
+public class ReportSourcesTest {
+ private static final long NOW = 1_800_000_000_000L;
+ private static final long HOUR = 3_600_000L;
+ private static final String APPLE_TAG = "{\"type\":\"custom_rolling_key_accessory\",\"identifier\":\"tag\"}";
+
+ private static BeaconLocationReport report(final long timestamp, final String provenance) {
+ return BeaconLocationReport.builder()
+ .timestamp(timestamp)
+ .publishedAt(timestamp)
+ .latitude(52.4)
+ .longitude(16.9)
+ .provenance(provenance)
+ .build();
+ }
+
+ /** The googlefind service, counting what it was asked. */
+ private static final class FakeGoogle implements ReportSources.Google {
+ final List reports = new ArrayList<>();
+ int locates;
+ boolean failing;
+
+ @Override
+ public void locate(final String googleId, final int waitS) {
+ this.locates++;
+ }
+
+ @Override
+ public List reports(final String googleId, final long sinceMs) throws Exception {
+ if (this.failing) {
+ throw new java.io.IOException("service down");
+ }
+ return new ArrayList<>(this.reports);
+ }
+ }
+
+ private final FakeGoogle google = new FakeGoogle();
+ private final AtomicLong clock = new AtomicLong(NOW);
+ private final Map firstLocates = new HashMap<>();
+
+ private ReportSources sources(final boolean configured, final Map links) {
+ return new ReportSources(() -> configured, links::get, this.google, this.clock::get, this.firstLocates);
+ }
+
+ private static FetchResult appleResult(final String beaconId, final BeaconLocationReport... reports) {
+ final Map> map = new HashMap<>();
+ map.put(beaconId, new ArrayList<>(List.of(reports)));
+ return new FetchResult(map, new HashMap<>(), Set.of(), Set.of());
+ }
+
+ private FetchResult fetch(final ReportSources sources, final String beaconId, final String json,
+ final boolean askGoogleNow, final Observable apple) {
+ return sources.fetch(new AccessoryRequest(beaconId, json), NOW - HOUR, NOW, askGoogleNow, apple)
+ .blockingFirst();
+ }
+
+ // --- with the feature off, nothing changes ------------------------------------------------------
+
+ /** The guarantee for everyone who never sets the service up: Apple's fetch, untouched. */
+ @Test
+ public void withNoServiceTheAppleFetchIsReturnedUntouched() {
+ final Observable apple = Observable.just(appleResult("tag"));
+
+ final ReportSources sources = this.sources(false, Map.of("tag", "g1"));
+
+ assertSame(apple, sources.fetch(new AccessoryRequest("tag", APPLE_TAG), NOW - HOUR, NOW, true, apple));
+ assertEquals(0, this.google.locates);
+ }
+
+ @Test
+ public void aDeviceWithNoGoogleLinkOnlyAsksApple() {
+ final Observable apple = Observable.just(appleResult("tag"));
+
+ final ReportSources sources = this.sources(true, Map.of());
+
+ assertSame(apple, sources.fetch(new AccessoryRequest("tag", APPLE_TAG), NOW - HOUR, NOW, true, apple));
+ assertEquals(0, this.google.locates);
+ }
+
+ // --- routing ----------------------------------------------------------------------------------
+
+ /** FindMy.py raises on a type it does not know, so a Google tracker must never reach it. */
+ @Test
+ public void aGoogleTrackerIsNeverHandedToApple() {
+ final AtomicBoolean appleAsked = new AtomicBoolean(false);
+ final Observable apple = Observable.never()
+ .doOnSubscribe(d -> appleAsked.set(true));
+ this.google.reports.add(report(NOW - 60_000, LocationReport.PROVENANCE_GOOGLE));
+
+ final FetchResult result = this.fetch(this.sources(true, Map.of()), "google-g1",
+ ExternalAccessory.newGoogle("g1", "Keys"), false, apple);
+
+ assertFalse(appleAsked.get());
+ assertEquals(1, result.getReports().get("google-g1").size());
+ }
+
+ @Test
+ public void aLinkedDeviceGetsBothNetworks() {
+ this.google.reports.add(report(NOW - 60_000, LocationReport.PROVENANCE_GOOGLE));
+
+ final FetchResult result = this.fetch(this.sources(true, Map.of("tag", "g1")), "tag", APPLE_TAG, false,
+ Observable.just(appleResult("tag", report(NOW - 120_000, LocationReport.PROVENANCE_APPLE))));
+
+ assertEquals(2, result.getReports().get("tag").size());
+ }
+
+ @Test
+ public void aFailingServiceDoesNotCostTheAppleReports() {
+ this.google.failing = true;
+
+ final FetchResult result = this.fetch(this.sources(true, Map.of("tag", "g1")), "tag", APPLE_TAG, false,
+ Observable.just(appleResult("tag", report(NOW - 120_000, LocationReport.PROVENANCE_APPLE))));
+
+ assertEquals(1, result.getReports().get("tag").size());
+ assertEquals(LocationReport.PROVENANCE_APPLE, result.getReports().get("tag").get(0).getProvenance());
+ }
+
+ @Test
+ public void aFailingAppleFetchDoesNotCostTheGoogleReports() {
+ this.google.reports.add(report(NOW - 60_000, LocationReport.PROVENANCE_GOOGLE));
+
+ final FetchResult result = this.fetch(this.sources(true, Map.of("tag", "g1")), "tag", APPLE_TAG, false,
+ Observable.error(new IllegalStateException("Apple said no")));
+
+ assertEquals(1, result.getReports().get("tag").size());
+ assertEquals(LocationReport.PROVENANCE_GOOGLE, result.getReports().get("tag").get(0).getProvenance());
+ }
+
+ @Test
+ public void reportsNewerThanTheWindowAreLeftOut() {
+ this.google.reports.add(report(NOW - 60_000, LocationReport.PROVENANCE_GOOGLE));
+ this.google.reports.add(report(NOW + 60_000, LocationReport.PROVENANCE_GOOGLE));
+
+ final FetchResult result = this.fetch(this.sources(true, Map.of()), "google-g1",
+ ExternalAccessory.newGoogle("g1", "Keys"), false, null);
+
+ assertEquals(1, result.getReports().get("google-g1").size());
+ }
+
+ // --- when Google is asked for a fresh location ----------------------------------------------------
+
+ @Test
+ public void aRefreshAsksGoogleForAFreshLocation() {
+ this.google.reports.add(report(NOW - 60_000, LocationReport.PROVENANCE_GOOGLE));
+
+ this.fetch(this.sources(true, Map.of()), "google-g1", ExternalAccessory.newGoogle("g1", "Keys"), true, null);
+
+ assertEquals(1, this.google.locates);
+ }
+
+ /** A scheduled fetch of a tracker that has reports only reads what the service holds. */
+ @Test
+ public void aScheduledFetchDoesNotAskGoogle() {
+ this.google.reports.add(report(NOW - 60_000, LocationReport.PROVENANCE_GOOGLE));
+
+ this.fetch(this.sources(true, Map.of()), "google-g1", ExternalAccessory.newGoogle("g1", "Keys"), false, null);
+
+ assertEquals(0, this.google.locates);
+ }
+
+ /**
+ * A tracker that has never been located has no card to refresh, so it is asked for on its
+ * own - but at most every 30 minutes, or every scheduled fetch would wake Google for it.
+ */
+ @Test
+ public void aNeverLocatedTrackerIsAskedForAtMostEveryHalfHour() {
+ final ReportSources sources = this.sources(true, Map.of());
+ final String json = ExternalAccessory.newGoogle("g1", "Keys");
+
+ this.fetch(sources, "google-g1", json, false, null);
+ this.clock.set(NOW + 10 * 60_000);
+ this.fetch(sources, "google-g1", json, false, null);
+ assertEquals("once within the half hour", 1, this.google.locates);
+
+ this.clock.set(NOW + 31 * 60_000);
+ this.fetch(sources, "google-g1", json, false, null);
+ assertEquals("and again after it", 2, this.google.locates);
+ }
+
+ // --- merging ------------------------------------------------------------------------------------
+
+ @Test
+ public void mergeKeepsBothNetworksOnOneDevice() {
+ final Map> apple = new HashMap<>();
+ apple.put("tag", new ArrayList<>(List.of(report(1000, LocationReport.PROVENANCE_APPLE))));
+ final FetchResult fromApple = new FetchResult(apple, Map.of("tag", "{\"json\":1}"), Set.of(), Set.of("tag"));
+
+ final FetchResult merged = ReportSources.merge("tag", fromApple,
+ List.of(report(2000, LocationReport.PROVENANCE_GOOGLE)));
+
+ assertEquals(2, merged.getReports().get("tag").size());
+ assertEquals(LocationReport.PROVENANCE_GOOGLE, merged.getReports().get("tag").get(1).getProvenance());
+ // The Apple side's bookkeeping travels unchanged
+ assertEquals("{\"json\":1}", merged.getUpdatedAccessoryJson().get("tag"));
+ assertTrue(merged.getWideSearch().contains("tag"));
+ }
+
+ @Test
+ public void googleReportsMeanTheDeviceIsNotSilent() {
+ final FetchResult fromApple = new FetchResult(new HashMap<>(), Map.of(), Set.of("tag"), Set.of("tag"));
+
+ final FetchResult withGoogle = ReportSources.merge("tag", fromApple,
+ List.of(report(2000, LocationReport.PROVENANCE_GOOGLE)));
+ final FetchResult withoutGoogle = ReportSources.merge("tag", fromApple, List.of());
+
+ assertFalse(withGoogle.getExhaustedWideSearch().contains("tag"));
+ assertTrue(withoutGoogle.getExhaustedWideSearch().contains("tag"));
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 38031a45..e104657c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,6 +1,8 @@
[versions]
agp = "8.8.2"
junit = "4.13.2"
+# JVM tests only: android.jar's org.json is a stub there, so parsing returns nothing.
+orgJson = "20240303"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
appcompat = "1.7.0"
@@ -46,6 +48,7 @@ commonsCsv = "1.14.1"
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
+org-json = { group = "org.json", name = "json", version.ref = "orgJson" }
ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espressoCore" }