Add support for Google Maps timeline functionality (#3331)

Co-authored-by: Marvin W <git@larma.de>
This commit is contained in:
DaVinci9196 2026-07-07 02:22:42 +08:00 committed by GitHub
parent 4e8200a1ea
commit e4a45f1831
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 6470 additions and 233 deletions

View file

@ -20,7 +20,7 @@ interface ISemanticLocationHistoryService {
void onDemandRestore(in IStatusCallback callback, in RequestCredentials requestCredentials, in List/*<Long>*/ list, in ApiMetadata apiMetadata) = 2;
void getInferredHome(in ISemanticLocationHistoryCallbacks callback, in RequestCredentials requestCredentials, in ApiMetadata apiMetadata) = 3;
void getInferredWork(in ISemanticLocationHistoryCallbacks callback, in RequestCredentials requestCredentials, in ApiMetadata apiMetadata) = 4;
void editSegments(in ISemanticLocationHistoryCallbacks callback, in List<LocationHistorySegment> list, in RequestCredentials requestCredentials, in ApiMetadata apiMetadata) = 5;
void editSegments(in ISemanticLocationHistoryCallbacks callback, in RequestCredentials requestCredentials, in List<LocationHistorySegment> list, in ApiMetadata apiMetadata) = 5;
void deleteHistory(in ISemanticLocationHistoryCallbacks callback, in RequestCredentials requestCredentials, long startTime, long endTime, in ApiMetadata apiMetadata) = 6;
void getUserLocationProfile(in ISemanticLocationHistoryCallbacks callback, in RequestCredentials requestCredentials, in ApiMetadata apiMetadata) = 7;
void getBackupSummary(in ISemanticLocationHistoryCallbacks callback, in RequestCredentials requestCredentials, in ApiMetadata apiMetadata) = 8;

View file

@ -68,6 +68,7 @@ public class PlaceCandidate extends AbstractSafeParcelable {
.end();
}
@SafeParcelable.Class
public static class Identifier extends AbstractSafeParcelable {
@Field(1)
public final long fprint;
@ -75,7 +76,7 @@ public class PlaceCandidate extends AbstractSafeParcelable {
public final long cellId;
@Constructor
public Identifier(@Param(1) long fprint, @Param(1) long cellId) {
public Identifier(@Param(1) long fprint, @Param(2) long cellId) {
this.fprint = fprint;
this.cellId = cellId;
}
@ -94,6 +95,7 @@ public class PlaceCandidate extends AbstractSafeParcelable {
}
}
@SafeParcelable.Class
public static class Point extends AbstractSafeParcelable {
@Field(1)
public final int latE7;
@ -101,7 +103,7 @@ public class PlaceCandidate extends AbstractSafeParcelable {
public final int lngE7;
@Constructor
public Point(@Param(1) int latE7, @Param(1) int lngE7) {
public Point(@Param(1) int latE7, @Param(2) int lngE7) {
this.latE7 = latE7;
this.lngE7 = lngE7;
}

View file

@ -40,6 +40,7 @@ public class TimelinePath extends AbstractSafeParcelable {
public static final SafeParcelableCreatorAndWriter<TimelinePath> CREATOR = findCreator(TimelinePath.class);
@SafeParcelable.Class
public static class SegmentPath extends AbstractSafeParcelable {
@Field(1)
String s1;

View file

@ -14,6 +14,7 @@ import com.google.android.gms.common.internal.safeparcel.SafeParcelable;
import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter;
import com.google.android.gms.semanticlocation.*;
import org.microg.gms.utils.ToStringHelper;
@SafeParcelable.Class
@ -24,9 +25,9 @@ public class LocationHistorySegment extends AbstractSafeParcelable {
@Field(2)
public final long endTimestamp;
@Field(3)
public final int startTimeTimezoneUtcOffsetMinutes;
public final int hierarchyLevel;
@Field(4)
public final int endTimeTimezoneUtcOffsetMinutes;
public final int finalizationState;
@Field(7)
public final String segmentId;
@Field(8)
@ -47,11 +48,11 @@ public class LocationHistorySegment extends AbstractSafeParcelable {
public final PeriodSummary periodSummary;
@Constructor
public LocationHistorySegment(@Param(1) long startTimestamp, @Param(2) long endTimestamp, @Param(3) int startTimeTimezoneUtcOffsetMinutes, @Param(4) int endTimeTimezoneUtcOffsetMinutes, @Param(7) String segmentId, @Param(8) int type, @Param(9) Visit visit, @Param(10) Activity activity, @Param(11) TimelinePath timelinePath, @Param(12) int displayMode, @Param(13) int finalizationStatus, @Param(14) TimelineMemory timelineMemory, @Param(15) PeriodSummary periodSummary) {
public LocationHistorySegment(@Param(1) long startTimestamp, @Param(2) long endTimestamp, @Param(3) int hierarchyLevel, @Param(4) int finalizationState, @Param(7) String segmentId, @Param(8) int type, @Param(9) Visit visit, @Param(10) Activity activity, @Param(11) TimelinePath timelinePath, @Param(12) int displayMode, @Param(13) int finalizationStatus, @Param(14) TimelineMemory timelineMemory, @Param(15) PeriodSummary periodSummary) {
this.startTimestamp = startTimestamp;
this.endTimestamp = endTimestamp;
this.startTimeTimezoneUtcOffsetMinutes = startTimeTimezoneUtcOffsetMinutes;
this.endTimeTimezoneUtcOffsetMinutes = endTimeTimezoneUtcOffsetMinutes;
this.hierarchyLevel = hierarchyLevel;
this.finalizationState = finalizationState;
this.segmentId = segmentId;
this.type = type;
this.visit = visit;
@ -76,8 +77,8 @@ public class LocationHistorySegment extends AbstractSafeParcelable {
return ToStringHelper.name("LocationHistorySegment")
.field("startTime", startTimestamp)
.field("endTime", endTimestamp)
.field("startTimeTimezoneUtcOffsetMinutes", startTimeTimezoneUtcOffsetMinutes)
.field("endTimeTimezoneUtcOffsetMinutes", endTimeTimezoneUtcOffsetMinutes)
.field("hierarchyLevel", hierarchyLevel)
.field("finalizationState", finalizationState)
.field("segmentId", segmentId)
.field("type", type)
.field("visit", visit)

View file

@ -1,23 +1,93 @@
/*
* SPDX-FileCopyrightText: 2025 microG Project Team
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.semanticlocationhistory;
import android.os.Parcel;
import androidx.annotation.NonNull;
import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable;
import com.google.android.gms.common.internal.safeparcel.SafeParcelable;
import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter;
import org.microg.gms.utils.ToStringHelper;
import java.util.List;
@SafeParcelable.Class
public class OdlhBackupSummary extends AbstractSafeParcelable {
@Field(1)
public final long databaseId;
@Field(2)
public final String databaseName;
@Field(3)
public final boolean isThisDevice;
@Field(4)
public final long lastSyncTime;
@Field(5)
public final List<String> gellerKeys;
@Field(6)
public final String deviceIdentifier;
@Field(7)
public final Integer protoSerializedSize;
@Field(8)
public final Integer metadataRowCount;
@Field(9)
public final Long earliestTimestamp;
@Constructor
public OdlhBackupSummary(
@Param(1) long databaseId,
@Param(2) String databaseName,
@Param(3) boolean isThisDevice,
@Param(4) long lastSyncTime,
@Param(5) List<String> gellerKeys,
@Param(6) String deviceIdentifier,
@Param(7) Integer protoSerializedSize,
@Param(8) Integer metadataRowCount,
@Param(9) Long earliestTimestamp) {
this.databaseId = databaseId;
this.databaseName = databaseName;
this.isThisDevice = isThisDevice;
this.lastSyncTime = lastSyncTime;
this.gellerKeys = gellerKeys;
this.deviceIdentifier = deviceIdentifier;
this.protoSerializedSize = protoSerializedSize;
this.metadataRowCount = metadataRowCount;
this.earliestTimestamp = earliestTimestamp;
}
@Override
public void writeToParcel(@NonNull Parcel dest, int flags) {
CREATOR.writeToParcel(this, dest, flags);
}
public static final SafeParcelableCreatorAndWriter<OdlhBackupSummary> CREATOR = findCreator(OdlhBackupSummary.class);
}
@NonNull
@Override
public String toString() {
return ToStringHelper.name("OdlhBackupSummary")
.field("databaseId", databaseId)
.field("databaseName", databaseName)
.field("isThisDevice", isThisDevice)
.field("lastSyncTime", lastSyncTime)
.field("deviceIdentifier", deviceIdentifier)
.field("protoSerializedSize", protoSerializedSize)
.field("metadataRowCount", metadataRowCount)
.field("earliestTimestamp", earliestTimestamp)
.end();
}
}

View file

@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.auth.folsom;
parcelable ProductKey;

View file

@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.auth.folsom;
parcelable SecurityDomainMember;

View file

@ -1,12 +1,13 @@
/*
* SPDX-FileCopyrightText: 2025 microG Project Team
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.auth.folsom.internal;
import com.google.android.gms.common.api.Status;
import com.google.android.gms.common.api.ApiMetadata;
interface IByteArrayCallback {
void onResult(in Status status, in byte[] bArr);
void onResult(in Status status, in byte[] bArr, in ApiMetadata apiMetadata);
}

View file

@ -17,6 +17,7 @@ import com.google.android.gms.auth.folsom.internal.IRecoveryResultCallback;
import com.google.android.gms.auth.folsom.internal.IByteArrayListCallback;
import com.google.android.gms.auth.folsom.internal.IByteArrayCallback;
import com.google.android.gms.auth.folsom.internal.ISecurityDomainMembersCallback;
import com.google.android.gms.auth.folsom.internal.IProductKeyCallback;
import com.google.android.gms.auth.folsom.internal.IBooleanCallback;
import com.google.android.gms.common.api.internal.IStatusCallback;
@ -40,4 +41,9 @@ interface IKeyRetrievalService {
void canSilentlyAddGaiaPassword(in IBooleanCallback callback, String accountName, in ApiMetadata metadata) = 16;
void addGaiaPasswordMember(in IStatusCallback callback, String accountName, in ApiMetadata metadata) = 17;
void getDomainState(in IByteArrayCallback callback, String accountName, in ApiMetadata metadata) = 18;
void getProductKeysOperation(in IProductKeyCallback callback, String accountName, String accountName2, in ApiMetadata metadata) = 19;
void createPrfMemberOperation(in IStatusCallback callback, String accountName, in byte[] bytes, in byte[] bytes2, in ApiMetadata metadata) = 20;
void addRecoveryContactToDependentKeychainOperation(in IStatusCallback callback, String accountName, String accountName2, in ApiMetadata metadata) = 21;
void createRetrievalPacketOperation(in IStatusCallback callback, String accountName, String accountName2, in byte[] bytes, in ApiMetadata metadata) = 22;
void setClaimantKeyOperation(in IStatusCallback callback, String accountName, in byte[] bytes, in byte[] bytes2, in ApiMetadata metadata) = 23;
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.auth.folsom.internal;
import com.google.android.gms.common.api.Status;
import com.google.android.gms.auth.folsom.ProductKey;
import com.google.android.gms.common.api.ApiMetadata;
interface IProductKeyCallback {
void onResult(in Status status, in ProductKey[] productKeyArr, in ApiMetadata apiMetadata);
}

View file

@ -1,12 +1,14 @@
/*
* SPDX-FileCopyrightText: 2025 microG Project Team
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.auth.folsom.internal;
import com.google.android.gms.common.api.Status;
import com.google.android.gms.common.api.ApiMetadata;
import com.google.android.gms.auth.folsom.SecurityDomainMember;
interface ISecurityDomainMembersCallback {
void onResult(in Status status, in List list);
void onResult(in Status status, in SecurityDomainMember[] members, in ApiMetadata apiMetadata);
}

View file

@ -0,0 +1,50 @@
/**
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.auth.folsom;
import android.os.Parcel;
import androidx.annotation.NonNull;
import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable;
import com.google.android.gms.common.internal.safeparcel.SafeParcelable;
import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter;
import org.microg.gms.utils.ToStringHelper;
import java.util.Objects;
@SafeParcelable.Class
public class ProductKey extends AbstractSafeParcelable {
@Field(1)
public int key;
@Field(2)
public byte[] keyMaterial;
public ProductKey() {
}
@Constructor
public ProductKey(@Param(1) int key, @Param(2) byte[] keyMaterial) {
Objects.requireNonNull(keyMaterial, "keyMaterial cannot be null");
this.key = key;
this.keyMaterial = keyMaterial;
}
@Override
public void writeToParcel(@NonNull Parcel dest, int flags) {
CREATOR.writeToParcel(this, dest, flags);
}
public static final SafeParcelableCreatorAndWriter<ProductKey> CREATOR = findCreator(ProductKey.class);
@NonNull
@Override
public String toString() {
return ToStringHelper.name("ProductKey").field("key", key).end();
}
}

View file

@ -0,0 +1,50 @@
/**
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.auth.folsom;
import android.os.Parcel;
import androidx.annotation.NonNull;
import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable;
import com.google.android.gms.common.internal.safeparcel.SafeParcelable;
import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter;
import org.microg.gms.utils.ToStringHelper;
@SafeParcelable.Class
public class SecurityDomainMember extends AbstractSafeParcelable {
@Field(1)
public int memberType;
@Field(2)
public byte[] memberMetadata;
public SecurityDomainMember() {
}
@Constructor
public SecurityDomainMember(@Param(1) int memberType, @Param(2) byte[] memberMetadata) {
this.memberType = memberType;
this.memberMetadata = memberMetadata;
}
@Override
public void writeToParcel(@NonNull Parcel dest, int flags) {
CREATOR.writeToParcel(this, dest, flags);
}
public static final SafeParcelableCreatorAndWriter<SecurityDomainMember> CREATOR = findCreator(SecurityDomainMember.class);
@NonNull
@Override
public String toString() {
return ToStringHelper.name("SecurityDomainMember")
.field("memberType", memberType)
.field("memberMetadata", memberMetadata != null ? memberMetadata.length : 0)
.end();
}
}

View file

@ -252,6 +252,8 @@ object SettingsContract {
const val ICHNAEA_ENDPOINT = "location_ichnaea_endpoint"
const val ONLINE_SOURCE = "location_online_source"
const val ICHNAEA_CONTRIBUTE = "location_ichnaea_contribute"
const val MAPS_TIMELINE = "location_timeline"
const val MAPS_TIMELINE_UPLOAD = "location_timeline_upload"
val PROJECTION = arrayOf(
WIFI_ICHNAEA,
@ -265,6 +267,8 @@ object SettingsContract {
ICHNAEA_ENDPOINT,
ONLINE_SOURCE,
ICHNAEA_CONTRIBUTE,
MAPS_TIMELINE,
MAPS_TIMELINE_UPLOAD,
)
}

View file

@ -334,6 +334,8 @@ class SettingsProvider : ContentProvider() {
Location.ICHNAEA_ENDPOINT -> getSettingsString(key, null)
Location.ONLINE_SOURCE -> getSettingsString(key, null)
Location.ICHNAEA_CONTRIBUTE -> getSettingsBoolean(key, false)
Location.MAPS_TIMELINE -> getSettingsBoolean(key, false)
Location.MAPS_TIMELINE_UPLOAD -> getSettingsBoolean(key, false)
else -> throw IllegalArgumentException("Unknown key: $key")
}
}
@ -355,6 +357,8 @@ class SettingsProvider : ContentProvider() {
Location.ICHNAEA_ENDPOINT -> (value as String).let { if (it.isBlank()) editor.remove(key) else editor.putString(key, it) }
Location.ONLINE_SOURCE -> (value as? String?).let { if (it.isNullOrBlank()) editor.remove(key) else editor.putString(key, it) }
Location.ICHNAEA_CONTRIBUTE -> editor.putBoolean(key, value as Boolean)
Location.MAPS_TIMELINE -> editor.putBoolean(key, value as Boolean)
Location.MAPS_TIMELINE_UPLOAD -> editor.putBoolean(key, value as Boolean)
else -> throw IllegalArgumentException("Unknown key: $key")
}
}

View file

@ -0,0 +1,396 @@
/**
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
syntax = "proto2";
package geller.oneplatform;
option java_outer_classname = "GellerProto";
option java_package = "com.google.android.gms.geller";
option java_multiple_files = true;
message BatchSyncRequest {
repeated SyncItem items = 1;
optional string clientId = 2;
optional RequestOptions options = 3;
optional SyncReason syncReason = 4;
}
message SyncItem {
optional GellerDataType dataType = 1;
optional string syncToken = 2;
optional string corpusName = 3;
repeated GellerElement mutations = 4;
repeated GellerElement deletions = 5;
}
message GellerElement {
optional string elementId = 2;
optional GellerAny payload = 3;
optional ElementTimestamp timestamp = 6;
optional ExtensionData extension = 9099;
}
message GellerAny {
required string typeUrl = 1;
required bytes value = 2;
}
message GellerE2eeElement {
optional bytes encryptedData = 1;
optional int32 encryptionVersion = 2;
}
message ElementTimestamp {
optional int64 timestampMicros = 1;
}
message ExtensionData {
}
message RequestOptions {
optional ClientInfo clientInfo = 1;
optional RequestReason requestReason = 4;
optional SyncMode syncMode = 7;
}
message ClientInfo {
optional DeviceInfo deviceInfo = 6;
}
message DeviceInfo {
optional DeviceCapabilities capabilities = 8;
map<string, DynamicValue> attributes = 22;
}
message DeviceCapabilities {
optional FeatureMap features = 4;
}
message FeatureMap {
map<string, DynamicValue> features = 1;
}
message DynamicValue {
oneof valueType {
int32 intValue = 1;
double doubleValue = 2;
string stringValue = 3;
bool boolValue = 4;
FeatureMap nestedMap = 5;
DynamicList listValue = 6;
}
}
message DynamicList {
repeated DynamicValue items = 1;
}
message BatchSyncResponse {
repeated SyncResponseItem items = 1;
}
message SyncResponseItem {
oneof result {
ErrorResult error = 1;
SyncResult syncResult = 2;
}
optional GellerDataType dataType = 3;
optional HasMoreInfo hasMore = 4;
}
message ErrorResult {
required int32 errorCode = 1;
required string errorMessage = 2;
repeated GellerAny details = 3;
}
message SyncResult {
optional GellerDataType dataType = 1;
optional string dataTypeName = 2;
optional string corpusName = 4;
repeated GellerElement mutations = 5;
repeated GellerElement results = 6;
optional string syncToken = 8;
}
message HasMoreInfo {
optional bool hasMore = 1;
}
enum SyncReason {
SYNC_REASON_UNKNOWN = 0;
SYNC_REASON_PERIODIC = 1;
SYNC_REASON_PUSH = 2;
SYNC_REASON_RESTORE = 3;
}
enum RequestReason {
UNKNOWN_REQUEST_REASON = 0;
PERIODIC = 1;
PUSH_UPDATES = 2;
ON_DEMAND = 4;
INITIALIZATION = 6;
DOWNLOAD_REQUIRED_CORPORA = 7;
INTEGRATION_TEST = 8;
}
enum SyncMode {
SYNC_MODE_UNKNOWN = 0;
SYNC_MODE_FULL = 1;
SYNC_MODE_DELTA = 2;
SYNC_MODE_RESTORE = 3;
}
enum GellerDataType {
UNKNOWN = 0;
PKG = 1;
ANSWERS = 4;
JINN_VOICE_PROFILE = 5;
HOME_GRAPH = 6;
PKG_ENTITIES = 8;
PLAYGROUND = 9;
JINN_STARLIGHT = 10;
JINN_ALL_DJ = 11;
PLAYBACK = 12;
PEOPLE_API = 13;
ACTION_HISTORY = 14;
HOME_AUTOMATION = 15;
DEEPLINK = 17;
ASSISTANT_SETTINGS = 18;
ASSISTANT_DISTILLED_ACTION_USER_MODEL = 19;
PRIVACY_SETTINGS = 21;
APP_ACTIONS = 22;
ACP_CONTEXT = 24;
HANDBAG_PERSONALIZED_SLICE_INFO = 25;
NGA_STASH_DEVICE_ENTITY = 26;
NGA_STASH_DEVICE_RECORD = 27;
NGA_STASH_CLOUD_ENTITY = 28;
NGA_STASH_CLOUD_RECORD = 29;
ANSWERS_EVAL = 30;
ASSISTANT_ACTION_INTERACTION_EVENT = 31;
HABITS_PROFILE = 32;
DEVICE_INSTALLED_APPS = 33;
ASSISTANT_GROWTH_PROFILE = 34;
ASSISTANT_CONTACT_AFFINITY = 35;
GELLER_CONFIG = 36;
INTERNAL_METRICS_CACHE_STATUS = 37;
INTERNAL_METRICS_CACHE_ACCESS = 38;
ASSISTANT_HISTORY = 39;
FOOTPRINTS_FALSE_ACCEPT = 40;
MAPS_SEARCH_CLICK = 41;
NGA_STASH_TRIGGER_SPEC = 42;
PIE_ASSISTANT_USAGE_STATS = 43;
ACTION_HISTORY_EPHEMERAL = 44;
HABITS_AA_PROFILES = 45;
HULK_ONDEVICE_PERSONALIZATION = 46;
TAPAS_REFLECTION_MODELS = 47;
TAPAS_REFLECTION_TRAINING_BUFFERS = 48;
ASSISTANT_UPDATES_CENTER_POOL = 49;
HOME_AUTOMATION_DISCOVERY = 50;
VAAV2_BLUE_BAR = 51;
TAPAS_USER_PROFILE = 52;
MAPS_VIEWPORT_UPDATE = 54;
APP_SHORTCUTS = 55;
ASSISTANT_REMINDERS = 56;
AMBIENT_ASSISTANT_LOCATION_FEEDBACK = 57;
FAST_PAIR = 58;
DEVICE_CAPABILITIES = 59;
CHALKBOARD = 60;
ASSISTANT_USAGE_STATISTICS = 61;
LOCAL_LEAF_PAGE_VIEW = 62;
APP_VOICIFICATION = 63;
TAPAS_OFFLINE_RANKED = 64;
SMARTSPACE_HEADPHONE_APP_USAGE_MODEL = 66;
SMARTSPACE_HEADPHONE_LOGS = 67;
NGA_STASH_METADATA = 68;
ONDEVICE_AD_EVENTS = 69;
LAUNCHER_DEEPLINKS = 70;
ASSISTANT_ALARM = 71;
TELEPORT_APP_URL_ANNOTATOR = 72;
TRANSLATE_HISTORY_ENTRIES = 73;
ASSISTANT_ASPIRE_ACTIVITY = 74;
NGA_STASH_COLLECTION_MEMBERSHIP = 75;
CONVERSATIONAL_ACTIONS = 76;
HOME_AUTOMATION_AGENT_INFO = 77;
WEB_SEARCH = 78;
ENCRYPTED_ONDEVICE_LOCATION_HISTORY = 79;
MEDIA_USER_CONTEXT_INFO = 80;
AOG_APP_USER_CONTEXT = 81;
IDENTITY_VAULT_DOCUMENT = 82;
IDENTITY_VAULT_EVENT = 83;
IDENTITY_VAULT_BLOB = 84;
PORTABLE_PROVIDER = 85;
CONTRIBUTOR_STUDIO_XGA_ELIGIBILITY = 86;
IDENTITY_VAULT_DOCUMENT_LARGE_BLOB = 87;
ASSISTANT_USER_DISPLAY_NAME = 88;
ASSISTANT_ARBITRATION_HOST_IP = 89;
HERON = 90;
ASSISTANT_EPHEMERAL_AUDIO = 91;
ASSISTANT_NLU_SERVER_AUX = 92;
ASSISTANT_ON_DEVICE_ACTIVITY = 93;
SMARTSPACE_CARD_UPDATE_RECORD = 94;
PKG_AIAI = 95;
CROSS_DEVICE_TIMER = 96;
MAPS_SEARCH_RESULT = 97;
SEARCH_CONSOLE_INSIGHTS = 98;
AUIS = 99;
TNG_ASSISTANT_TOP_CONTACTS = 100;
SAVES_LISTS = 101;
SEARCH_PERSONAL_INFO_REMOVAL_REQUEST = 102;
SEARCH_PERSONAL_INFO_REMOVAL_REQUEST_HISTORY = 103;
SEARCH_PERSONAL_INFO_REMOVAL_USER_SETTINGS = 104;
ASSISTANT_WHOLE_HOME_STATE_UPLOAD = 105;
ASSISTANT_ON_DEVICE_DISCOVERY = 106;
MAPS_SEARCH_QUERY = 107;
ACCOUNT_CAPABILITIES = 108;
CROSS_DEVICE_ALARM = 109;
LOCAL_NETWORK_SYNC_METADATA = 110;
ASSISTANT_DEVICE_YOUTUBE_SETTINGS = 111;
ASSISTANT_REMINDER_USER_PROFILE = 112;
ASSISTANT_UUDP_PROFILE = 113;
MAPS_PLANNED_VEHICLE_TRIP = 114;
ASSISTANT_HISTORY_ON_DEVICE_UNREDACTED = 115;
PORTABLE_PROVIDER_WEB_FULFILLMENT = 116;
OEM_ANSWERS_OPAQUE_TAGS = 117;
ASSISTANT_USER_PROFILE = 118;
ANDROID_PROMOTIONAL_NOTIFICATIONS = 119;
PORTABLE_PROVIDER_NAME_ANNOTATION = 120;
AIP_TOP_ENTITIES = 121;
MAPS_TRIP_PLANNING_CONSENT = 122;
GPAC_INBOX = 123;
ASSISTANT_HISTORY_KIDS = 124;
GPAC_CONTEXT = 125;
ASSISTANT_ROUTINES = 126;
PAS_PDS_DEMO = 127;
FEDERATED_HOTWORD_SIGNALS = 128;
GPAC_HISTORY = 129;
YOUTUBE_SEARCH = 130;
ENCRYPTED_ONDEVICE_LOCATION_HISTORY_TRIAL = 131;
GPAC_INBOX_LOCAL = 132;
ASSISTANT_AUTO_EMBEDDED_PAIRED_CONTACTS = 133;
ASSISTANT_VOICE_SETTINGS = 134;
ASSISTANT_DRIVING_SETTINGS = 135;
ASSISTANT_HELP_IMPROVE_ASSISTANT_SETTINGS = 136;
ASSISTANT_DEVICE_SETTINGS = 137;
ASSISTANT_PRODUCTIVITY_SETTINGS = 138;
ASSISTANT_NOTES_AND_LISTS_SETTINGS = 139;
ASSISTANT_ACCESSIBILITY_SETTINGS = 140;
ASSISTANT_LANGUAGE_PARTNER_SETTINGS = 141;
ASSISTANT_CALENDAR_SETTINGS = 142;
ASSISTANT_SHELDON_EMAIL_STATUS_SETTINGS = 143;
ASSISTANT_LOCKSCREEN_SETTINGS = 144;
ASSISTANT_DEVICE_SETTINGS_MUTATIONS = 145;
ASSISTANT_DRIVING_SETTINGS_MUTATIONS = 146;
ASSISTANT_LOCKSCREEN_SETTINGS_MUTATIONS = 147;
LENS_HISTORY_QUERY = 148;
LENS_HISTORY_IMAGE_METADATA = 149;
LENS_HISTORY_IMAGE_DATA_ORIGINAL = 150;
ASSISTANT_SETTINGS_METADATA = 151;
ACTIVITY_CONTROLS_SETTINGS = 152;
UGC_TASK_COMPLETIONS = 153;
MAPS_USER_GENERATED_VEHICLE_PROFILE = 154;
LENS_HISTORY_IMAGE_DATA_THUMBNAIL = 155;
YOUTUBE_VIDEO_AND_ACCOUNT_INFO = 156;
TRUSTLET_PLACE = 157;
ASSISTANT_ROBIN_SUGGESTIONS = 158;
DC_NOTIFICATION_SYNC = 159;
PLAYGROUND_FOOTPRINTS_BACKEND = 160;
LENS_HISTORY_IMAGE_DATA_SUGGEST_THUMBNAIL = 161;
MAPS_SEARCH_DELETION = 162;
VOGON_ON_DEVICE_NLU_CACHE = 163;
ICING_CONFIG = 164;
SOUND_SEARCH_VAA2_AUDIO_METADATA = 165;
SOUND_SEARCH_VAA2_AUDIO_WAA_METADATA = 166;
SONG_SEARCH_HISTORY = 167;
PLAYGROUND_ICING = 168;
ANIMA_CONTENT_INTERESTS = 169;
DC_NOTIFICATION_SYNC_DEVICES = 170;
SAVES = 171;
MAPS_SEARCH_QUERY_HISTORY = 172;
ACCOUNT_LOCATIONS_HOME_ICONS = 173;
ACCOUNT_LOCATIONS_WORK_ICONS = 174;
DAND_USER_PROFILE = 176;
GMAIL_WONDER_OBJECTS = 177;
PIXEL_BESTIES_USER_INTERESTS = 178;
ICING_TUTORIAL = 179;
ANDROID_CONTEXT_ENGINE = 180;
ANDROID_CONTEXT_ENGINE_TEAMFOOD = 181;
GDD_NEVER_USE_THIS_SEE_OMG_28475 = 1000;
GDD_WEBREF = 1001;
GDD_NGA_GENIE_FM = 1002;
GDD_APA_GENIE_FM = 1003;
GDD_APA_BISTO = 1004;
GDD_APA_WARMACTIONS = 1005;
GDD_WEBREF_NGA = 1006;
GDD_WEBREF_NGA_DEV = 1007;
GDD_APA_LIGHTWEIGHT_TOKENS = 1008;
GDD_MDD_SAMPLE_APP_MULTI_VARIANTS = 1009;
GDD_APA_HOTWORD_MODEL = 1010;
GDD_APA_UCM_TFL = 1011;
GDD_APA_DICTATION_FORMATTING = 1012;
GDD_APA_CORRECTIONS = 1013;
GDD_APA_HEAD_SUGGEST = 1014;
GDD_APA_SMART_ACTION_MODELS = 1015;
GDD_APA_BISTO_DEVICE_CUSTOMIZE_INFO = 1016;
GDD_LENS_TEXT = 1017;
GDD_APA_ARC_POP_NLU_MODELS = 1018;
GDD_MOBSERVE_CODELAB = 1019;
GDD_APA_HOTMATCH = 1020;
GDD_AGSA_GROWTH_TRACKING = 1021;
GDD_APA_POP = 1022;
GDD_LENS_AVS = 1023;
GDD_MAPS_VEHICLE_INFO = 1024;
GDD_ANDROID_AUTOFILL_VCN_MERCHANT_OPT_OUT = 1025;
GDD_AAE_SMART_ACTION_MODELS = 1026;
GDD_ANDROID_AUTOFILL_FIELD_PREDICTIONS = 1027;
GDD_APA_RIOD = 1028;
GDD_LENS_OFFLINE_TEXT = 1029;
GDD_LENS_INPAINTING = 1030;
GDD_WELLBEING_INTELLIGENCE = 1031;
GDD_SCONE_UE_CAPA_DOWNLOADER = 1032;
GDD_ODLH_FA_REGIONS = 1033;
GDD_AIP_TOAST_QUALITY = 1034;
GDD_BUGLE_DATA_DOWNLOAD = 1035;
GDD_GOOGLE_PLAY_BOOKS_ANDROID_READING_PRACTICE_SOUND_OUT = 1036;
GDD_BUGLE_EMOJIFY = 1037;
GDD_ASSISTANT_AUTO_EMBEDDED_POP = 1039;
GDD_AGSA_APA_TEST_GROUP = 1040;
GDD_BUGLE_SUMMARIZATION = 1041;
GDD_AGSA_APA_SUMMARIZE = 1042;
GDD_LENS_SCENE_X = 1043;
GDD_AGSA_APA_CONTACT = 1044;
GDD_FILES_OCR_ML_MODEL = 1045;
GDD_WALLET_ISSUER_LOCATION = 1046;
GDD_AGSA_APA_ROADWAY_RECAP_MODELS = 1047;
GDD_MAPS_VEHICLE_ENERGY_MODEL = 1048;
GDD_BUGLE_SMARTS = 1049;
GDD_LENS_TEXT_CLASSIFIER = 1050;
GDD_APA_TELEPORT = 1051;
GDD_AGSA_APA_TEXT_CLASSIFIER = 1053;
GDD_AGSA_APA_TCLIB_PERSON_NAME = 1054;
GDD_PIXELMERLIN_MODELS = 1055;
GDD_LENS_SEGMENTATION = 1056;
GDD_LENS_EDU = 1057;
GDD_LENS_RAID = 1058;
GDD_SPEECH_DSP_HOTWORD_MODEL = 1059;
GDD_CAMERA_FEATURE_COMBINATION_QUERY_GDD = 1060;
GDD_CUSTOMIZATIONBUNDLE_THEMEPACK = 1061;
GDD_TR_TRANSLITERATION = 1062;
GDD_TR_NMT = 1063;
GDD_TR_DICTIONARY = 1064;
GDD_CAST_DEVICECONFIGS = 1065;
GDD_PIXELCARE_AGENT_RESOURCES = 1066;
GDD_SBG_SPEECH_MODEL_DOWNLOAD = 1067;
GDD_AGSA_UNIFIED_MIC = 1068;
GDD_WALLET_OCR_IMAGE_PASSES = 1069;
GDD_MAPS_LIVE_VEHICLE_INFO = 1070;
GOOGLE_LEVEL_VOICE_MATCH = 1071;
VOICE_MATCH_AUDIO_METADATA = 1072;
}
service GellerService {
rpc BatchSync (BatchSyncRequest) returns (BatchSyncResponse);
}

View file

@ -0,0 +1,86 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
syntax = "proto2";
package geller.externaldb;
option java_outer_classname = "ExternalDbSyncProto";
option java_package = "com.google.android.gms.geller.externaldb";
option java_multiple_files = true;
message ExternalDbSync {
optional int32 syncType = 1;
optional string gmsVersion = 2;
optional ExternalDbSnapshot snapshot = 3;
}
message ExternalDbSnapshot {
optional string tableName = 1;
optional int64 startId = 2;
optional int64 endId = 3;
repeated string columnNames = 4;
repeated SnapshotRow rows = 5;
optional int64 databaseId = 6;
optional string deviceModel = 7;
optional TimestampMicros timestamp = 8;
optional string deviceIdentifier = 9;
}
message SnapshotRow {
repeated SnapshotValue values = 1;
}
message SnapshotValue {
oneof value {
int64 intValue = 1;
double doubleValue = 2;
string stringValue = 3;
bytes bytesValue = 4;
bool boolValue = 5;
}
}
message TimestampMicros {
optional int64 timestampMicros = 1;
optional int32 timezoneOffsetMinutes = 2;
}
message ExternalDbDescriptor {
optional int32 version = 2;
repeated ExternalDbTable tables = 3;
}
message ExternalDbTable {
optional string tableName = 1;
optional bool isPrimary = 2;
repeated ColumnDef columns = 3;
repeated FilterDef filters = 4;
}
message ColumnDef {
optional string name = 1;
optional int32 type = 2;
optional bool isKey = 3;
optional bool isIndexed = 4;
optional ColumnDefaultValue defaultValue = 5;
}
message ColumnDefaultValue {
oneof value {
int64 intValue = 1;
double doubleValue = 2;
string stringValue = 3;
bytes bytesValue = 4;
bool boolValue = 5;
}
}
message FilterDef {
optional string columnName = 1;
optional bool enabled = 2;
repeated string values = 3;
repeated int32 ops = 4;
}

View file

@ -0,0 +1,116 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
syntax = "proto2";
package org.microg.gms.auth.folsom;
option java_outer_classname = "FolsomKeyStoreProto";
option java_package = "org.microg.gms.auth.folsom";
option java_multiple_files = true;
message FolsomKeyStore {
message AccountsEntry {
optional string key = 1;
optional AccountData value = 2;
}
repeated AccountsEntry accounts = 3;
repeated bytes encryptionKeys = 5;
repeated string encryptionKeyAliases = 6;
optional int64 lastGlobalSyncTimestamp = 7;
}
message AccountData {
message DomainsEntry {
optional string key = 1;
optional DomainData value = 2;
}
repeated DomainsEntry domains = 2;
repeated KeyPair accountKeyPairs = 3;
optional int32 syncCount = 10;
optional bool needsSync = 11;
optional int64 accountTimestamp = 12;
}
message DomainData {
repeated Keys keys = 4;
optional bytes domainMetadata = 6;
optional int64 lastFetchTimestamp = 7;
optional int32 recoverabilityStatus = 8;
optional int32 localRecoveryStatus = 10;
optional int64 memberSyncTimestamp = 11;
optional int32 serverStatus = 12;
repeated Keys legacyKeys = 14;
optional int64 recoverabilityStatusTimestamp = 20;
optional int64 localRecoveryStatusTimestamp = 21;
optional int64 domainCreatedTimestamp = 22;
optional int64 lastModifiedTimestamp = 23;
}
message Keys {
optional int32 unknownFieldB = 1;
optional int32 keyVersion = 2;
optional bytes keyMetadata = 3;
optional bytes extraData = 4;
optional bytes keyMaterial = 10;
optional bytes encryptedKeyMaterial = 11;
}
message KeyPair {
optional bytes publicKey = 1;
optional bytes privateKey = 2;
message SignaturesEntry {
optional string key = 1;
optional bytes value = 2;
}
repeated SignaturesEntry signatures = 3;
optional int32 version = 4;
optional int32 keyPairType = 5;
optional bytes encryptedPrivateKey = 6;
}
message KeyDeliveryInfo {
optional KeyDeliveryOperationType operationType = 1;
oneof data {
StartKeyRetrievalRequest keyRetrieval = 2;
DegradedRecoverabilityFixRequest degradedFix = 3;
InitialEnrollmentRequest initialEnrollment = 4;
LskfConsentRequest consentRequest = 5;
}
optional string sessionId = 6;
}
enum KeyDeliveryOperationType {
KEY_DELIVERY_OPERATION_TYPE_UNSPECIFIED = 0;
START_KEY_RETRIEVAL = 1;
DEGRADED_RECOVERABILITY_FIX = 2;
INITIAL_ENROLLMENT = 3;
LSKF_CONSENT = 4;
}
message StartKeyRetrievalRequest {
optional string domain = 1;
optional bool reset = 2;
}
message DegradedRecoverabilityFixRequest {
optional string domain = 1;
optional bool available = 2;
}
message InitialEnrollmentRequest {
}
message LskfConsentRequest {
optional LskfConsentOperation operation = 1;
optional string domain = 2;
}
enum LskfConsentOperation {
LSKF_CONSENT_OPERATION_UNSPECIFIED = 0;
LSKF_CONSENT_ENROLL = 1;
LSKF_CONSENT_VERIFY = 2;
LSKF_CONSENT_ADD_MEMBER = 3;
}

View file

@ -0,0 +1,163 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
syntax = "proto2";
package google.internal.identity.securitydomain.v1;
option java_outer_classname = "SecurityDomainProto";
option java_package = "org.microg.gms.auth.folsom";
option java_multiple_files = true;
message FilterOptions {
optional bool includeDeleted = 1;
}
message GetSecurityDomainRequest {
optional string name = 1;
optional int32 view = 2;
optional string requestId = 3;
}
message GetSecurityDomainResponse {
optional string name = 1;
optional int32 currentEpoch = 2;
optional SecurityDomainState state = 3;
}
message SecurityDomainState {
oneof stateType {
DegradedState degraded = 1;
}
optional int32 stateEnum = 3;
}
message DegradedState {
optional bool isDegraded = 1;
}
message GetSecurityDomainMemberRequest {
optional string name = 1;
optional int32 view = 2;
optional FilterOptions filterOptions = 3;
optional string requestId = 4;
}
message ListSecurityDomainMembersRequest {
optional string parent = 1;
repeated int32 view = 2 [packed = true];
repeated string filter = 3;
optional int32 pageSize = 4;
optional string pageToken = 6;
optional FilterOptions filterOptions = 7;
optional string requestId = 8;
}
message ListSecurityDomainMembersResponse {
repeated SecurityDomainMemberResponse members = 1;
optional string nextPageToken = 2;
}
message SecurityDomainMemberResponse {
optional string name = 1;
optional bytes createTime = 2;
repeated MemberSecurityDomain securityDomains = 3;
optional int32 memberType = 4;
optional MemberMetadata memberMetadata = 6;
}
message MemberSecurityDomain {
optional string name = 1;
repeated MemberKey memberKeys = 3;
repeated TrustedVaultKey trustedVaultKeys = 4;
optional CurrentKeyMetadata currentKeyMetadata = 5;
}
message MemberKey {
optional int32 keyType = 1;
optional bytes publicKey = 2;
optional bytes wrappedKey = 3;
}
message TrustedVaultKey {
optional int32 epoch = 1;
optional bytes wrappedKey = 2;
}
message CurrentKeyMetadata {
oneof metadata {
KeyRotationInfo keyRotationInfo = 1;
}
}
message KeyRotationInfo {
optional bool needsRotation = 1;
optional bool isRotating = 2;
}
message MemberMetadata {
optional bool verified = 1;
oneof deviceMetadata {
ChromeBrowserMetadata chromeBrowser = 2;
IOSMetadata ios = 3;
AndroidMetadata android = 4;
LSKFMetadata lskf = 5;
ICloudKeychainMetadata icloudKeychain = 6;
GooglePasswordManagerMetadata googlePasswordManager = 7;
}
}
message ChromeBrowserMetadata {
optional bool isPrimary = 1;
}
message IOSMetadata {
optional int32 deviceType = 1;
}
message AndroidMetadata {
optional bool isPrimary = 2;
}
message LSKFMetadata {
optional bytes encryptedData = 2;
optional int32 algorithm = 3;
}
message ICloudKeychainMetadata {
optional bytes publicKey = 1;
optional bytes keyData = 2;
}
message GooglePasswordManagerMetadata {
optional bytes vaultHandle = 1;
optional VaultKey vaultKey = 2;
optional bytes encryptedSecurityDomainSecret = 3;
optional string accountName = 4;
repeated RecoveryKeyInfo recoveryKeys = 5;
}
message VaultKey {
optional bytes keyData = 1;
}
message RecoveryKeyInfo {
optional string hint = 1;
optional bytes publicKey = 2;
optional bytes encryptedKey = 3;
optional KeyVersion keyVersion = 4;
}
message KeyVersion {
optional bytes versionBytes = 1;
optional int32 versionType = 2;
}
service SecurityDomainService {
rpc GetSecurityDomain(GetSecurityDomainRequest) returns (GetSecurityDomainResponse);
rpc GetSecurityDomainMember(GetSecurityDomainMemberRequest) returns (SecurityDomainMemberResponse);
rpc ListSecurityDomainMembers(ListSecurityDomainMembersRequest) returns (ListSecurityDomainMembersResponse);
}

View file

@ -0,0 +1,227 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
syntax = "proto2";
package semanticlocationhistory;
option java_package = "org.microg.gms.semanticlocationhistory";
option java_outer_classname = "SegmentProto";
option java_multiple_files = true;
message LocationHistorySegmentProto {
optional Timestamp start_time = 1;
optional Timestamp end_time = 2;
optional SegmentTypeUnion segment_data = 3;
optional bool is_deleted = 4;
optional string segment_id = 6;
optional int32 hierarchy_level = 7;
optional int32 finalization_state = 8;
optional FinalizationStatus finalization_status = 9;
optional TimelineDisplayMode display_mode = 10;
optional SegmentSource source = 11;
optional SegmentMetadata metadata = 12;
}
message Timestamp {
optional int64 seconds = 1;
optional int32 nanos = 2;
}
message SegmentTypeUnion {
oneof type {
VisitProto visit = 1;
ActivityProto activity = 2;
PathProto path = 3;
MemoryProto memory = 4;
PeriodSummaryProto summary = 5;
}
}
message VisitProto {
optional int32 hierarchy_level = 1;
optional float probability = 2;
optional PlaceCandidate place = 4;
optional bool is_inferred = 5;
optional bool is_confirmed = 6;
}
message PlaceCandidate {
optional FeatureId feature_id = 1;
optional SemanticType semantic_type = 2;
optional float probability = 3;
optional LatLngE7 location = 5;
optional int32 unknown_field_6 = 6;
optional TopPlaceType top_place_type = 7;
optional double radius_meters = 8;
}
message FeatureId {
optional fixed64 high = 1;
optional fixed64 low = 2;
}
message LatLngE7 {
optional sfixed32 lat_e7 = 1;
optional sfixed32 lng_e7 = 2;
}
message ActivityProto {
optional LatLngE7 start_location = 1;
optional LatLngE7 end_location = 2;
optional float distance_meters = 3;
optional float duration_seconds = 4;
optional ActivityCandidate candidate = 6;
}
message ActivityCandidate {
optional int32 activity_type = 1;
optional float probability = 2;
}
message PathProto {
repeated sfixed32 lat_e7s = 4 [packed = true];
repeated sfixed32 lng_e7s = 5 [packed = true];
repeated int32 offset_minutes = 6 [packed = true];
}
message MemoryProto {
oneof type {
TripProto trip = 1;
NoteProto note = 2;
}
}
message TripProto {
optional int64 duration_seconds = 1;
repeated DestinationProto destinations = 2;
optional TripNameComponents name_components = 3;
optional TripOrigin origin = 4;
optional bool is_complete = 5;
}
message DestinationProto {
optional FeatureId feature_id = 1;
}
message TripNameComponents {
repeated DestinationProto destinations = 1;
}
message TripOrigin {
optional FeatureId feature_id = 1;
optional LatLngE7 location = 2;
}
message NoteProto {
optional string content = 1;
}
message PeriodSummaryProto {
repeated VisitProto top_visits = 1;
optional DateProto date = 3;
}
message DateProto {
optional int32 year = 1;
optional int32 month = 2;
optional int32 day = 3;
}
message ParkingProto {
optional Timestamp start_time = 1;
optional Timestamp end_time = 2;
optional ParkingLocation location = 3;
optional int32 parking_type = 4;
optional int32 parking_source = 5;
optional float radius_meters = 6;
}
message ParkingLocation {
optional LatLngE7 location = 1;
optional int32 level = 3;
}
message AdditionalPlaceCandidates {
repeated PlaceCandidate candidates = 1;
}
message AdditionalActivityCandidates {
repeated ActivityCandidate candidates = 1;
}
message TemporarilyClosedPlaceCandidates {
repeated PlaceCandidate candidates = 1;
}
message SegmentMetadata {
optional int32 unknown_field_1 = 1;
repeated int32 unknown_field_2 = 2 [packed = true];
optional int32 unknown_field_4 = 4;
repeated MetadataEntry entries = 5;
oneof data {
MetadataItem item = 3;
MetadataItems items = 6;
}
}
message MetadataItem {
optional MetadataContent content = 1;
optional Timestamp timestamp = 3;
optional int32 unknown_field_4 = 4;
}
message MetadataContent {
optional bytes data = 1;
optional string text = 2;
}
message MetadataItems {
repeated MetadataItem items = 1;
}
message MetadataEntry {
optional string key = 1;
optional int32 value = 2;
}
enum FinalizationStatus {
UNSPECIFIED_FINALIZATION_STATUS = 0;
STABILIZED = 1;
FINALIZED = 2;
USER_EDITED = 3;
BACKFILLED = 4;
}
enum TimelineDisplayMode {
UNSPECIFIED_TIMELINE_DISPLAY_MODE = 0;
DEFAULT_UI = 1;
CONFIRM_UI = 2;
NEIGHBORHOOD_UI = 3;
PLATINUM_EDIT_UI = 4;
}
enum SemanticType {
SEMANTIC_TYPE_UNKNOWN = 0;
SEMANTIC_TYPE_HOME = 1;
SEMANTIC_TYPE_WORK = 2;
SEMANTIC_TYPE_INFERRED_HOME = 3;
SEMANTIC_TYPE_INFERRED_WORK = 4;
SEMANTIC_TYPE_SEARCHED_ADDRESS = 5;
SEMANTIC_TYPE_ALIASED_LOCATION = 6;
}
enum TopPlaceType {
UNKNOWN_TOP_PLACE_TYPE = 0;
NEAREST_PLACE = 1;
GJ_UPGRADE_HOME = 2;
GJ_UPGRADE_WORK = 3;
}
enum SegmentSource {
SOURCE_UNKNOWN = 0;
SOURCE_INFERRED = 1;
SOURCE_USER_INPUT = 2;
}

View file

@ -106,6 +106,8 @@ dependencies {
implementation "androidx.credentials:credentials:$credentialsVersion"
implementation "androidx.work:work-runtime-ktx:$workVersion"
implementation "com.google.crypto.tink:tink-android:$tinkVersion"
}
android {

View file

@ -1229,6 +1229,17 @@
</intent-filter>
</service>
<service android:name=".semanticlocationhistory.db.backup.OdlhBackupService"
android:exported="false" />
<receiver android:name=".semanticlocationhistory.db.backup.OdlhBackupReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<activity
android:name="com.google.android.location.settings.LocationHistorySettingsActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar"

View file

@ -5,9 +5,12 @@
package com.google.android.gms.semanticlocationhistory
import android.content.ContentValues
import android.content.Context
import android.os.Parcel
import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.Feature
import com.google.android.gms.common.api.ApiMetadata
@ -18,17 +21,23 @@ import com.google.android.gms.common.data.DataHolder
import com.google.android.gms.common.internal.ConnectionInfo
import com.google.android.gms.common.internal.GetServiceRequest
import com.google.android.gms.common.internal.IGmsCallbacks
import com.google.android.gms.common.internal.safeparcel.SafeParcelableSerializer
import com.google.android.gms.location.reporting.ReportingState
import com.google.android.gms.semanticlocation.SemanticLocationState
import com.google.android.gms.semanticlocationhistory.db.OdlhStorageManager
import com.google.android.gms.semanticlocationhistory.db.backup.BackupRestoreHandler
import com.google.android.gms.semanticlocationhistory.db.backup.OdlhBackupProcessor
import com.google.android.gms.semanticlocationhistory.db.backup.OdlhBackupService
import com.google.android.gms.semanticlocationhistory.internal.ISemanticLocationHistoryCallbacks
import com.google.android.gms.semanticlocationhistory.internal.ISemanticLocationHistoryService
import com.google.android.gms.semanticlocationhistory.utils.SegmentEditHandler
import com.google.android.gms.semanticlocationhistory.utils.SegmentQueryHandler
import kotlinx.coroutines.launch
import org.microg.gms.BaseService
import org.microg.gms.common.GmsService
import org.microg.gms.common.PackageUtils
import org.microg.gms.location.LocationSettings
import org.microg.gms.utils.warnOnTransactionIssues
private const val TAG = "LocationHistoryService"
private val FEATURES = arrayOf(
Feature("semantic_location_history", 12),
Feature("odlh_get_backup_summary", 2),
@ -42,145 +51,320 @@ private val FEATURES = arrayOf(
class SemanticLocationHistoryService : BaseService(TAG, GmsService.SEMANTIC_LOCATION_HISTORY) {
override fun handleServiceRequest(callback: IGmsCallbacks, request: GetServiceRequest, service: GmsService) {
Log.d(TAG, "handleServiceRequest: packageName: ${request.packageName}")
val packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName)
Log.d(TAG, "handleServiceRequest: packageName: $packageName")
OdlhBackupService.scheduleBackup(this)
callback.onPostInitCompleteWithConnectionInfo(
ConnectionResult.SUCCESS, SemanticLocationHistoryServiceImpl().asBinder(), ConnectionInfo().apply {
ConnectionResult.SUCCESS, SemanticLocationHistoryServiceImpl(this, lifecycle).asBinder(), ConnectionInfo().apply {
features = FEATURES
}
)
})
}
}
class SemanticLocationHistoryServiceImpl : ISemanticLocationHistoryService.Stub() {
class SemanticLocationHistoryServiceImpl(val context: Context, override val lifecycle: Lifecycle) : ISemanticLocationHistoryService.Stub(), LifecycleOwner {
private val storageManager by lazy { OdlhStorageManager.getInstance(context) }
override fun getSegments(
callback: ISemanticLocationHistoryCallbacks?,
requestCredentials: RequestCredentials?,
request: LocationHistorySegmentRequest?,
apiMetadata: ApiMetadata?
) {
Log.d(TAG, "Not yet implemented: getSegments requestCredentials:$requestCredentials request:$request")
val holder = DataHolder.empty(CommonStatusCodes.SUCCESS)
callback?.onGetSegmentsResponse(holder, ApiMetadata.SKIP)
override fun getSegments(callback: ISemanticLocationHistoryCallbacks?, requestCredentials: RequestCredentials?, request: LocationHistorySegmentRequest?, apiMetadata: ApiMetadata?) {
Log.d(TAG, "getSegments: requestCredentials=$requestCredentials, request=$request")
val allowedMapsTimelineFeature = LocationSettings(context).mapsTimeline
if (!allowedMapsTimelineFeature) {
Log.w(TAG, "Not Allowed!!")
callback?.onGetSegmentsResponse(DataHolder.empty(CommonStatusCodes.SUCCESS), ApiMetadata.SKIP)
return
}
val accountName = requestCredentials?.account?.name
if (accountName.isNullOrEmpty()) {
Log.w(TAG, "getSegments: accountName is null or empty")
callback?.onGetSegmentsResponse(DataHolder.empty(CommonStatusCodes.ERROR), ApiMetadata.DEFAULT)
return
}
lifecycleScope.launch {
try {
val gaiaId = context.getObfuscatedGaiaId(accountName)
Log.d(TAG, "getSegments: gaiaId=${gaiaId.take(8)}...")
val holder = SegmentQueryHandler.queryAndFilterSegments(storageManager, gaiaId, request)
Log.d(TAG, "getSegments holder: $holder")
callback?.onGetSegmentsResponse(holder, ApiMetadata.DEFAULT)
} catch (e: Exception) {
Log.e(TAG, "getSegments: failed", e)
callback?.onGetSegmentsResponse(DataHolder.empty(CommonStatusCodes.ERROR), ApiMetadata.DEFAULT)
}
}
}
override fun onDemandBackup(
callback: IStatusCallback?,
requestCredentials: RequestCredentials?,
apiMetadata: ApiMetadata?
) {
Log.d(TAG, "Not yet implemented: onDemandBackup requestCredentials:$requestCredentials")
callback?.onResult(Status.SUCCESS)
override fun onDemandBackup(callback: IStatusCallback?, requestCredentials: RequestCredentials?, apiMetadata: ApiMetadata?) {
Log.d(TAG, "onDemandBackup: requestCredentials=$requestCredentials")
val allowedMapsTimelineFeature = LocationSettings(context).mapsTimeline
if (!allowedMapsTimelineFeature) {
Log.w(TAG, "Not Allowed!!")
callback?.onResult(Status.SUCCESS)
return
}
val accountName = requestCredentials?.account?.name
if (accountName.isNullOrEmpty()) {
Log.w(TAG, "onDemandBackup: accountName is null or empty")
callback?.onResult(Status.SUCCESS)
return
}
lifecycleScope.launch {
try {
val result = OdlhBackupProcessor.performBackup(context, accountName)
Log.d(TAG, "onDemandBackup: result=$result")
callback?.onResult(if (result.success) Status.SUCCESS else Status(CommonStatusCodes.ERROR, result.message))
} catch (e: Exception) {
Log.e(TAG, "onDemandBackup: failed", e)
callback?.onResult(Status(CommonStatusCodes.ERROR, e.message))
}
}
}
override fun onDemandRestore(
callback: IStatusCallback?,
requestCredentials: RequestCredentials?,
list: List<*>?,
apiMetadata: ApiMetadata?
) {
Log.d(TAG, "Not yet implemented: onDemandRestore requestCredentials:$requestCredentials list:$list")
callback?.onResult(Status.SUCCESS)
override fun onDemandRestore(callback: IStatusCallback?, requestCredentials: RequestCredentials?, list: List<*>?, apiMetadata: ApiMetadata?) {
Log.d(TAG, "onDemandRestore: requestCredentials=$requestCredentials")
val allowedMapsTimelineFeature = LocationSettings(context).mapsTimeline
if (!allowedMapsTimelineFeature) {
Log.w(TAG, "Not Allowed!!")
callback?.onResult(Status.SUCCESS)
return
}
val accountName = requestCredentials?.account?.name
if (accountName.isNullOrEmpty()) {
Log.w(TAG, "onDemandRestore: accountName is null or empty")
callback?.onResult(Status(CommonStatusCodes.ERROR, "Invalid account"))
return
}
val databaseIds = list?.filterIsInstance<Long>()
if (databaseIds.isNullOrEmpty()) {
Log.w(TAG, "onDemandRestore: no valid databaseIds")
callback?.onResult(Status(CommonStatusCodes.ERROR, "No databaseIds"))
return
}
lifecycleScope.launch {
try {
val gaiaId = context.getObfuscatedGaiaId(accountName)
val result = BackupRestoreHandler.restoreBackups(context, accountName, gaiaId, databaseIds, storageManager)
if (result.hasData) {
storageManager.removeTombstonesOverlapping(gaiaId, result.minStartSec, result.maxEndSec)
}
Log.d(TAG, "onDemandRestore: restored ${result.restoredCount} segments")
callback?.onResult(Status.SUCCESS)
} catch (e: Exception) {
Log.e(TAG, "onDemandRestore: failed", e)
callback?.onResult(Status(CommonStatusCodes.ERROR, e.message))
}
}
}
override fun getInferredHome(
callback: ISemanticLocationHistoryCallbacks?,
requestCredentials: RequestCredentials?,
apiMetadata: ApiMetadata?
) {
Log.d(TAG, "Not yet implemented: getInferredHome requestCredentials:$requestCredentials")
callback?.onGetInferredHomeResponse(Status.SUCCESS, null, ApiMetadata.SKIP)
override fun getInferredHome(callback: ISemanticLocationHistoryCallbacks?, requestCredentials: RequestCredentials?, apiMetadata: ApiMetadata?) {
Log.d(TAG, "getInferredHome: requestCredentials=$requestCredentials")
val allowedMapsTimelineFeature = LocationSettings(context).mapsTimeline
if (!allowedMapsTimelineFeature) {
Log.w(TAG, "Not Allowed!!")
callback?.onGetInferredHomeResponse(Status.SUCCESS, null, ApiMetadata.SKIP)
return
}
val accountName = requestCredentials?.account?.name
if (accountName.isNullOrEmpty()) {
callback?.onGetInferredHomeResponse(Status.SUCCESS, null, ApiMetadata.DEFAULT)
return
}
lifecycleScope.launch {
val now = System.currentTimeMillis() / 1000
val windowStart = now - SEARCH_WINDOW_DAYS * SECONDS_PER_DAY
val visitSegments = storageManager.querySegments(
gaiaId = context.getObfuscatedGaiaId(accountName), startTime = windowStart, endTime = now, segmentTypes = intArrayOf(SEGMENT_TYPE_VISIT)
)
val inferredPlace = getInferredPlace(visitSegments, SEMANTIC_TYPE_HOME)
callback?.onGetInferredWorkResponse(Status.SUCCESS, inferredPlace, ApiMetadata.DEFAULT)
}
}
override fun getInferredWork(
callback: ISemanticLocationHistoryCallbacks?,
requestCredentials: RequestCredentials?,
apiMetadata: ApiMetadata?
) {
Log.d(TAG, "Not yet implemented: getInferredWork requestCredentials:$requestCredentials")
callback?.onGetInferredWorkResponse(Status.SUCCESS, null, ApiMetadata.SKIP)
override fun getInferredWork(callback: ISemanticLocationHistoryCallbacks?, requestCredentials: RequestCredentials?, apiMetadata: ApiMetadata?) {
Log.d(TAG, "getInferredWork: requestCredentials=$requestCredentials")
val allowedMapsTimelineFeature = LocationSettings(context).mapsTimeline
if (!allowedMapsTimelineFeature) {
Log.w(TAG, "Not Allowed!!")
callback?.onGetInferredWorkResponse(Status.SUCCESS, null, ApiMetadata.SKIP)
return
}
val accountName = requestCredentials?.account?.name
if (accountName.isNullOrEmpty()) {
callback?.onGetInferredWorkResponse(Status.SUCCESS, null, ApiMetadata.DEFAULT)
return
}
lifecycleScope.launch {
val now = System.currentTimeMillis() / 1000
val windowStart = now - SEARCH_WINDOW_DAYS * SECONDS_PER_DAY
val visitSegments = storageManager.querySegments(
gaiaId = context.getObfuscatedGaiaId(accountName), startTime = windowStart, endTime = now, segmentTypes = intArrayOf(SEGMENT_TYPE_VISIT)
)
val inferredPlace = getInferredPlace(visitSegments, SEMANTIC_TYPE_WORK)
callback?.onGetInferredWorkResponse(Status.SUCCESS, inferredPlace, ApiMetadata.DEFAULT)
}
}
override fun editSegments(
callback: ISemanticLocationHistoryCallbacks?,
list: List<LocationHistorySegment>?,
requestCredentials: RequestCredentials?,
apiMetadata: ApiMetadata?
) {
Log.d(TAG, "Not yet implemented: editSegments requestCredentials:$requestCredentials list:$list")
callback?.onEditSegmentsResponse(Status.SUCCESS, ApiMetadata.SKIP)
override fun editSegments(callback: ISemanticLocationHistoryCallbacks?, requestCredentials: RequestCredentials?, list: List<LocationHistorySegment>?, apiMetadata: ApiMetadata?) {
Log.d(TAG, "editSegments: requestCredentials=$requestCredentials")
val allowedMapsTimelineFeature = LocationSettings(context).mapsTimeline
if (!allowedMapsTimelineFeature) {
Log.w(TAG, "Not Allowed!!")
callback?.onEditSegmentsResponse(Status.SUCCESS, ApiMetadata.SKIP)
return
}
val accountName = requestCredentials?.account?.name
if (accountName.isNullOrEmpty() || list.isNullOrEmpty()) {
Log.w(TAG, "editSegments: invalid parameters")
callback?.onEditSegmentsResponse(Status(CommonStatusCodes.ERROR, "Invalid parameters"), ApiMetadata.DEFAULT)
return
}
lifecycleScope.launch {
try {
val gaiaId = context.getObfuscatedGaiaId(accountName)
SegmentEditHandler.editSegments(storageManager, gaiaId, list)
callback?.onEditSegmentsResponse(Status.SUCCESS, ApiMetadata.DEFAULT)
} catch (e: IllegalArgumentException) {
Log.w(TAG, "editSegments: ${e.message}")
callback?.onEditSegmentsResponse(Status(CommonStatusCodes.ERROR, "Invalid segment time range"), ApiMetadata.DEFAULT)
} catch (e: Exception) {
Log.e(TAG, "editSegments: failed", e)
callback?.onEditSegmentsResponse(Status(CommonStatusCodes.ERROR, "Edit failed: ${e.message}"), ApiMetadata.DEFAULT)
}
}
}
override fun deleteHistory(
callback: ISemanticLocationHistoryCallbacks?,
requestCredentials: RequestCredentials,
startTime: Long,
endTime: Long,
apiMetadata: ApiMetadata?
) {
Log.d(TAG, "Not yet implemented: deleteHistory requestCredentials:$requestCredentials startTime:$startTime endTime:$endTime")
callback?.onDeleteHistoryResponse(Status.SUCCESS, ApiMetadata.SKIP)
override fun deleteHistory(callback: ISemanticLocationHistoryCallbacks?, requestCredentials: RequestCredentials, startTime: Long, endTime: Long, apiMetadata: ApiMetadata?) {
Log.d(TAG, "deleteHistory: requestCredentials=$requestCredentials startTime:$startTime endTime:$endTime")
val allowedMapsTimelineFeature = LocationSettings(context).mapsTimeline
if (!allowedMapsTimelineFeature) {
Log.w(TAG, "Not Allowed!!")
callback?.onDeleteHistoryResponse(Status.SUCCESS, ApiMetadata.SKIP)
return
}
val accountName = requestCredentials.account?.name
if (accountName.isNullOrEmpty()) {
Log.w(TAG, "deleteHistory: accountName is null or empty")
callback?.onDeleteHistoryResponse(Status(CommonStatusCodes.ERROR, "Invalid account"), ApiMetadata.DEFAULT)
return
}
lifecycleScope.launch {
try {
val gaiaId = context.getObfuscatedGaiaId(accountName)
SegmentEditHandler.deleteHistory(storageManager, gaiaId, startTime, endTime)
val tombstone = OdlhStorageManager.Tombstone(
createdMillis = System.currentTimeMillis(), startTimeSec = startTime, endTimeSec = endTime
)
storageManager.addTombstone(gaiaId, tombstone)
Log.d(TAG, "deleteHistory: added tombstone [$startTime, $endTime)")
callback?.onDeleteHistoryResponse(Status.SUCCESS, ApiMetadata.DEFAULT)
} catch (e: Exception) {
Log.e(TAG, "deleteHistory: failed", e)
callback?.onDeleteHistoryResponse(Status(CommonStatusCodes.ERROR, "Delete failed: ${e.message}"), ApiMetadata.DEFAULT)
}
}
}
override fun getUserLocationProfile(
callback: ISemanticLocationHistoryCallbacks?,
requestCredentials: RequestCredentials?,
apiMetadata: ApiMetadata?
) {
Log.d(TAG, "Not yet implemented: getUserLocationProfile requestCredentials:$requestCredentials")
callback?.onGetUserLocationProfileResponse(Status.SUCCESS, null, ApiMetadata.SKIP)
override fun getBackupSummary(callback: ISemanticLocationHistoryCallbacks?, requestCredentials: RequestCredentials?, apiMetadata: ApiMetadata?) {
Log.d(TAG, "getBackupSummary: requestCredentials=$requestCredentials")
val allowedMapsTimelineFeature = LocationSettings(context).mapsTimeline
if (!allowedMapsTimelineFeature) {
Log.w(TAG, "Not Allowed!!")
callback?.onGetBackupSummaryResponse(Status.SUCCESS, emptyList<OdlhBackupSummary>(), ApiMetadata.SKIP)
return
}
val accountName = requestCredentials?.account?.name
if (accountName.isNullOrEmpty()) {
callback?.onGetBackupSummaryResponse(Status.SUCCESS, emptyList<OdlhBackupSummary>(), ApiMetadata.DEFAULT)
return
}
lifecycleScope.launch {
try {
val summaries = BackupRestoreHandler.fetchBackupSummaries(context, accountName, storageManager)
Log.d(TAG, "getBackupSummary: returning ${summaries.size} summaries")
callback?.onGetBackupSummaryResponse(Status.SUCCESS, summaries, ApiMetadata.DEFAULT)
} catch (e: Exception) {
Log.e(TAG, "getBackupSummary: failed", e)
callback?.onGetBackupSummaryResponse(
Status(CommonStatusCodes.ERROR, "Backup summary failed: ${e.message}"), emptyList<OdlhBackupSummary>(), ApiMetadata.DEFAULT
)
}
}
}
override fun getBackupSummary(
callback: ISemanticLocationHistoryCallbacks?,
requestCredentials: RequestCredentials?,
apiMetadata: ApiMetadata?
) {
Log.d(TAG, "Not yet implemented: getBackupSummary requestCredentials:$requestCredentials")
callback?.onGetBackupSummaryResponse(Status.SUCCESS, emptyList<OdlhBackupSummary>(), ApiMetadata.SKIP)
override fun deleteBackups(callback: IStatusCallback?, requestCredentials: RequestCredentials?, list: List<*>?, apiMetadata: ApiMetadata?) {
Log.d(TAG, "deleteBackups: requestCredentials=$requestCredentials")
val allowedMapsTimelineFeature = LocationSettings(context).mapsTimeline
if (!allowedMapsTimelineFeature) {
Log.w(TAG, "Not Allowed!!")
callback?.onResult(Status.SUCCESS)
return
}
val accountName = requestCredentials?.account?.name
if (accountName.isNullOrEmpty()) {
Log.w(TAG, "deleteBackups: accountName is null or empty")
callback?.onResult(Status(CommonStatusCodes.ERROR, "Invalid account"))
return
}
val databaseIds = list?.filterIsInstance<Long>()
if (databaseIds.isNullOrEmpty()) {
Log.w(TAG, "deleteBackups: no valid databaseIds")
callback?.onResult(Status(CommonStatusCodes.ERROR, "No databaseIds"))
return
}
lifecycleScope.launch {
try {
BackupRestoreHandler.deleteBackups(context, accountName, databaseIds, storageManager)
Log.d(TAG, "deleteBackups: completed successfully")
callback?.onResult(Status.SUCCESS)
} catch (e: Exception) {
Log.e(TAG, "deleteBackups: failed", e)
callback?.onResult(Status(CommonStatusCodes.ERROR, e.message))
}
}
}
override fun deleteBackups(
callback: IStatusCallback?,
requestCredentials: RequestCredentials?,
list: List<*>?,
apiMetadata: ApiMetadata?
) {
Log.d(TAG, "Not yet implemented: deleteBackups requestCredentials:$requestCredentials list:$list")
callback?.onResult(Status.SUCCESS)
override fun getUserLocationProfile(callback: ISemanticLocationHistoryCallbacks?, requestCredentials: RequestCredentials?, apiMetadata: ApiMetadata?) {
Log.d(TAG, "getUserLocationProfile: requestCredentials=$requestCredentials")
val allowedMapsTimelineFeature = LocationSettings(context).mapsTimeline
if (!allowedMapsTimelineFeature) {
Log.w(TAG, "Not Allowed!!")
callback?.onGetUserLocationProfileResponse(Status.SUCCESS, null, ApiMetadata.SKIP)
return
}
val accountName = requestCredentials?.account?.name
Log.d(TAG, "getUserLocationProfile: account=$accountName")
callback?.onGetUserLocationProfileResponse(Status.SUCCESS, null, ApiMetadata.DEFAULT)
}
override fun getLocationHistorySettings(
callback: ISemanticLocationHistoryCallbacks?,
requestCredentials: RequestCredentials?,
apiMetadata: ApiMetadata?
) {
Log.d(TAG, "Not yet implemented: getLocationHistorySettings requestCredentials:$requestCredentials")
callback?.onLocationHistorySettings(
Status.SUCCESS,
LocationHistorySettings(false, 0, ReportingState(-1, -1, false, false, 1, 1, 0, false, true)),
ApiMetadata.SKIP
)
override fun getLocationHistorySettings(callback: ISemanticLocationHistoryCallbacks?, requestCredentials: RequestCredentials?, apiMetadata: ApiMetadata?) {
Log.d(TAG, "getLocationHistorySettings: requestCredentials=$requestCredentials")
val allowedMapsTimelineFeature = LocationSettings(context).mapsTimeline
if (!allowedMapsTimelineFeature) {
Log.w(TAG, "Not Allowed!!")
callback?.onLocationHistorySettings(
Status.SUCCESS, LocationHistorySettings(false, 0, ReportingState(-1, -1, false, false, 1, 1, 0, false, true)), ApiMetadata.SKIP
)
return
}
val dbSize = storageManager.getDatabaseSize()
val accountName = requestCredentials?.account?.name
if (!accountName.isNullOrEmpty()) {
val segmentCount = storageManager.getSegmentCount(accountName)
Log.d(TAG, "getLocationHistorySettings: account=$accountName, segments=$segmentCount, dbSize=$dbSize")
}
val deviceTag = if (!accountName.isNullOrEmpty()) storageManager.getDeviceTag(accountName) else 0
Log.d(TAG, "getLocationHistorySettings_deviceTag: $deviceTag")
val reportingState = ReportingState(-1, 1, false, false, 1, 1, deviceTag, false, true)
val settings = LocationHistorySettings(true, deviceTag, reportingState)
callback?.onLocationHistorySettings(Status.SUCCESS, settings, ApiMetadata.DEFAULT)
}
override fun getExperimentVisits(
callback: ISemanticLocationHistoryCallbacks?,
requestCredentials: RequestCredentials?,
apiMetadata: ApiMetadata?
) {
override fun getExperimentVisits(callback: ISemanticLocationHistoryCallbacks?, requestCredentials: RequestCredentials?, apiMetadata: ApiMetadata?) {
Log.d(TAG, "Not yet implemented: getExperimentVisits requestCredentials:$requestCredentials")
val deviceMetadata = DeviceMetadata(listOf("0"), false, false, emptyList<DeletionRange>(), 0)
val response = ExperimentVisitsResponse(emptyList<LocationHistorySegment>(), 0, deviceMetadata)
callback?.onGetExperimentVisitsResponse(Status.SUCCESS, response, ApiMetadata.SKIP)
}
override fun editCsl(
callback: IStatusCallback?,
requestCredentials: RequestCredentials?,
editInputs: SemanticLocationEditInputs?,
state: SemanticLocationState?,
apiMetadata: ApiMetadata?
) {
override fun editCsl(callback: IStatusCallback?, requestCredentials: RequestCredentials?, editInputs: SemanticLocationEditInputs?, state: SemanticLocationState?, apiMetadata: ApiMetadata?) {
Log.d(TAG, "Not yet implemented: editCsl editInputs:$editInputs state:$state")
callback?.onResult(Status.SUCCESS)
}

View file

@ -0,0 +1,81 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.semanticlocationhistory.api
import android.content.Context
import android.util.Log
import com.google.android.gms.geller.BatchSyncRequest
import com.google.android.gms.geller.BatchSyncResponse
import com.google.android.gms.geller.GellerDataType
import com.google.android.gms.geller.GellerElement
import com.google.android.gms.geller.GellerServiceClient
import com.google.android.gms.geller.RequestOptions
import com.google.android.gms.geller.RequestReason
import com.google.android.gms.geller.SyncItem
import com.google.android.gms.geller.SyncMode
import com.google.android.gms.geller.SyncReason
import com.google.android.gms.semanticlocationhistory.requestGellerOauthToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.microg.gms.gcm.createGrpcClient
private const val TAG = "GellerSyncClient"
private const val CLIENT_ID = "SEMANTICLOCATION"
private const val GELLER_BASE_URL = "https://geller-pa.googleapis.com/"
object GellerSyncClient {
private fun gellerGrpcClient(oauthToken: String): GellerServiceClient {
return createGrpcClient<GellerServiceClient>(GELLER_BASE_URL, oauthToken)
}
private fun buildSyncItem(
syncToken: String? = null, mutations: List<GellerElement> = emptyList(), deletions: List<GellerElement> = emptyList()
) = SyncItem(
dataType = GellerDataType.ENCRYPTED_ONDEVICE_LOCATION_HISTORY, syncToken = syncToken ?: "", mutations = mutations, deletions = deletions
)
private suspend fun syncOdlh(context: Context, accountName: String, items: List<SyncItem>, syncReason: SyncReason): BatchSyncResponse? = withContext(Dispatchers.IO) {
Log.d(TAG, "syncOdlh: items=${items.size}, syncReason=$syncReason")
return@withContext runCatching {
val oauthToken = context.requestGellerOauthToken(accountName)
gellerGrpcClient(oauthToken).BatchSync().executeBlocking(
BatchSyncRequest(
items = items,
clientId = CLIENT_ID,
options = RequestOptions(
clientInfo = null, requestReason = RequestReason.ON_DEMAND, syncMode = SyncMode.SYNC_MODE_FULL
),
syncReason = syncReason,
)
).also {
Log.d(TAG, "syncOdlh: response items=${it}")
}
}.onFailure {
Log.e(TAG, "syncOdlh failed", it)
}.getOrNull()
}
suspend fun uploadOdlh(context: Context, accountName: String, syncToken: String? = null, mutations: List<GellerElement>): BatchSyncResponse? {
Log.d(TAG, "uploadOdlh: ${mutations.size} entries")
return syncOdlh(
context, accountName, items = listOf(buildSyncItem(syncToken, mutations = mutations)), syncReason = SyncReason.SYNC_REASON_PUSH
)
}
suspend fun deleteOdlh(context: Context, accountName: String, syncToken: String? = null, deletions: List<GellerElement>): BatchSyncResponse? {
Log.d(TAG, "deleteOdlh: ${deletions.size} entries")
return syncOdlh(
context, accountName, items = listOf(buildSyncItem(syncToken, deletions = deletions)), syncReason = SyncReason.SYNC_REASON_RESTORE
)
}
suspend fun queryOdlh(context: Context, accountName: String, syncToken: String? = null): BatchSyncResponse? {
return syncOdlh(
context, accountName, items = listOf(buildSyncItem(syncToken)), syncReason = SyncReason.SYNC_REASON_PERIODIC
)
}
}

View file

@ -0,0 +1,341 @@
/**
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.semanticlocationhistory.db.backup
import android.content.Context
import android.util.Log
import com.google.android.gms.geller.ElementTimestamp
import com.google.android.gms.geller.GellerAny
import com.google.android.gms.geller.GellerDataType
import com.google.android.gms.geller.GellerE2eeElement
import com.google.android.gms.geller.GellerElement
import com.google.android.gms.geller.externaldb.ExternalDbSnapshot
import com.google.android.gms.geller.externaldb.ExternalDbSync
import com.google.android.gms.semanticlocationhistory.E2EE_TYPE_URL
import com.google.android.gms.semanticlocationhistory.OdlhBackupSummary
import com.google.android.gms.semanticlocationhistory.TAG
import com.google.android.gms.semanticlocationhistory.api.GellerSyncClient
import com.google.android.gms.semanticlocationhistory.db.OdlhStorageManager
object BackupRestoreHandler {
@Volatile
private var cachedAllMutations: List<GellerElement>? = null
suspend fun fetchBackupSummaries(
context: Context,
accountName: String,
storageManager: OdlhStorageManager
): List<OdlhBackupSummary> {
val response = GellerSyncClient.queryOdlh(
context = context,
accountName = accountName,
syncToken = null
)
if (response == null) {
Log.w(TAG, "fetchBackupSummaries: queryOdlh returned null")
return emptyList()
}
val localDatabaseId = storageManager.getDatabaseId()
val allMutations = response.items
.filter { it.dataType == GellerDataType.ENCRYPTED_ONDEVICE_LOCATION_HISTORY }
.mapNotNull { it.syncResult }
.flatMap { it.mutations }
cachedAllMutations = allMutations
if (allMutations.isEmpty()) {
Log.d(TAG, "fetchBackupSummaries: no mutations in response, returning empty list")
return emptyList()
}
Log.d(TAG, "fetchBackupSummaries_allMutations: $allMutations")
val deviceMap = mutableMapOf<Long, BackupSnapshotData>()
var decryptedCount = 0
var fallbackCount = 0
for (entry in allMutations) {
val key = entry.elementId ?: continue
val databaseIdFromKey = parseDatabaseIdFromKey(key) ?: continue
val parsed = tryParseSnapshot(context, accountName, entry)
if (parsed != null) decryptedCount++ else fallbackCount++
val snapshot = parsed?.first
val serializedSize = parsed?.second ?: (entry.payload?.value_?.size ?: 0)
val databaseId = snapshot?.databaseId ?: databaseIdFromKey
val entryTimestamp = entry.timestamp?.timestampMicros ?: 0L
val existing = deviceMap[databaseId]
if (existing == null) {
deviceMap[databaseId] = createBackupSnapshotData(snapshot, entryTimestamp, serializedSize, key)
} else {
mergeBackupSnapshotData(existing, snapshot, entryTimestamp, serializedSize, key)
}
}
if (deviceMap.isEmpty()) {
Log.d(TAG, "fetchBackupSummaries: no valid backup data found")
return emptyList()
}
Log.d(TAG, "fetchBackupSummaries: found ${deviceMap.size} devices (decrypted=$decryptedCount, fallback=$fallbackCount), localDatabaseId=$localDatabaseId")
return deviceMap.map { (databaseId, info) ->
Log.d(TAG, "fetchBackupSummaries_info: $info")
OdlhBackupSummary(
databaseId,
info.deviceModel,
databaseId == localDatabaseId,
info.latestTimestamp,
info.keys.toList(),
info.deviceName,
info.serializedSize.takeIf { it > 0 },
info.rowCount.takeIf { it > 0 },
info.earliestTimestamp.takeIf { it != Long.MAX_VALUE }
)
}
}
private fun parseDatabaseIdFromKey(key: String): Long? {
val parts = key.split(";")
if (parts.size != 4) return null
return parts[0].toLongOrNull()
}
private fun createBackupSnapshotData(
snapshot: ExternalDbSnapshot?,
entryTimestamp: Long,
serializedSize: Int,
key: String
): BackupSnapshotData {
return BackupSnapshotData(
deviceModel = snapshot?.deviceModel ?: "",
deviceName = snapshot?.deviceIdentifier ?: "",
latestTimestamp = snapshot?.timestamp?.timestampMicros ?: entryTimestamp,
rowCount = snapshot?.rows?.size ?: 0,
serializedSize = serializedSize,
earliestTimestamp = snapshot?.let { computeEarliestTimestamp(it) } ?: Long.MAX_VALUE,
keys = mutableSetOf(key)
)
}
private fun mergeBackupSnapshotData(
existing: BackupSnapshotData,
snapshot: ExternalDbSnapshot?,
entryTimestamp: Long,
serializedSize: Int,
key: String
) {
val newTs = snapshot?.timestamp?.timestampMicros ?: entryTimestamp
if (newTs > existing.latestTimestamp) {
snapshot?.deviceModel?.takeIf { it.isNotEmpty() }?.let { existing.deviceModel = it }
snapshot?.deviceIdentifier?.takeIf { it.isNotEmpty() }?.let { existing.deviceName = it }
existing.latestTimestamp = newTs
}
existing.rowCount += snapshot?.rows?.size ?: 0
existing.serializedSize += serializedSize
val newEarliest = snapshot?.let { computeEarliestTimestamp(it) } ?: Long.MAX_VALUE
if (newEarliest < existing.earliestTimestamp) {
existing.earliestTimestamp = newEarliest
}
existing.keys.add(key)
}
private fun tryParseSnapshot(context: Context, accountName: String, entry: GellerElement): Pair<ExternalDbSnapshot, Int>? {
val typedValue = entry.payload ?: return null
val typeUrl = typedValue.typeUrl
val valueBytes = typedValue.value_.toByteArray()
return try {
val externalDbSyncBytes: ByteArray
if (typeUrl == E2EE_TYPE_URL) {
val e2eeElement = GellerE2eeElement.ADAPTER.decode(bytes = valueBytes)
val decryptedBytes = OdlhSyncProcessor.decryptE2eeElement(context, accountName, e2eeElement)
?: return null
val innerTypedValue = GellerAny.ADAPTER.decode(decryptedBytes)
if (!innerTypedValue.typeUrl.contains("ExternalDbSync")) return null
externalDbSyncBytes = innerTypedValue.value_.toByteArray()
} else if (typeUrl.contains("ExternalDbSync")) {
externalDbSyncBytes = valueBytes
} else {
return null
}
val dbSync = ExternalDbSync.ADAPTER.decode(externalDbSyncBytes)
val snapshot = dbSync.snapshot ?: return null
Pair(snapshot, externalDbSyncBytes.size)
} catch (e: Exception) {
Log.w(TAG, "tryParseSnapshot: failed for key=${entry.elementId}: ${e.message}")
null
}
}
private fun computeEarliestTimestamp(tableSync: ExternalDbSnapshot): Long {
val colIndex = tableSync.columnNames.indexOf("start_timestamp_seconds")
if (colIndex < 0) return Long.MAX_VALUE
var earliest = Long.MAX_VALUE
for (row in tableSync.rows) {
if (colIndex >= row.values.size) continue
val ts = row.values[colIndex].intValue ?: continue
if (ts > 0 && ts < earliest) {
earliest = ts
}
}
return earliest
}
data class RestoreResult(
val restoredCount: Int,
val minStartSec: Long = Long.MAX_VALUE,
val maxEndSec: Long = Long.MIN_VALUE
) {
val hasData get() = restoredCount > 0 && minStartSec != Long.MAX_VALUE
}
fun restoreBackups(
context: Context,
accountName: String,
gaiaId: String,
databaseIds: List<Long>,
storageManager: OdlhStorageManager
): RestoreResult {
val mutations = cachedAllMutations
if (mutations.isNullOrEmpty()) {
Log.w(TAG, "restoreBackups: no cached mutations available")
return RestoreResult(0)
}
val localDatabaseId = storageManager.getDatabaseId()
val databaseIdSet = databaseIds.toSet()
var restoredCount = 0
var failedCount = 0
var minStart = Long.MAX_VALUE
var maxEnd = Long.MIN_VALUE
for (entry in mutations) {
val key = entry.elementId ?: continue
val entryDatabaseId = parseDatabaseIdFromKey(key) ?: continue
if (entryDatabaseId !in databaseIdSet) continue
try {
val segments = OdlhSyncProcessor.processMutation(context, accountName, entry, localDatabaseId)
for (segment in segments) {
Log.d(
TAG, "restoreBackups: segment segmentId=${segment.segmentId}, type=${segment.segmentType}, " +
"start=${segment.startTimestamp}, end=${segment.endTimestamp}, " +
"hierarchy=${segment.hierarchyLevel}, dataSize=${segment.data.size}"
)
val rowId = storageManager.insertOrUpdateSegment(gaiaId, segment)
if (rowId >= 0) {
restoredCount++
minStart = minOf(minStart, segment.startTimestamp)
maxEnd = maxOf(maxEnd, segment.endTimestamp)
} else {
failedCount++
Log.w(TAG, "restoreBackups: insertOrUpdateSegment returned $rowId for segmentId=${segment.segmentId}")
}
}
} catch (e: Exception) {
failedCount++
Log.e(TAG, "restoreBackups: failed to process entry key=$key", e)
}
}
if (restoredCount > 0 && minStart != Long.MAX_VALUE) {
val clearedEdits = storageManager.deleteEditedSegmentsByTimeRange(gaiaId, minStart, maxEnd)
if (clearedEdits > 0) {
Log.d(TAG, "restoreBackups: cleared $clearedEdits stale edits in restored time range [$minStart, $maxEnd]")
}
}
cachedAllMutations = null
Log.d(
TAG, "restoreBackups: restored=$restoredCount, failed=$failedCount, " +
"timeRange=[$minStart, $maxEnd] for databaseIds=$databaseIds"
)
return RestoreResult(restoredCount, minStart, maxEnd)
}
suspend fun deleteBackups(
context: Context,
accountName: String,
databaseIds: List<Long>,
storageManager: OdlhStorageManager
) {
val response = GellerSyncClient.queryOdlh(
context = context,
accountName = accountName,
syncToken = null
)
if (response == null) {
Log.w(TAG, "deleteBackups: queryOdlh returned null")
return
}
val databaseIdSet = databaseIds.toSet()
var syncToken: String? = null
val latestEntryByKey = mutableMapOf<String, GellerElement>()
for (item in response.items) {
if (item.dataType != GellerDataType.ENCRYPTED_ONDEVICE_LOCATION_HISTORY) continue
val syncResult = item.syncResult ?: continue
if (!syncResult.syncToken.isNullOrEmpty()) {
syncToken = syncResult.syncToken
}
val entries = syncResult.mutations + syncResult.results
for (entry in entries) {
val key = entry.elementId ?: continue
val databaseId = parseDatabaseIdFromKey(key) ?: continue
if (databaseId !in databaseIdSet) continue
val ts = entry.timestamp?.timestampMicros ?: 0L
val existing = latestEntryByKey[key]
if (existing == null || ts > (existing.timestamp?.timestampMicros ?: 0L)) {
latestEntryByKey[key] = GellerElement(elementId = key, timestamp = ElementTimestamp(timestampMicros = ts))
}
}
}
Log.d(TAG, "deleteBackups: found ${latestEntryByKey.size} unique keys to delete for databaseIds=$databaseIds, syncToken=${syncToken?.take(16)}...")
if (latestEntryByKey.isEmpty()) return
val deleteResponse = GellerSyncClient.deleteOdlh(
context = context,
accountName = accountName,
syncToken = syncToken,
deletions = latestEntryByKey.values.toList()
)
Log.d(TAG, "deleteBackups: deleteOdlh response=$deleteResponse")
if (storageManager.getDatabaseId() in databaseIdSet) {
storageManager.clearSyncToken(GellerDataType.ENCRYPTED_ONDEVICE_LOCATION_HISTORY.value)
Log.d(TAG, "deleteBackups: cleared local syncToken for this device")
}
}
private data class BackupSnapshotData(
var deviceModel: String = "",
var deviceName: String = "",
var latestTimestamp: Long = 0L,
var rowCount: Int = 0,
var serializedSize: Int = 0,
var earliestTimestamp: Long = Long.MAX_VALUE,
val keys: MutableSet<String> = mutableSetOf()
)
}

View file

@ -0,0 +1,440 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.semanticlocationhistory.db.backup
import android.content.Context
import android.os.Build
import android.util.Log
import com.google.android.gms.geller.ElementTimestamp
import com.google.android.gms.geller.GellerAny
import com.google.android.gms.geller.GellerE2eeElement
import com.google.android.gms.geller.GellerElement
import com.google.android.gms.geller.externaldb.ExternalDbSnapshot
import com.google.android.gms.geller.externaldb.ExternalDbSync
import com.google.android.gms.geller.externaldb.SnapshotRow
import com.google.android.gms.geller.externaldb.SnapshotValue
import com.google.android.gms.geller.externaldb.TimestampMicros
import com.google.android.gms.semanticlocationhistory.AES_GCM_IV_SIZE
import com.google.android.gms.semanticlocationhistory.AES_GCM_TAG_BITS
import com.google.android.gms.semanticlocationhistory.E2EE_TYPE_URL
import com.google.android.gms.semanticlocationhistory.api.GellerSyncClient
import com.google.android.gms.semanticlocationhistory.db.OdlhStorageManager
import com.google.android.gms.semanticlocationhistory.db.RawSegmentRow
import com.google.android.gms.semanticlocationhistory.getObfuscatedGaiaId
import com.google.android.gms.semanticlocationhistory.loadKeyMaterials
import okio.ByteString.Companion.toByteString
import org.microg.gms.common.Constants
import org.microg.gms.location.LocationSettings
import java.security.SecureRandom
import java.util.TimeZone
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
private const val EXTERNAL_DB_SYNC_TYPE_URL = "type.googleapis.com/geller.externaldb.ExternalDbSync"
private const val BACKUP_SYNC_TOKEN_KEY = "backup_sync_token"
private const val SHARD_SHIFT = 10
private const val SHARD_SIZE = 1 shl SHARD_SHIFT // 1024
private const val MAX_BATCH_SIZE = 3_670_016
private val BACKUP_COLUMNS = listOf(
"_id", "timestamp_millis", "database_id", "origin_id", "segment_id",
"semantic_segment", "obfuscated_gaia_id", "shown_in_timeline", "is_finalized",
"start_timestamp_seconds", "end_timestamp_seconds", "segment_type", "hierarchy_level"
)
data class BackupResult(
val success: Boolean,
val message: String? = null,
val uploadedShards: Int = 0
)
private const val TAG = "OdlhBackupProcessor"
object OdlhBackupProcessor {
/**
* Performs a full backup of all location history data.
*/
suspend fun performBackup(context: Context, accountName: String): BackupResult {
val allowedUpload = LocationSettings(context).mapsTimelineUpload
if (!allowedUpload) {
Log.w(TAG, "<performBackup> user not allowed report")
return BackupResult(false, "Upload Not allowed!")
}
Log.d(TAG, "performBackup: starting for account=$accountName")
val gaiaId = context.getObfuscatedGaiaId(accountName)
val storageManager = OdlhStorageManager.getInstance(context)
val databaseId = storageManager.getDatabaseId()
Log.d(TAG, "performBackup: gaiaId=${gaiaId.take(8)}..., databaseId=$databaseId")
val flushed = storageManager.flushEditsToMainTable(gaiaId)
Log.d(TAG, "performBackup: flushed $flushed edit blocks before backup")
val keyMaterials = loadKeyMaterials(context, accountName)
if (keyMaterials.isEmpty()) {
Log.e(TAG, "performIncrementalBackup: no encryption keys found")
return BackupResult(false, "No encryption keys available")
}
val (keyMaterial, keyVersion) = keyMaterials.first()
val shards = readAndShardData(storageManager, gaiaId, databaseId)
if (shards.isEmpty()) {
Log.d(TAG, "performBackup: no data to backup")
return BackupResult(true, "No data to backup", 0)
}
Log.d(TAG, "performBackup: ${shards.size} shards to upload")
val allEntries = buildShardEntries(context, accountName, gaiaId, databaseId, shards, keyMaterial, keyVersion)
val backupSyncToken = storageManager.getMetadata(BACKUP_SYNC_TOKEN_KEY)
var totalUploaded = 0
var lastSyncToken = backupSyncToken
val batches = splitIntoBatches(allEntries)
Log.d(TAG, "performBackup: ${batches.size} batch(es) to upload")
for (batch in batches) {
val response = GellerSyncClient.uploadOdlh(
context = context,
accountName = accountName,
syncToken = lastSyncToken,
mutations = batch
)
if (response == null) {
Log.e(TAG, "performBackup: upload failed for batch")
return BackupResult(false, "Upload failed", totalUploaded)
}
totalUploaded += batch.size
for (item in response.items) {
item.syncResult?.syncToken?.let { token ->
lastSyncToken = token
storageManager.setMetadata(BACKUP_SYNC_TOKEN_KEY, token)
Log.d(TAG, "performBackup: saved new backup syncToken")
}
}
}
Log.d(TAG, "performBackup: completed, uploaded $totalUploaded shards")
return BackupResult(true, uploadedShards = totalUploaded)
}
private fun buildShardEntries(
context: Context,
accountName: String,
gaiaId: String,
databaseId: Long,
shards: Map<Long, List<RawSegmentRow>>,
keyMaterial: ByteArray,
keyVersion: Int
): List<GellerElement> {
return shards.map { (shardIndex, rows) ->
buildShardDataEntry(context, accountName, gaiaId, databaseId, shardIndex, rows, keyMaterial, keyVersion)
}
}
/**
* Incremental sync backup - follows GMS three-step delta comparison.
*
* Flow:
* 1. Read previous sync snapshot (getShardSyncSnapshot)
* 2. Read current SQLite shard states (queryShardStates: shardIndex, rowCount, maxTimestampMillis)
* 3. Delta comparison: rowCount or maxTimestampMillis changed -> modified shard; previously existed but now missing -> deleted shard
* 4. Upload changed shards, delete removed shards
* 5. Update shard sync snapshot + sync_token
*/
suspend fun performIncrementalBackup(context: Context, accountName: String): BackupResult {
Log.d(TAG, "performIncrementalBackup: starting for account=$accountName")
val allowedUpload = LocationSettings(context).mapsTimelineUpload
if (!allowedUpload) {
Log.w(TAG, "<performIncrementalBackup> user not allowed report")
return BackupResult(false, "Upload Not allowed!")
}
val gaiaId = context.getObfuscatedGaiaId(accountName)
val storageManager = OdlhStorageManager.getInstance(context)
val databaseId = storageManager.getDatabaseId()
Log.d(TAG, "performIncrementalBackup: gaiaId=${gaiaId.take(8)}..., databaseId=$databaseId")
val flushed = storageManager.flushEditsToMainTable(gaiaId)
Log.d(TAG, "performIncrementalBackup: flushed $flushed edit blocks before backup")
val keyMaterials = loadKeyMaterials(context, accountName)
if (keyMaterials.isEmpty()) {
Log.e(TAG, "performIncrementalBackup: no encryption keys found")
return BackupResult(false, "No encryption keys available")
}
val (keyMaterial, keyVersion) = keyMaterials.first()
// Step 1: Read previous sync snapshot
val previousShards = storageManager.getShardSyncSnapshot(gaiaId, databaseId).toMutableMap()
Log.d(TAG, "performIncrementalBackup: ${previousShards.size} previously synced shards")
// Step 2: Read current SQLite shard states
val currentShards = storageManager.queryShardStates(gaiaId, databaseId)
Log.d(TAG, "performIncrementalBackup: ${currentShards.size} current shards")
// Step 3: Delta comparison
val changedShardIndices = mutableListOf<Long>()
for ((shardIndex, currentState) in currentShards) {
val prevState = previousShards.remove(shardIndex)
if (prevState == null ||
prevState.rowCount != currentState.rowCount ||
prevState.maxTimestampMillis != currentState.maxTimestampMillis
) {
changedShardIndices.add(shardIndex)
}
}
// Remaining entries in previousShards = deleted shards
val deletedShardIndices = previousShards.keys.toList()
Log.d(TAG, "performIncrementalBackup: ${changedShardIndices.size} changed, ${deletedShardIndices.size} deleted")
if (changedShardIndices.isEmpty() && deletedShardIndices.isEmpty()) {
Log.d(TAG, "performIncrementalBackup: no changes detected")
return BackupResult(true, "No changes to sync", 0)
}
val backupSyncToken = storageManager.getMetadata(BACKUP_SYNC_TOKEN_KEY)
var lastSyncToken = backupSyncToken
var totalUploaded = 0
// Upload changed shards
if (changedShardIndices.isNotEmpty()) {
val entries = buildChangedShardEntries(
storageManager, changedShardIndices, gaiaId, databaseId,
context, accountName, keyMaterial, keyVersion
)
if (entries.isNotEmpty()) {
val batches = splitIntoBatches(entries)
Log.d(TAG, "performIncrementalBackup: uploading ${entries.size} changed shards in ${batches.size} batch(es)")
for (batch in batches) {
val response = GellerSyncClient.uploadOdlh(
context = context,
accountName = accountName,
syncToken = lastSyncToken,
mutations = batch
)
if (response == null) {
Log.e(TAG, "performIncrementalBackup: upload failed")
return BackupResult(false, "Upload failed", totalUploaded)
}
totalUploaded += batch.size
for (item in response.items) {
item.syncResult?.syncToken?.let { token ->
lastSyncToken = token
}
}
}
}
}
// Delete removed shards
if (deletedShardIndices.isNotEmpty()) {
val deletions = buildShardDeletions(databaseId, deletedShardIndices)
Log.d(TAG, "performIncrementalBackup: deleting ${deletions.size} removed shards")
val response = GellerSyncClient.deleteOdlh(
context = context,
accountName = accountName,
syncToken = lastSyncToken,
deletions = deletions
)
if (response != null) {
for (item in response.items) {
item.syncResult?.syncToken?.let { token ->
lastSyncToken = token
}
}
}
}
// Save sync_token for upload direction (independent from download sync_token)
val finalSyncToken = lastSyncToken
if (finalSyncToken != null && finalSyncToken != backupSyncToken) {
storageManager.setMetadata(BACKUP_SYNC_TOKEN_KEY, finalSyncToken)
Log.d(TAG, "performIncrementalBackup: saved new backup syncToken")
}
// Save updated shard sync snapshot
storageManager.saveShardSyncSnapshot(gaiaId, databaseId, currentShards.values)
Log.d(TAG, "performIncrementalBackup: completed, $totalUploaded uploaded + ${deletedShardIndices.size} deleted")
return BackupResult(true, uploadedShards = totalUploaded)
}
private fun buildChangedShardEntries(
storageManager: OdlhStorageManager,
changedShardIndices: List<Long>,
gaiaId: String,
databaseId: Long,
context: Context,
accountName: String,
keyMaterial: ByteArray,
keyVersion: Int
): List<GellerElement> {
return changedShardIndices.mapNotNull { shardIndex ->
val minId = shardIndex * SHARD_SIZE
val maxId = minId + SHARD_SIZE - 1
val rows = storageManager.queryRawSegmentRowsForShard(gaiaId, databaseId, minId, maxId)
if (rows.isNotEmpty()) {
buildShardDataEntry(context, accountName, gaiaId, databaseId, shardIndex, rows, keyMaterial, keyVersion)
} else {
null
}
}
}
private fun buildShardDeletions(databaseId: Long, deletedShardIndices: List<Long>): List<GellerElement> {
return deletedShardIndices.map { shardIndex ->
val minId = shardIndex * SHARD_SIZE
val maxId = minId + SHARD_SIZE - 1
val gellerKey = "$databaseId;semantic_segment_table;$minId;$maxId"
GellerElement(elementId = gellerKey)
}
}
private fun readAndShardData(storageManager: OdlhStorageManager, gaiaId: String, databaseId: Long): Map<Long, List<RawSegmentRow>> {
val rows = storageManager.queryRawSegmentRows(gaiaId, databaseId)
Log.d(TAG, "readAndShardData: ${rows.size} rows total")
return rows.groupBy { it.id shr SHARD_SHIFT }
}
private fun buildSnapshotRow(row: RawSegmentRow, gaiaId: String): SnapshotRow {
return SnapshotRow(
values = listOf(
SnapshotValue(intValue = row.id),
SnapshotValue(intValue = row.timestampMillis),
SnapshotValue(intValue = row.databaseId),
if (row.originId != null) SnapshotValue(intValue = row.originId) else SnapshotValue(),
SnapshotValue(stringValue = row.segmentId),
SnapshotValue(bytesValue = row.semanticSegment.toByteString()),
SnapshotValue(stringValue = gaiaId),
SnapshotValue(boolValue = row.shownInTimeline),
SnapshotValue(boolValue = row.isFinalized),
SnapshotValue(intValue = row.startTimestampSeconds),
SnapshotValue(intValue = row.endTimestampSeconds),
SnapshotValue(intValue = row.segmentType.toLong()),
if (row.hierarchyLevel != null) SnapshotValue(boolValue = row.hierarchyLevel > 0) else SnapshotValue()
)
)
}
private fun buildExternalDbSync(
context: Context,
accountName: String,
gaiaId: String,
databaseId: Long,
rows: List<RawSegmentRow>
): ExternalDbSync {
val minId = rows.minOf { it.id }
val maxId = rows.maxOf { it.id }
val currentTimeSeconds = System.currentTimeMillis() / 1000
val timezoneOffsetMinutes = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 60_000
val protoRows = rows.map { buildSnapshotRow(it, gaiaId) }
val deviceTag = OdlhStorageManager.getInstance(context).getDeviceTag(accountName)
val tableSync = ExternalDbSnapshot(
tableName = "semantic_segment_table",
startId = minId,
endId = maxId,
columnNames = BACKUP_COLUMNS,
rows = protoRows,
databaseId = databaseId,
deviceModel = Build.MODEL ?: "",
timestamp = TimestampMicros(
timestampMicros = currentTimeSeconds,
timezoneOffsetMinutes = timezoneOffsetMinutes
),
deviceIdentifier = deviceTag.toString()
)
return ExternalDbSync(
syncType = 1,
gmsVersion = Constants.GMS_VERSION_CODE.toString(),
snapshot = tableSync
)
}
private fun encryptExternalDbSync(externalDbSync: ExternalDbSync, key: ByteArray, keyVersion: Int): GellerE2eeElement {
val dbSyncBytes = ExternalDbSync.ADAPTER.encode(externalDbSync)
val typedValue = GellerAny(typeUrl = EXTERNAL_DB_SYNC_TYPE_URL, value_ = dbSyncBytes.toByteString())
val plaintext = GellerAny.ADAPTER.encode(typedValue)
val iv = ByteArray(AES_GCM_IV_SIZE).apply { SecureRandom().nextBytes(this) }
val ciphertextAndTag = Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(AES_GCM_TAG_BITS, iv))
}.doFinal(plaintext)
return GellerE2eeElement(
encryptedData = (iv + ciphertextAndTag).toByteString(),
encryptionVersion = keyVersion
)
}
private fun buildShardDataEntry(
context: Context,
accountName: String,
gaiaId: String,
databaseId: Long,
shardIndex: Long,
rows: List<RawSegmentRow>,
key: ByteArray,
keyVersion: Int
): GellerElement {
val minId = shardIndex * SHARD_SIZE
val maxId = minId + SHARD_SIZE - 1
val gellerKey = "$databaseId;semantic_segment_table;$minId;$maxId"
val externalDbSync = buildExternalDbSync(context, accountName, gaiaId, databaseId, rows)
val e2eeElement = encryptExternalDbSync(externalDbSync, key, keyVersion)
val e2eeBytes = GellerE2eeElement.ADAPTER.encode(e2eeElement)
val typedValue = GellerAny(typeUrl = E2EE_TYPE_URL, value_ = e2eeBytes.toByteString())
return GellerElement(
elementId = gellerKey,
payload = typedValue,
timestamp = ElementTimestamp(timestampMicros = System.currentTimeMillis() * 1000)
)
}
private fun splitIntoBatches(entries: List<GellerElement>): List<List<GellerElement>> {
if (entries.isEmpty()) return emptyList()
val batches = mutableListOf<List<GellerElement>>()
var currentBatch = mutableListOf<GellerElement>()
var currentSize = 0
for (entry in entries) {
val entrySize = GellerElement.ADAPTER.encodedSize(entry)
if (currentSize + entrySize > MAX_BATCH_SIZE) {
if (currentBatch.isNotEmpty()) {
batches.add(currentBatch)
currentBatch = mutableListOf()
currentSize = 0
}
}
currentBatch.add(entry)
currentSize += entrySize
}
if (currentBatch.isNotEmpty()) {
batches.add(currentBatch)
}
return batches
}
}

View file

@ -0,0 +1,123 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.semanticlocationhistory.db.backup
import android.accounts.AccountManager
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.os.SystemClock
import android.util.Log
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.microg.gms.auth.AuthConstants
import org.microg.gms.location.LocationSettings
private const val TAG = "OdlhBackupService"
class OdlhBackupService : LifecycleService() {
companion object {
private const val ACTION_BACKUP = "com.google.android.gms.semanticlocationhistory.ACTION_PERIODIC_BACKUP"
private const val BACKUP_INTERVAL_MS = 8 * 60 * 60 * 1000L // 8h
fun scheduleBackup(context: Context) {
val allowedMapsTimelineFeature = LocationSettings(context).mapsTimelineUpload
if (!allowedMapsTimelineFeature) {
Log.w(TAG, "scheduleBackup: not allowed report")
return
}
val alarmManager = context.getSystemService(ALARM_SERVICE) as AlarmManager
val intent = Intent(context, OdlhBackupService::class.java).setAction(ACTION_BACKUP)
val pendingIntent = PendingIntent.getService(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.setInexactRepeating(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + BACKUP_INTERVAL_MS,
BACKUP_INTERVAL_MS,
pendingIntent
)
Log.d(TAG, "scheduleBackup: periodic backup scheduled every ${BACKUP_INTERVAL_MS / 3600000}h")
}
fun cancelBackup(context: Context) {
val alarmManager = context.getSystemService(ALARM_SERVICE) as AlarmManager
val intent = Intent(context, OdlhBackupService::class.java).setAction(ACTION_BACKUP)
val pendingIntent = PendingIntent.getService(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
Log.d(TAG, "cancelBackup: periodic backup cancelled")
}
}
override fun onBind(intent: Intent): IBinder? {
return super.onBind(intent)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action != ACTION_BACKUP) {
stopSelf(startId)
return START_NOT_STICKY
}
Log.d(TAG, "onStartCommand: periodic backup triggered")
lifecycleScope.launch {
try {
performBackupForAllAccounts()
} catch (e: Exception) {
Log.e(TAG, "onStartCommand: backup failed", e)
} finally {
stopSelf(startId)
}
}
return super.onStartCommand(intent, flags, startId)
}
private suspend fun performBackupForAllAccounts() {
val accounts = AccountManager.get(this)
.getAccountsByType(AuthConstants.DEFAULT_ACCOUNT_TYPE)
if (accounts.isEmpty()) {
Log.d(TAG, "performBackupForAllAccounts: no Google accounts found")
return
}
Log.d(TAG, "performBackupForAllAccounts: ${accounts.size} account(s)")
for (account in accounts) {
try {
Log.d(TAG, "performBackupForAllAccounts: starting for ${account.name}")
val result = OdlhBackupProcessor.performIncrementalBackup(this, account.name)
Log.d(TAG, "performBackupForAllAccounts: ${account.name} result=$result")
} catch (e: Exception) {
Log.e(TAG, "performBackupForAllAccounts: failed for ${account.name}", e)
}
}
}
}
class OdlhBackupReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED -> {
Log.d(TAG, "onReceive: ${intent.action}, re-scheduling backup")
OdlhBackupService.scheduleBackup(context)
}
}
}
}

View file

@ -0,0 +1,236 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.semanticlocationhistory.db.backup
import android.content.Context
import android.util.Log
import com.google.android.gms.geller.GellerAny
import com.google.android.gms.geller.GellerE2eeElement
import com.google.android.gms.geller.GellerElement
import com.google.android.gms.geller.externaldb.ExternalDbSnapshot
import com.google.android.gms.geller.externaldb.ExternalDbSync
import com.google.android.gms.semanticlocationhistory.AES_GCM_IV_SIZE
import com.google.android.gms.semanticlocationhistory.AES_GCM_TAG_BITS
import com.google.android.gms.semanticlocationhistory.AES_KEY_SIZE
import com.google.android.gms.semanticlocationhistory.E2EE_TYPE_URL
import com.google.android.gms.semanticlocationhistory.SEGMENT_TYPE_ACTIVITY
import com.google.android.gms.semanticlocationhistory.SEGMENT_TYPE_MEMORY
import com.google.android.gms.semanticlocationhistory.SEGMENT_TYPE_PATH
import com.google.android.gms.semanticlocationhistory.SEGMENT_TYPE_PERIOD_SUMMARY
import com.google.android.gms.semanticlocationhistory.SEGMENT_TYPE_UNKNOWN
import com.google.android.gms.semanticlocationhistory.SEGMENT_TYPE_VISIT
import com.google.android.gms.semanticlocationhistory.db.SemanticSegment
import com.google.android.gms.semanticlocationhistory.loadKeyMaterials
import org.microg.gms.semanticlocationhistory.FinalizationStatus
import org.microg.gms.semanticlocationhistory.LocationHistorySegmentProto
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.runCatching
private const val TAG = "OdlhSyncProcessor"
object OdlhSyncProcessor {
internal fun processMutation(
context: Context,
accountName: String,
mutation: GellerElement,
databaseId: Long
): List<SemanticSegment> {
val typedValue = mutation.payload
if (typedValue == null) {
Log.w(TAG, "DataEntry has no value: key=${mutation.elementId}, timestamp=${mutation.timestamp}")
return emptyList()
}
val typeUrl = typedValue.typeUrl
val valueBytes = typedValue.value_.toByteArray()
if (typeUrl == E2EE_TYPE_URL) {
val e2eeElement = runCatching { GellerE2eeElement.ADAPTER.decode(bytes = valueBytes) }.getOrNull() ?: return emptyList()
val decryptedBytes = decryptE2eeElement(context, accountName, e2eeElement) ?: return emptyList()
return parseDecryptedData(decryptedBytes, mutation.elementId, databaseId)
}
return parseSegmentData(typeUrl, valueBytes, mutation.elementId, databaseId)
}
private fun parseSegmentData(typeUrl: String, data: ByteArray, key: String?, databaseId: Long): List<SemanticSegment> {
return when {
typeUrl.contains("ExternalDbSync") -> {
runCatching {
val externalDbSync = ExternalDbSync.ADAPTER.decode(data)
val snapshot = externalDbSync.snapshot
if (snapshot != null) {
parseSnapshot(snapshot, databaseId)
} else {
emptyList()
}
}.getOrElse { e ->
Log.e(TAG, "Failed to parse ExternalDbSync: ${e.message}", e)
emptyList()
}
}
typeUrl.contains("LocationHistorySegmentProto") -> {
parseLocationHistorySegment(data, key, databaseId)?.let { listOf(it) } ?: emptyList()
}
else -> {
Log.w(TAG, "Unknown type_url: $typeUrl")
emptyList()
}
}
}
private fun parseDecryptedData(decryptedBytes: ByteArray, key: String?, databaseId: Long): List<SemanticSegment> {
return runCatching {
val typedValue = GellerAny.ADAPTER.decode(decryptedBytes)
val innerTypeUrl = typedValue.typeUrl
val innerValue = typedValue.value_.toByteArray()
if (innerValue.isNotEmpty()) {
parseSegmentData(innerTypeUrl, innerValue, key, databaseId)
} else {
ExternalDbSync.ADAPTER.decode(decryptedBytes).snapshot?.let { parseSnapshot(it, databaseId) }
?: run {
Log.w(TAG, "parseDecryptedData: no valid data found")
emptyList()
}
}
}.getOrElse { e ->
Log.e(TAG, "Failed to parse decrypted data", e)
parseLocationHistorySegment(decryptedBytes, key, databaseId)?.let { listOf(it) } ?: emptyList()
}
}
private fun parseSnapshot(snapshot: ExternalDbSnapshot, databaseId: Long): List<SemanticSegment> {
val columns = snapshot.columnNames
val segmentColumnIndex = columns.indexOf("semantic_segment")
if (segmentColumnIndex < 0) {
Log.w(TAG, "No semantic_segment column found in columns: $columns")
return emptyList()
}
val segmentIdColumnIndex = columns.indexOf("segment_id")
val segments = snapshot.rows.mapNotNull { row ->
runCatching {
val values = row.values
if (segmentColumnIndex >= values.size) return@runCatching null
val segmentId = if (segmentIdColumnIndex in 0 until values.size) {
values[segmentIdColumnIndex].stringValue
} else null
val bytes = values[segmentColumnIndex].bytesValue?.toByteArray()
if (bytes != null) {
parseLocationHistorySegment(bytes, segmentId, databaseId)
} else {
null
}
}.getOrElse { e ->
Log.w(TAG, "Failed to parse snapshot row: ${e.message}")
null
}
}
Log.d(TAG, "parseSnapshot: parsed ${segments.size}/${snapshot.rows.size} rows")
return segments
}
private fun parseLocationHistorySegment(data: ByteArray, key: String?, databaseId: Long): SemanticSegment? {
return runCatching {
val proto = LocationHistorySegmentProto.ADAPTER.decode(data)
val segmentId = proto.segment_id ?: key ?: UUID.randomUUID().toString()
val startTime = proto.start_time?.seconds ?: 0L
val endTime = proto.end_time?.seconds ?: 0L
val segmentData = proto.segment_data
val (segmentType, visitHierarchyLevel, visitFprint) = when {
segmentData?.visit != null -> {
val visit = segmentData.visit!!
val fp = visit.place?.feature_id?.low
Triple(SEGMENT_TYPE_VISIT, visit.hierarchy_level ?: 0, fp)
}
segmentData?.activity != null -> Triple(SEGMENT_TYPE_ACTIVITY, 0, null)
segmentData?.path != null -> Triple(SEGMENT_TYPE_PATH, 0, null)
segmentData?.memory != null -> Triple(SEGMENT_TYPE_MEMORY, 0, null)
segmentData?.summary != null -> Triple(SEGMENT_TYPE_PERIOD_SUMMARY, 0, null)
else -> Triple(SEGMENT_TYPE_UNKNOWN, 0, null)
}
val hierarchyLevel = proto.hierarchy_level ?: visitHierarchyLevel
val fprint = proto.finalization_state?.toLong() ?: visitFprint
val shownInTimeline = !(proto.is_deleted ?: false)
val isFinalized = proto.finalization_status in listOf(
FinalizationStatus.BACKFILLED,
FinalizationStatus.USER_EDITED
)
SemanticSegment(
segmentId = segmentId,
segmentType = segmentType,
data = data,
startTimestamp = startTime,
endTimestamp = endTime,
hierarchyLevel = hierarchyLevel,
fprint = fprint,
shownInTimeline = shownInTimeline,
timestampMillis = System.currentTimeMillis(),
databaseId = databaseId,
isFinalized = isFinalized
)
}.getOrElse { e ->
Log.e(TAG, "Failed to parse LocationHistorySegmentProto: ${e.message}", e)
null
}
}
internal fun decryptE2eeElement(context: Context, accountName: String, e2eeElement: GellerE2eeElement): ByteArray? {
val encryptedData = e2eeElement.encryptedData?.toByteArray() ?: return null
val encryptionVersion = e2eeElement.encryptionVersion ?: 0
val keyMaterials = loadKeyMaterials(context, accountName)
if (keyMaterials.isEmpty()) {
Log.e(TAG, "No decryption keys found for ODLH")
return null
}
val sortedMaterials = keyMaterials.let { materials ->
if (encryptionVersion != 0) {
materials.sortedByDescending { it.second == encryptionVersion }
} else {
materials
}
}
for ((keyMaterial, _) in sortedMaterials) {
for (aad in listOf(ByteArray(0), null)) {
runCatching { decryptAesGcm(keyMaterial, encryptedData, aad) }
.onSuccess { return it }
}
}
Log.e(TAG, "All decryption attempts failed")
return null
}
internal fun decryptAesGcm(key: ByteArray, encryptedData: ByteArray, aad: ByteArray? = null): ByteArray {
require(key.size == AES_KEY_SIZE) { "Key must be $AES_KEY_SIZE bytes (AES-256)" }
require(encryptedData.size > AES_GCM_IV_SIZE + 16) { "Encrypted data too short" }
val iv = encryptedData.copyOfRange(0, AES_GCM_IV_SIZE)
val ciphertext = encryptedData.copyOfRange(AES_GCM_IV_SIZE, encryptedData.size)
return Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(AES_GCM_TAG_BITS, iv))
aad?.takeIf { it.isNotEmpty() }?.let { updateAAD(it) }
}.doFinal(ciphertext)
}
}

View file

@ -0,0 +1,91 @@
/**
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.semanticlocationhistory
import android.accounts.AccountManager
import android.content.Context
import com.google.android.gms.semanticlocation.PlaceCandidate
import com.google.android.gms.semanticlocationhistory.db.SemanticSegment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.microg.gms.auth.AuthConstants
import org.microg.gms.auth.folsom.utils.LocalKeyManager
import org.microg.gms.semanticlocationhistory.LocationHistorySegmentProto
import java.util.WeakHashMap
const val TAG = "LocationHistoryService"
const val SEGMENT_TYPE_DELETED = -1
const val SEGMENT_TYPE_UNKNOWN = 0
const val SEGMENT_TYPE_VISIT = 1
const val SEGMENT_TYPE_ACTIVITY = 2
const val SEGMENT_TYPE_PATH = 3
const val SEGMENT_TYPE_MEMORY = 4
const val SEGMENT_TYPE_PERIOD_SUMMARY = 5
const val ODLH_SECURITY_DOMAIN = "users/me/securitydomains/on_device_location_history"
const val ODLH_SECURITY_DOMAIN_SHORT = "on_device_location_history"
const val E2EE_TYPE_URL = "type.googleapis.com/geller.oneplatform.GellerE2eeElement"
const val AES_GCM_IV_SIZE = 12
const val AES_GCM_TAG_BITS = 128
const val AES_KEY_SIZE = 32
const val SEMANTIC_TYPE_HOME = 1
const val SEMANTIC_TYPE_WORK = 2
const val SEARCH_WINDOW_DAYS = 30L
const val SECONDS_PER_DAY = 86400L
suspend fun Context.requestGellerOauthToken(accountName: String, scope: String = "oauth2:https://www.googleapis.com/auth/webhistory"): String {
val accountManager = AccountManager.get(this)
val account = accountManager.getAccountsByType(AuthConstants.DEFAULT_ACCOUNT_TYPE).find {
it.name == accountName
}
if (account == null) throw RuntimeException("account is null")
return withContext(Dispatchers.IO) {
accountManager.blockingGetAuthToken(account, scope, true)
} ?: throw RuntimeException("oauthToken is null")
}
suspend fun Context.getObfuscatedGaiaId(accountName: String) = requestGellerOauthToken(accountName, AuthConstants.SCOPE_GET_ACCOUNT_ID)
private val weakCachedKeyMap = WeakHashMap<String, List<Pair<ByteArray, Int>>>()
fun loadKeyMaterials(context: Context, accountName: String): List<Pair<ByteArray, Int>> {
val result = weakCachedKeyMap.get(accountName)
if (!result.isNullOrEmpty()) {
return result
}
val keyManager = LocalKeyManager.getInstance(context)
val keys = keyManager.getKeysForDomain(accountName, ODLH_SECURITY_DOMAIN)
.ifEmpty { keyManager.getKeysForDomain(accountName, ODLH_SECURITY_DOMAIN_SHORT) }
val materials = keys.mapNotNull { key ->
val material = key.keyMaterial?.toByteArray() ?: return@mapNotNull null
if (material.size != AES_KEY_SIZE) return@mapNotNull null
material to (key.keyVersion ?: 0)
}
weakCachedKeyMap.put(accountName, materials)
return materials
}
fun getInferredPlace(visitSegments: List<SemanticSegment>, semanticType: Int): InferredPlace? {
for (i in visitSegments.indices.reversed()) {
val segment = visitSegments[i]
val proto = LocationHistorySegmentProto.ADAPTER.decode(segment.data)
val place = proto.segment_data?.visit?.place ?: continue
if ((place.semantic_type?.value ?: 0) == semanticType) {
val featureId = place.feature_id
val location = place.location
val inferredPlace = InferredPlace(
PlaceCandidate.Identifier(featureId?.high ?: 0L, featureId?.low ?: 0L),
PlaceCandidate.Point(location?.lat_e7 ?: 0, location?.lng_e7 ?: 0),
semanticType
)
return inferredPlace
}
}
return null
}

View file

@ -0,0 +1,500 @@
/**
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.semanticlocationhistory.utils
import android.util.Log
import com.google.android.gms.semanticlocation.Activity
import com.google.android.gms.semanticlocation.ActivityCandidate
import com.google.android.gms.semanticlocation.Date
import com.google.android.gms.semanticlocation.Note
import com.google.android.gms.semanticlocation.Path
import com.google.android.gms.semanticlocation.PeriodSummary
import com.google.android.gms.semanticlocation.PlaceCandidate
import com.google.android.gms.semanticlocation.PointWithDetails
import com.google.android.gms.semanticlocation.TimelineMemory
import com.google.android.gms.semanticlocation.TimelinePath
import com.google.android.gms.semanticlocation.Trip
import com.google.android.gms.semanticlocation.Visit
import com.google.android.gms.semanticlocationhistory.LocationHistorySegment
import com.google.android.gms.semanticlocationhistory.SEGMENT_TYPE_ACTIVITY
import com.google.android.gms.semanticlocationhistory.SEGMENT_TYPE_MEMORY
import com.google.android.gms.semanticlocationhistory.SEGMENT_TYPE_PATH
import com.google.android.gms.semanticlocationhistory.SEGMENT_TYPE_PERIOD_SUMMARY
import com.google.android.gms.semanticlocationhistory.SEGMENT_TYPE_UNKNOWN
import com.google.android.gms.semanticlocationhistory.SEGMENT_TYPE_VISIT
import com.google.android.gms.semanticlocationhistory.db.SemanticSegment
import org.microg.gms.semanticlocationhistory.ActivityProto
import org.microg.gms.semanticlocationhistory.FinalizationStatus
import org.microg.gms.semanticlocationhistory.DateProto
import org.microg.gms.semanticlocationhistory.DestinationProto
import org.microg.gms.semanticlocationhistory.TimelineDisplayMode
import org.microg.gms.semanticlocationhistory.FeatureId
import org.microg.gms.semanticlocationhistory.LatLngE7
import org.microg.gms.semanticlocationhistory.LocationHistorySegmentProto
import org.microg.gms.semanticlocationhistory.MemoryProto
import org.microg.gms.semanticlocationhistory.NoteProto
import org.microg.gms.semanticlocationhistory.PathProto
import org.microg.gms.semanticlocationhistory.PeriodSummaryProto
import org.microg.gms.semanticlocationhistory.TopPlaceType
import org.microg.gms.semanticlocationhistory.SemanticType
import org.microg.gms.semanticlocationhistory.SegmentTypeUnion
import org.microg.gms.semanticlocationhistory.Timestamp
import org.microg.gms.semanticlocationhistory.TripNameComponents
import org.microg.gms.semanticlocationhistory.TripOrigin
import org.microg.gms.semanticlocationhistory.TripProto
import org.microg.gms.semanticlocationhistory.VisitProto
private typealias PlaceCandidateProto = org.microg.gms.semanticlocationhistory.PlaceCandidate
private typealias ActivityCandidateProto = org.microg.gms.semanticlocationhistory.ActivityCandidate
object SegmentConverter {
private const val TAG = "SegmentConverter"
fun toLocationHistorySegment(segment: SemanticSegment): LocationHistorySegment? {
return try {
val proto = LocationHistorySegmentProto.ADAPTER.decode(segment.data)
val startTimeSec = getTimestampSeconds(proto.start_time)
val endTimeSec = getTimestampSeconds(proto.end_time)
val segmentId = proto.segment_id ?: ""
val hierarchyLevel = proto.hierarchy_level ?: 0
val finalizationState = proto.finalization_state ?: 0
val displayMode = proto.display_mode?.value ?: 0
val finalizationStatus = proto.finalization_status?.value ?: 0
val segmentData = proto.segment_data
when {
segmentData?.visit != null -> {
val visit = convertVisit(segmentData.visit!!)
LocationHistorySegment(
startTimeSec, endTimeSec, hierarchyLevel, finalizationState, segmentId,
SEGMENT_TYPE_VISIT, visit, null, null,
displayMode, finalizationStatus, null, null
)
}
segmentData?.activity != null -> {
val activity = convertActivity(segmentData.activity!!)
LocationHistorySegment(
startTimeSec, endTimeSec, hierarchyLevel, finalizationState, segmentId,
SEGMENT_TYPE_ACTIVITY, null, activity, null,
displayMode, finalizationStatus, null, null
)
}
segmentData?.path != null -> {
val path = convertPath(segmentData.path!!, startTimeSec)
LocationHistorySegment(
startTimeSec, endTimeSec, hierarchyLevel, finalizationState, segmentId,
SEGMENT_TYPE_PATH, null, null, path,
displayMode, finalizationStatus, null, null
)
}
segmentData?.memory != null -> {
val memory = convertMemory(segmentData.memory!!)
LocationHistorySegment(
startTimeSec, endTimeSec, hierarchyLevel, finalizationState, segmentId,
SEGMENT_TYPE_MEMORY, null, null, null,
displayMode, finalizationStatus, memory, null
)
}
segmentData?.summary != null -> {
val summary = convertPeriodSummary(segmentData.summary!!)
LocationHistorySegment(
startTimeSec, endTimeSec, hierarchyLevel, finalizationState, segmentId,
SEGMENT_TYPE_PERIOD_SUMMARY, null, null, null,
displayMode, finalizationStatus, null, summary
)
}
else -> {
Log.d(TAG, "Segment has no segment_data (type unknown), segmentId=$segmentId")
LocationHistorySegment(
startTimeSec, endTimeSec, hierarchyLevel, finalizationState, segmentId,
SEGMENT_TYPE_UNKNOWN, null, null, null,
displayMode, finalizationStatus, null, null
)
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to convert segment", e)
null
}
}
fun toProtoBytes(segment: LocationHistorySegment): ByteArray =
buildProto(segment).encode()
fun buildProto(segment: LocationHistorySegment, overrideFinalizationStatus: FinalizationStatus? = null): LocationHistorySegmentProto {
val segmentData = when (segment.type) {
SEGMENT_TYPE_VISIT -> segment.visit?.let {
SegmentTypeUnion(visit = convertVisitToProto(it))
}
SEGMENT_TYPE_ACTIVITY -> segment.activity?.let {
SegmentTypeUnion(activity = convertActivityToProto(it))
}
SEGMENT_TYPE_PATH -> segment.timelinePath?.let {
SegmentTypeUnion(path = convertPathToProto(it, segment.startTimestamp))
}
SEGMENT_TYPE_MEMORY -> segment.timelineMemory?.let {
SegmentTypeUnion(memory = convertMemoryToProto(it))
}
SEGMENT_TYPE_PERIOD_SUMMARY -> segment.periodSummary?.let {
SegmentTypeUnion(summary = convertPeriodSummaryToProto(it))
}
else -> null
}
return LocationHistorySegmentProto(
start_time = createTimestamp(segment.startTimestamp),
end_time = createTimestamp(segment.endTimestamp),
segment_data = segmentData,
segment_id = segment.segmentId,
hierarchy_level = segment.hierarchyLevel,
finalization_state = segment.finalizationState,
display_mode = TimelineDisplayMode.fromValue(segment.displayMode),
finalization_status = overrideFinalizationStatus ?: FinalizationStatus.fromValue(segment.finalizationStatus)
)
}
private fun getTimestampSeconds(timestamp: Timestamp?): Long =
timestamp?.seconds ?: 0L
private fun createTimestamp(seconds: Long): Timestamp {
return Timestamp(seconds = seconds)
}
private fun convertVisit(proto: VisitProto): Visit {
val placeCandidate = convertPlaceCandidate(proto.place)
return Visit(
proto.hierarchy_level ?: 0,
proto.probability ?: 0f,
placeCandidate,
null,
proto.is_inferred ?: false,
null
)
}
private fun convertPlaceCandidate(proto: PlaceCandidateProto?): PlaceCandidate {
if (proto == null) return createDefaultPlaceCandidate()
val featureId = proto.feature_id
val identifier = PlaceCandidate.Identifier(
featureId?.high ?: 0L,
featureId?.low ?: 0L
)
val location = proto.location
val point = PlaceCandidate.Point(
location?.lat_e7 ?: 0,
location?.lng_e7 ?: 0
)
val topPlaceType = proto.top_place_type
val isCurrentLocation = topPlaceType == TopPlaceType.NEAREST_PLACE
val isHome = topPlaceType == TopPlaceType.GJ_UPGRADE_HOME
return PlaceCandidate(
identifier,
proto.semantic_type?.value ?: 0,
proto.probability ?: 0f,
point,
isCurrentLocation,
isHome,
proto.radius_meters ?: 0.0
)
}
private fun convertActivity(proto: ActivityProto): Activity {
val startLocation = proto.start_location
val startPoint = PlaceCandidate.Point(
startLocation?.lat_e7 ?: 0,
startLocation?.lng_e7 ?: 0
)
val endLocation = proto.end_location
val endPoint = PlaceCandidate.Point(
endLocation?.lat_e7 ?: 0,
endLocation?.lng_e7 ?: 0
)
val candidate = proto.candidate
val activityCandidate = ActivityCandidate(
candidate?.activity_type ?: 0,
candidate?.probability ?: 0f
)
return Activity(
startPoint,
endPoint,
proto.distance_meters ?: 0f,
proto.duration_seconds ?: 0f,
activityCandidate,
null,
null
)
}
private fun convertPath(proto: PathProto, startTimeSec: Long): TimelinePath {
val latE7s = proto.lat_e7s
val lngE7s = proto.lng_e7s
val offsets = proto.offset_minutes
val size = minOf(latE7s.size, lngE7s.size, offsets.size)
val points = (0 until size).map { i ->
PointWithDetails(
PlaceCandidate.Point(latE7s[i], lngE7s[i]),
startTimeSec + offsets[i] * 60L
)
}
return TimelinePath(Path(points))
}
private fun convertMemory(proto: MemoryProto): TimelineMemory {
return when {
proto.trip != null -> {
val trip = convertTrip(proto.trip!!)
TimelineMemory(trip, null)
}
proto.note != null -> {
TimelineMemory(null, Note(proto.note!!.content ?: ""))
}
else -> TimelineMemory(null, null)
}
}
private fun convertTrip(proto: TripProto): Trip {
val destinations = proto.destinations.map { dest ->
Trip.Destination(
PlaceCandidate.Identifier(
dest.feature_id?.high ?: 0L,
dest.feature_id?.low ?: 0L
)
)
}
val nameDestinations = proto.name_components?.destinations?.map { dest ->
Trip.Destination(
PlaceCandidate.Identifier(
dest.feature_id?.high ?: 0L,
dest.feature_id?.low ?: 0L
)
)
} ?: emptyList()
val origin = proto.origin?.let { originProto ->
Trip.Origin(
PlaceCandidate.Identifier(
originProto.feature_id?.high ?: 0L,
originProto.feature_id?.low ?: 0L
),
PlaceCandidate.Point(
originProto.location?.lat_e7 ?: 0,
originProto.location?.lng_e7 ?: 0
)
)
}
return Trip(
proto.duration_seconds ?: 0L,
destinations,
Trip.NameComponents(nameDestinations),
origin
)
}
private fun convertPeriodSummary(proto: PeriodSummaryProto): PeriodSummary {
val topVisits = proto.top_visits.map { visitProto ->
convertVisit(visitProto)
}
val date = proto.date
return PeriodSummary(
topVisits,
emptyList(),
Date(date?.year ?: 0, date?.month ?: 0, date?.day ?: 0)
)
}
private fun convertVisitToProto(visit: Visit): VisitProto {
return VisitProto(
hierarchy_level = visit.hierarchyLevel,
probability = visit.probability,
place = convertPlaceCandidateToProto(visit.place),
is_inferred = visit.isTimelessVisit
)
}
private fun convertPlaceCandidateToProto(place: PlaceCandidate?): PlaceCandidateProto {
if (place == null) {
return PlaceCandidateProto()
}
val metadataType = when {
place.isSensitiveForGorUsage -> TopPlaceType.NEAREST_PLACE
place.isEligibleForGorUsage -> TopPlaceType.GJ_UPGRADE_HOME
else -> null
}
return PlaceCandidateProto(
feature_id = FeatureId(
high = place.identifier.fprint,
low = place.identifier.cellId
),
semantic_type = SemanticType.fromValue(place.semanticType),
probability = place.probability,
location = LatLngE7(
lat_e7 = place.placeLocation.latE7,
lng_e7 = place.placeLocation.lngE7
),
radius_meters = place.semanticTypeConfidenceScore,
top_place_type = metadataType
)
}
private fun convertActivityToProto(activity: Activity): ActivityProto {
return ActivityProto(
start_location = LatLngE7(
lat_e7 = activity.start.latE7,
lng_e7 = activity.start.lngE7
),
end_location = LatLngE7(
lat_e7 = activity.end.latE7,
lng_e7 = activity.end.lngE7
),
distance_meters = activity.distanceMeters,
duration_seconds = activity.probability,
candidate = ActivityCandidateProto(
activity_type = activity.activityCandidate.type,
probability = activity.activityCandidate.probability
)
)
}
private fun convertPathToProto(path: TimelinePath, startTimeSec: Long): PathProto {
val latE7s = mutableListOf<Int>()
val lngE7s = mutableListOf<Int>()
val offsetMinutes = mutableListOf<Int>()
path.path?.points?.forEach { point ->
latE7s.add(point.point.latE7)
lngE7s.add(point.point.lngE7)
offsetMinutes.add(((point.timeOffset - startTimeSec) / 60).toInt())
}
return PathProto(
lat_e7s = latE7s,
lng_e7s = lngE7s,
offset_minutes = offsetMinutes
)
}
private fun convertMemoryToProto(memory: TimelineMemory): MemoryProto {
return when {
memory.trip != null -> {
val trip = memory.trip
MemoryProto(
trip = TripProto(
duration_seconds = trip.distance,
destinations = trip.destinations.map { dest ->
DestinationProto(
feature_id = FeatureId(
high = dest.identifier.fprint,
low = dest.identifier.cellId
)
)
},
name_components = TripNameComponents(
destinations = trip.nameComponents.components.map { dest ->
DestinationProto(
feature_id = FeatureId(
high = dest.identifier.fprint,
low = dest.identifier.cellId
)
)
}
),
origin = trip.origin?.let { origin ->
TripOrigin(
feature_id = FeatureId(
high = origin.identifier.fprint,
low = origin.identifier.cellId
),
location = LatLngE7(
lat_e7 = origin.point.latE7,
lng_e7 = origin.point.lngE7
)
)
}
)
)
}
memory.note != null -> {
MemoryProto(
note = NoteProto(content = memory.note.text)
)
}
else -> MemoryProto()
}
}
private fun convertPeriodSummaryToProto(summary: PeriodSummary): PeriodSummaryProto {
return PeriodSummaryProto(
top_visits = summary.visits.map { visit ->
convertVisitToProto(visit)
},
date = DateProto(
year = summary.date.year,
month = summary.date.month,
day = summary.date.day
)
)
}
fun updateSegmentTimestamps(segment: SemanticSegment, newStartSec: Long, newEndSec: Long): SemanticSegment {
val updatedData = try {
val proto = LocationHistorySegmentProto.ADAPTER.decode(segment.data)
val updatedProto = proto.copy(
start_time = createTimestamp(newStartSec),
end_time = createTimestamp(newEndSec)
)
updatedProto.encode()
} catch (e: Exception) {
Log.w(TAG, "Failed to update segment timestamps in proto, using original data", e)
segment.data
}
return segment.copy(
startTimestamp = newStartSec,
endTimestamp = newEndSec,
data = updatedData
)
}
private fun createDefaultPlaceCandidate(): PlaceCandidate {
return PlaceCandidate(
PlaceCandidate.Identifier(0, 0),
0, 0f,
PlaceCandidate.Point(0, 0),
false, false, 0.0
)
}
}

View file

@ -0,0 +1,221 @@
/**
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.semanticlocationhistory.utils
import android.util.Log
import com.google.android.gms.semanticlocationhistory.LocationHistorySegment
import com.google.android.gms.semanticlocationhistory.SEGMENT_TYPE_DELETED
import com.google.android.gms.semanticlocationhistory.SEGMENT_TYPE_MEMORY
import com.google.android.gms.semanticlocationhistory.TAG
import com.google.android.gms.semanticlocationhistory.db.OdlhStorageManager
import com.google.android.gms.semanticlocationhistory.db.SemanticSegment
import com.google.android.gms.semanticlocationhistory.utils.SegmentUtils.getHierarchyLevel
import com.google.android.gms.semanticlocationhistory.utils.SegmentUtils.toUserEditedProtoBytes
import com.google.android.gms.semanticlocationhistory.utils.SegmentUtils.TYPE_VISIT
import com.google.android.gms.semanticlocationhistory.utils.SegmentUtils.TYPE_ACTIVITY
import com.google.android.gms.semanticlocationhistory.utils.SegmentUtils.TYPE_MEMORY
object SegmentEditHandler {
private const val TYPE_DELETION = 0
fun editSegments(storageManager: OdlhStorageManager, gaiaId: String, segments: List<LocationHistorySegment>) {
if (segments.all { it.type == TYPE_MEMORY }) {
editMemorySegments(storageManager, gaiaId, segments)
return
}
val allowedTypes = setOf(TYPE_DELETION, TYPE_VISIT, TYPE_ACTIVITY)
for (seg in segments) {
if (seg.type !in allowedTypes) {
throw IllegalArgumentException(
"Segments must all be either Timeline Memories or Visits/Activities/Deletions, got type=${seg.type}"
)
}
}
editVisitActivitySegments(storageManager, gaiaId, segments)
}
private fun editVisitActivitySegments(
storageManager: OdlhStorageManager,
gaiaId: String,
segments: List<LocationHistorySegment>
) {
val sorted = segments.sortedWith(
compareBy<LocationHistorySegment> { it.startTimestamp }
.thenBy { getHierarchyLevel(it) }
)
validateBatchRules(sorted)
val editedSegments = sorted.map { seg ->
SemanticSegment(
segmentId = seg.segmentId ?: "edit_${System.nanoTime()}",
segmentType = seg.type,
data = toUserEditedProtoBytes(seg),
startTimestamp = seg.startTimestamp,
endTimestamp = seg.endTimestamp,
hierarchyLevel = if (seg.type == TYPE_VISIT) seg.visit?.hierarchyLevel ?: 0 else 0
)
}
checkContiguity(editedSegments)
storageManager.runInTransaction {
val editRange = calculateNonSubVisitRange(editedSegments)
val existingEdits = storageManager.queryEditedSegments(gaiaId, editRange.first, editRange.second)
.filter { it.segmentType != SEGMENT_TYPE_DELETED }
storageManager.deleteEditedSegmentsByTimeRange(gaiaId, editRange.first, editRange.second, excludeDeleted = true)
val merged = storageManager.mergeSegments(existingEdits, editedSegments)
val mergedRange = calculateNonSubVisitRange(merged)
for (seg in merged) {
if (seg.startTimestamp >= mergedRange.first && seg.endTimestamp <= mergedRange.second) {
val result = storageManager.insertEditedSegment(
gaiaId,
seg.copy(
blockStartTimestamp = mergedRange.first,
blockEndTimestamp = mergedRange.second,
isEditUploaded = false
)
)
if (result == -1L) {
throw RuntimeException("Failed to insert edited segment ${seg.segmentId}")
}
}
}
Log.d(TAG, "editSegments: wrote ${merged.size} edited segments (range=${mergedRange.first}..${mergedRange.second})")
}
}
private fun validateBatchRules(segments: List<LocationHistorySegment>) {
val grouped = mutableMapOf<Int, MutableList<LocationHistorySegment>>()
for (seg in segments) {
val level = getHierarchyLevel(seg)
if (seg.startTimestamp <= 0 || seg.endTimestamp <= 0 || seg.startTimestamp >= seg.endTimestamp) {
throw IllegalArgumentException("Invalid segment time range: start=${seg.startTimestamp}, end=${seg.endTimestamp}")
}
grouped.getOrPut(level) { mutableListOf() }.add(seg)
}
grouped[0]?.let { level0 ->
val sortedLevel0 = level0.sortedBy { it.startTimestamp }
for (i in 1 until sortedLevel0.size) {
if (sortedLevel0[i].startTimestamp != sortedLevel0[i - 1].endTimestamp) {
throw IllegalArgumentException("Level-0 segments must be contiguous")
}
}
}
for ((_, segs) in grouped) {
val sortedSegs = segs.sortedBy { it.startTimestamp }
for (i in 1 until sortedSegs.size) {
if (sortedSegs[i].startTimestamp < sortedSegs[i - 1].endTimestamp) {
throw IllegalArgumentException("Segments at the same hierarchy level must not overlap")
}
}
}
for ((level, segs) in grouped) {
if (level == 0) continue
val parentLevel = level - 1
val parents = grouped[parentLevel]
?: throw IllegalArgumentException("Hierarchy level $level has no parent level $parentLevel")
for (seg in segs) {
val contained = parents.any { parent ->
parent.type == TYPE_VISIT
&& seg.startTimestamp >= parent.startTimestamp
&& seg.endTimestamp <= parent.endTimestamp
}
if (!contained) {
throw IllegalArgumentException("Sub-level segment not contained by parent VISIT")
}
}
}
}
private fun checkContiguity(segments: List<SemanticSegment>) {
if (segments.isEmpty()) return
val first = segments[0]
if (first.isSubVisit) {
throw IllegalArgumentException("First segment cannot be a sub-visit")
}
var prevEnd = first.endTimestamp
for (i in 1 until segments.size) {
val seg = segments[i]
if (!seg.isSubVisit) {
if (seg.startTimestamp != prevEnd) {
throw IllegalArgumentException("Non-sub-visit segments must be contiguous")
}
prevEnd = seg.endTimestamp
}
}
}
private fun editMemorySegments(storageManager: OdlhStorageManager, gaiaId: String, segments: List<LocationHistorySegment>) {
for (segment in segments) {
val segmentId = segment.segmentId ?: continue
val protoBytes = toUserEditedProtoBytes(segment)
val isEmpty = segment.timelineMemory?.trip == null && segment.timelineMemory?.note == null
if (isEmpty) {
val deleted = storageManager.deleteBySegmentId(gaiaId, segmentId)
if (deleted == -1) {
throw RuntimeException("Failed to delete Timeline Memory segment $segmentId")
}
Log.d(TAG, "editSegments: deleted memory segment $segmentId")
} else {
val dbSegment = SemanticSegment(
segmentId = segmentId,
segmentType = SEGMENT_TYPE_MEMORY,
data = protoBytes,
startTimestamp = segment.startTimestamp,
endTimestamp = segment.endTimestamp,
hierarchyLevel = 0,
shownInTimeline = true,
databaseId = storageManager.getDatabaseId()
)
storageManager.runInTransaction {
storageManager.deleteBySegmentId(gaiaId, segmentId)
val result = storageManager.insertSegment(gaiaId, dbSegment)
if (result == -1L) {
throw RuntimeException("Failed to store Timeline Memory segment $segmentId")
}
}
Log.d(TAG, "editSegments: updated memory segment $segmentId")
}
}
}
private fun calculateNonSubVisitRange(segments: List<SemanticSegment>): Pair<Long, Long> {
if (segments.isEmpty()) return Pair(0L, 0L)
val lastNonSubVisitIdx = segments.indexOfLast { !it.isSubVisit }
if (lastNonSubVisitIdx == -1) return Pair(0L, 0L)
return Pair(segments.first().startTimestamp, segments[lastNonSubVisitIdx].endTimestamp)
}
fun deleteHistory(storageManager: OdlhStorageManager, gaiaId: String, startTime: Long, endTime: Long): Pair<Int, Int> {
val deletedSegments = storageManager.deleteSegmentsByTimeRange(gaiaId, startTime, endTime)
Log.d(TAG, "deleteHistory: deleted $deletedSegments segments")
val deletedEdits = storageManager.deleteEditedSegmentsByTimeRange(gaiaId, startTime, endTime)
Log.d(TAG, "deleteHistory: deleted $deletedEdits edits")
if (deletedSegments == -1 || deletedEdits == -1) {
Log.w(TAG, "deleteHistory: some delete operations failed")
}
return Pair(deletedSegments, deletedEdits)
}
}

View file

@ -0,0 +1,221 @@
/**
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.semanticlocationhistory.utils
import android.content.ContentValues
import android.os.Parcel
import android.util.Log
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.common.data.DataHolder
import com.google.android.gms.geller.GellerDataType
import com.google.android.gms.semanticlocationhistory.LocationHistorySegment
import com.google.android.gms.semanticlocationhistory.LocationHistorySegmentRequest
import com.google.android.gms.semanticlocationhistory.TAG
import com.google.android.gms.semanticlocationhistory.db.OdlhStorageManager
import com.google.android.gms.semanticlocationhistory.utils.SegmentUtils.checkConfirmationLevel
import com.google.android.gms.semanticlocationhistory.utils.SegmentUtils.TYPE_VISIT
object SegmentQueryHandler {
private const val DEFAULT_RETENTION_DAYS = 36500L
private const val LOOKUP_TYPE_SEGMENT_ID = 1
private const val LOOKUP_TYPE_TIME_RANGE = 2
private const val LOOKUP_TYPE_INCLUDE_DELETED = 3
private const val LOOKUP_TYPE_SEGMENT_TYPE = 4
private const val LOOKUP_TYPE_FINALIZATION = 5
private const val LOOKUP_TYPE_PLACE_FEATURE_ID = 6
internal data class SegmentQueryParams(
val startTime: Long = 0L,
val endTime: Long = Long.MAX_VALUE,
val segmentType: Int = 0,
val segmentId: String? = null,
val includeDeleted: Int = 1,
val confirmationLevel: Int = -1,
val placeFprint: Long? = null
)
internal fun parseSegmentRequest(request: LocationHistorySegmentRequest?): SegmentQueryParams? {
if (request?.parameters.isNullOrEmpty()) return SegmentQueryParams()
var startTime = 0L
var endTime = Long.MAX_VALUE
var segmentType = 0
var segmentId: String? = null
var includeDeleted = 1
var confirmationLevel = -1
var placeFprint: Long? = null
val seenTypes = mutableSetOf<Int>()
for (param in request!!.parameters) {
if (!seenTypes.add(param.type)) {
Log.w(TAG, "getSegments: duplicate LookupParameters type=${param.type}")
return null
}
when (param.type) {
LOOKUP_TYPE_SEGMENT_ID -> {
segmentId = param.segmentId
}
LOOKUP_TYPE_TIME_RANGE -> {
param.timeRangeFilter?.let { range ->
range.startTime?.let { startTime = it }
range.endTime?.let { endTime = it }
}
if (startTime >= endTime) {
Log.w(TAG, "getSegments: invalid time range: start=$startTime >= end=$endTime")
return null
}
}
LOOKUP_TYPE_INCLUDE_DELETED -> {
includeDeleted = if (param.b4) 3 else 2
}
LOOKUP_TYPE_SEGMENT_TYPE -> {
segmentType = param.i5 ?: 0
}
LOOKUP_TYPE_FINALIZATION -> {
confirmationLevel = param.i6 ?: -1
}
LOOKUP_TYPE_PLACE_FEATURE_ID -> {
placeFprint = param.fprint
}
}
}
return SegmentQueryParams(
startTime = startTime,
endTime = endTime,
segmentType = segmentType,
segmentId = segmentId,
includeDeleted = includeDeleted,
confirmationLevel = confirmationLevel,
placeFprint = placeFprint
)
}
internal fun applyPostFilters(segments: List<LocationHistorySegment>, params: SegmentQueryParams): List<LocationHistorySegment> {
return segments.filter { segment ->
if (params.segmentId != null && segment.segmentId != params.segmentId) {
return@filter false
}
if (params.segmentType > 0 && segment.type != params.segmentType) {
return@filter false
}
if (!checkConfirmationLevel(segment, params.confirmationLevel)) {
return@filter false
}
if (params.placeFprint != null && segment.type == TYPE_VISIT) {
val visitFprint = segment.visit?.place?.identifier?.fprint ?: 0L
if (visitFprint != params.placeFprint) {
return@filter false
}
}
true
}
}
private val DATA_COLUMNS = arrayOf("data")
fun buildSegmentsDataHolder(segments: List<LocationHistorySegment>): DataHolder {
val builder = DataHolder.builder(DATA_COLUMNS)
if (segments.isEmpty()) {
return builder.build(CommonStatusCodes.SUCCESS)
}
segments.forEach { segment ->
val parcel = Parcel.obtain()
try {
segment.writeToParcel(parcel, 0)
val bytes = parcel.marshall()
val values = ContentValues().apply {
put("data", bytes)
}
builder.withRow(values)
} finally {
parcel.recycle()
}
}
return builder.build(CommonStatusCodes.SUCCESS)
}
fun queryAndFilterSegments(storageManager: OdlhStorageManager, gaiaId: String, request: LocationHistorySegmentRequest?): DataHolder {
val params = parseSegmentRequest(request)
if (params == null) {
Log.w(TAG, "getSegments: invalid request parameters")
return DataHolder.empty(CommonStatusCodes.SUCCESS)
}
Log.d(TAG, "getSegments: params=$params")
val retentionStartSec = System.currentTimeMillis() / 1000 - DEFAULT_RETENTION_DAYS * 86400
val adjustedStart = maxOf(params.startTime, retentionStartSec)
storageManager.purgeDeletedEdits(gaiaId, adjustedStart)
val lastSyncTime = storageManager.getLastSyncTime(
GellerDataType.ENCRYPTED_ONDEVICE_LOCATION_HISTORY.value
)
val pathMemoryEnd = if (lastSyncTime != null && lastSyncTime > 0)
minOf(params.endTime, lastSyncTime / 1000) else null
val segmentTypeFilter = if (params.segmentType > 0) intArrayOf(params.segmentType) else null
val dbSegments = storageManager.querySegments(
gaiaId = gaiaId,
startTime = adjustedStart,
endTime = params.endTime,
segmentTypes = segmentTypeFilter,
placeFprint = params.placeFprint,
pathMemoryEndTime = pathMemoryEnd
)
Log.d(TAG, "getSegments: found ${dbSegments.size} segments in database")
val converted = dbSegments.mapNotNull { segment ->
try {
SegmentConverter.toLocationHistorySegment(segment)
} catch (e: Exception) {
Log.w(TAG, "getSegments: failed to convert segment ${segment.segmentId}", e)
null
}
}
val filtered = applyPostFilters(converted, params)
Log.d(TAG, "getSegments: ${converted.size} -> ${filtered.size} after filtering")
storageManager.purgeTombstones(gaiaId)
val tombstones = storageManager.getTombstones(gaiaId)
val afterTombstone = if (tombstones.isNotEmpty()) {
applyTombstoneFilter(filtered, tombstones)
} else {
filtered
}
Log.d(TAG, "getSegments: ${filtered.size} -> ${afterTombstone.size} after tombstone filter")
return buildSegmentsDataHolder(afterTombstone)
}
internal fun applyTombstoneFilter(segments: List<LocationHistorySegment>, tombstones: List<OdlhStorageManager.Tombstone>): List<LocationHistorySegment> {
val sorted = tombstones.sortedBy { it.startTimeSec }
return segments.filter { segment ->
sorted.none { ts ->
if (ts.startTimeSec >= segment.endTimestamp) return@none false
ts.endTimeSec > segment.startTimestamp
}
}
}
}

View file

@ -0,0 +1,49 @@
/**
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.semanticlocationhistory.utils
import android.util.Log
import com.google.android.gms.semanticlocationhistory.LocationHistorySegment
import com.google.android.gms.semanticlocationhistory.TAG
import org.microg.gms.semanticlocationhistory.FinalizationStatus
object SegmentUtils {
const val TYPE_VISIT = 1
const val TYPE_ACTIVITY = 2
const val TYPE_MEMORY = 4
const val TYPE_PATH = 3
const val TYPE_PERIOD_SUMMARY = 5
const val FINALIZATION_STABILIZED = 1
const val FINALIZATION_FINALIZED = 2
const val FINALIZATION_USER_EDITED = 3
const val FINALIZATION_BACKFILLED = 4
internal fun getHierarchyLevel(segment: LocationHistorySegment): Int =
if (segment.type == TYPE_VISIT) segment.visit?.hierarchyLevel ?: 0 else 0
fun toUserEditedProtoBytes(segment: LocationHistorySegment): ByteArray {
return try {
SegmentConverter.buildProto(segment, FinalizationStatus.USER_EDITED).encode()
} catch (e: Exception) {
Log.w(TAG, "Failed to build USER_EDITED proto, falling back", e)
SegmentConverter.toProtoBytes(segment)
}
}
internal fun checkConfirmationLevel(segment: LocationHistorySegment, confirmationLevel: Int): Boolean {
if (confirmationLevel <= 0) return true
val status = segment.finalizationStatus
return when (confirmationLevel) {
1 -> status >= FINALIZATION_STABILIZED
2 -> status >= FINALIZATION_FINALIZED
3 -> status == FINALIZATION_USER_EDITED
4 -> status == FINALIZATION_BACKFILLED || status == FINALIZATION_USER_EDITED
else -> true
}
}
}

View file

@ -5,14 +5,16 @@
package org.microg.gms.auth.folsom
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import android.os.Parcel
import android.util.Log
import androidx.core.app.PendingIntentCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.gms.auth.folsom.ProductKey
import com.google.android.gms.auth.folsom.RecoveryRequest
import com.google.android.gms.auth.folsom.RecoveryResult
import com.google.android.gms.auth.folsom.SecurityDomainMember
import com.google.android.gms.auth.folsom.SharedKey
import com.google.android.gms.auth.folsom.internal.IBooleanCallback
import com.google.android.gms.auth.folsom.internal.IByteArrayCallback
@ -21,6 +23,7 @@ import com.google.android.gms.auth.folsom.internal.IKeyRetrievalCallback
import com.google.android.gms.auth.folsom.internal.IKeyRetrievalConsentCallback
import com.google.android.gms.auth.folsom.internal.IKeyRetrievalService
import com.google.android.gms.auth.folsom.internal.IKeyRetrievalSyncStatusCallback
import com.google.android.gms.auth.folsom.internal.IProductKeyCallback
import com.google.android.gms.auth.folsom.internal.IRecoveryResultCallback
import com.google.android.gms.auth.folsom.internal.ISecurityDomainMembersCallback
import com.google.android.gms.auth.folsom.internal.ISharedKeyCallback
@ -34,13 +37,18 @@ import com.google.android.gms.common.internal.ConnectionInfo
import com.google.android.gms.common.internal.GetServiceRequest
import com.google.android.gms.common.internal.IGmsCallbacks
import org.microg.gms.BaseService
import org.microg.gms.auth.folsom.ui.GenericActivity
import org.microg.gms.common.Constants.GMS_PACKAGE_NAME
import org.microg.gms.auth.folsom.utils.LocalKeyManager
import org.microg.gms.common.GmsService
import org.microg.gms.common.PackageUtils
import org.microg.gms.utils.warnOnTransactionIssues
import java.util.UUID
private const val TAG = "KeyRetrievalService"
private const val KEY_SECURITY_DOMAIN = "SECURITY_DOMAIN"
private const val KEY_SESSION_ID = "SESSION_ID"
private const val KEY_OFFER_RESET = "OFFER_RESET"
private val FEATURES = arrayOf(
Feature("key_retrieval", 2L),
Feature("list_recovered_security_domains", 1L),
@ -61,149 +69,306 @@ private val FEATURES = arrayOf(
class KeyRetrievalService : BaseService(TAG, GmsService.FOLSOM) {
override fun handleServiceRequest(callback: IGmsCallbacks, request: GetServiceRequest, service: GmsService) {
Log.d(TAG, "handleServiceRequest: packageName: ${request.packageName}")
callback.onPostInitCompleteWithConnectionInfo(CommonStatusCodes.SUCCESS, KeyRetrievalServiceImpl(this), ConnectionInfo().apply { features = FEATURES })
val packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName)
val bundle = request.extras
Log.d(TAG, "handleServiceRequest: packageName=${packageName}, extras=$bundle")
val securityDomain = bundle?.getString(KEY_SECURITY_DOMAIN)
if (securityDomain.isNullOrEmpty()) {
Log.w(TAG, "Security domain is not set")
callback.onPostInitComplete(ERROR_CODE_SECURITY_DOMAIN_NOT_SET, null, null)
return
}
val sessionId = bundle.getString(KEY_SESSION_ID, UUID.randomUUID().toString())
val offerReset = bundle.getBoolean(KEY_OFFER_RESET, false)
callback.onPostInitCompleteWithConnectionInfo(
CommonStatusCodes.SUCCESS,
KeyRetrievalServiceImpl(this, lifecycle, securityDomain, sessionId, offerReset),
ConnectionInfo().apply { features = FEATURES }
)
}
}
class KeyRetrievalServiceImpl(val context: Context) : IKeyRetrievalService.Stub() {
class KeyRetrievalServiceImpl(
private val context: Context,
override val lifecycle: Lifecycle,
private val domainId: String,
private val sessionId: String,
private val offerReset: Boolean
) : IKeyRetrievalService.Stub(), LifecycleOwner {
override fun setConsent(
callback: IKeyRetrievalConsentCallback?, accountName: String?, force: Boolean, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented setConsent accountName:$accountName force:$force metadata:$metadata")
override fun setConsent(callback: IKeyRetrievalConsentCallback?, accountName: String?, force: Boolean, metadata: ApiMetadata?) {
Log.d(TAG, "Not implemented setConsent accountName:$accountName force:$force")
callback?.onResult(Status.SUCCESS, true)
}
override fun getConsent(
callback: IKeyRetrievalConsentCallback?, accountName: String?, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented getConsent accountName:$accountName metadata:$metadata")
override fun getConsent(callback: IKeyRetrievalConsentCallback?, accountName: String?, metadata: ApiMetadata?) {
Log.d(TAG, "Not implemented getConsent accountName:$accountName")
callback?.onResult(Status.SUCCESS, false)
}
override fun getSyncStatus(callback: IKeyRetrievalSyncStatusCallback?, accountName: String?, metadata: ApiMetadata?) {
Log.d(TAG, "Not implemented getSyncStatus accountName:$accountName")
callback?.onResult(Status.SUCCESS, true)
}
override fun getSyncStatus(
callback: IKeyRetrievalSyncStatusCallback?, accountName: String?, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented getSyncStatus accountName:$accountName metadata:$metadata")
callback?.onResult(Status.SUCCESS, true)
override fun markLocalKeysAsStale(callback: IKeyRetrievalCallback?, accountName: String?, metadata: ApiMetadata?) {
Log.d(TAG, "markLocalKeysAsStale accountName:$accountName")
if (accountName.isNullOrEmpty()) {
callback?.onResult(Status.INTERNAL_ERROR)
return
}
lifecycleScope.launchWhenStarted {
try {
val localKeyManager = LocalKeyManager.getInstance(context)
val currentStatus = localKeyManager.getDomainStatus(accountName, domainId)
Log.d(TAG, "markLocalKeysAsStale: currentStatus=$currentStatus")
if (currentStatus != DomainStatus.UNKNOWN) {
localKeyManager.setDomainStatus(accountName, domainId, DomainStatus.RECOVERABLE)
localKeyManager.updateLastFetchTimestamp(accountName, domainId, 0L)
Log.d(TAG, "markLocalKeysAsStale: marked as stale (timestamp reset)")
}
callback?.onResult(Status.SUCCESS)
} catch (e: Exception) {
Log.e(TAG, "markLocalKeysAsStale failed", e)
callback?.onResult(Status(CommonStatusCodes.INTERNAL_ERROR))
}
}
}
override fun markLocalKeysAsStale(
callback: IKeyRetrievalCallback?, accountName: String?, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented markLocalKeysAsStale accountName:$accountName metadata:$metadata")
callback?.onResult(Status.SUCCESS)
override fun getKeyMaterial(callback: ISharedKeyCallback?, accountName: String?, metadata: ApiMetadata?) {
Log.d(TAG, "getKeyMaterial accountName:$accountName securityDomain:$domainId")
if (accountName.isNullOrEmpty()) {
Log.w(TAG, "getKeyMaterial: accountName is null or empty")
callback?.onResult(Status(CommonStatusCodes.DEVELOPER_ERROR), emptyArray<SharedKey>())
return
}
fun errorResult(domainStatus: DomainStatus) {
val errorStatus = context.buildKeyRetrievalStatus(accountName, domainId, 1, sessionId, offerReset) {
val statusMessage = when (domainStatus) {
DomainStatus.NO_KEYS -> "Empty domain"
DomainStatus.NOT_RECOVERABLE -> "Domain is not retrievable"
else -> "No shared keys available"
}
Status(CommonStatusCodes.SIGN_IN_REQUIRED, statusMessage, it)
}
callback?.onResult(errorStatus, emptyArray<SharedKey>())
}
lifecycleScope.launchWhenStarted {
try {
val localKeyManager = LocalKeyManager.getInstance(context)
val domainStatus = localKeyManager.getDomainStatus(accountName, domainId)
Log.d(TAG, "getKeyMaterial: domainStatus=$domainStatus")
val localKeys = localKeyManager.getLocalKeysOrSync(context, accountName, domainId, sessionId)
if (localKeys.isEmpty()) {
Log.w(TAG, "getKeyMaterial: no keys available")
return@launchWhenStarted errorResult(domainStatus)
}
val validKeys = localKeys.filter { it.keyVersion != 0 }
if (validKeys.isEmpty() && localKeys.any { it.keyVersion == 0 }) {
Log.w(TAG, "getKeyMaterial: only invalid keys (version=0) available")
return@launchWhenStarted errorResult(domainStatus)
}
val sharedKeys = localKeys.mapNotNull { key ->
val keyMaterial = key.keyMaterial?.toByteArray()
if (keyMaterial != null && keyMaterial.isNotEmpty()) {
SharedKey(key.keyVersion ?: 0, keyMaterial)
} else {
null
}
}.toTypedArray()
if (sharedKeys.isEmpty()) {
Log.w(TAG, "getKeyMaterial: no valid key material")
return@launchWhenStarted errorResult(domainStatus)
}
Log.d(TAG, "getKeyMaterial: returning ${sharedKeys.size} keys")
callback?.onResult(Status.SUCCESS, sharedKeys)
} catch (e: Exception) {
Log.e(TAG, "getKeyMaterial failed", e)
callback?.onResult(Status(ERROR_CODE_NO_KEYS, "No shared keys available"), emptyArray<SharedKey>())
}
}
}
override fun getKeyMaterial(
callback: ISharedKeyCallback?, accountName: String?, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented getKeyMaterial accountName:$accountName metadata:$metadata")
callback?.onResult(Status.INTERNAL_ERROR, emptyArray<SharedKey>())
}
override fun setKeyMaterial(
callback: IKeyRetrievalCallback?, accountName: String?, keys: Array<out SharedKey?>?, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented setKeyMaterial accountName:$accountName keys:$keys metadata:$metadata")
override fun setKeyMaterial(callback: IKeyRetrievalCallback?, accountName: String?, keys: Array<out SharedKey?>?, metadata: ApiMetadata?) {
Log.d(TAG, "Not implemented setKeyMaterial accountName:$accountName")
callback?.onResult(Status.INTERNAL_ERROR)
}
override fun getRecoveredSecurityDomains(
callback: IStringListCallback?, accountName: String?, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented getRecoveredSecurityDomains accountName:$accountName metadata:$metadata")
override fun getRecoveredSecurityDomains(callback: IStringListCallback?, accountName: String?, metadata: ApiMetadata?) {
Log.d(TAG, "Not implemented getRecoveredSecurityDomains accountName:$accountName")
callback?.onResult(Status.SUCCESS, emptyArray<String>())
}
override fun startRecoveryOperation(
callback: IRecoveryResultCallback?, metadata: ApiMetadata?, request: RecoveryRequest?
) {
Log.d(TAG, "Not implemented startRecoveryOperation request:$request metadata:$metadata")
override fun startRecoveryOperation(callback: IRecoveryResultCallback?, metadata: ApiMetadata?, request: RecoveryRequest?) {
Log.d(TAG, "Not implemented startRecoveryOperation request:$request")
callback?.onResult(Status.SUCCESS, RecoveryResult())
}
override fun listVaultsOperation(
callback: IByteArrayListCallback?, accountName: String?, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented listVaultsOperation accountName:$accountName metadata:$metadata")
override fun listVaultsOperation(callback: IByteArrayListCallback?, accountName: String?, metadata: ApiMetadata?) {
Log.d(TAG, "Not implemented listVaultsOperation accountName:$accountName")
callback?.onResult(Status.SUCCESS, emptyList<ByteArray>())
}
override fun getProductDetails(
callback: IByteArrayCallback?, accountName: String?, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented getProductDetails accountName:$accountName metadata:$metadata")
callback?.onResult(Status.SUCCESS, byteArrayOf())
override fun generateOpenVaultRequestOperation(callback: IByteArrayCallback?, request: RecoveryRequest?, metadata: ApiMetadata?) {
Log.d(TAG, "Not implemented generateOpenVaultRequestOperation request:$request")
callback?.onResult(Status.SUCCESS, byteArrayOf(), ApiMetadata.DEFAULT)
}
override fun joinSecurityDomain(
callback: IStatusCallback?, accountName: String?, bytes: ByteArray?, type: Int, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented joinSecurityDomain accountName:$accountName type:$type metadata:$metadata")
override fun joinSecurityDomain(callback: IStatusCallback?, accountName: String?, bytes: ByteArray?, type: Int, metadata: ApiMetadata?) {
Log.d(TAG, "Not implemented joinSecurityDomain accountName:$accountName type:$type")
callback?.onResult(Status.SUCCESS)
}
override fun startUxFlow(
callback: IKeyRetrievalCallback?, accountName: String?, type: Int, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented startUxFlow accountName:$accountName type:$type metadata:$metadata")
val intent = Intent().apply { setClassName(GMS_PACKAGE_NAME, GenericActivity::class.java.name) }
val pendingIntent = PendingIntentCompat.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT, false)
val states = Status(CommonStatusCodes.SUCCESS, "UX flow PendingIntent retrieved.", pendingIntent)
callback?.onResult(states)
}
override fun promptForLskfConsent(
callback: IKeyRetrievalCallback?, accountName: String?, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented promptForLskfConsent accountName:$accountName metadata:$metadata")
override fun resetSecurityDomain(callback: IStatusCallback?, accountName: String?, metadata: ApiMetadata?) {
Log.d(TAG, "Not implemented resetSecurityDomain accountName:$accountName")
callback?.onResult(Status.SUCCESS)
}
override fun resetSecurityDomain(
callback: IStatusCallback?, accountName: String?, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented resetSecurityDomain accountName:$accountName metadata:$metadata")
override fun listSecurityDomainMembers(callback: ISecurityDomainMembersCallback?, accountName: String?, metadata: ApiMetadata?) {
if (accountName.isNullOrEmpty()) {
Log.w(TAG, "listSecurityDomainMembers: accountName is null or empty")
callback?.onResult(Status.INTERNAL_ERROR, emptyArray<SecurityDomainMember>(), metadata ?: ApiMetadata.DEFAULT)
return
}
Log.d(TAG, "listSecurityDomainMembers accountName:$accountName securityDomain:$domainId")
lifecycleScope.launchWhenStarted {
try {
val response = runCatching {
loadSecurityDomainMembers(context, accountName, sessionId, domainId)
}.onFailure {
Log.w(TAG, "listSecurityDomainMembers: load error!", it)
}.getOrNull()
Log.d(TAG, "listSecurityDomainMembers: response members count=${response?.members?.size ?: 0}")
if (response == null) {
Log.w(TAG, "listSecurityDomainMembers: response is null, domain may not exist")
try {
LocalKeyManager.getInstance(context).clearDomainKeys(accountName, domainId)
Log.d(TAG, "listSecurityDomainMembers: cleared local cache for domain=$domainId")
} catch (e: Exception) {
Log.w(TAG, "listSecurityDomainMembers: failed to clear cache", e)
}
callback?.onResult(Status.SUCCESS, emptyArray<SecurityDomainMember>(), ApiMetadata.DEFAULT)
return@launchWhenStarted
}
val memberList = response.members.map { member ->
SecurityDomainMember(
member.memberType ?: 0, member.memberMetadata?.encode() ?: byteArrayOf()
)
}.toTypedArray()
Log.d(TAG, "listSecurityDomainMembers: returning ${memberList.size} members")
callback?.onResult(Status.SUCCESS, memberList, ApiMetadata.DEFAULT)
} catch (e: Exception) {
Log.e(TAG, "listSecurityDomainMembers failed", e)
callback?.onResult(Status.INTERNAL_ERROR, emptyArray<SecurityDomainMember>(), ApiMetadata.DEFAULT)
}
}
}
override fun getDomainState(callback: IByteArrayCallback?, accountName: String?, metadata: ApiMetadata?) {
if (accountName.isNullOrEmpty()) {
Log.w(TAG, "getDomainState: accountName is null or empty")
callback?.onResult(Status.INTERNAL_ERROR, byteArrayOf(), ApiMetadata.DEFAULT)
return
}
Log.d(TAG, "getDomainState accountName:$accountName securityDomain:$domainId")
lifecycleScope.launchWhenStarted {
try {
val localKeyManager = LocalKeyManager.getInstance(context)
try {
localKeyManager.updateLastFetchTimestamp(accountName, domainId, 0L)
Log.d(TAG, "getDomainState: reset fetch timestamp")
} catch (e: Exception) {
Log.w(TAG, "getDomainState: failed to reset timestamp, continuing", e)
}
var serverState: GetSecurityDomainResponse? = null
var membersResponse: ListSecurityDomainMembersResponse? = null
try {
serverState = getSecurityDomain(context, accountName, sessionId, domainId)
membersResponse = loadSecurityDomainMembers(context, accountName, sessionId, domainId)
Log.d(TAG, "getDomainState: serverState=$serverState, members=${membersResponse.members.size}")
} catch (e: Exception) {
Log.e(TAG, "getDomainState: failed to sync domain state", e)
}
val computedState = localKeyManager.computeDomainStatus(accountName, domainId, serverState, membersResponse).also {
localKeyManager.setDomainStatus(accountName, domainId, it)
localKeyManager.updateLastFetchTimestamp(accountName, domainId, System.currentTimeMillis())
}
Log.d(TAG, "getDomainState: computedState=$computedState")
val responseBytes = serializeDomainStateResponse(computedState.code)
Log.d(TAG, "getDomainState: returning state=$computedState")
callback?.onResult(Status.SUCCESS, responseBytes, ApiMetadata.DEFAULT)
} catch (e: Exception) {
Log.e(TAG, "getDomainState failed", e)
val errorResponse = serializeDomainStateResponse(DomainStatus.UNKNOWN_ERROR.code)
callback?.onResult(Status.INTERNAL_ERROR, errorResponse, ApiMetadata.DEFAULT)
}
}
}
override fun startUxFlow(callback: IKeyRetrievalCallback?, accountName: String?, type: Int, metadata: ApiMetadata?) {
Log.d(TAG, "startUxFlow accountName:$accountName type:$type")
if (accountName.isNullOrEmpty()) {
callback?.onResult(Status(CommonStatusCodes.DEVELOPER_ERROR))
return
}
val status = context.buildKeyRetrievalStatus(accountName, domainId, type, sessionId, offerReset) {
Status(CommonStatusCodes.SUCCESS, "UX flow PendingIntent retrieved.", it)
}
callback?.onResult(status)
}
override fun promptForLskfConsent(callback: IKeyRetrievalCallback?, accountName: String?, metadata: ApiMetadata?) {
Log.d(TAG, "Not implemented promptForLskfConsent accountName:$accountName")
callback?.onResult(Status.SUCCESS)
}
override fun listSecurityDomainMembers(
callback: ISecurityDomainMembersCallback?, accountName: String?, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented listSecurityDomainMembers accountName:$accountName metadata:$metadata")
callback?.onResult(Status.SUCCESS, emptyList<Int>())
}
override fun generateOpenVaultRequestOperation(
callback: IByteArrayCallback?, request: RecoveryRequest?, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented generateOpenVaultRequestOperation request:$request metadata:$metadata")
callback?.onResult(Status.SUCCESS, byteArrayOf())
}
override fun canSilentlyAddGaiaPassword(
callback: IBooleanCallback?, accountName: String?, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented canSilentlyAddGaiaPassword accountName:$accountName metadata:$metadata")
override fun canSilentlyAddGaiaPassword(callback: IBooleanCallback?, accountName: String?, metadata: ApiMetadata?) {
Log.d(TAG, "Not implemented canSilentlyAddGaiaPassword accountName:$accountName")
callback?.onResult(Status.SUCCESS, true)
}
override fun addGaiaPasswordMember(
callback: IStatusCallback?, accountName: String?, metadata: ApiMetadata?
) {
Log.d(TAG, "Not implemented addGaiaPasswordMember accountName:$accountName metadata:$metadata")
override fun addGaiaPasswordMember(callback: IStatusCallback?, accountName: String?, metadata: ApiMetadata?) {
Log.d(TAG, "Not implemented addGaiaPasswordMember accountName:$accountName")
callback?.onResult(Status.SUCCESS)
}
override fun getDomainState(
callback: IByteArrayCallback?,
accountName: String?,
metadata: ApiMetadata?
) {
Log.d(TAG, "Not yet implemented: getDomainState")
callback?.onResult(Status.SUCCESS, byteArrayOf())
override fun getProductDetails(callback: IByteArrayCallback?, accountName: String?, metadata: ApiMetadata?) {
Log.d(TAG, "Not implemented getProductDetails accountName:$accountName")
callback?.onResult(Status.SUCCESS, byteArrayOf(), ApiMetadata.DEFAULT)
}
override fun getProductKeysOperation(callback: IProductKeyCallback?, accountName: String?, accountName2: String?, metadata: ApiMetadata?) {
Log.d(TAG, "Not yet implemented: GetProductKeysOperation accountName:$accountName")
callback?.onResult(Status.SUCCESS, emptyArray<ProductKey>(), metadata)
}
override fun createPrfMemberOperation(callback: IStatusCallback?, accountName: String?, bytes: ByteArray?, bytes2: ByteArray?, metadata: ApiMetadata?) {
Log.d(TAG, "Not yet implemented: CreatePrfMemberOperation accountName:$accountName")
callback?.onResult(Status.SUCCESS)
}
override fun addRecoveryContactToDependentKeychainOperation(callback: IStatusCallback?, accountName: String?, accountName2: String?, metadata: ApiMetadata?) {
Log.d(TAG, "Not yet implemented: AddRecoveryContactToDependentKeychainOperation accountName:$accountName")
callback?.onResult(Status.SUCCESS)
}
override fun createRetrievalPacketOperation(callback: IStatusCallback?, accountName: String?, accountName2: String?, bytes: ByteArray?, metadata: ApiMetadata?) {
Log.d(TAG, "Not yet implemented: CreateRetrievalPacketOperation accountName:$accountName")
callback?.onResult(Status.SUCCESS)
}
override fun setClaimantKeyOperation(callback: IStatusCallback?, accountName: String?, bytes: ByteArray?, bytes2: ByteArray?, metadata: ApiMetadata?) {
Log.d(TAG, "Not yet implemented: SetClaimantKeyOperation accountName:$accountName")
callback?.onResult(Status.SUCCESS)
}
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {

View file

@ -0,0 +1,155 @@
/**
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.auth.folsom
import android.accounts.AccountManager
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import android.util.Base64
import androidx.core.app.PendingIntentCompat
import com.google.android.gms.common.api.Status
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.microg.gms.auth.AuthConstants
import org.microg.gms.auth.folsom.ui.GenericActivity
import org.microg.gms.auth.folsom.ui.GenericActivity.Companion.EXTRA_ACCOUNT_NAME
import org.microg.gms.auth.folsom.ui.GenericActivity.Companion.EXTRA_OFFER_RESET
import org.microg.gms.auth.folsom.ui.GenericActivity.Companion.EXTRA_OPERATION
import org.microg.gms.auth.folsom.ui.GenericActivity.Companion.EXTRA_SECURITY_DOMAIN
import org.microg.gms.auth.folsom.ui.GenericActivity.Companion.EXTRA_SESSION_ID
import org.microg.gms.gcm.createGrpcClient
const val ERROR_CODE_NO_KEYS = 38500
const val ERROR_CODE_SECURITY_DOMAIN_NOT_SET = 38501
const val SECURITY_WEB_BASE_URL = "https://accounts.google.com/encryption/unlock/android"
const val SECURITY_DOMAIN_BASE_URL = "https://securitydomain-pa.googleapis.com/"
const val SERVICE_SECURITY_DOMAIN_SCOPE = "oauth2:https://www.googleapis.com/auth/cryptauth"
const val USERS = "users/me"
const val MEMBERS = "/members/"
const val SECURITY_DOMAINS = "/securitydomains/"
enum class DomainStatus(val code: Int) {
/** Unknown error occurred */
UNKNOWN_ERROR(1),
/** Domain state is unknown */
UNKNOWN(2),
/** Domain is not recoverable */
NOT_RECOVERABLE(3),
/** Recovery is pending */
PENDING_RECOVERY(4),
/** Domain is recoverable */
RECOVERABLE(5),
/** Recovery is in progress */
RECOVERY_IN_PROGRESS(6),
/** No keys available in domain */
NO_KEYS(7);
}
fun buildKeyDeliveryInfo(sessionId: String, securityDomain: String, offerReset: Boolean): ByteArray =
KeyDeliveryInfo(
operationType = KeyDeliveryOperationType.START_KEY_RETRIEVAL,
keyRetrieval = StartKeyRetrievalRequest(domain = securityDomain, reset = offerReset),
sessionId = sessionId
).encode()
fun serializeDomainStateResponse(state: Int): ByteArray {
if (state < 128) {
return byteArrayOf(0x08, state.toByte())
}
val result = mutableListOf<Byte>(0x08)
var v = state
while (v >= 128) {
result.add(((v and 0x7F) or 0x80).toByte())
v = v ushr 7
}
result.add(v.toByte())
return result.toByteArray()
}
fun Context.buildKeyRetrievalStatus(
accountName: String,
domainId: String,
operationType: Int,
sessionId: String,
offerReset: Boolean,
block: (PendingIntent?) -> Status
): Status {
val intent = Intent(this, GenericActivity::class.java).apply {
putExtra(EXTRA_ACCOUNT_NAME, accountName)
putExtra(EXTRA_SECURITY_DOMAIN, domainId)
putExtra(EXTRA_OPERATION, operationType)
putExtra(EXTRA_SESSION_ID, sessionId)
putExtra(EXTRA_OFFER_RESET, offerReset)
}
return block(PendingIntentCompat.getActivity(this, 0, intent, FLAG_UPDATE_CURRENT, false))
}
fun computeMemberName(publicKeyBytes: ByteArray): String {
val encoded = Base64.encodeToString(publicKeyBytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
return "$USERS$MEMBERS$encoded"
}
fun Context.requestOauthToken(accountName: String, scope: String = SERVICE_SECURITY_DOMAIN_SCOPE): String {
val accountManager = AccountManager.get(this)
val account = accountManager.getAccountsByType(AuthConstants.DEFAULT_ACCOUNT_TYPE).find {
it.name == accountName
}
if (account == null) throw RuntimeException("account is null")
return accountManager.blockingGetAuthToken(account, scope, true)
?: throw RuntimeException("oauthToken is null")
}
suspend fun loadSecurityDomainMembers(context: Context, accountName: String, sessionId: String, domainId: String) =
withContext(Dispatchers.IO) {
createGrpcClient<SecurityDomainServiceClient>(SECURITY_DOMAIN_BASE_URL, context.requestOauthToken(accountName))
.ListSecurityDomainMembers()
.executeBlocking(
ListSecurityDomainMembersRequest(
parent = USERS,
view = emptyList(),
filter = listOf(USERS + SECURITY_DOMAINS + domainId),
pageSize = 3,
pageToken = "",
filterOptions = FilterOptions(includeDeleted = true),
requestId = sessionId
)
)
}
suspend fun getSecurityDomain(context: Context, accountName: String, sessionId: String, domainId: String) =
withContext(Dispatchers.IO) {
createGrpcClient<SecurityDomainServiceClient>(SECURITY_DOMAIN_BASE_URL, context.requestOauthToken(accountName))
.GetSecurityDomain()
.executeBlocking(
GetSecurityDomainRequest(
name = USERS + SECURITY_DOMAINS + domainId,
view = 2,
requestId = sessionId
)
)
}
suspend fun getSecurityDomainMember(context: Context, accountName: String, sessionId: String, domainId: String) =
withContext(Dispatchers.IO) {
createGrpcClient<SecurityDomainServiceClient>(SECURITY_DOMAIN_BASE_URL, context.requestOauthToken(accountName))
.GetSecurityDomainMember()
.executeBlocking(
GetSecurityDomainMemberRequest(
name = USERS + MEMBERS + domainId,
view = 2,
filterOptions = FilterOptions(includeDeleted = true),
requestId = sessionId
)
)
}

View file

@ -0,0 +1,258 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.auth.folsom.ui
import android.content.res.Configuration
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.FrameLayout
import android.widget.ProgressBar
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import com.google.android.gms.auth.folsom.SharedKey
import okio.ByteString.Companion.toByteString
import org.json.JSONObject
import org.microg.gms.auth.folsom.Keys
import org.microg.gms.auth.folsom.ui.GenericActivity.Companion.EXTRA_ACCOUNT_NAME
import org.microg.gms.auth.folsom.ui.GenericActivity.Companion.EXTRA_OFFER_RESET
import org.microg.gms.auth.folsom.ui.GenericActivity.Companion.EXTRA_OPERATION
import org.microg.gms.auth.folsom.ui.GenericActivity.Companion.EXTRA_SECURITY_DOMAIN
import org.microg.gms.auth.folsom.ui.GenericActivity.Companion.EXTRA_SESSION_ID
import org.microg.gms.auth.folsom.utils.LocalKeyManager
import java.util.concurrent.atomic.AtomicBoolean
data class KeyRetrievalMetadata(
val keys: Map<String, List<SharedKey>>,
val consent: Map<String, Boolean>
)
class FolsomWebFragment : Fragment() {
companion object {
fun newInstance(
accountName: String,
securityDomain: String,
operation: Int = 0,
sessionId: String = "",
offerReset: Boolean = false
): FolsomWebFragment = FolsomWebFragment().apply {
arguments = Bundle().apply {
putString(EXTRA_ACCOUNT_NAME, accountName)
putString(EXTRA_SECURITY_DOMAIN, securityDomain)
putInt(EXTRA_OPERATION, operation)
putString(EXTRA_SESSION_ID, sessionId)
putBoolean(EXTRA_OFFER_RESET, offerReset)
}
}
}
private var webView: WebView? = null
private var progressBar: ProgressBar? = null
private var jsBridge: FolsomJsBridge? = null
private var webViewHelper: FolsomWebViewHelper? = null
private val accountName: String by lazy {
requireArguments().getString(EXTRA_ACCOUNT_NAME, "")
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return setupContainerView()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupBackPressedHandler()
setupWebView()
}
private fun setupContainerView(): FrameLayout {
val container = FrameLayout(requireContext())
container.setBackgroundColor(if (isDarkMode()) Color.BLACK else Color.WHITE)
progressBar = ProgressBar(requireContext()).also { pb ->
val size = (48 * resources.displayMetrics.density).toInt()
val params = FrameLayout.LayoutParams(size, size).apply { gravity = Gravity.CENTER }
container.addView(pb, params)
}
return container
}
private fun setupBackPressedHandler() {
requireActivity().onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (webView?.canGoBack() == true) {
webView?.goBack()
} else {
requireActivity().setResult(AppCompatActivity.RESULT_CANCELED)
requireActivity().finish()
}
}
})
}
private fun setupWebView() {
val container = view as? FrameLayout ?: return
val wv = WebView(requireContext()).apply {
isVisible = false
}
container.addView(
wv, FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
)
webViewHelper = FolsomWebViewHelper(this, wv, accountName).also { helper ->
helper.prepareWebViewSettings()
helper.setupWebViewClient(
onPageStarted = { onPageStarted() },
onPageFinished = { onPageFinished() }
)
}
jsBridge = FolsomJsBridge(wv, ::onKeyRetrievalResult)
wv.addJavascriptInterface(jsBridge!!, "mm")
webView = wv
loadKeyRetrievalUrl()
}
private fun loadKeyRetrievalUrl() {
if (Build.VERSION.SDK_INT < 21) {
finishWithResult(null)
return
}
val args = requireArguments()
val targetUrl = webViewHelper?.buildKeyRetrievalUrl(
sessionId = args.getString(EXTRA_SESSION_ID, ""),
securityDomain = args.getString(EXTRA_SECURITY_DOMAIN, ""),
offerReset = true,
darkMode = isDarkMode()
) ?: return
webViewHelper?.loadUrlWithAuthentication(targetUrl)
}
private fun isDarkMode(): Boolean =
resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
private fun onPageStarted() {
progressBar?.isVisible = true
webView?.isVisible = false
}
private fun onPageFinished() {
progressBar?.isVisible = false
webView?.isVisible = true
}
private fun onKeyRetrievalResult(status: Int, metadata: KeyRetrievalMetadata?) {
metadata?.let { data ->
val localKeyManager = LocalKeyManager.getInstance(requireContext())
data.keys.forEach { (domain, keyList) ->
if (keyList.isNotEmpty()) {
val keysToSave = keyList.map { sharedKey ->
Keys(keyVersion = sharedKey.key, keyMaterial = sharedKey.keyMaterial?.toByteString())
}
localKeyManager.saveKeysForDomain(accountName, domain, keysToSave)
}
}
}
finishWithResult(if (status == AppCompatActivity.RESULT_OK) status else null)
}
private fun finishWithResult(status: Int?) {
requireActivity().setResult(status ?: AppCompatActivity.RESULT_CANCELED)
requireActivity().finish()
}
override fun onDestroy() {
super.onDestroy()
webView?.stopLoading()
webView?.destroy()
webView = null
webViewHelper?.destroy()
webViewHelper = null
jsBridge = null
progressBar = null
}
}
private class FolsomJsBridge(
private val webView: WebView,
private val onResult: (Int, KeyRetrievalMetadata?) -> Unit
) {
private val keysSet = AtomicBoolean(false)
private val mainHandler = Handler(Looper.getMainLooper())
private val collectedKeys = mutableMapOf<String, List<SharedKey>>()
private val collectedConsent = mutableMapOf<String, Boolean>()
@JavascriptInterface
fun setVaultSharedKeys(accountId: String, keysJson: String) {
Log.d("FolsomJsBridge", "setVaultSharedKeys called: accountId=$accountId, keysJson=${keysJson.take(200)}")
runCatching {
JSONObject(keysJson).let { json ->
json.keys().forEach { domain ->
collectedKeys[domain] = buildList {
json.getJSONArray(domain).let { arr ->
(0 until arr.length()).forEach { i ->
arr.getJSONObject(i).let { keyObj ->
parseKeyMaterial(keyObj.getJSONObject("key"))?.let { km ->
add(SharedKey(keyObj.getInt("epoch"), km))
}
}
}
}
}
}
}
keysSet.set(true)
notifyWebView(0)
}.onFailure { notifyWebView(1) }
}
@JavascriptInterface
fun setConsent(accountId: String, domain: String, consent: Boolean) {
Log.d("FolsomJsBridge", "setConsent called: accountId=$accountId, domain=$domain, consent=$consent")
collectedConsent[domain] = consent
notifyWebView(0)
}
@JavascriptInterface
fun closeView() {
val status = if (keysSet.get()) AppCompatActivity.RESULT_OK else AppCompatActivity.RESULT_CANCELED
val metadata = keysSet.takeIf { it.get() }?.let {
KeyRetrievalMetadata(collectedKeys.toMap(), collectedConsent.toMap())
}
onResult(status, metadata)
}
private fun parseKeyMaterial(keyData: JSONObject): ByteArray? = runCatching {
keyData.optString("keyMaterial").takeIf { !it.isNullOrEmpty() }?.let {
Base64.decode(it, Base64.DEFAULT)
} ?: if (keyData.length() > 0) {
ByteArray(keyData.length()) { i -> keyData.getInt(i.toString()).toByte() }
} else null
}.getOrNull()
private fun notifyWebView(status: Int) {
mainHandler.post {
webView.loadUrl("javascript:window.onKeyDataSet(${JSONObject().put("status", status)})")
}
}
}

View file

@ -0,0 +1,130 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.auth.folsom.ui
import android.app.Activity
import android.util.Base64
import android.util.Log
import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebView
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.webkit.WebResourceErrorCompat
import androidx.webkit.WebViewClientCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.microg.gms.auth.AuthManager
import org.microg.gms.auth.folsom.SECURITY_WEB_BASE_URL
import org.microg.gms.auth.folsom.buildKeyDeliveryInfo
import org.microg.gms.common.Constants.GMS_PACKAGE_NAME
import org.microg.gms.profile.Build.VERSION.SDK_INT
import java.net.URLEncoder
import java.util.Locale
private const val TAG = "FolsomWebViewHelper"
class FolsomWebViewHelper(
private val fragment: Fragment,
private val webView: WebView,
private val accountName: String
) {
fun prepareWebViewSettings() {
webView.settings.apply {
javaScriptEnabled = true
allowFileAccess = false
databaseEnabled = false
setNeedInitialFocus(false)
useWideViewPort = false
setSupportZoom(false)
javaScriptCanOpenWindowsAutomatically = false
}
}
fun setupWebViewClient(
onPageStarted: (() -> Unit)? = null,
onPageFinished: (() -> Unit)? = null
) {
webView.webViewClient = object : WebViewClientCompat() {
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceErrorCompat) {
Log.w(TAG, "Error loading: ${error.description}")
}
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
super.onPageStarted(view, url, favicon)
onPageStarted?.invoke()
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
onPageFinished?.invoke()
}
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
Log.d(TAG, "Navigating to $url")
return url.toUri().host?.endsWith("google.com") != true
}
}
}
fun loadUrlWithAuthentication(targetUrl: String) {
fragment.lifecycleScope.launch {
val authUrl = withContext(Dispatchers.IO) {
getAuthenticatedUrl(targetUrl)
}
if (authUrl != null) {
setupCookies()
webView.loadUrl(authUrl)
} else {
Log.w(TAG, "Failed to get authenticated URL")
fragment.requireActivity().setResult(Activity.RESULT_CANCELED)
fragment.requireActivity().finish()
}
}
}
private fun getAuthenticatedUrl(targetUrl: String): String? = runCatching {
val service = "weblogin:continue=" + URLEncoder.encode(targetUrl, "UTF-8")
AuthManager(fragment.requireContext(), accountName, GMS_PACKAGE_NAME, service)
.requestAuthWithForegroundResolution(false)
.auth
?.takeUnless { it.contains("WILL_NOT_SIGN_IN") }
}.getOrNull()
private fun setupCookies() {
CookieManager.getInstance().apply {
setAcceptCookie(true)
if (SDK_INT >= 21) {
setAcceptThirdPartyCookies(webView, true)
}
}
}
fun buildKeyRetrievalUrl(
sessionId: String,
securityDomain: String,
offerReset: Boolean,
darkMode: Boolean
): String {
val locale = if (SDK_INT >= 21) {
Locale.getDefault().toLanguageTag()
} else {
Locale.getDefault().language
}
val kdi = buildKeyDeliveryInfo(sessionId, securityDomain, offerReset)
return SECURITY_WEB_BASE_URL.toUri().buildUpon().apply {
appendQueryParameter("kdi", Base64.encodeToString(kdi, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING))
if (locale.isNotEmpty()) appendQueryParameter("hl", locale)
if (darkMode) appendQueryParameter("color_scheme", "dark")
}.build().toString()
}
fun destroy() {
webView.stopLoading()
}
}

View file

@ -1,5 +1,5 @@
/**
* SPDX-FileCopyrightText: 2025 microG Project Team
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
@ -12,9 +12,36 @@ import com.google.android.gms.R
class GenericActivity : AppCompatActivity() {
companion object {
const val EXTRA_ACCOUNT_NAME = "account_name"
const val EXTRA_SECURITY_DOMAIN = "security_domain"
const val EXTRA_OPERATION = "operation"
const val EXTRA_SESSION_ID = "session_id"
const val EXTRA_OFFER_RESET = "offer_reset"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Toast.makeText(this, getString(R.string.backup_disabled), Toast.LENGTH_SHORT).show()
finish()
val extras = intent.extras
val accountName = extras?.getString(EXTRA_ACCOUNT_NAME)
val domain = extras?.getString(EXTRA_SECURITY_DOMAIN)
if (accountName.isNullOrEmpty() || domain.isNullOrEmpty()) {
Toast.makeText(this, getString(R.string.backup_disabled), Toast.LENGTH_SHORT).show()
finish()
return
}
if (savedInstanceState == null) {
FolsomWebFragment.newInstance(
accountName = accountName,
securityDomain = domain,
operation = extras.getInt(EXTRA_OPERATION, 0),
sessionId = extras.getString(EXTRA_SESSION_ID, ""),
offerReset = extras.getBoolean(EXTRA_OFFER_RESET, false)
).also { fragment ->
supportFragmentManager.beginTransaction()
.add(android.R.id.content, fragment)
.commit()
}
}
}
}

View file

@ -0,0 +1,397 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.auth.folsom.utils
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import com.google.crypto.tink.Aead
import com.google.crypto.tink.CleartextKeysetHandle
import com.google.crypto.tink.JsonKeysetReader
import com.google.crypto.tink.JsonKeysetWriter
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.KeysetHandle
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.integration.android.AndroidKeysetManager
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.microg.gms.auth.folsom.AccountData
import org.microg.gms.auth.folsom.DomainData
import org.microg.gms.auth.folsom.DomainStatus
import org.microg.gms.auth.folsom.FolsomKeyStore
import org.microg.gms.auth.folsom.GetSecurityDomainResponse
import org.microg.gms.auth.folsom.KeyPair
import org.microg.gms.auth.folsom.Keys
import org.microg.gms.auth.folsom.ListSecurityDomainMembersResponse
import org.microg.gms.auth.folsom.SecurityDomainMemberResponse
import org.microg.gms.auth.folsom.computeMemberName
import org.microg.gms.auth.folsom.loadSecurityDomainMembers
import java.io.File
import java.io.IOException
import java.security.MessageDigest
import java.util.Random
private const val TAG = "LocalKeyManager"
private const val STORE_DIR = "folsom"
private const val STORE_FILE = "FolsomKeyStore.pb"
private const val TINK_KEYSET_PREF_NAME = "folsom_tink_keyset"
private const val TINK_KEYSET_KEY = "folsom_aead_keyset"
private const val TINK_MASTER_KEY_URI = "android-keystore://folsom_tink_master_key"
private const val MAX_KEYS_PER_DOMAIN = 100
private const val TIMESTAMP_JITTER_RANGE = 60000L
private val SENSITIVE_DOMAINS = setOf(
"users/me/securitydomains/on_device_location_history",
"users/me/securitydomains/passwords",
"users/me/securitydomains/gpm_passkeys",
"users/me/securitydomains/chrome_signin",
"on_device_location_history",
"passwords",
"gpm_passkeys",
"chrome_signin"
)
private object StoredStatus {
const val UNKNOWN = 0
const val NOT_RECOVERABLE = 1
const val PENDING_RECOVERY = 2
const val RECOVERABLE = 3
}
class TinkEncryptionException(message: String, cause: Throwable? = null) : Exception(message, cause)
class LocalKeyManager(private val context: Context) {
private val storeFile: File by lazy {
File(context.filesDir, STORE_DIR).apply { mkdirs() }.resolve(STORE_FILE)
}
private val random = Random()
@Volatile
private var cachedStore: FolsomKeyStore? = null
private val storeLock = Any()
@Volatile
private var lastFileModifiedTime: Long = 0L
private val aead: Aead by lazy {
runCatching {
AeadConfig.register()
AndroidKeysetManager.Builder()
.withSharedPref(context, TINK_KEYSET_KEY, TINK_KEYSET_PREF_NAME)
.withKeyTemplate(KeyTemplates.get("AES256_GCM"))
.withMasterKeyUri(TINK_MASTER_KEY_URI)
.build()
.keysetHandle.getPrimitive(Aead::class.java)
}.getOrElse { Log.e(TAG, "Failed to initialize AEAD", it); throw TinkEncryptionException("Failed to initialize", it) }
}
private val fallbackAead: Aead? by lazy {
runCatching {
AeadConfig.register()
val keysetFile = File(context.filesDir, "$STORE_DIR/tink_keyset.json")
(if (keysetFile.exists()) CleartextKeysetHandle.read(JsonKeysetReader.withFile(keysetFile))
else KeysetHandle.generateNew(KeyTemplates.get("AES256_GCM")).also { h ->
keysetFile.parentFile?.mkdirs()
CleartextKeysetHandle.write(h, JsonKeysetWriter.withFile(keysetFile))
}).getPrimitive(Aead::class.java)
}.onFailure { Log.e(TAG, "Failed to initialize fallback AEAD", it) }.getOrNull()
}
private fun obtainAead(): Aead = runCatching { aead }
.getOrElse {
Log.w(TAG, "Primary AEAD unavailable, using fallback", it)
fallbackAead ?: throw TinkEncryptionException("No AEAD available")
}
private fun readStore(): FolsomKeyStore = synchronized(storeLock) {
val currentModifiedTime = if (storeFile.exists()) storeFile.lastModified() else 0L
if (cachedStore != null && currentModifiedTime == lastFileModifiedTime) return@synchronized cachedStore!!
val store = runCatching { FolsomKeyStore.ADAPTER.decode(storeFile.readBytes()) }
.getOrElse { Log.e(TAG, "Failed to read store file, creating new one", it); FolsomKeyStore() }
.takeIf { storeFile.exists() } ?: FolsomKeyStore()
cachedStore = store; lastFileModifiedTime = currentModifiedTime; store
}
private fun writeStore(store: FolsomKeyStore) = synchronized(storeLock) {
runCatching { storeFile.parentFile?.mkdirs(); storeFile.writeBytes(store.encode()); cachedStore = store }
.onFailure { Log.e(TAG, "Failed to write store file", it) }
.getOrElse { throw IOException("Failed to write store", it) }
}
private inline fun updateStore(transform: (FolsomKeyStore) -> FolsomKeyStore) {
synchronized(storeLock) { writeStore(transform(readStore())) }
}
private fun FolsomKeyStore.findAccount(obfuscatedId: String): AccountData? = accounts.find { it.key == obfuscatedId }?.value_
private fun AccountData.findDomain(domainId: String): DomainData? = domains.find { it.key == domainId }?.value_
private fun <T> updateEntry(
list: List<T>,
keySelector: (T) -> String?,
key: String,
newEntry: T,
): List<T> {
val mutable = list.toMutableList()
val idx = mutable.indexOfFirst { keySelector(it) == key }
return if (idx >= 0) {
mutable[idx] = newEntry; mutable
} else {
mutable + newEntry
}
}
private fun FolsomKeyStore.updateAccount(obfuscatedId: String, accountData: AccountData): FolsomKeyStore =
copy(
accounts = updateEntry(
accounts, { it.key }, obfuscatedId,
FolsomKeyStore.AccountsEntry(key = obfuscatedId, value_ = accountData)
)
)
private fun AccountData.updateDomain(domainId: String, domainData: DomainData): AccountData =
copy(
domains = updateEntry(
domains, { it.key }, domainId,
AccountData.DomainsEntry(key = domainId, value_ = domainData)
)
)
private fun obfuscateAccountName(accountName: String): String =
MessageDigest.getInstance("SHA-256").digest(accountName.toByteArray()).joinToString("") { "%02x".format(it) }
private fun DomainStatus.convertStatus(): Int = when (this) {
DomainStatus.UNKNOWN -> StoredStatus.UNKNOWN
DomainStatus.NOT_RECOVERABLE -> StoredStatus.NOT_RECOVERABLE
DomainStatus.PENDING_RECOVERY -> StoredStatus.PENDING_RECOVERY
DomainStatus.RECOVERABLE -> StoredStatus.RECOVERABLE
else -> StoredStatus.UNKNOWN
}
private fun Int.toDomainStatus(): DomainStatus = when (this) {
StoredStatus.UNKNOWN -> DomainStatus.UNKNOWN
StoredStatus.NOT_RECOVERABLE -> DomainStatus.NOT_RECOVERABLE
StoredStatus.PENDING_RECOVERY -> DomainStatus.PENDING_RECOVERY
StoredStatus.RECOVERABLE -> DomainStatus.RECOVERABLE
else -> DomainStatus.UNKNOWN_ERROR
}
private fun cipher(encrypt: Boolean, data: ByteArray, aad: ByteArray = ByteArray(0)): ByteArray = runCatching {
obtainAead().let { if (encrypt) it.encrypt(data, aad) else it.decrypt(data, aad) }
}.getOrElse { throw TinkEncryptionException(if (encrypt) "Encryption" else "Decryption" + " failed", it) }
private fun encrypt(plaintext: ByteArray, aad: ByteArray = ByteArray(0)) = cipher(true, plaintext, aad)
private fun decrypt(ciphertext: ByteArray, aad: ByteArray = ByteArray(0)) = cipher(false, ciphertext, aad)
private fun addJitter(timestamp: Long): Long = if (timestamp == 0L) 0L else timestamp - (random.nextFloat() * TIMESTAMP_JITTER_RANGE).toLong()
private fun isSensitiveDomain(domainId: String): Boolean = SENSITIVE_DOMAINS.any { domainId.contains(it) || it.contains(domainId) }
fun getDomainStatus(accountName: String, domainId: String): DomainStatus {
val obfuscatedId = obfuscateAccountName(accountName)
val store = readStore()
val account = store.findAccount(obfuscatedId) ?: return DomainStatus.UNKNOWN
val domain = account.findDomain(domainId) ?: return DomainStatus.UNKNOWN
return (domain.recoverabilityStatus ?: 0).toDomainStatus()
}
fun getKeysForDomain(accountName: String, domainId: String): List<Keys> {
val obfuscatedId = obfuscateAccountName(accountName)
val store = readStore()
val account = store.findAccount(obfuscatedId) ?: return emptyList()
val domain = account.findDomain(domainId) ?: return emptyList()
return decryptKeys(domain.keys, domainId)
}
private fun hasValidKeys(accountName: String, domainId: String): Boolean = runCatching {
getKeysForDomain(accountName, domainId).let { it.isNotEmpty() && it.any { (it.keyVersion ?: 0) != 0 } }
}.getOrElse { Log.e(TAG, "Error checking hasValidKeys", it); false }
fun setDomainStatus(accountName: String, domainId: String, status: DomainStatus) {
val obfuscatedId = obfuscateAccountName(accountName)
updateStore { store ->
val account = store.findAccount(obfuscatedId) ?: AccountData()
val domain = account.findDomain(domainId) ?: DomainData()
val updatedDomain = domain.copy(
recoverabilityStatus = status.convertStatus(),
recoverabilityStatusTimestamp = addJitter(System.currentTimeMillis())
)
store.updateAccount(obfuscatedId, account.updateDomain(domainId, updatedDomain))
}
}
fun updateLastFetchTimestamp(accountName: String, domainId: String, timestamp: Long) {
val obfuscatedId = obfuscateAccountName(accountName)
updateStore { store ->
val account = store.findAccount(obfuscatedId) ?: AccountData()
val domain = account.findDomain(domainId) ?: DomainData()
store.updateAccount(obfuscatedId, account.updateDomain(domainId, domain.copy(lastFetchTimestamp = addJitter(timestamp))))
}
}
fun clearDomainKeys(accountName: String, domainId: String) {
val obfuscatedId = obfuscateAccountName(accountName)
updateStore { store ->
val account = store.findAccount(obfuscatedId) ?: return@updateStore store
val domain = account.findDomain(domainId) ?: DomainData()
store.updateAccount(obfuscatedId, account.updateDomain(domainId, domain.copy(keys = emptyList())))
}
}
fun saveKeysForDomain(accountName: String, domainId: String, keys: List<Keys>, callerPackage: String? = null) {
val obfuscatedId = obfuscateAccountName(accountName)
val keysToSave = keys.takeLast(MAX_KEYS_PER_DOMAIN).also {
if (keys.size > MAX_KEYS_PER_DOMAIN) Log.w(TAG, "Truncating keys from ${keys.size} to $MAX_KEYS_PER_DOMAIN")
}
Log.d(TAG, "saveKeysForDomain: domainId=$domainId, keyCount=${keysToSave.size}, caller=$callerPackage")
updateStore { store ->
val account = store.findAccount(obfuscatedId) ?: AccountData()
val domain = account.findDomain(domainId) ?: DomainData()
store.updateAccount(
obfuscatedId, account.updateDomain(
domainId, domain.copy(
keys = encryptKeysIfNeeded(domainId, keysToSave),
lastModifiedTimestamp = System.currentTimeMillis()
)
)
)
}
}
private fun decryptKeys(encryptedKeys: List<Keys>, domainId: String): List<Keys> {
if (encryptedKeys.isEmpty()) return emptyList()
val aad = domainId.toByteArray()
return encryptedKeys.map { key ->
key.encryptedKeyMaterial?.takeIf { it.size > 0 }?.let { em ->
runCatching {
key.copy(keyMaterial = decrypt(em.toByteArray(), aad).toByteString(), encryptedKeyMaterial = null)
}.getOrElse { throw TinkEncryptionException("Failed to decrypt key", it) }
} ?: key
}
}
private fun encryptKeysIfNeeded(domainId: String, keys: List<Keys>): List<Keys> {
if (keys.isEmpty() || !isSensitiveDomain(domainId)) return keys
val aad = domainId.toByteArray()
return keys.map { key ->
(key.keyMaterial ?: key.keyMetadata)?.takeIf { it.size > 0 }?.let { km ->
key.copy(keyMaterial = null, encryptedKeyMaterial = encrypt(km.toByteArray(), aad).toByteString())
} ?: key
}
}
private fun getAccountKeyPairs(accountName: String): List<KeyPair> {
val obfuscatedId = obfuscateAccountName(accountName)
return readStore().findAccount(obfuscatedId)?.accountKeyPairs ?: emptyList()
}
private fun getPhysicalDeviceKeyPair(accountName: String): KeyPair? {
val keyPairs = getAccountKeyPairs(accountName)
return keyPairs.find { it.keyPairType == 3 } ?: keyPairs.find { it.keyPairType == 1 }
}
private fun buildKeyPairBytes(privateKey: ByteArray, publicKey: ByteArray): ByteArray = ByteArray(97).apply {
System.arraycopy(privateKey, 0, this, 0, minOf(privateKey.size, 32))
System.arraycopy(publicKey, 0, this, 32, minOf(publicKey.size, 65))
}
fun computeDomainStatus(
accountName: String,
domainId: String,
serverState: GetSecurityDomainResponse?,
membersResponse: ListSecurityDomainMembersResponse?
): DomainStatus {
if (serverState == null) {
val hasLocalKeys = hasValidKeys(accountName, domainId)
return if (hasLocalKeys) DomainStatus.NOT_RECOVERABLE else DomainStatus.UNKNOWN
}
val members = membersResponse?.members
if (members.isNullOrEmpty()) {
return DomainStatus.NO_KEYS
}
val hasKeys = members.any { member ->
member.securityDomains.any { sd ->
sd.memberKeys.isNotEmpty() || sd.trustedVaultKeys.isNotEmpty()
}
}
if (!hasKeys) {
return DomainStatus.NO_KEYS
}
if (hasValidKeys(accountName, domainId)) {
return DomainStatus.RECOVERABLE
}
val localKeyPair = getPhysicalDeviceKeyPair(accountName)
if (localKeyPair != null) {
val localPublicKey = localKeyPair.publicKey?.toByteArray()
if (localPublicKey != null) {
val localMemberName = computeMemberName(localPublicKey)
val isDeviceRegistered = members.any { it.name == localMemberName }
if (isDeviceRegistered) {
return DomainStatus.RECOVERABLE
}
}
}
return DomainStatus.PENDING_RECOVERY
}
suspend fun getLocalKeysOrSync(
context: Context,
accountName: String,
domainId: String,
sessionId: String
): List<Keys> = runCatching {
getKeysForDomain(accountName, domainId).takeIf { it.isNotEmpty() }
?: loadSecurityDomainMembers(context, accountName, sessionId, domainId)
.members.takeIf { it.isNotEmpty() }
?.let { extractKeysFromMembers(it, domainId, accountName) }
?.also { saveKeysForDomain(accountName, domainId, it) }
?: emptyList()
}.getOrDefault(emptyList())
private fun extractKeysFromMembers(
members: List<SecurityDomainMemberResponse>,
domainId: String,
accountName: String
): List<Keys> = getPhysicalDeviceKeyPair(accountName)
?.let { keyPair ->
val pubKey = keyPair.publicKey?.toByteArray()
val priKey = keyPair.privateKey?.toByteArray()
if (pubKey != null && priKey != null) {
members.find { it.name == computeMemberName(pubKey) }
?.let { decryptAllKeys(it, domainId, buildKeyPairBytes(priKey, pubKey)) }
} else null
} ?: emptyList()
private fun decryptAllKeys(
member: SecurityDomainMemberResponse,
domainId: String,
localKeyPairBytes: ByteArray
): List<Keys> = member.securityDomains
.filter { it.name?.contains(domainId) == true }
.flatMap { domain ->
domain.trustedVaultKeys.mapNotNull { key -> decryptKey(key.epoch, key.wrappedKey, localKeyPairBytes) } +
domain.memberKeys.mapNotNull { key -> decryptKey(key.keyType, key.wrappedKey, localKeyPairBytes) }
}
private fun decryptKey(
version: Int?,
wrappedKey: ByteString?,
localKeyPairBytes: ByteArray
): Keys? = runCatching {
version?.takeIf { it != 0 } ?: return@runCatching null
val wrappedKeyBytes = wrappedKey?.toByteArray() ?: return@runCatching null
val keyMaterial = SecureBox.unwrapKey(localKeyPairBytes, wrappedKeyBytes)
Keys(keyVersion = version, keyMaterial = ByteString.of(*keyMaterial))
}.getOrNull()
companion object {
@SuppressLint("StaticFieldLeak")
@Volatile
private var instance: LocalKeyManager? = null
fun getInstance(context: Context): LocalKeyManager = instance ?: synchronized(this) {
instance ?: LocalKeyManager(context.applicationContext).also { instance = it }
}
}
}

View file

@ -0,0 +1,140 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.auth.folsom.utils
import com.google.crypto.tink.subtle.EllipticCurves
import com.google.crypto.tink.subtle.Hkdf
import java.nio.ByteBuffer
import java.security.InvalidKeyException
import java.security.PrivateKey
import java.security.PublicKey
import java.security.interfaces.ECPrivateKey
import java.security.interfaces.ECPublicKey
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
object SecureBox {
private val VERSION = byteArrayOf(0x02, 0x00)
private val HKDF_SALT = "SECUREBOX".toByteArray() + byteArrayOf(0x02, 0x00)
private val HKDF_INFO_P256 = "P256 HKDF-SHA-256 AES-128-GCM".toByteArray()
private val HKDF_INFO_SHARED = "SHARED HKDF-SHA-256 AES-128-GCM".toByteArray()
private const val AES_KEY_SIZE = 16
private const val GCM_IV_SIZE = 12
private const val GCM_TAG_SIZE = 128
private const val KEY_PAIR_SIZE = 97
private const val PUBLIC_KEY_SIZE = 65
private const val UNCOMPRESSED_POINT_PREFIX: Byte = 0x04
fun deserializePrivateKey(keyBytes: ByteArray): PrivateKey {
if (keyBytes.size != KEY_PAIR_SIZE) {
throw InvalidKeyException("Invalid key pair size: expected $KEY_PAIR_SIZE bytes, got ${keyBytes.size}")
}
val privateKeyBytes = keyBytes.copyOf(AES_KEY_SIZE * 2)
return EllipticCurves.getEcPrivateKey(EllipticCurves.CurveType.NIST_P256, privateKeyBytes)
}
fun deserializePublicKey(keyBytes: ByteArray): PublicKey {
if (keyBytes.size != PUBLIC_KEY_SIZE || keyBytes[0] != UNCOMPRESSED_POINT_PREFIX) {
throw InvalidKeyException("Invalid public key: expected $PUBLIC_KEY_SIZE bytes starting with 0x04")
}
return EllipticCurves.getEcPublicKey(
EllipticCurves.CurveType.NIST_P256,
EllipticCurves.PointFormatType.UNCOMPRESSED,
keyBytes
)
}
private fun performECDH(privateKey: PrivateKey, publicKey: PublicKey): ByteArray {
return EllipticCurves.computeSharedSecret(
privateKey as ECPrivateKey,
(publicKey as ECPublicKey).w
)
}
private fun deriveKey(ikm: ByteArray, salt: ByteArray, info: ByteArray, outputLength: Int = AES_KEY_SIZE): ByteArray {
return Hkdf.computeHkdf("HMACSHA256", ikm, salt, info, outputLength)
}
fun decrypt(
privateKey: PrivateKey?,
sharedSecret: ByteArray?,
header: ByteArray?,
encryptedPayload: ByteArray
): ByteArray {
val secret = sharedSecret ?: ByteArray(0)
val aad = header ?: ByteArray(0)
require(privateKey != null || secret.isNotEmpty()) {
"Both private key and shared secret are empty"
}
val buffer = ByteBuffer.wrap(encryptedPayload)
val version = ByteArray(VERSION.size)
buffer.get(version)
require(version.contentEquals(VERSION)) {
"Invalid SecureBox version: expected ${VERSION.joinToString(",")}, got ${version.joinToString(",")}"
}
val ecdhSecret: ByteArray
val hkdfInfo: ByteArray
if (privateKey == null) {
ecdhSecret = ByteArray(0)
hkdfInfo = HKDF_INFO_SHARED
} else {
val ephemeralPublicKeyBytes = ByteArray(PUBLIC_KEY_SIZE)
buffer.get(ephemeralPublicKeyBytes)
val ephemeralPublicKey = deserializePublicKey(ephemeralPublicKeyBytes)
ecdhSecret = performECDH(privateKey, ephemeralPublicKey)
hkdfInfo = HKDF_INFO_P256
}
val iv = ByteArray(GCM_IV_SIZE)
buffer.get(iv)
val ciphertext = ByteArray(buffer.remaining())
buffer.get(ciphertext)
val combinedSecret = concat(ecdhSecret, secret)
val aesKeyBytes = deriveKey(combinedSecret, HKDF_SALT, hkdfInfo)
val aesKey = SecretKeySpec(aesKeyBytes, "AES")
return aesGcmDecrypt(aesKey, iv, ciphertext, aad)
}
private fun aesGcmEncrypt(key: SecretKeySpec, iv: ByteArray, plaintext: ByteArray, aad: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(GCM_TAG_SIZE, iv))
cipher.updateAAD(aad)
return cipher.doFinal(plaintext)
}
private fun aesGcmDecrypt(key: SecretKeySpec, iv: ByteArray, ciphertext: ByteArray, aad: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_SIZE, iv))
cipher.updateAAD(aad)
return cipher.doFinal(ciphertext)
}
private fun concat(vararg arrays: ByteArray): ByteArray {
val totalLength = arrays.sumOf { it.size }
val result = ByteArray(totalLength)
var offset = 0
for (array in arrays) {
System.arraycopy(array, 0, result, offset, array.size)
offset += array.size
}
return result
}
fun unwrapKey(localKeyPairBytes: ByteArray, wrappedKey: ByteArray, header: ByteArray? = null): ByteArray {
val privateKey = deserializePrivateKey(localKeyPairBytes)
return decrypt(privateKey, null, header, wrappedKey)
}
}

View file

@ -88,6 +88,22 @@ class AuthHeaderInterceptor(
}
}
inline fun <reified S : Service> createGrpcClient(
baseUrl: String,
oauthToken: String,
minMessageToCompress: Long = Long.MAX_VALUE
): S {
val client = OkHttpClient.Builder().apply {
addInterceptor(AuthHeaderInterceptor(oauthToken))
}.build()
val grpcClient = GrpcClient.Builder()
.client(client)
.baseUrl(baseUrl)
.minMessageToCompress(minMessageToCompress)
.build()
return grpcClient.create(S::class)
}
inline fun <reified S : Service> createGrpcClient(
baseUrl: String,
interceptor: Interceptor,

View file

@ -400,6 +400,7 @@ microG GmsCore 内置一套自由的 SafetyNet 实现,但是官方服务器要
<string name="pref_app_install_other_apps_note">授权允许安装从其他渠道下载的应用程序。</string>
<string name="pref_app_install_permission_instruction">为确保您的应用程序正常运行,请授权安装从其他来源下载的应用程序。该应用程序的某些服务需要必要的权限才能运行,拒绝权限可能会限制或禁用该应用程序的功能。</string>
<string name="prefcat_app_install_list_title">授权渠道</string>
<string name="credentials_service_sign_in_with_google_label">用 Google 登录</string>
<string name="credentials_service_remote_custom_subtitle">安全密钥、智能手机或平板</string>
</resources>

View file

@ -99,4 +99,12 @@ class LocationSettings(private val context: Context) {
var ichnaeaContribute: Boolean
get() = getSettings(SettingsContract.Location.ICHNAEA_CONTRIBUTE) { c -> c.getInt(0) != 0 }
set(value) = setSettings { put(SettingsContract.Location.ICHNAEA_CONTRIBUTE, value) }
var mapsTimeline: Boolean
get() = getSettings(SettingsContract.Location.MAPS_TIMELINE) { c -> c.getInt(0) != 0 }
set(value) = setSettings { put(SettingsContract.Location.MAPS_TIMELINE, value) }
var mapsTimelineUpload: Boolean
get() = getSettings(SettingsContract.Location.MAPS_TIMELINE_UPLOAD) { c -> c.getInt(0) != 0 }
set(value) = setSettings { put(SettingsContract.Location.MAPS_TIMELINE_UPLOAD, value) }
}

View file

@ -56,6 +56,8 @@ class LocationPreferencesFragment : PreferenceFragmentCompat() {
private lateinit var cellLearning: TwoStatePreference
private lateinit var nominatim: TwoStatePreference
private lateinit var database: LocationAppsDatabase
private lateinit var timelineEnabled: TwoStatePreference
private lateinit var timelineUpload: TwoStatePreference
init {
setHasOptionsMenu(true)
@ -271,6 +273,8 @@ class LocationPreferencesFragment : PreferenceFragmentCompat() {
cellIchnaea = preferenceScreen.findPreference("pref_location_cell_mls_enabled") ?: cellIchnaea
cellLearning = preferenceScreen.findPreference("pref_location_cell_learning_enabled") ?: cellLearning
nominatim = preferenceScreen.findPreference("pref_geocoder_nominatim_enabled") ?: nominatim
timelineEnabled = preferenceScreen.findPreference("pref_location_timeline") ?: timelineEnabled
timelineUpload = preferenceScreen.findPreference("pref_location_timeline_upload") ?: timelineUpload
locationAppsAll.setOnPreferenceClickListener {
findNavController().navigate(requireContext(), R.id.openAllLocationApps)
@ -314,6 +318,17 @@ class LocationPreferencesFragment : PreferenceFragmentCompat() {
}
configureChangeListener(cellLearning) { LocationSettings(requireContext()).cellLearning = it }
configureChangeListener(nominatim) { LocationSettings(requireContext()).geocoderNominatim = it }
configureChangeListener(timelineEnabled) {
LocationSettings(requireContext()).mapsTimeline = it
if (!it) {
LocationSettings(requireContext()).mapsTimelineUpload = false
timelineUpload.isChecked = false
timelineUpload.isEnabled = false
} else {
timelineUpload.isEnabled = true
}
}
configureChangeListener(timelineUpload) { LocationSettings(requireContext()).mapsTimelineUpload = it }
networkProviderCategory.isVisible = requireContext().hasNetworkLocationServiceBuiltIn()
wifiLearning.isVisible =
@ -349,6 +364,9 @@ class LocationPreferencesFragment : PreferenceFragmentCompat() {
cellIchnaea.isChecked = LocationSettings(context).cellIchnaea
cellLearning.isChecked = LocationSettings(context).cellLearning
nominatim.isChecked = LocationSettings(context).geocoderNominatim
timelineEnabled.isChecked = LocationSettings(context).mapsTimeline
timelineUpload.isEnabled = LocationSettings(context).mapsTimeline
timelineUpload.isChecked = LocationSettings(context).mapsTimelineUpload
val (apps, showAll) = withContext(Dispatchers.IO) {
val apps = database.listAppsByAccessTime()
val res = apps.map { app ->

View file

@ -45,4 +45,8 @@
<string name="notification_config_required_text_online_sources">要继续使用在线位置服务,您需要选择一个位置数据服务。</string>
<string name="location_data_export_wifi_title">导出本地 Wi-Fi 位置数据库</string>
<string name="prefcat_app_last_location">最后报告的位置</string>
<string name="prefcat_location_timeline_title">Google Maps 时间轴</string>
<string name="pref_location_timeline_summary">启用后,历史位置服务将定期记录您的位置信息,以确保 Google 地图时间线正常运行。</string>
<string name="pref_location_timeline_upload_title">允许同步至服务器</string>
<string name="pref_location_timeline_upload_summary">启用后历史位置服务将周期性的将您的位置信息上报至Google服务器以便其他设备同步显示。</string>
</resources>

View file

@ -57,4 +57,9 @@
<string name="location_data_import_title">Import location data from file</string>
<string name="location_data_import_result_toast">Imported %1$d records.</string>
<string name="prefcat_location_timeline_title">Google Maps Timeline</string>
<string name="pref_location_timeline_title">Enable location history</string>
<string name="pref_location_timeline_summary">"Periodically record your location information and make it available to Google Maps Timeline."</string>
<string name="pref_location_timeline_upload_title">Synchronize to Google account</string>
<string name="pref_location_timeline_upload_summary">"Synchronize location history to Google servers, so it can be displayed on other devices."</string>
</resources>

View file

@ -82,4 +82,20 @@
app:iconSpaceReserved="false" />
</PreferenceCategory>
</PreferenceCategory>
<PreferenceCategory
android:title="@string/prefcat_location_timeline_title"
app:iconSpaceReserved="false">
<SwitchPreferenceCompat
android:key="pref_location_timeline"
android:persistent="false"
android:title="@string/pref_location_timeline_title"
android:summary="@string/pref_location_timeline_summary"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:key="pref_location_timeline_upload"
android:persistent="false"
android:summary="@string/pref_location_timeline_upload_summary"
android:title="@string/pref_location_timeline_upload_title"
app:iconSpaceReserved="false" />
</PreferenceCategory>
</PreferenceScreen>