Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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());
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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);
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<ExtraSourcesSettingsActivity> 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));
}
}
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@
android:exported="false"
android:label="@string/information"
android:theme="@style/Theme.OpenTagViewer.GenericGreyishActivity" />
<activity
android:name=".ExtraSourcesSettingsActivity"
android:exported="false"
android:theme="@style/Theme.OpenTagViewer.GenericGreyishActivity" />
<activity
android:name=".SettingsActivity"
android:exported="false"
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/assets/app.properties
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ anisetteWikiPage=https://github.com/parawanderer/OpenTagViewer/wiki/%5BAnisette%
anisetteApkWikiPage=https://github.com/parawanderer/OpenTagViewer/wiki/%5BAnisette%5D-Overview#supplying-your-own-apple-music-apk
# Linked from the tag page when nothing recognises an accessory: its vendor id is a real
# Bluetooth SIG company identifier, and the registry is where anyone can settle what it is
# Linked from Settings > 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/
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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");

Expand All @@ -1216,13 +1226,21 @@ 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");

if (menuItem.getItemId() == R.id.device_location_history) {
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();
}
Expand Down
Loading
Loading