mirror of
https://github.com/microg/android_packages_apps_GmsCore
synced 2026-08-06 14:26:05 -04:00
Add support for Google Maps timeline functionality (#3331)
Co-authored-by: Marvin W <git@larma.de>
This commit is contained in:
parent
4e8200a1ea
commit
e4a45f1831
48 changed files with 6470 additions and 233 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
396
play-services-core-proto/src/main/proto/batchsync.proto
Normal file
396
play-services-core-proto/src/main/proto/batchsync.proto
Normal 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);
|
||||
}
|
||||
86
play-services-core-proto/src/main/proto/externaldbsync.proto
Normal file
86
play-services-core-proto/src/main/proto/externaldbsync.proto
Normal 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;
|
||||
}
|
||||
116
play-services-core-proto/src/main/proto/folsomkeystore.proto
Normal file
116
play-services-core-proto/src/main/proto/folsomkeystore.proto
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
227
play-services-core-proto/src/main/proto/segment.proto
Normal file
227
play-services-core-proto/src/main/proto/segment.proto
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -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)})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue