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 95df86fc..39bfd80f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -105,6 +105,7 @@ 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.ExtraSourcesApi; import dev.wander.android.opentagviewer.source.ExtraSourcesSettings; import dev.wander.android.opentagviewer.source.ReportSources; import dev.wander.android.opentagviewer.python.AccessoryRequest; @@ -2706,7 +2707,17 @@ private synchronized void updateBeaconCards() { ? 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); + String line = source == null ? updated : updated + " · " + source; + // Google answers a refresh with a place name ("Home") rather than a position when + // the owner's own phone saw the tracker; say so, or the refresh looks unanswered + final ExtraSourcesApi.NamedPlace place = + this.reportSources.newerNamedPlace(beaconId, lastLocation.getTimestamp()); + if (place != null) { + line += " · " + this.getString(R.string.google_named_place_x_y, place.name, + DateUtils.getRelativeTimeSpanString( + place.timestampMs, now, DateUtils.MINUTE_IN_MILLIS).toString()); + } + deviceLastUpdate.setText(line); // Nothing live to pulse for once the sighting has aged out. ((ImageView) v.findViewById(R.id.device_nearby_pulse)).setVisibility(GONE); } 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 index 22cb6483..d472bdcb 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesApi.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/source/ExtraSourcesApi.java @@ -2,6 +2,8 @@ import android.util.Base64; +import androidx.annotation.Nullable; + import org.json.JSONArray; import org.json.JSONObject; @@ -166,16 +168,52 @@ public boolean googleLocate(final String googleId, final int waitS) throws Excep return new JSONObject(text).optBoolean("answered"); } + /** + * A place Google named instead of giving a position - "Home", typically, when the owner's + * own phone saw the tracker there. Nothing to draw, but it does say the tracker was seen. + */ + public static final class NamedPlace { + public final String name; + public final long timestampMs; + + public NamedPlace(final String name, final long timestampMs) { + this.name = name; + this.timestampMs = timestampMs; + } + } + + /** What the service holds for one tracker. */ + public static final class GoogleReports { + /** Reports with a position. */ + public final List positions; + /** The newest named place, if Google sent any. */ + @Nullable + public final NamedPlace newestPlace; + + public GoogleReports(final List positions, @Nullable final NamedPlace newestPlace) { + this.positions = positions; + this.newestPlace = newestPlace; + } + } + /** 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 { + public GoogleReports 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<>(); + NamedPlace newestPlace = null; 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; + if (r.isNull("latitude") || r.isNull("longitude")) { + // A semantic report ("Home") has no position to draw, only a name + final String name = r.optString("semantic", ""); + if (!r.isNull("semantic") && !name.isEmpty() + && (newestPlace == null || timeMs > newestPlace.timestampMs)) { + newestPlace = new NamedPlace(name, timeMs); + } + continue; + } out.add(BeaconLocationReport.builder() .publishedAt(r.optLong("received", r.getLong("time")) * 1000L) .description(r.optBoolean("own") ? "Google Find Hub (own device)" : "Google Find Hub") @@ -188,6 +226,6 @@ public List googleReports(final String googleId, final lon .provenance(LocationReport.PROVENANCE_GOOGLE) .build()); } - return out; + return new GoogleReports(out, newestPlace); } } 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 index c272e9ba..1ab9be9f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/source/ReportSources.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/source/ReportSources.java @@ -20,6 +20,8 @@ import dev.wander.android.opentagviewer.python.AccessoryRequest; import dev.wander.android.opentagviewer.python.FetchResult; import dev.wander.android.opentagviewer.python.PythonAppleService; +import dev.wander.android.opentagviewer.source.ExtraSourcesApi.GoogleReports; +import dev.wander.android.opentagviewer.source.ExtraSourcesApi.NamedPlace; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.schedulers.Schedulers; @@ -44,12 +46,19 @@ public final class ReportSources { 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 newest place Google named for each device, by beacon id. Shared like the throttle: the + * map's cards read what any fetch found. Not stored, since every fetch reads it again. + */ + private static final Map NEWEST_PLACE = new ConcurrentHashMap<>(); + /** How far back a fetch reads Google's reports, whatever window it asked for. */ + private static final long GOOGLE_WINDOW_MS = 7 * 86_400_000L; /** 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; + GoogleReports reports(String googleId, long sinceMs) throws Exception; } private final BooleanSupplier googleConfigured; @@ -57,6 +66,7 @@ interface Google { private final Google google; private final LongSupplier clock; private final Map lastFirstLocate; + private final Map newestPlace; /** * Seams rather than the settings, links and HTTP client themselves, which all need a @@ -64,12 +74,14 @@ interface Google { * 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) { + final Google google, final LongSupplier clock, final Map lastFirstLocate, + final Map newestPlace) { this.googleConfigured = googleConfigured; this.linkedGoogleId = linkedGoogleId; this.google = google; this.clock = clock; this.lastFirstLocate = lastFirstLocate; + this.newestPlace = newestPlace; } public static ReportSources create(final Context context) { @@ -83,10 +95,10 @@ public void locate(final String googleId, final int waitS) throws Exception { } @Override - public List reports(final String googleId, final long sinceMs) throws Exception { + public GoogleReports reports(final String googleId, final long sinceMs) throws Exception { return api.googleReports(googleId, sinceMs); } - }, System::currentTimeMillis, LAST_FIRST_LOCATE); + }, System::currentTimeMillis, LAST_FIRST_LOCATE, NEWEST_PLACE); } /** @@ -131,25 +143,39 @@ public Observable fetch(final AccessoryRequest request, } // 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 long googleSince = Math.min(sinceMs, this.clock.getAsLong() - GOOGLE_WINDOW_MS); + GoogleReports fetched = this.google.reports(googleId, googleSince); + final List positions = new ArrayList<>(fetched.positions); + if (positions.isEmpty()) { + final GoogleReports all = this.google.reports(googleId, 0); + if (!all.positions.isEmpty()) { + // Nothing in the window, but Google located it before: the newest of those is + // still where it was last seen, and without it a tracker that went quiet for + // a week would have nothing on the map at all + positions.add(newest(all.positions)); + } else if (!askGoogleNow) { + // 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); + positions.addAll(fetched.positions); + } catch (final Exception e) { + Log.w(TAG, "First Google locate failed for " + beaconId, e); + } } } } + if (fetched.newestPlace != null) { + this.newestPlace.merge(beaconId, fetched.newestPlace, + (held, found) -> found.timestampMs > held.timestampMs ? found : held); + } final List reports = new ArrayList<>(); - for (final BeaconLocationReport r : fetched) { + for (final BeaconLocationReport r : positions) { if (r.getTimestamp() <= untilMs) reports.add(r); } return reports; @@ -171,6 +197,24 @@ public Observable fetch(final AccessoryRequest request, (fromApple, fromGoogle) -> merge(beaconId, fromApple, fromGoogle)); } + /** + * The place Google named for a device, if it is newer than the position the card shows: a + * refresh that Google answered with "Home" should not look like no answer at all. + */ + @Nullable + public NamedPlace newerNamedPlace(final String beaconId, final long shownTimestampMs) { + final NamedPlace place = this.newestPlace.get(beaconId); + return place != null && place.timestampMs > shownTimestampMs ? place : null; + } + + private static BeaconLocationReport newest(final List reports) { + BeaconLocationReport newest = reports.get(0); + for (final BeaconLocationReport r : reports) { + if (r.getTimestamp() > newest.getTimestamp()) newest = r; + } + return newest; + } + private static FetchResult empty(final String beaconId) { return withReports(beaconId, new ArrayList<>()); } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6bda6f93..5568ff2e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -423,4 +423,5 @@ Wenn Apple die Anfrage lediglich abgelehnt hat, lohnt es sich, zu warten und es Klingeln fehlgeschlagen: %1$s Kein https: Das API-Token wird unverschlüsselt gesendet. So richtest du das ein + Google: %1$s, %2$s \ 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 9bd596cf..ad3a68cd 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -423,4 +423,5 @@ If Apple simply refused the request, waiting and trying again is worth doing fir Ring failed: %1$s Not https: the API token will be sent unencrypted. How to set this up + Google: %1$s, %2$s \ 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 86ce02ed..975647cd 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -423,4 +423,5 @@ Si Apple a simplement refusé la requête, il vaut mieux attendre et réessayer. Échec de la sonnerie : %1$s Pas en https : le jeton d\'API sera envoyé sans chiffrement. Comment le configurer + Google : %1$s, %2$s \ 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 89f8366e..1d27fe7b 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -423,4 +423,5 @@ Appleが要求を拒否しただけなら、少し待ってからもう一度お 鳴らせませんでした: %1$s https ではありません。API トークンは暗号化されずに送信されます。 設定方法 + Google:%1$s、%2$s \ 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 868a1313..34c508bf 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -423,4 +423,5 @@ Apple이 요청을 거부한 것뿐이라면 잠시 기다렸다가 다시 시 소리 재생 실패: %1$s https가 아닙니다. API 토큰이 암호화되지 않은 채 전송됩니다. 설정 방법 + Google: %1$s, %2$s \ 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 d2e116d2..08d1ff2e 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -423,4 +423,5 @@ Als Apple het verzoek simpelweg weigerde, is even wachten en opnieuw proberen he Laten afgaan mislukt: %1$s Geen https: het API-token wordt onversleuteld verstuurd. Zo stel je dit in + Google: %1$s, %2$s \ 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 96a992cb..cf790d48 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -423,4 +423,5 @@ Не удалось подать сигнал: %1$s Не https: токен API будет передан без шифрования. Как это настроить + Google: %1$s, %2$s \ 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 5f1810a5..6f3dc913 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -423,4 +423,5 @@ 响铃失败:%1$s 不是 https:API 令牌将以未加密方式发送。 如何设置 + Google:%1$s,%2$s diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 9bf37589..456a0c0f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -423,4 +423,5 @@ 響鈴失敗:%1$s 不是 https:API 權杖將以未加密方式傳送。 如何設定 + Google:%1$s,%2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f33053d..1104150a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -458,4 +458,5 @@ If Apple simply refused the request, waiting and trying again is worth doing fir Ring failed: %1$s Not https: the API token will be sent unencrypted. How to set this up + Google: %1$s, %2$s 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 index d53f6de0..42a81091 100644 --- a/app/src/test/java/dev/wander/android/opentagviewer/source/ExtraSourcesApiTest.java +++ b/app/src/test/java/dev/wander/android/opentagviewer/source/ExtraSourcesApiTest.java @@ -125,22 +125,24 @@ public void devicesAreRead() throws Exception { } /** - * A report with no coordinates ("home", a semantic location) is skipped, since there is + * A report with no coordinates ("Home", a semantic location) is not a position, since there is * nowhere to draw it. Times arrive in seconds and are stored in milliseconds. */ @Test - public void reportsAreConvertedAndTheOnesWithoutAPositionSkipped() throws Exception { + public void reportsAreConvertedAndTheOnesWithoutAPositionLeftOut() 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); + final ExtraSourcesApi.GoogleReports answer = 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); + final List reports = answer.positions; assertEquals(1, reports.size()); + assertNull("no name, so no place either", answer.newestPlace); final BeaconLocationReport report = reports.get(0); assertEquals(1_700_000_000_000L, report.getTimestamp()); assertEquals(1_700_000_060_000L, report.getPublishedAt()); @@ -150,6 +152,22 @@ public void reportsAreConvertedAndTheOnesWithoutAPositionSkipped() throws Except assertEquals(LocationReport.PROVENANCE_GOOGLE, report.getProvenance()); } + /** Google answers "Home" when the owner's phone saw the tracker; the newest such name is kept. */ + @Test + public void theNewestNamedPlaceIsRead() throws Exception { + this.server.answer("/api/devices/abc/reports", 200, "[" + + "{\"time\":1700000300,\"latitude\":null,\"longitude\":null,\"semantic\":\"Home\",\"status\":0}," + + "{\"time\":1700000100,\"latitude\":null,\"longitude\":null,\"semantic\":\"Work\",\"status\":0}," + + "{\"time\":1700000200,\"latitude\":null,\"longitude\":null,\"semantic\":null}" + + "]"); + + final ExtraSourcesApi.GoogleReports answer = this.api().googleReports("abc", 0); + + assertTrue(answer.positions.isEmpty()); + assertEquals("Home", answer.newestPlace.name); + assertEquals(1_700_000_300_000L, answer.newestPlace.timestampMs); + } + @Test public void ringMaterialIsParsedAndTheEidsLowercased() throws Exception { this.server.answer("/api/devices/abc/ring", 200, 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 index 5c926324..e9011f99 100644 --- a/app/src/test/java/dev/wander/android/opentagviewer/source/ReportSourcesTest.java +++ b/app/src/test/java/dev/wander/android/opentagviewer/source/ReportSourcesTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -40,6 +41,7 @@ private static BeaconLocationReport report(final long timestamp, final String pr /** The googlefind service, counting what it was asked. */ private static final class FakeGoogle implements ReportSources.Google { final List reports = new ArrayList<>(); + final List places = new ArrayList<>(); int locates; boolean failing; @@ -48,21 +50,34 @@ public void locate(final String googleId, final int waitS) { this.locates++; } + /** Like the service: what was recorded at or after {@code sinceMs}. */ @Override - public List reports(final String googleId, final long sinceMs) throws Exception { + public ExtraSourcesApi.GoogleReports reports(final String googleId, final long sinceMs) throws Exception { if (this.failing) { throw new IOException("service down"); } - return new ArrayList<>(this.reports); + final List positions = new ArrayList<>(); + for (final BeaconLocationReport r : this.reports) { + if (r.getTimestamp() >= sinceMs) positions.add(r); + } + ExtraSourcesApi.NamedPlace newest = null; + for (final ExtraSourcesApi.NamedPlace p : this.places) { + if (p.timestampMs >= sinceMs && (newest == null || p.timestampMs > newest.timestampMs)) { + newest = p; + } + } + return new ExtraSourcesApi.GoogleReports(positions, newest); } } private final FakeGoogle google = new FakeGoogle(); private final AtomicLong clock = new AtomicLong(NOW); private final Map firstLocates = new HashMap<>(); + private final Map places = new HashMap<>(); private ReportSources sources(final boolean configured, final Map links) { - return new ReportSources(() -> configured, links::get, this.google, this.clock::get, this.firstLocates); + return new ReportSources(() -> configured, links::get, this.google, this.clock::get, this.firstLocates, + this.places); } private static FetchResult appleResult(final String beaconId, final BeaconLocationReport... reports) { @@ -200,6 +215,94 @@ public void aNeverLocatedTrackerIsAskedForAtMostEveryHalfHour() { assertEquals("and again after it", 2, this.google.locates); } + // --- a tracker Google has not seen for a while ----------------------------------------------------- + + /** + * Only the last week is read, so a tracker nobody has passed for longer used to come back with + * nothing and had no position on the map. The newest older one is still where it was last seen. + */ + @Test + public void aTrackerQuietForOverAWeekStillHasItsLastKnownPosition() { + this.google.reports.add(report(NOW - 9 * 24 * HOUR, LocationReport.PROVENANCE_GOOGLE)); + this.google.reports.add(report(NOW - 8 * 24 * HOUR, LocationReport.PROVENANCE_GOOGLE)); + + final FetchResult result = this.fetch(this.sources(true, Map.of()), "google-g1", + ExternalAccessory.newGoogle("g1", "Keys"), false, null); + + final List reports = result.getReports().get("google-g1"); + assertEquals("only the newest of the old ones", 1, reports.size()); + assertEquals(NOW - 8 * 24 * HOUR, reports.get(0).getTimestamp()); + } + + /** It was located once, so it is not the never-located case that asks Google by itself. */ + @Test + public void aQuietTrackerIsNotAskedForLikeANeverLocatedOne() { + this.google.reports.add(report(NOW - 8 * 24 * HOUR, LocationReport.PROVENANCE_GOOGLE)); + + this.fetch(this.sources(true, Map.of()), "google-g1", ExternalAccessory.newGoogle("g1", "Keys"), false, null); + + assertEquals(0, this.google.locates); + } + + @Test + public void withReportsInTheWindowNothingOlderIsAdded() { + this.google.reports.add(report(NOW - 8 * 24 * HOUR, 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()); + assertEquals(NOW - 60_000, result.getReports().get("google-g1").get(0).getTimestamp()); + } + + // --- places Google names instead of giving a position ------------------------------------------ + + /** + * When the owner's own phone sees the tracker, Google answers "Home" with no coordinates. It is + * not a position, but the card should say Google answered. + */ + @Test + public void aNamedPlaceNewerThanTheShownPositionIsOffered() { + this.google.places.add(new ExtraSourcesApi.NamedPlace("Home", NOW - 60_000)); + final ReportSources sources = this.sources(true, Map.of("tag", "g1")); + + final FetchResult result = this.fetch(sources, "tag", APPLE_TAG, true, + Observable.just(appleResult("tag", report(NOW - HOUR, LocationReport.PROVENANCE_APPLE)))); + + assertEquals("a name is not a position", 1, result.getReports().get("tag").size()); + final ExtraSourcesApi.NamedPlace place = sources.newerNamedPlace("tag", NOW - HOUR); + assertEquals("Home", place == null ? null : place.name); + } + + @Test + public void aNamedPlaceOlderThanTheShownPositionIsNot() { + this.google.places.add(new ExtraSourcesApi.NamedPlace("Home", NOW - 2 * HOUR)); + final ReportSources sources = this.sources(true, Map.of("tag", "g1")); + + this.fetch(sources, "tag", APPLE_TAG, false, + Observable.just(appleResult("tag", report(NOW - HOUR, LocationReport.PROVENANCE_APPLE)))); + + assertNull(sources.newerNamedPlace("tag", NOW - HOUR)); + } + + /** A history fetch for an earlier day must not replace a newer place with an older one. */ + @Test + public void theNewestNamedPlaceIsKept() { + final ReportSources sources = this.sources(true, Map.of()); + final String json = ExternalAccessory.newGoogle("g1", "Keys"); + this.google.reports.add(report(NOW - 60_000, LocationReport.PROVENANCE_GOOGLE)); + this.google.places.add(new ExtraSourcesApi.NamedPlace("Home", NOW - 30_000)); + this.fetch(sources, "google-g1", json, false, null); + + this.google.places.clear(); + this.google.places.add(new ExtraSourcesApi.NamedPlace("Work", NOW - 3 * HOUR)); + this.fetch(sources, "google-g1", json, false, null); + + final ExtraSourcesApi.NamedPlace place = sources.newerNamedPlace("google-g1", 0); + assertEquals("Home", place == null ? null : place.name); + } + // --- merging ------------------------------------------------------------------------------------ @Test