Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import android.util.Base64;

import androidx.annotation.Nullable;

import org.json.JSONArray;
import org.json.JSONObject;

Expand Down Expand Up @@ -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<BeaconLocationReport> positions;
/** The newest named place, if Google sent any. */
@Nullable
public final NamedPlace newestPlace;

public GoogleReports(final List<BeaconLocationReport> 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<BeaconLocationReport> 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<BeaconLocationReport> 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")
Expand All @@ -188,6 +226,6 @@ public List<BeaconLocationReport> googleReports(final String googleId, final lon
.provenance(LocationReport.PROVENANCE_GOOGLE)
.build());
}
return out;
return new GoogleReports(out, newestPlace);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -44,32 +46,42 @@ 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<String, Long> 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<String, NamedPlace> 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<BeaconLocationReport> reports(String googleId, long sinceMs) throws Exception;
GoogleReports reports(String googleId, long sinceMs) throws Exception;
}

private final BooleanSupplier googleConfigured;
private final Function<String, String> linkedGoogleId;
private final Google google;
private final LongSupplier clock;
private final Map<String, Long> lastFirstLocate;
private final Map<String, NamedPlace> newestPlace;

/**
* 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<String, String> linkedGoogleId,
final Google google, final LongSupplier clock, final Map<String, Long> lastFirstLocate) {
final Google google, final LongSupplier clock, final Map<String, Long> lastFirstLocate,
final Map<String, NamedPlace> 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) {
Expand All @@ -83,10 +95,10 @@ public void locate(final String googleId, final int waitS) throws Exception {
}

@Override
public List<BeaconLocationReport> 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);
}

/**
Expand Down Expand Up @@ -131,25 +143,39 @@ public Observable<FetchResult> 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<BeaconLocationReport> 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<BeaconLocationReport> 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<BeaconLocationReport> reports = new ArrayList<>();
for (final BeaconLocationReport r : fetched) {
for (final BeaconLocationReport r : positions) {
if (r.getTimestamp() <= untilMs) reports.add(r);
}
return reports;
Expand All @@ -171,6 +197,24 @@ public Observable<FetchResult> 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<BeaconLocationReport> 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<>());
}
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -423,4 +423,5 @@ Wenn Apple die Anfrage lediglich abgelehnt hat, lohnt es sich, zu warten und es
<string name="ring_failed_reason">Klingeln fehlgeschlagen: %1$s</string>
<string name="server_url_not_https">Kein https: Das API-Token wird unverschlüsselt gesendet.</string>
<string name="google_find_hub_setup_guide">So richtest du das ein</string>
<string name="google_named_place_x_y">Google: %1$s, %2$s</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-en/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -423,4 +423,5 @@ If Apple simply refused the request, waiting and trying again is worth doing fir
<string name="ring_failed_reason">Ring failed: %1$s</string>
<string name="server_url_not_https">Not https: the API token will be sent unencrypted.</string>
<string name="google_find_hub_setup_guide">How to set this up</string>
<string name="google_named_place_x_y">Google: %1$s, %2$s</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -423,4 +423,5 @@ Si Apple a simplement refusé la requête, il vaut mieux attendre et réessayer.
<string name="ring_failed_reason">Échec de la sonnerie : %1$s</string>
<string name="server_url_not_https">Pas en https : le jeton d\'API sera envoyé sans chiffrement.</string>
<string name="google_find_hub_setup_guide">Comment le configurer</string>
<string name="google_named_place_x_y">Google : %1$s, %2$s</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-ja/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -423,4 +423,5 @@ Appleが要求を拒否しただけなら、少し待ってからもう一度お
<string name="ring_failed_reason">鳴らせませんでした: %1$s</string>
<string name="server_url_not_https">https ではありません。API トークンは暗号化されずに送信されます。</string>
<string name="google_find_hub_setup_guide">設定方法</string>
<string name="google_named_place_x_y">Google:%1$s、%2$s</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-ko/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -423,4 +423,5 @@ Apple이 요청을 거부한 것뿐이라면 잠시 기다렸다가 다시 시
<string name="ring_failed_reason">소리 재생 실패: %1$s</string>
<string name="server_url_not_https">https가 아닙니다. API 토큰이 암호화되지 않은 채 전송됩니다.</string>
<string name="google_find_hub_setup_guide">설정 방법</string>
<string name="google_named_place_x_y">Google: %1$s, %2$s</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-nl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -423,4 +423,5 @@ Als Apple het verzoek simpelweg weigerde, is even wachten en opnieuw proberen he
<string name="ring_failed_reason">Laten afgaan mislukt: %1$s</string>
<string name="server_url_not_https">Geen https: het API-token wordt onversleuteld verstuurd.</string>
<string name="google_find_hub_setup_guide">Zo stel je dit in</string>
<string name="google_named_place_x_y">Google: %1$s, %2$s</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-ru/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -423,4 +423,5 @@
<string name="ring_failed_reason">Не удалось подать сигнал: %1$s</string>
<string name="server_url_not_https">Не https: токен API будет передан без шифрования.</string>
<string name="google_find_hub_setup_guide">Как это настроить</string>
<string name="google_named_place_x_y">Google: %1$s, %2$s</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-zh-rCN/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -423,4 +423,5 @@
<string name="ring_failed_reason">响铃失败:%1$s</string>
<string name="server_url_not_https">不是 https:API 令牌将以未加密方式发送。</string>
<string name="google_find_hub_setup_guide">如何设置</string>
<string name="google_named_place_x_y">Google:%1$s,%2$s</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-zh-rTW/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -423,4 +423,5 @@
<string name="ring_failed_reason">響鈴失敗:%1$s</string>
<string name="server_url_not_https">不是 https:API 權杖將以未加密方式傳送。</string>
<string name="google_find_hub_setup_guide">如何設定</string>
<string name="google_named_place_x_y">Google:%1$s,%2$s</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -458,4 +458,5 @@ If Apple simply refused the request, waiting and trying again is worth doing fir
<string name="ring_failed_reason">Ring failed: %1$s</string>
<string name="server_url_not_https">Not https: the API token will be sent unencrypted.</string>
<string name="google_find_hub_setup_guide">How to set this up</string>
<string name="google_named_place_x_y">Google: %1$s, %2$s</string>
</resources>
Loading
Loading