diff --git a/play-services-base/src/main/java/com/google/android/gms/common/images/ImageManager.java b/play-services-base/src/main/java/com/google/android/gms/common/images/ImageManager.java
index 46bd845899..ff2831fc38 100644
--- a/play-services-base/src/main/java/com/google/android/gms/common/images/ImageManager.java
+++ b/play-services-base/src/main/java/com/google/android/gms/common/images/ImageManager.java
@@ -96,6 +96,21 @@ public byte[] compressBitmap(Bitmap original, int newWidth, int newHeight) {
return byteArrayOutputStream.toByteArray();
}
+ @WorkerThread
+ public byte[] covertBitmap(final String url) {
+ Bitmap cachedBitmap = getBitmapFromCache(url);
+ if (cachedBitmap == null) {
+ cachedBitmap = downloadBitmap(url);
+ if (cachedBitmap == null) {
+ return null;
+ }
+ addBitmapToCache(url, cachedBitmap);
+ }
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ cachedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream);
+ return outputStream.toByteArray();
+ }
+
public void loadImage(final String url, final ImageView imageView) {
if (imageView == null) {
Log.d(TAG, "loadImage: imageView is null");
diff --git a/play-services-core-proto/src/main/proto/contacts.proto b/play-services-core-proto/src/main/proto/contacts.proto
new file mode 100644
index 0000000000..b49d201fa9
--- /dev/null
+++ b/play-services-core-proto/src/main/proto/contacts.proto
@@ -0,0 +1,536 @@
+package google.internal.people.v2;
+
+option java_outer_classname = "ContactsProto";
+
+option java_package = "org.microg.gms.people";
+option java_multiple_files = true;
+
+service InternalPeopleService {
+ rpc SyncPeople(SyncPeopleRequest) returns (SyncPeopleResponse);
+ rpc BulkInsertContacts(InsertRequest) returns (InsertResponse);
+ rpc DeletePeople(DeleteRequest) returns (DeleteResponse);
+ rpc UpdatePerson(UpdateRequest) returns (UpdateResponse);
+
+ rpc UpdatePersonPhoto(UpdatePhotoRequest) returns (UpdatePhotoResponse);
+ rpc DeletePersonPhoto(DeletePhotoRequest) returns (EmptyResponse);
+
+ rpc ListContactGroups(ContactGroupRequest) returns (ContactGroupResponse);
+ rpc CreateContactGroups(CreateGroupRequest) returns (CreateGroupResponse);
+ rpc DeleteContactGroups(DeleteGroupRequest) returns (DeleteGroupResponse);
+ rpc UpdateContactGroups(UpdateGroupRequest) returns (CreateGroupResponse);
+}
+
+message EmptyResponse{}
+
+message DeletePhotoRequest {
+ optional string sourceId = 1;
+ optional GmsVersion gmsVersion = 2;
+}
+
+message UpdatePhotoRequest {
+ optional int32 type = 1;
+ optional string sourceId = 2;
+ optional int32 content = 3;
+ optional bytes photoBytes = 5;
+ optional GmsVersion gmsVersion = 6;
+}
+
+message UpdatePhotoResponse {
+ optional string syncToken = 1;
+ optional string url = 2;
+}
+
+message UpdateGroupRequest {
+ repeated UpdateGroupContent content = 1;
+ optional GmsVersion gmsVersion = 3;
+}
+
+message UpdateGroupContent {
+ optional GroupId groupId = 1;
+ optional GroupSource groupSource = 2;
+ optional string syncStr = 3;
+}
+
+message DeleteGroupRequest {
+ repeated string groupId = 1;
+ optional GmsVersion gmsVersion = 3;
+}
+
+message DeleteGroupResponse {
+ repeated DeleteGroup delete = 1;
+}
+
+message DeleteGroup {
+ optional string error = 2;
+ optional GroupId groupId = 4;
+}
+
+message CreateGroupResponse {
+ repeated ContactGroup content = 1;
+}
+
+message CreateGroupRequest {
+ repeated CreateGroupContent content = 1;
+ optional GmsVersion gmsVersion = 3;
+}
+
+message CreateGroupContent {
+ optional GroupId groupId = 1;
+ optional GroupSource groupSource = 2;
+ optional string syncStr = 3;
+}
+
+message ContactGroupResponse {
+ repeated ContactGroup contactGroup = 1;
+ optional string syncToken = 3;
+ optional int32 size = 4;
+}
+
+message ContactGroup {
+ optional GroupSource responseBody = 3;
+ optional GroupId groupId = 4;
+ optional string sync = 5;
+}
+
+message ContactGroupRequest {
+ optional string syncToken = 1;
+ repeated int32 type = 2 [packed = true];
+ optional GroupTypeList typeList = 3;
+ optional int32 syncSize = 4;
+ optional GmsVersion gmsVersion = 6;
+}
+
+message GroupTypeList {
+ repeated int32 type = 1 [packed = true];
+}
+
+message GroupSource {
+ optional GroupId groupId = 1;
+ optional GroupInfo groupInfo = 2;
+ optional GroupUpdateTime updateTime = 3;
+ optional GroupOperate groupOperate = 4;
+ optional GroupType groupType = 5;
+}
+
+message GroupOperate {
+ optional int32 operate = 1;
+}
+
+message GroupType {
+ optional int32 type = 1;
+}
+
+message GroupId {
+ optional string id = 1;
+}
+
+message GroupInfo {
+ optional string tag = 1;
+ optional string name = 2;
+}
+
+message GroupUpdateTime {
+ optional string date = 1;
+ optional int64 timeStamp = 2;
+}
+
+message UpdateRequest {
+ optional string eTag = 1;
+ optional Person person = 2;
+ optional PropertyList propertyList = 3;
+ optional int32 type = 4;
+ optional bool isPrimary = 8;
+ optional RequestData requestData = 13;
+}
+
+message UpdateResponse {
+ optional Content content = 1;
+}
+
+message DeleteRequest {
+ repeated string source = 2;
+ optional GmsVersion gmsVersion = 3;
+ optional DeviceMetadata deviceMetadata = 4;
+}
+
+message DeviceMetadata {
+ optional DeviceInfo deviceInfo = 1;
+}
+
+message DeviceInfo {
+ oneof model {
+ DeviceModel deviceModel = 3;
+ }
+ optional int32 type = 1;
+}
+
+message DeviceModel {
+ optional string model = 1;
+}
+
+message DeleteResponse {
+}
+
+message InsertResponse {
+ repeated Contact contact = 1;
+}
+
+message Contact {
+ optional Content content = 1;
+}
+
+message Content {
+ optional Person person = 3;
+}
+
+message InsertRequest {
+ repeated PersonData personData = 1;
+ optional PropertyList propertyList = 2;
+ optional RequestData requestData = 4;
+}
+
+message RequestData {
+ optional Property property = 2;
+ optional GroupData gdata = 3;
+ optional GmsVersion gmsVersion = 4;
+ optional int32 type = 6;
+ optional RequestMetadata requestMetadata = 8;
+}
+
+message SyncPeopleResponse {
+ repeated Person person = 1;
+ optional string syncToken = 2;
+ optional string token = 3;
+}
+
+message PersonData{
+ optional Person person = 1;
+ optional uint64 sourceId = 2;
+}
+
+message GroupProfile {
+ optional string path = 1;
+}
+
+message GroupProfileMetadata {
+ optional int32 type = 2;
+ optional GroupProfile groupProfile = 4;
+}
+
+message FieldMetadata {
+ optional bool verified = 3;
+ optional string sourceId = 9;
+ optional bool primary = 12;
+ repeated GroupProfileMetadata groupProfileMetadata = 13;
+ optional int32 type = 15;
+}
+
+message ExternalId {
+ optional FieldMetadata metadata = 1;
+ optional string value = 2;
+ optional string type = 3;
+}
+
+message Extend {
+ optional FieldMetadata metadata = 1;
+ optional string system = 2;
+ optional string value = 3;
+ optional string type = 4;
+}
+
+message Profile {
+ optional string sourceId = 2;
+ optional int64 syncTime = 4;
+ optional string syncTag = 5;
+ optional int32 objectType = 6;
+}
+
+message ProfileMetadata{
+ repeated Profile profile = 3;
+}
+
+message PersonMetadata {
+ optional uint32 deleted = 1;
+ repeated int64 sourceId = 6;
+ optional uint32 createTime = 16;
+ optional ProfileMetadata profileMetadata = 22;
+ optional int32 objectType = 25;
+}
+
+message Membership {
+ optional FieldMetadata metadata = 1;
+ optional string groupSourceId = 2;
+ optional int32 type = 3;
+}
+
+message Name {
+ optional FieldMetadata metadata = 1;
+ optional string displayName = 2;
+ optional string displayNameLastFirst = 3;
+ optional string givenName = 4;
+ optional string familyName = 5;
+ optional string middleName = 6;
+ optional string prefix = 7;
+ optional string suffix = 8;
+ optional string phoneticGivenName = 9;
+ optional string phoneticFamilyName = 10;
+ optional string fullNameStyle = 13;
+ optional string phoneticMiddleName = 15;
+ optional string phoneticNameStyle = 16;
+}
+
+message Photo {
+ optional FieldMetadata metadata = 1;
+ optional string url = 2;
+ optional bool default = 3;
+ optional string image = 4;
+}
+
+message Gender {
+ optional FieldMetadata metadata = 1;
+ optional string value = 2;
+ optional string addressMeAs = 3;
+}
+
+message Email {
+ optional FieldMetadata metadata = 1;
+ optional string address = 2;
+ optional string type = 3;
+ optional string label = 4;
+ optional string displayName = 6;
+}
+
+message Phone {
+ optional FieldMetadata metadata = 1;
+ optional string number = 2;
+ optional string type = 3;
+ optional string label = 5;
+}
+
+message Note {
+ optional FieldMetadata metadata = 1;
+ optional string content = 2;
+}
+
+message WebSite {
+ optional FieldMetadata metadata = 1;
+ optional string url = 2;
+ optional string type = 3;
+ optional string label = 4;
+}
+
+message Date{
+ optional int32 year = 1;
+ optional int32 month = 2;
+ optional int32 day = 3;
+}
+
+message Birthday {
+ optional FieldMetadata metadata = 1;
+ optional int64 timestamp = 2;
+ optional string text = 4;
+ optional Date date = 5;
+}
+
+message NickName {
+ optional FieldMetadata metadata = 1;
+ optional string name = 2;
+ optional string type = 3;
+ optional string label = 4;
+}
+
+message ContactGroupMembership {
+ optional FieldMetadata metadata = 1;
+ oneof GroupMembership {
+ string contactGroupId = 2;
+ string contactGroupResourceName = 3;
+ }
+}
+
+message Organization {
+ optional FieldMetadata metadata = 1;
+ optional string company = 2;
+ optional string department = 3;
+ optional string title = 4;
+ optional string phoneticName = 5;
+ optional string location = 6;
+ optional string symbol = 7;
+ optional string jobDescription = 8;
+ optional string phoneticNameStyle = 9;
+ optional string type = 16;
+ optional string label = 17;
+}
+
+message Skill {
+ optional FieldMetadata metadata = 1;
+ optional string value = 2;
+}
+
+message Address {
+ optional FieldMetadata metadata = 1;
+ optional string type = 2;
+ optional string address = 3;
+ optional string poBox = 4;
+ optional string streetAddress = 5;
+ optional string city = 6;
+ optional string region = 7;
+ optional string postalCode = 8;
+ optional string country = 9;
+ optional string label = 11;
+ optional string neighborhood = 12;
+}
+
+message Relation {
+ optional FieldMetadata metadata = 1;
+ optional string type = 2;
+ optional string name = 3;
+ optional string label = 4;
+}
+
+message ImClient {
+ optional FieldMetadata metadata = 1;
+ optional string username = 2;
+ optional string type = 3;
+ optional string protocol = 4;
+ optional string label = 5;
+}
+
+message Event {
+ optional FieldMetadata metadata = 1;
+ optional int64 timestamp = 2;
+ optional int32 type = 3;
+ optional string label = 4;
+ optional Date date = 5;
+}
+
+message UserDefined {
+ optional FieldMetadata metadata = 1;
+ optional string label = 2;
+ optional string content = 3;
+}
+
+message FileAs {
+ optional FieldMetadata metadata = 1;
+ optional string value = 2;
+}
+
+message SipAddress {
+ optional FieldMetadata metadata = 1;
+ optional string address = 2;
+ optional string type = 3;
+ optional string label = 4;
+}
+
+message Hobby {
+ optional FieldMetadata metadata = 1;
+ optional string value = 2;
+}
+
+message MiscKeyword {
+ optional FieldMetadata metadata = 1;
+ optional string value = 2;
+ optional string type = 3;
+}
+
+message CalendarUrl {
+ optional FieldMetadata metadata = 1;
+ optional string value = 2;
+ optional string type = 3;
+}
+
+message Language {
+ optional FieldMetadata metadata = 1;
+ optional string value = 2;
+}
+
+message Person {
+ optional string eTag = 1;
+ optional PersonMetadata metadata = 2;
+ repeated Name name = 3;
+ repeated Photo photo = 4;
+ repeated Note note = 6;
+ repeated WebSite website = 7;
+ repeated Birthday birthday = 8;
+ repeated Gender gender = 9;
+ repeated Email email = 10;
+ repeated NickName nickName = 11;
+ repeated Phone phone = 12;
+ repeated Organization organization = 13;
+ repeated Address address = 15;
+ repeated Relation relation = 17;
+ repeated ImClient imClient = 18;
+ repeated Event event = 19;
+ repeated UserDefined userDefined = 20;
+ repeated Skill skill = 27;
+ repeated FileAs fileAs = 35;
+ repeated SipAddress sipAddress = 37;
+ repeated Hobby hobby = 39;
+ repeated MiscKeyword miscKeyword = 40;
+ repeated CalendarUrl calendarUrl = 41;
+ repeated Language language = 42;
+ repeated ExternalId externalId = 43;
+ repeated Extend extend = 44;
+ repeated Membership membership = 104;
+}
+
+message Token {
+ optional string token = 1;
+ optional int32 status = 2;
+}
+
+message PropertyList {
+ repeated string property = 1;
+}
+
+message PropertyStatus {
+ optional int32 status = 2;
+}
+
+message Property {
+ optional PropertyList propertyList = 1;
+ repeated int32 packs = 3;
+ optional PropertyStatus propertyStatus = 7;
+}
+
+message Model {
+ optional string model = 1;
+ optional string version = 2;
+}
+
+message VersionStatus {
+ optional bool isPrimary = 1;
+}
+
+message GmsVersion {
+ optional Model model = 4;
+ optional VersionStatus status = 5;
+}
+
+message RequestType {
+ optional int32 type = 1;
+}
+
+message RequestMetadata {
+ optional RequestType requestType = 6;
+ repeated int32 type = 9 [packed = true];
+}
+
+message GroupData {
+ repeated int32 compatibility = 1 [packed = true];
+}
+
+message SyncPeopleRequest {
+ optional int32 pageSize = 1;
+ optional string msg = 2;
+ optional Token token = 3;
+ optional Property property = 4;
+ optional GmsVersion gmsVersion = 5;
+ optional RequestMetadata requestMetadata = 6;
+ optional GroupData gdata = 8;
+ optional string aid = 9;
+}
+
+message AccountData {
+ optional string token = 1;
+ optional string msg = 2;
+}
\ No newline at end of file
diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml
index 4d8ffaddee..4a1665f981 100644
--- a/play-services-core/src/main/AndroidManifest.xml
+++ b/play-services-core/src/main/AndroidManifest.xml
@@ -148,6 +148,7 @@
+
diff --git a/play-services-core/src/main/java/org/microg/gms/people/ContactSyncService.java b/play-services-core/src/main/java/org/microg/gms/people/ContactSyncService.java
deleted file mode 100644
index 76d5ce6910..0000000000
--- a/play-services-core/src/main/java/org/microg/gms/people/ContactSyncService.java
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Copyright (C) 2017 microG Project Team
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.microg.gms.people;
-
-import android.accounts.Account;
-import android.app.Service;
-import android.content.AbstractThreadedSyncAdapter;
-import android.content.ContentProviderClient;
-import android.content.Intent;
-import android.content.SyncResult;
-import android.os.Bundle;
-import android.os.IBinder;
-import android.util.Log;
-
-import androidx.annotation.Nullable;
-
-public class ContactSyncService extends Service {
- private static final String TAG = "GmsContactSync";
-
- @Nullable
- @Override
- public IBinder onBind(Intent intent) {
- return (new AbstractThreadedSyncAdapter(this, true) {
- @Override
- public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
- Log.d(TAG, "unimplemented Method: onPerformSync");
- }
- }).getSyncAdapterBinder();
- }
-}
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/people/ContactSyncService.kt b/play-services-core/src/main/kotlin/org/microg/gms/people/ContactSyncService.kt
new file mode 100644
index 0000000000..f1186b4a0f
--- /dev/null
+++ b/play-services-core/src/main/kotlin/org/microg/gms/people/ContactSyncService.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2017 microG Project Team
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.microg.gms.people
+
+import android.Manifest
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.IBinder
+import android.util.Log
+import androidx.annotation.RequiresApi
+import org.microg.gms.auth.phone.EXTRA_PERMISSIONS
+import org.microg.gms.location.manager.AskPermissionActivity
+
+private const val TAG = "ContactSyncService"
+
+private val REQUIRED_PERMISSIONS = arrayOf("android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS")
+
+class ContactSyncService : Service() {
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ override fun onBind(intent: Intent?): IBinder? {
+ Log.d(TAG, "onBind: ")
+ if (isMissingPermissions(this)) {
+ Log.d(TAG, "onPerformSync isMissingPermissions")
+ val permissionIntent = Intent(this, AskPermissionActivity::class.java)
+ permissionIntent.putExtra(EXTRA_PERMISSIONS, arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS))
+ permissionIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivity(permissionIntent)
+ }
+ return SyncAdapterProxy.get(this).syncAdapterBinder
+ }
+
+ override fun onUnbind(intent: Intent?): Boolean {
+ Log.d(TAG, "onUnbind: ")
+ return super.onUnbind(intent)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ private fun isMissingPermissions(context: Context): Boolean {
+ for (str in REQUIRED_PERMISSIONS) {
+ if (context.checkSelfPermission(str) != PackageManager.PERMISSION_GRANTED) {
+ return true
+ }
+ }
+ return false
+ }
+
+}
\ No newline at end of file
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/people/SyncAdapterProxy.kt b/play-services-core/src/main/kotlin/org/microg/gms/people/SyncAdapterProxy.kt
new file mode 100644
index 0000000000..a352aab00b
--- /dev/null
+++ b/play-services-core/src/main/kotlin/org/microg/gms/people/SyncAdapterProxy.kt
@@ -0,0 +1,227 @@
+/**
+ * SPDX-FileCopyrightText: 2024 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.people
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.content.AbstractThreadedSyncAdapter
+import android.content.ContentProviderClient
+import android.content.Context
+import android.content.SyncResult
+import android.os.Build
+import android.os.Bundle
+import android.os.RemoteException
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.collection.arraySetOf
+import com.squareup.wire.GrpcClient
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import org.microg.gms.people.contacts.ContactData
+import org.microg.gms.people.contacts.ContactGroupInfo
+import org.microg.gms.people.contacts.ContactProviderHelper
+import org.microg.gms.people.contacts.ContactSyncHelper
+import java.io.IOException
+import java.sql.SQLException
+
+private const val TAG = "SyncAdapterProxy"
+
+class SyncAdapterProxy(context: Context) : AbstractThreadedSyncAdapter(context, true) {
+
+ companion object {
+ @Volatile
+ private var instance: SyncAdapterProxy? = null
+ fun get(context: Context): SyncAdapterProxy {
+ return instance ?: synchronized(this) {
+ instance ?: SyncAdapterProxy(context).also { instance = it }
+ }
+ }
+ }
+
+ private val mAbortLock = Any()
+
+ @Volatile
+ private var mIsCancelled = false
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
+ try {
+ Log.d(TAG, "onPerformSync: localThreadName: ${Thread.currentThread().name}")
+ Log.d(TAG, "Method: onPerformSync called, account:${account.name} authority:$authority extras:$extras")
+ synchronized(mAbortLock) {
+ reset()
+ if (mIsCancelled) {
+ return
+ }
+ try {
+ innerPerformSync(account, provider, syncResult)
+ } catch (unused: RemoteException) {
+ Log.d(TAG, "onPerformSync: ", unused)
+ syncResult.stats.numParseExceptions++
+ } catch (unused: IOException) {
+ Log.d(TAG, "onPerformSync: ", unused)
+ syncResult.stats.numIoExceptions++
+ }
+ }
+ } catch (sqlException: SQLException) {
+ try {
+ syncResult.stats.numParseExceptions++
+ } catch (unused: Throwable) {
+ Log.d(TAG, "onPerformSync: ", unused)
+ throw unused
+ }
+ } catch (unused: Throwable) {
+ Log.d(TAG, "onPerformSync unused: ", unused)
+ throw unused
+ }
+ }
+
+ private fun innerPerformSync(account: Account, provider: ContentProviderClient, syncResult: SyncResult) {
+ val peopleClient = getPeopleServiceClient(account)
+ syncGroups(account, provider, peopleClient)
+ syncContacts(account, provider, peopleClient, syncResult)
+ }
+
+ private fun syncContacts(
+ account: Account,
+ provider: ContentProviderClient,
+ peopleClient: InternalPeopleServiceClient,
+ syncResult: SyncResult
+ ) {
+ val localContacts = ContactProviderHelper.get(context).queryLocalContacts(account, provider)
+ val insertList = arraySetOf()
+ val updateList = arraySetOf()
+ val deleteList = arraySetOf()
+ for (values in localContacts) {
+ if (values.deleted != null && values.deleted > 0) {
+ deleteList.add(values)
+ } else if (values.sourceId == null) {
+ insertList.add(values)
+ } else if (values.dirty != null && values.dirty > 0) {
+ updateList.add(values)
+ }
+ }
+ insertList.takeIf { it.isNotEmpty() }?.run {
+ ContactSyncHelper.insertUpload(this, upload = {
+ peopleClient.BulkInsertContacts().executeBlocking(it)
+ }, sync = { contactId, person ->
+ ContactProviderHelper.get(context).insertOrUpdateContacts(person, account, syncResult, provider, contactId)
+ }, uploadPhoto = {
+ runCatching { peopleClient.UpdatePersonPhoto().executeBlocking(it) }.getOrNull()
+ }, syncPhoto = { sourceId, syncToken, url, bytes ->
+ ContactProviderHelper.get(context).syncContactPhoto(sourceId, account, provider, url, syncToken, bytes)
+ })
+ }
+ deleteList.takeIf { it.isNotEmpty() }?.run {
+ ContactSyncHelper.deletedUpload(this, upload = {
+ peopleClient.DeletePeople().executeBlocking(it)
+ }, sync = {
+ ContactProviderHelper.get(context).deleteContact(it, account, syncResult, provider)
+ })
+ }
+ updateList.takeIf { it.isNotEmpty() }?.run {
+ val groupInfo = ContactProviderHelper.get(context).getCurrentGroupList(account, provider).find { it.isDefault }
+ ContactSyncHelper.dirtyUpload(this, groupInfo, upload = {
+ runCatching { peopleClient.UpdatePerson().executeBlocking(it) }.getOrNull()
+ }, sync = { contactId, person ->
+ ContactProviderHelper.get(context).insertOrUpdateContacts(person, account, syncResult, provider, contactId)
+ }, uploadPhoto = {
+ runCatching { peopleClient.UpdatePersonPhoto().executeBlocking(it) }.getOrNull()
+ }, deletePhoto = {
+ runCatching { peopleClient.DeletePersonPhoto().executeBlocking(it) }
+ }, syncPhoto = { sourceId, syncToken, url, bytes ->
+ ContactProviderHelper.get(context).syncContactPhoto(sourceId, account, provider, url, syncToken, bytes)
+ })
+ }
+
+ ContactSyncHelper.syncServerContact(lastToken = {
+ ContactProviderHelper.get(context).lastSyncToken(account, provider)
+ }, load = {
+ peopleClient.SyncPeople().executeBlocking(it)
+ }, sync = {
+ ContactProviderHelper.get(context).insertOrUpdateContacts(it, account, syncResult, provider)
+ }, saveToken = {
+ ContactProviderHelper.get(context).saveSyncToken(account, provider, it)
+ })
+ }
+
+ private fun syncGroups(account: Account, provider: ContentProviderClient, peopleClient: InternalPeopleServiceClient) {
+ val allGroupList = ContactProviderHelper.get(context).getCurrentGroupList(account, provider)
+ val createdGroups = arraySetOf()
+ val updatedGroups = arraySetOf()
+ val deletedGroups = arraySetOf()
+ for (groupInfo in allGroupList) {
+ if (groupInfo.deleted) {
+ deletedGroups.add(groupInfo)
+ } else if (groupInfo.created) {
+ createdGroups.add(groupInfo)
+ } else if (groupInfo.updated) {
+ updatedGroups.add(groupInfo)
+ }
+ }
+ createdGroups.takeIf { it.isNotEmpty() }?.run {
+ ContactSyncHelper.insertGroupUpload(this, upload = {
+ peopleClient.CreateContactGroups().executeBlocking(it)
+ }, sync = {
+ ContactProviderHelper.get(context).syncPersonGroup(it, allGroupList, account, provider)
+ })
+ }
+ updatedGroups.takeIf { it.isNotEmpty() }?.run {
+ ContactSyncHelper.updateGroupUpload(this, upload = {
+ peopleClient.UpdateContactGroups().executeBlocking(it)
+ }, sync = {
+ ContactProviderHelper.get(context).syncPersonGroup(it, allGroupList, account, provider)
+ })
+ }
+ deletedGroups.takeIf { it.isNotEmpty() }?.run {
+ ContactSyncHelper.deleteGroupUpload(this, upload = {
+ peopleClient.DeleteContactGroups().executeBlocking(it)
+ }, sync = {
+ ContactProviderHelper.get(context).syncPersonGroup(null, allGroupList, account, provider, deleteGroupId = it.groupId?.id)
+ })
+ }
+ ContactSyncHelper.syncServerGroup(lastToken = {
+ ContactProviderHelper.get(context).lastProfileSyncToken(account, provider)
+ }, load = {
+ peopleClient.ListContactGroups().executeBlocking(it)
+ }, sync = {
+ ContactProviderHelper.get(context).syncPersonGroup(it, allGroupList, account, provider)
+ }, saveToken = {
+ ContactProviderHelper.get(context).saveProfileSyncToken(account, provider, it)
+ })
+ }
+
+ @RequiresApi(Build.VERSION_CODES.M)
+ override fun onSyncCanceled() {
+ synchronized(this.mAbortLock) {
+ Log.d(TAG, "Calling the sync off...")
+ this.mIsCancelled = true
+ }
+ }
+
+ private fun reset() {
+ synchronized(this.mAbortLock) {
+ this.mIsCancelled = false
+ }
+ }
+
+ private fun getPeopleServiceClient(account: Account): InternalPeopleServiceClient {
+ val authTokenType = "oauth2:https://www.googleapis.com/auth/peopleapi.readwrite"
+ val token = AccountManager.get(context).blockingGetAuthToken(account, authTokenType, true)
+ val client = OkHttpClient().newBuilder().addInterceptor(HeaderInterceptor(token)).build()
+ val grpcClient = GrpcClient.Builder().client(client).baseUrl("https://people-pa.googleapis.com").minMessageToCompress(Long.MAX_VALUE).build()
+ return grpcClient.create(InternalPeopleServiceClient::class)
+ }
+
+ private class HeaderInterceptor(
+ private val oauthToken: String,
+ ) : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
+ val original = chain.request().newBuilder().header("Authorization", "Bearer $oauthToken")
+ return chain.proceed(original.build())
+ }
+ }
+}
\ No newline at end of file
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/people/SyncAdapterUtils.kt b/play-services-core/src/main/kotlin/org/microg/gms/people/SyncAdapterUtils.kt
new file mode 100644
index 0000000000..c0cff7cba8
--- /dev/null
+++ b/play-services-core/src/main/kotlin/org/microg/gms/people/SyncAdapterUtils.kt
@@ -0,0 +1,169 @@
+/**
+ * SPDX-FileCopyrightText: 2024 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.people
+
+import android.accounts.Account
+import android.net.Uri
+import android.provider.ContactsContract
+import okio.ByteString.Companion.toByteString
+import org.microg.gms.profile.Build
+
+object SyncAdapterUtils {
+
+ private val PERSON_PROPERTIES = mutableListOf(
+ "person.about", "person.address", "person.birthday", "person.calendar",
+ "person.client_data", "person.contact_group_membership", "person.email", "person.event", "person.external_id",
+ "person.file_as", "person.gender", "person.im", "person.interest", "person.language", "person.name", "person.nickname",
+ "person.occupation", "person.organization", "person.other_keyword", "person.phone", "person.relation", "person.sip_address",
+ "person.user_defined", "person.website"
+ )
+
+ private val SYNC_PROPERTIES = PERSON_PROPERTIES + listOf("person.photo", "person.metadata")
+
+ object Selections {
+ private const val QUERY_SOURCE_ID = "(sourceId in ('%s') OR (sync2 in ('%s') AND sourceId IS NULL)) OR (data_set IS NULL AND sourceId IS NULL AND sync3 IS NOT NULL)"
+ private const val QUERY_EMPTY_SOURCE_ID = "data_set IS NULL AND (sourceId IS NULL OR dirty != 0 OR deleted != 0)"
+
+ fun getExistPersonSelection(sourceId: String?) = sourceId?.let { String.format(QUERY_SOURCE_ID, it, it) } ?: QUERY_EMPTY_SOURCE_ID
+ }
+
+ object ContentUri {
+ fun addQueryParameters(contentUri: Uri, account: Account?): Uri {
+ if (account == null) {
+ return contentUri
+ }
+ val builder =
+ contentUri.buildUpon().appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name).appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
+ .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
+ return builder.build()
+ }
+ }
+
+ private fun getGmsVersion() = GmsVersion.Builder().apply {
+ model(Model.Builder().apply {
+ model("GMS FSA2")
+ version("22.46.19 (190408-515739919)")
+ }.build())
+ status(VersionStatus.Builder().apply {
+ isPrimary(true)
+ }.build())
+ }.build()
+
+ fun buildUpdatePersonPhoto(sourceId: String, data: ByteArray) = UpdatePhotoRequest.Builder().apply {
+ type(1)
+ sourceId(sourceId)
+ content(1)
+ photoBytes(data.toByteString())
+ gmsVersion(getGmsVersion())
+ }.build()
+
+ fun buildDeletePersonPhoto(sourceId: String) = DeletePhotoRequest.Builder().apply {
+ sourceId(sourceId)
+ gmsVersion(getGmsVersion())
+ }.build()
+
+ fun buildCreateForGroup(groups: List) = CreateGroupRequest.Builder().apply {
+ content(groups)
+ gmsVersion(getGmsVersion())
+ }.build()
+
+ fun buildUpdateForGroup(groups: List) = UpdateGroupRequest.Builder().apply {
+ content(groups)
+ gmsVersion(getGmsVersion())
+ }.build()
+
+ fun buildDeleteForGroup(groupIds: List) = DeleteGroupRequest.Builder().apply {
+ groupId(groupIds)
+ gmsVersion(getGmsVersion())
+ }.build()
+
+ fun buildRequestForGroup(syncToken: String?) = ContactGroupRequest.Builder().apply {
+ syncToken(syncToken)
+ type(arrayListOf(1))
+ typeList(GroupTypeList(arrayListOf(1)))
+ syncSize(100)
+ gmsVersion(getGmsVersion())
+ }.build()
+
+ fun buildRequestForSyncPeople(syncToken: String?) = SyncPeopleRequest.Builder().apply {
+ token(Token.Builder().apply {
+ token(syncToken)
+ status(1)
+ }.build())
+ pageSize(1000)
+ gmsVersion(getGmsVersion())
+ property_(Property.Builder().apply {
+ propertyList(PropertyList.Builder().apply { property_(SYNC_PROPERTIES) }.build())
+ propertyStatus(PropertyStatus.Builder().apply { status(0) }.build())
+ }.build())
+ requestMetadata(RequestMetadata.Builder().apply {
+ requestType(RequestType.Builder().apply { type(2) }.build())
+ type(arrayListOf(3))
+ }.build())
+ }.build()
+
+ fun buildRequestForInsert(dataList: List) = InsertRequest.Builder().apply {
+ personData(dataList)
+ propertyList(PropertyList.Builder().apply {
+ property_(PERSON_PROPERTIES)
+ }.build())
+ requestData(RequestData.Builder().apply {
+ type(1)
+ gdata(GroupData.Builder().apply {
+ compatibility(arrayListOf(8))
+ }.build())
+ gmsVersion(getGmsVersion())
+ property_(Property.Builder().apply {
+ propertyList(PropertyList.Builder().apply {
+ property_(SYNC_PROPERTIES)
+ }.build())
+ }.build())
+ requestMetadata(RequestMetadata.Builder().apply {
+ type(arrayListOf(3))
+ requestType(RequestType.Builder().apply {
+ type(2)
+ }.build())
+ }.build())
+ }.build())
+ }.build()
+
+ fun buildRequestForUpdate(person: Person) = UpdateRequest.Builder().apply {
+ eTag(person.eTag)
+ person(person)
+ propertyList(PropertyList.Builder().apply {
+ property_(PERSON_PROPERTIES)
+ }.build())
+ type(2)
+ isPrimary(false)
+ requestData(RequestData.Builder().apply {
+ type(1)
+ gdata(GroupData.Builder().apply { compatibility(arrayListOf(8)) }.build())
+ gmsVersion(getGmsVersion())
+ property_(Property.Builder().apply {
+ propertyList(PropertyList.Builder().apply {
+ property_(SYNC_PROPERTIES)
+ }.build())
+ }.build())
+ requestMetadata(RequestMetadata.Builder().apply {
+ requestType(RequestType.Builder().apply { type(2) }.build())
+ type(arrayListOf(3))
+ }.build())
+ }.build())
+ }.build()
+
+ fun buildRequestForDelete(sourceIdList: List) = DeleteRequest.Builder().apply {
+ source(sourceIdList)
+ gmsVersion(getGmsVersion())
+ deviceMetadata(DeviceMetadata.Builder().apply {
+ deviceInfo(DeviceInfo.Builder().apply {
+ type(6)
+ deviceModel(DeviceModel.Builder().apply {
+ model(Build.MANUFACTURER + " - " + Build.MODEL)
+ }.build())
+ }.build())
+ }.build())
+ }.build()
+}
\ No newline at end of file
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactConverter.kt b/play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactConverter.kt
new file mode 100644
index 0000000000..3f70d10ae5
--- /dev/null
+++ b/play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactConverter.kt
@@ -0,0 +1,677 @@
+/**
+ * SPDX-FileCopyrightText: 2024 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.people.contacts
+
+import android.content.ContentValues
+import android.content.Context
+import android.database.Cursor
+import android.provider.ContactsContract
+import android.text.TextUtils
+import androidx.collection.ArraySet
+import androidx.core.database.getBlobOrNull
+import androidx.core.database.getFloatOrNull
+import androidx.core.database.getLongOrNull
+import androidx.core.database.getStringOrNull
+import com.google.android.gms.common.images.ImageManager
+import org.microg.gms.people.Address
+import org.microg.gms.people.Birthday
+import org.microg.gms.people.Email
+import org.microg.gms.people.Event
+import org.microg.gms.people.FieldMetadata
+import org.microg.gms.people.ImClient
+import org.microg.gms.people.Membership
+import org.microg.gms.people.Name
+import org.microg.gms.people.NickName
+import org.microg.gms.people.Note
+import org.microg.gms.people.Organization
+import org.microg.gms.people.Person
+import org.microg.gms.people.Phone
+import org.microg.gms.people.Photo
+import org.microg.gms.people.Relation
+import org.microg.gms.people.SipAddress
+import org.microg.gms.people.UserDefined
+import org.microg.gms.people.WebSite
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+private const val USER_DEFINED_FIELD: String = "vnd.com.google.cursor.item/contact_user_defined_field"
+
+object ContactConverter {
+
+ fun toContentValues(context: Context, person: Person, sourceId: String): List {
+ val result = arrayListOf()
+ person.email.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.onEach {
+ addPropertyIdentity(it.metadata)?.run { result.add(this) }
+ }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.Email.ADDRESS, it.address)
+ put(ContactsContract.CommonDataKinds.Email.TYPE, it.type)
+ put(ContactsContract.CommonDataKinds.Email.LABEL, it.label)
+ put(ContactsContract.CommonDataKinds.Email.DISPLAY_NAME, it.displayName)
+ }
+ }?.forEach { result.add(it) }
+ person.event.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.Event.START_DATE, convertTimestampToDate(it.timestamp))
+ put(ContactsContract.CommonDataKinds.Event.TYPE, it.type)
+ put(ContactsContract.CommonDataKinds.Event.LABEL, it.label)
+ }
+ }?.forEach { result.add(it) }
+ person.membership.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId && it.type == null }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.GroupMembership.GROUP_SOURCE_ID, it.groupSourceId)
+ }
+ }?.forEach { result.add(it) }
+ person.imClient.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.Im.DATA, it.username)
+ put(ContactsContract.CommonDataKinds.Im.PROTOCOL, it.protocol)
+ put(ContactsContract.CommonDataKinds.Im.CUSTOM_PROTOCOL, it.protocol)
+ put(ContactsContract.CommonDataKinds.Im.TYPE, it.type)
+ put(ContactsContract.CommonDataKinds.Im.LABEL, it.label)
+ }
+ }?.forEach { result.add(it) }
+ person.nickName.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Nickname.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.Nickname.NAME, it.name)
+ put(ContactsContract.CommonDataKinds.Nickname.TYPE, it.type)
+ put(ContactsContract.CommonDataKinds.Nickname.LABEL, it.label)
+ }
+ }?.forEach { result.add(it) }
+ person.note.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Note.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.Note.NOTE, it.content)
+ }
+ }?.forEach { result.add(it) }
+ person.organization.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.Organization.COMPANY, it.company)
+ put(ContactsContract.CommonDataKinds.Organization.TITLE, it.title)
+ put(ContactsContract.CommonDataKinds.Organization.DEPARTMENT, it.department)
+ put(ContactsContract.CommonDataKinds.Organization.JOB_DESCRIPTION, it.jobDescription)
+ put(ContactsContract.CommonDataKinds.Organization.SYMBOL, it.symbol)
+ put(ContactsContract.CommonDataKinds.Organization.PHONETIC_NAME, it.phoneticName)
+ put(ContactsContract.CommonDataKinds.Organization.OFFICE_LOCATION, it.location)
+ put(ContactsContract.CommonDataKinds.Organization.PHONETIC_NAME_STYLE, it.phoneticNameStyle)
+ put(ContactsContract.CommonDataKinds.Organization.TYPE, it.type)
+ put(ContactsContract.CommonDataKinds.Organization.LABEL, it.label)
+ }
+ }?.forEach { result.add(it) }
+ person.phone.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.onEach {
+ addPropertyIdentity(it.metadata)?.run { result.add(this) }
+ }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.Phone.NUMBER, it.number)
+ put(ContactsContract.CommonDataKinds.Phone.TYPE, it.type)
+ put(ContactsContract.CommonDataKinds.Phone.LABEL, it.label)
+ }
+ }?.forEach { result.add(it) }
+ person.photo.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.Photo.PHOTO, covertPhotoUrl(context, it.url))
+ put(ContactsContract.CommonDataKinds.Photo.SYNC1, it.url)
+ put(ContactsContract.CommonDataKinds.Photo.SYNC2, it.image)
+ }
+ }?.forEach { result.add(it) }
+ person.relation.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Relation.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.Relation.NAME, it.name)
+ put(ContactsContract.CommonDataKinds.Relation.TYPE, it.type)
+ put(ContactsContract.CommonDataKinds.Relation.LABEL, it.label)
+ }
+ }?.forEach { result.add(it) }
+ person.sipAddress.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS, it.address)
+ put(ContactsContract.CommonDataKinds.SipAddress.TYPE, it.type)
+ put(ContactsContract.CommonDataKinds.SipAddress.LABEL, it.label)
+ }
+ }?.forEach { result.add(it) }
+ person.name.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, it.displayName)
+ put(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, it.givenName)
+ put(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME, it.familyName)
+ put(ContactsContract.CommonDataKinds.StructuredName.PREFIX, it.prefix)
+ put(ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME, it.middleName)
+ put(ContactsContract.CommonDataKinds.StructuredName.SUFFIX, it.suffix)
+ put(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_GIVEN_NAME, it.phoneticGivenName)
+ put(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_MIDDLE_NAME, it.phoneticMiddleName)
+ put(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_FAMILY_NAME, it.phoneticFamilyName)
+ put(ContactsContract.CommonDataKinds.StructuredName.FULL_NAME_STYLE, it.fullNameStyle)
+ put(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_NAME_STYLE, it.phoneticNameStyle)
+ }
+ }?.forEach { result.add(it) }
+ person.address.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS, it.address)
+ put(ContactsContract.CommonDataKinds.StructuredPostal.STREET, it.streetAddress)
+ put(ContactsContract.CommonDataKinds.StructuredPostal.POBOX, it.poBox)
+ put(ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD, it.neighborhood)
+ put(ContactsContract.CommonDataKinds.StructuredPostal.CITY, it.city)
+ put(ContactsContract.CommonDataKinds.StructuredPostal.REGION, it.region)
+ put(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE, it.postalCode)
+ put(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY, it.country)
+ put(ContactsContract.CommonDataKinds.StructuredPostal.TYPE, it.type)
+ put(ContactsContract.CommonDataKinds.StructuredPostal.LABEL, it.label)
+ }
+ }?.forEach { result.add(it) }
+ person.website.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.onEach {
+ addPropertyIdentity(it.metadata)?.run { result.add(this) }
+ }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Website.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.Website.URL, it.url)
+ put(ContactsContract.CommonDataKinds.Website.TYPE, it.type)
+ put(ContactsContract.CommonDataKinds.Website.LABEL, it.label)
+ }
+ }?.forEach { result.add(it) }
+ person.userDefined.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, USER_DEFINED_FIELD)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.Data.DATA1, it.label)
+ put(ContactsContract.Data.DATA2, it.content)
+ }
+ }?.forEach { result.add(it) }
+ person.birthday.takeIf { it.isNotEmpty() }?.filter { it.metadata?.sourceId == sourceId }?.map {
+ ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE)
+ put(ContactsContract.Data.IS_PRIMARY, if (it.metadata?.primary == true) 1 else 0)
+ put(ContactsContract.CommonDataKinds.Event.START_DATE, convertTimestampToDate(it.timestamp))
+ put(ContactsContract.CommonDataKinds.Event.TYPE, ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY)
+ put(ContactsContract.CommonDataKinds.Event.LABEL, ContactsContract.CommonDataKinds.Event.getTypeResource(ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY))
+ }
+ }?.forEach { result.add(it) }
+ return result
+ }
+
+ fun toPersonProperty(contactData: ContactData, fieldMetadata: FieldMetadata, builder: Person.Builder) {
+ when (contactData.mimeType) {
+ ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE -> {
+ contactData.data?.let {
+ Email.Builder().apply {
+ metadata(fieldMetadata)
+ address(it.getAsString(ContactsContract.CommonDataKinds.Email.ADDRESS))
+ displayName(it.getAsString(ContactsContract.CommonDataKinds.Email.DISPLAY_NAME))
+ type(it.getAsString(ContactsContract.CommonDataKinds.Email.TYPE))
+ label(it.getAsString(ContactsContract.CommonDataKinds.Email.LABEL))
+ }.build()
+ }?.also {
+ builder.email(ArraySet(builder.email).apply { add(it) }.toList())
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE -> {
+ val type = contactData.data?.getAsInteger(ContactsContract.CommonDataKinds.Event.TYPE)
+ val date = contactData.data?.getAsString(ContactsContract.CommonDataKinds.Event.START_DATE)
+ val label = contactData.data?.getAsString(ContactsContract.CommonDataKinds.Event.LABEL)
+ val time = convertDateToTimestamp(date)
+ if (ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY == type) {
+ builder.birthday(ArraySet(builder.birthday).apply { add(Birthday(fieldMetadata, time, label)) }.toList())
+ } else {
+ builder.event(ArraySet(builder.event).apply { add(Event(fieldMetadata, time, type, label)) }.toList())
+ }
+ }
+
+ ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE -> {
+ contactData.data?.let {
+ val sourceId = it.getAsString(ContactsContract.CommonDataKinds.GroupMembership.GROUP_SOURCE_ID)
+ if (sourceId.isNullOrEmpty()) {
+ null
+ } else {
+ Membership.Builder().apply {
+ metadata(fieldMetadata)
+ groupSourceId(sourceId)
+ }.build()
+ }
+ }?.also {
+ builder.membership(ArraySet(builder.membership).apply { add(it) }.toList())
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE -> {
+ contactData.data?.let {
+ ImClient.Builder().apply {
+ metadata(fieldMetadata)
+ username(it.getAsString(ContactsContract.CommonDataKinds.Im.DATA))
+ protocol(it.getAsString(ContactsContract.CommonDataKinds.Im.PROTOCOL))
+ type(it.getAsString(ContactsContract.CommonDataKinds.Im.TYPE))
+ label(it.getAsString(ContactsContract.CommonDataKinds.Im.LABEL))
+ }.build()
+ }?.also {
+ builder.imClient(ArraySet(builder.imClient).apply { add(it) }.toList())
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Nickname.CONTENT_ITEM_TYPE -> {
+ contactData.data?.let {
+ NickName.Builder().apply {
+ metadata(fieldMetadata)
+ name(it.getAsString(ContactsContract.CommonDataKinds.Nickname.NAME))
+ type(it.getAsString(ContactsContract.CommonDataKinds.Nickname.TYPE))
+ label(it.getAsString(ContactsContract.CommonDataKinds.Nickname.LABEL))
+ }.build()
+ }?.also {
+ builder.nickName(ArraySet(builder.nickName).apply { add(it) }.toList())
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Note.CONTENT_ITEM_TYPE -> {
+ contactData.data?.let {
+ Note.Builder().apply {
+ metadata(fieldMetadata)
+ content(it.getAsString(ContactsContract.CommonDataKinds.Note.NOTE))
+ }.build()
+ }?.also {
+ builder.note(ArraySet(builder.note).apply { add(it) }.toList())
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE -> {
+ contactData.data?.let {
+ Organization.Builder().apply {
+ metadata(fieldMetadata)
+ company(it.getAsString(ContactsContract.CommonDataKinds.Organization.COMPANY))
+ title(it.getAsString(ContactsContract.CommonDataKinds.Organization.TITLE))
+ department(it.getAsString(ContactsContract.CommonDataKinds.Organization.DEPARTMENT))
+ jobDescription(it.getAsString(ContactsContract.CommonDataKinds.Organization.JOB_DESCRIPTION))
+ symbol(it.getAsString(ContactsContract.CommonDataKinds.Organization.SYMBOL))
+ phoneticName(it.getAsString(ContactsContract.CommonDataKinds.Organization.PHONETIC_NAME))
+ location(it.getAsString(ContactsContract.CommonDataKinds.Organization.OFFICE_LOCATION))
+ phoneticNameStyle(it.getAsString(ContactsContract.CommonDataKinds.Organization.PHONETIC_NAME_STYLE))
+ type(it.getAsString(ContactsContract.CommonDataKinds.Organization.TYPE))
+ label(it.getAsString(ContactsContract.CommonDataKinds.Organization.LABEL))
+ }.build()
+ }?.also {
+ builder.organization(ArraySet(builder.organization).apply { add(it) }.toList())
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE -> {
+ contactData.data?.let {
+ Phone.Builder().apply {
+ metadata(fieldMetadata)
+ number(it.getAsString(ContactsContract.CommonDataKinds.Phone.NUMBER))
+ type(it.getAsString(ContactsContract.CommonDataKinds.Phone.TYPE))
+ label(it.getAsString(ContactsContract.CommonDataKinds.Phone.LABEL))
+ }.build()
+ }?.also {
+ builder.phone(ArraySet(builder.phone).apply { add(it) }.toList())
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE -> {
+ contactData.data?.let {
+ Photo.Builder().apply {
+ metadata(fieldMetadata)
+ url(it.getAsString(ContactsContract.CommonDataKinds.Photo.SYNC1))
+ image(it.getAsString(ContactsContract.CommonDataKinds.Photo.SYNC2))
+ }.build()
+ }?.also {
+ builder.photo(arrayListOf(it))
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Relation.CONTENT_ITEM_TYPE -> {
+ contactData.data?.let {
+ Relation.Builder().apply {
+ metadata(fieldMetadata)
+ name(it.getAsString(ContactsContract.CommonDataKinds.Relation.NAME))
+ type(it.getAsString(ContactsContract.CommonDataKinds.Relation.TYPE))
+ label(it.getAsString(ContactsContract.CommonDataKinds.Relation.LABEL))
+ }.build()
+ }?.also {
+ builder.relation(ArraySet(builder.relation).apply { add(it) }.toList())
+ }
+ }
+
+ ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE -> {
+ contactData.data?.let {
+ SipAddress.Builder().apply {
+ metadata(fieldMetadata)
+ address(it.getAsString(ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS))
+ type(it.getAsString(ContactsContract.CommonDataKinds.SipAddress.TYPE))
+ label(it.getAsString(ContactsContract.CommonDataKinds.SipAddress.LABEL))
+ }.build()
+ }?.also {
+ builder.sipAddress(ArraySet(builder.sipAddress).apply { add(it) }.toList())
+ }
+ }
+
+ ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> {
+ contactData.data?.let {
+ Name.Builder().apply {
+ metadata(fieldMetadata)
+ displayName(it.getAsString(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME))
+ givenName(it.getAsString(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME))
+ familyName(it.getAsString(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME))
+ prefix(it.getAsString(ContactsContract.CommonDataKinds.StructuredName.PREFIX))
+ middleName(it.getAsString(ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME))
+ suffix(it.getAsString(ContactsContract.CommonDataKinds.StructuredName.SUFFIX))
+ phoneticGivenName(it.getAsString(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_GIVEN_NAME))
+ phoneticMiddleName(it.getAsString(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_MIDDLE_NAME))
+ phoneticFamilyName(it.getAsString(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_FAMILY_NAME))
+ fullNameStyle(it.getAsString(ContactsContract.CommonDataKinds.StructuredName.FULL_NAME_STYLE))
+ phoneticNameStyle(it.getAsString(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_NAME_STYLE))
+ }.build()
+ }?.also {
+ builder.name(arrayListOf(it))
+ }
+ }
+
+ ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE -> {
+ contactData.data?.let {
+ Address.Builder().apply {
+ metadata(fieldMetadata)
+ address(it.getAsString(ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS))
+ streetAddress(it.getAsString(ContactsContract.CommonDataKinds.StructuredPostal.STREET))
+ poBox(it.getAsString(ContactsContract.CommonDataKinds.StructuredPostal.POBOX))
+ neighborhood(it.getAsString(ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD))
+ city(it.getAsString(ContactsContract.CommonDataKinds.StructuredPostal.CITY))
+ region(it.getAsString(ContactsContract.CommonDataKinds.StructuredPostal.REGION))
+ postalCode(it.getAsString(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE))
+ country(it.getAsString(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY))
+ type(it.getAsString(ContactsContract.CommonDataKinds.StructuredPostal.TYPE))
+ label(it.getAsString(ContactsContract.CommonDataKinds.StructuredPostal.LABEL))
+ }.build()
+ }?.also {
+ builder.address(arrayListOf(it))
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Website.CONTENT_ITEM_TYPE -> {
+ contactData.data?.let {
+ WebSite.Builder().apply {
+ metadata(fieldMetadata)
+ url(it.getAsString(ContactsContract.CommonDataKinds.Website.URL))
+ type(it.getAsString(ContactsContract.CommonDataKinds.Website.TYPE))
+ label(it.getAsString(ContactsContract.CommonDataKinds.Website.LABEL))
+ }.build()
+ }?.also {
+ builder.website(ArraySet(builder.website).apply { add(it) }.toList())
+ }
+ }
+
+ USER_DEFINED_FIELD -> {
+ contactData.data?.let {
+ UserDefined.Builder().apply {
+ metadata(fieldMetadata)
+ label(it.getAsString(ContactsContract.Data.DATA1))
+ content(it.getAsString(ContactsContract.Data.DATA2))
+ }.build()
+ }?.also {
+ builder.userDefined(ArraySet(builder.userDefined).apply { add(it) }.toList())
+ }
+ }
+ }
+ }
+
+ private fun covertPhotoUrl(context: Context, url: String?): ByteArray? {
+ if (url.isNullOrEmpty()) return null
+ return ImageManager.create(context).covertBitmap(url)
+ }
+
+ private fun convertTimestampToDate(timestamp: Long?): String {
+ val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
+ val date = Date(timestamp ?: return "")
+ return dateFormat.format(date)
+ }
+
+ private fun convertDateToTimestamp(dateString: String?): Long {
+ if (dateString == null) {
+ return 0
+ }
+ val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
+ val date = dateFormat.parse(dateString)
+ return date?.time ?: throw IllegalArgumentException("Invalid date format")
+ }
+
+ private fun addPropertyIdentity(fieldMetadata: FieldMetadata?): ContentValues? {
+ if (fieldMetadata == null) return null
+ val list = fieldMetadata.groupProfileMetadata.filter {
+ it.groupProfile != null
+ }.map { data ->
+ "gprofile:${data.groupProfile?.path?.dropWhile { it == '0' }}"
+ }
+ if (list.isEmpty()) {
+ return null
+ }
+ return ContentValues().apply {
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Identity.CONTENT_ITEM_TYPE)
+ put(ContactsContract.CommonDataKinds.Identity.IDENTITY, TextUtils.join(",", list))
+ put(ContactsContract.CommonDataKinds.Identity.NAMESPACE, "com.google")
+ }
+ }
+}
+
+data class ContactData(
+ val rowId: Long? = null,
+ val mimeType: String? = null,
+ val dataId: Long? = null,
+ val dataVersion: Long? = null,
+ val isPrimary: Long? = null,
+ val dirty: Long? = null,
+ val deleted: Long? = null,
+ val sourceId: String? = null,
+ val syncTag: String? = null,
+ val syncTime: Long? = null,
+ val isPhotoType: Boolean = false,
+ val data: ContentValues? = null
+) {
+ companion object {
+ fun parseToContactData(cursor: Cursor, mimeType: String): ContactData {
+ val rowId = cursor.getLongOrNull(cursor.getColumnIndexOrThrow(ContactsContract.Data._ID))
+ val dataVersion = cursor.getLongOrNull(cursor.getColumnIndexOrThrow(ContactsContract.Data.DATA_VERSION))
+ val isPrimary = cursor.getLongOrNull(cursor.getColumnIndexOrThrow(ContactsContract.Data.IS_PRIMARY))
+ val dirty = cursor.getLongOrNull(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.DIRTY))
+ val deleted = cursor.getLongOrNull(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.DELETED))
+ val sourceId = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.SOURCE_ID))
+ val syncTag = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.SYNC2))
+ val syncTime = cursor.getLongOrNull(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.SYNC3))
+ val dataId = cursor.getLongOrNull(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.Entity.DATA_ID))
+ var photoType = false
+ val data = when (mimeType) {
+ ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredName.PREFIX, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredName.SUFFIX, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_GIVEN_NAME, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_MIDDLE_NAME, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_FAMILY_NAME, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredName.FULL_NAME_STYLE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_NAME_STYLE, this)
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Nickname.CONTENT_ITEM_TYPE -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Nickname.NAME, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Nickname.TYPE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Nickname.LABEL, this)
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Phone.NUMBER, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Phone.TYPE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Phone.LABEL, this)
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Email.ADDRESS, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Email.DISPLAY_NAME, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Email.TYPE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Email.LABEL, this)
+ }
+ }
+
+ ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredPostal.STREET, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredPostal.POBOX, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredPostal.CITY, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredPostal.REGION, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredPostal.TYPE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.StructuredPostal.LABEL, this)
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Im.CONTENT_ITEM_TYPE -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Im.PROTOCOL, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Im.CUSTOM_PROTOCOL, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Im.DATA, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Im.TYPE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Im.LABEL, this)
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Organization.COMPANY, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Organization.TITLE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Organization.DEPARTMENT, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Organization.JOB_DESCRIPTION, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Organization.SYMBOL, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Organization.PHONETIC_NAME, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Organization.OFFICE_LOCATION, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Organization.PHONETIC_NAME_STYLE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Organization.TYPE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Organization.LABEL, this)
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Relation.CONTENT_ITEM_TYPE -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Relation.NAME, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Relation.TYPE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Relation.LABEL, this)
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Event.TYPE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Event.START_DATE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Event.LABEL, this)
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE -> {
+ photoType = true
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Photo.PHOTO, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Photo.PHOTO_FILE_ID, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Photo.SYNC1, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Photo.SYNC2, this)
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Note.CONTENT_ITEM_TYPE -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Note.NOTE, this)
+ }
+ }
+
+ ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.GroupMembership.GROUP_SOURCE_ID, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID, this)
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Website.CONTENT_ITEM_TYPE -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Website.URL, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Website.TYPE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Website.LABEL, this)
+ }
+ }
+
+ ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.SipAddress.SIP_ADDRESS, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.SipAddress.TYPE, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.SipAddress.LABEL, this)
+ }
+ }
+
+ ContactsContract.CommonDataKinds.Identity.CONTENT_ITEM_TYPE -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Identity.IDENTITY, this)
+ cursor.coverToCV(ContactsContract.CommonDataKinds.Identity.NAMESPACE, this)
+ }
+ }
+
+ USER_DEFINED_FIELD -> {
+ ContentValues().apply {
+ cursor.coverToCV(ContactsContract.Data.DATA1, this)
+ cursor.coverToCV(ContactsContract.Data.DATA2, this)
+ }
+ }
+
+ else -> null
+ }
+ return ContactData(rowId, mimeType, dataId, dataVersion, isPrimary, dirty, deleted, sourceId, syncTag, syncTime, photoType, data)
+ }
+
+ private fun Cursor.coverToCV(field: String, contentValues: ContentValues) {
+ val index = getColumnIndexOrThrow(field)
+ when (getType(index)) {
+ Cursor.FIELD_TYPE_STRING -> contentValues.put(field, getStringOrNull(index))
+ Cursor.FIELD_TYPE_INTEGER -> contentValues.put(field, getLongOrNull(index))
+ Cursor.FIELD_TYPE_FLOAT -> contentValues.put(field, getFloatOrNull(index))
+ Cursor.FIELD_TYPE_BLOB -> contentValues.put(field, getBlobOrNull(index))
+ else -> contentValues.putNull(field)
+ }
+ }
+ }
+
+ override fun toString(): String {
+ return "ContactData(rowId=$rowId, mimeType=$mimeType, dataId=$dataId, dataVersion=$dataVersion, isPrimary=$isPrimary, dirty=$dirty, deleted=$deleted, sourceId=$sourceId, syncTag=$syncTag, syncTime=$syncTime, data=$data)"
+ }
+}
\ No newline at end of file
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactGroupInfo.kt b/play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactGroupInfo.kt
new file mode 100644
index 0000000000..4c25bc9485
--- /dev/null
+++ b/play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactGroupInfo.kt
@@ -0,0 +1,8 @@
+/**
+ * SPDX-FileCopyrightText: 2024 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.people.contacts
+
+data class ContactGroupInfo(val isDefault: Boolean = false, val groupId: String?, val groupTitle: String?, val syncStr: String?, val created: Boolean, val deleted: Boolean, val updated: Boolean)
\ No newline at end of file
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactProviderHelper.kt b/play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactProviderHelper.kt
new file mode 100644
index 0000000000..9923975aab
--- /dev/null
+++ b/play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactProviderHelper.kt
@@ -0,0 +1,327 @@
+/**
+ * SPDX-FileCopyrightText: 2024 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.people.contacts
+
+import android.accounts.Account
+import android.annotation.SuppressLint
+import android.content.ContentProviderClient
+import android.content.ContentProviderOperation
+import android.content.ContentValues
+import android.content.Context
+import android.content.SyncResult
+import android.provider.ContactsContract
+import android.util.Log
+import androidx.collection.arraySetOf
+import androidx.core.database.getIntOrNull
+import androidx.core.database.getLongOrNull
+import androidx.core.database.getStringOrNull
+import org.microg.gms.people.ContactGroup
+import org.microg.gms.people.Person
+import org.microg.gms.people.SyncAdapterUtils
+
+private const val TAG = "ContactProviderHelper"
+
+class ContactProviderHelper(private val context: Context) {
+
+ companion object {
+ @SuppressLint("StaticFieldLeak")
+ @Volatile
+ private var instance: ContactProviderHelper? = null
+ fun get(context: Context): ContactProviderHelper {
+ return instance ?: synchronized(this) {
+ instance ?: ContactProviderHelper(context.applicationContext).also { instance = it }
+ }
+ }
+ }
+
+ @OptIn(ExperimentalStdlibApi::class)
+ fun insertOrUpdateContacts(
+ person: Person, account: Account, syncResult: SyncResult, provider: ContentProviderClient, contactId: Long? = null
+ ) {
+ val operations = ArrayList()
+ val sourceIdList = person.metadata?.sourceId ?: return
+ sourceIdList.map {
+ convertToOperations(account, syncResult, person, it.toHexString().trimStart('0'), provider, contactId)
+ }.forEach {
+ operations.addAll(it)
+ }
+ if (operations.isNotEmpty()) {
+ provider.applyBatch(operations)
+ }
+ }
+
+ private fun convertToOperations(
+ account: Account, syncResult: SyncResult, person: Person, sourceId: String, provider: ContentProviderClient, contactRowId: Long? = null
+ ): List {
+ Log.d(TAG, "convertToOperations: sourceId: $sourceId")
+ val contactId = getContactIdBySourceId(account, sourceId, provider) ?: contactRowId ?: -1
+ if (person.metadata?.deleted == 1 || person.metadata?.createTime == 0) {
+ Log.d(TAG, "convertToOperations: membership is empty, delete contactId:$contactId")
+ deleteContact(contactId, account, syncResult, provider)
+ return emptyList()
+ }
+ val personData = ContactConverter.toContentValues(context, person, sourceId)
+ if (personData.isEmpty()) {
+ return emptyList()
+ }
+ Log.d(TAG, "convertToOperations: update contactId: $contactId or insert sourceId: $sourceId")
+
+ val operations = arrayListOf()
+ syncPersonProfile(person, sourceId, account, contactId).also { operations.addAll(it) }
+
+ if (contactId < 0) {
+ val rawContactId = operations.size - 1
+ insertNewContact(rawContactId, account, personData, syncResult).also { operations.addAll(it) }
+ return operations
+ }
+
+ val uri = SyncAdapterUtils.ContentUri.addQueryParameters(ContactsContract.Data.CONTENT_URI, account)
+ val localContacts = queryLocalContacts(account, provider, sourceId)
+ val (toInsert, toDelete, toUpdate) = compareContacts(personData, localContacts)
+ for (type in toDelete) {
+ Log.d(TAG, "toDelete: $type")
+ val selection = "${ContactsContract.Data.RAW_CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?"
+ val operation = ContentProviderOperation.newDelete(uri).withSelection(selection, arrayOf(contactId.toString(), type)).build()
+ operations.add(operation)
+ syncResult.stats.numDeletes++
+ }
+ for (contentValues in toInsert) {
+ Log.d(TAG, "toInsert: $contentValues")
+ operations.add(ContentProviderOperation.newInsert(uri).withValues(contentValues).withValue(ContactsContract.Data.RAW_CONTACT_ID, contactId).build())
+ syncResult.stats.numInserts++
+ }
+ for (contentValues in toUpdate) {
+ Log.d(TAG, "toUpdate: $contentValues")
+ val imageUrl = contentValues.getAsString(ContactsContract.CommonDataKinds.Photo.SYNC1)
+ val syncToken = contentValues.getAsString(ContactsContract.CommonDataKinds.Photo.SYNC2)
+ val bytes = contentValues.getAsByteArray(ContactsContract.CommonDataKinds.Photo.PHOTO)
+ syncContactPhoto(sourceId, account, provider, imageUrl, syncToken, bytes)
+ }
+ return operations
+ }
+
+ private fun compareContacts(serviceData: List, localData: List): Triple, Set, Set> {
+ val toInsert = mutableSetOf()
+ val toDelete = mutableSetOf()
+ val toUpdate = mutableSetOf()
+ for (service in serviceData) {
+ val mimeType = service.getAsString(ContactsContract.Data.MIMETYPE)
+ if (localData.any { it.mimeType == mimeType }) {
+ if (mimeType == ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE) {
+ toUpdate.add(service)
+ continue
+ }
+ toDelete.add(mimeType)
+ }
+ toInsert.add(service)
+ }
+ val serviceMap = serviceData.associateBy { it.getAsString(ContactsContract.Data.MIMETYPE) }
+ for (localDatum in localData) {
+ if (serviceMap.all { it.key != localDatum.mimeType }) {
+ toDelete.add(localDatum.mimeType)
+ }
+ }
+ return Triple(toInsert, toDelete, toUpdate)
+ }
+
+ private fun insertNewContact(
+ rawContactId: Int, account: Account, personData: List, syncResult: SyncResult
+ ): List {
+ val operations = arrayListOf()
+ val dataContactUri = SyncAdapterUtils.ContentUri.addQueryParameters(ContactsContract.Data.CONTENT_URI, account)
+ for (personValue in personData) {
+ operations.add(ContentProviderOperation.newInsert(dataContactUri).withValues(personValue).withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, rawContactId).build())
+ syncResult.stats.numInserts++
+ }
+ return operations
+ }
+
+ private fun syncPersonProfile(
+ person: Person,
+ sourceId: String,
+ account: Account,
+ contactId: Long,
+ ): List {
+ person.metadata?.profileMetadata?.profile?.firstOrNull {
+ it.sourceId == sourceId
+ }?.also {
+ val sync = ContentValues().apply {
+ put(ContactsContract.RawContacts.DIRTY, 0)
+ put(ContactsContract.RawContacts.SOURCE_ID, sourceId)
+ put(ContactsContract.RawContacts.SYNC2, it.syncTag ?: "")
+ put(ContactsContract.RawContacts.SYNC3, it.syncTime ?: 0)
+ }
+ val uri = SyncAdapterUtils.ContentUri.addQueryParameters(ContactsContract.RawContacts.CONTENT_URI, account)
+ val operations = arrayListOf()
+ if (contactId >= 0) {
+ val selection = "${ContactsContract.Data._ID}=?"
+ val selectionArgs = arrayOf(contactId.toString())
+ operations.add(ContentProviderOperation.newUpdate(uri).withValues(sync).withSelection(selection, selectionArgs).build())
+ } else {
+ operations.add(ContentProviderOperation.newInsert(uri).withValues(sync).build())
+ }
+ return operations
+ }
+ return emptyList()
+ }
+
+ private fun getContactIdBySourceId(account: Account, sourceId: String, provider: ContentProviderClient): Long? {
+ val uri = SyncAdapterUtils.ContentUri.addQueryParameters(ContactsContract.RawContacts.CONTENT_URI, account)
+ val projection = arrayOf(ContactsContract.Data._ID)
+ val selection = SyncAdapterUtils.Selections.getExistPersonSelection(sourceId)
+ provider.query(uri, projection, selection, null, null)?.use {
+ if (it.moveToFirst()) {
+ return it.getLongOrNull(it.getColumnIndexOrThrow(ContactsContract.Data._ID))
+ }
+ }
+ return null
+ }
+
+ fun syncPersonGroup(contactGroup: ContactGroup?, currentGroupList: Set, account: Account, provider: ContentProviderClient, deleteGroupId: String? = null) {
+ val groupId = contactGroup?.responseBody?.groupId?.id ?: contactGroup?.groupId?.id ?: deleteGroupId ?: return
+ val uri = SyncAdapterUtils.ContentUri.addQueryParameters(ContactsContract.Groups.CONTENT_URI, account)
+ if (contactGroup?.responseBody?.groupOperate?.operate == 1 || currentGroupList.find { it.groupId == groupId }?.deleted == true) {
+ provider.delete(uri, "${ContactsContract.Groups.SOURCE_ID}=?", arrayOf(groupId))
+ return
+ }
+ val groupTitle = contactGroup?.responseBody?.groupInfo?.name ?: contactGroup?.responseBody?.groupInfo?.tag ?: return
+ val groupType = contactGroup?.responseBody?.groupType?.type
+ val groupUpdateTime = contactGroup?.responseBody?.updateTime?.date
+ if ("Starred in Android" == groupTitle || (groupType == 2 && groupUpdateTime == null && groupId != "6")) {
+ return
+ }
+ val values = ContentValues().apply {
+ put(ContactsContract.Groups.TITLE, groupTitle)
+ put(ContactsContract.Groups.SOURCE_ID, groupId)
+ put(ContactsContract.Groups.SYNC1, contactGroup?.sync)
+ put(ContactsContract.Groups.DELETED, 0)
+ put(ContactsContract.Groups.DIRTY, 0)
+ if ("6" == groupId) {
+ put(ContactsContract.Groups.GROUP_IS_READ_ONLY, 1)
+ put(ContactsContract.Groups.GROUP_VISIBLE, 1)
+ put(ContactsContract.Groups.AUTO_ADD, 1)
+ } else {
+ put(ContactsContract.Groups.GROUP_VISIBLE, 0)
+ }
+ }
+ if (currentGroupList.any { info -> info.groupTitle == groupTitle }) {
+ provider.update(uri, values, "${ContactsContract.Groups.TITLE}=?", arrayOf(groupTitle))
+ } else {
+ provider.insert(uri, values)
+ }
+ }
+
+ fun getCurrentGroupList(account: Account, provider: ContentProviderClient): Set {
+ val projection = arrayOf(
+ ContactsContract.Groups.TITLE,
+ ContactsContract.Groups.SOURCE_ID,
+ ContactsContract.Groups.SYNC1,
+ ContactsContract.Groups.DIRTY,
+ ContactsContract.Groups.DELETED,
+ ContactsContract.Groups.AUTO_ADD,
+ ContactsContract.Groups.GROUP_VISIBLE
+ )
+ val allGroupList = arraySetOf()
+ val uri = SyncAdapterUtils.ContentUri.addQueryParameters(ContactsContract.Groups.CONTENT_URI, account)
+ provider.query(uri, projection, null, null, null)?.use {
+ while (it.moveToNext()) {
+ val title = it.getStringOrNull(it.getColumnIndexOrThrow(ContactsContract.Groups.TITLE)) ?: continue
+ val sourceId = it.getStringOrNull(it.getColumnIndexOrThrow(ContactsContract.Groups.SOURCE_ID))
+ val syncStr = it.getStringOrNull(it.getColumnIndexOrThrow(ContactsContract.Groups.SYNC1))
+ val delete = it.getIntOrNull(it.getColumnIndexOrThrow(ContactsContract.Groups.DELETED))
+ val dirty = it.getIntOrNull(it.getColumnIndexOrThrow(ContactsContract.Groups.DIRTY))
+ val default = it.getIntOrNull(it.getColumnIndexOrThrow(ContactsContract.Groups.AUTO_ADD))
+ val groupInfo = ContactGroupInfo(default == 1, sourceId, title, syncStr, sourceId == null, delete == 1, dirty == 1)
+ allGroupList.add(groupInfo)
+ }
+ }
+ Log.d(TAG, "getCurrentGroupList: allGroupList -> $allGroupList")
+ return allGroupList
+ }
+
+ fun queryLocalContacts(account: Account, provider: ContentProviderClient, sourceId: String? = null): List {
+ val localList = mutableListOf()
+ val selection = SyncAdapterUtils.Selections.getExistPersonSelection(sourceId)
+ val uri = SyncAdapterUtils.ContentUri.addQueryParameters(ContactsContract.RawContactsEntity.CONTENT_URI, account)
+ provider.query(uri, null, selection, null, null)?.use {
+ while (it.moveToNext()) {
+ val mimetype = it.getStringOrNull(it.getColumnIndexOrThrow(ContactsContract.Data.MIMETYPE)) ?: continue
+ val contactData = ContactData.parseToContactData(it, mimetype).also { data -> Log.d(TAG, data.toString()) }
+ localList.add(contactData)
+ }
+ }
+ return localList
+ }
+
+ fun syncContactPhoto(sourceId: String, account: Account, provider: ContentProviderClient, imageUrl: String?, syncToken: String?, imageBytes: ByteArray?) {
+ val rawUri = SyncAdapterUtils.ContentUri.addQueryParameters(ContactsContract.RawContacts.CONTENT_URI, account)
+ provider.query(rawUri, arrayOf(ContactsContract.RawContacts._ID), "${ContactsContract.RawContacts.SOURCE_ID} = ?", arrayOf(sourceId), null)?.use {
+ if (it.moveToFirst()) {
+ val rawContactId = it.getLong(it.getColumnIndexOrThrow(ContactsContract.RawContacts._ID))
+ val uri = SyncAdapterUtils.ContentUri.addQueryParameters(ContactsContract.Data.CONTENT_URI, account)
+ provider.query(
+ uri,
+ arrayOf(ContactsContract.Data._ID, ContactsContract.CommonDataKinds.Photo.SYNC2),
+ "${ContactsContract.Data.RAW_CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?",
+ arrayOf(rawContactId.toString(), ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE),
+ null
+ )?.use { photoCursor ->
+ if (photoCursor.moveToFirst()) {
+ val dataId = photoCursor.getLongOrNull(photoCursor.getColumnIndexOrThrow(ContactsContract.Data._ID))
+ val lastSyncToken = photoCursor.getStringOrNull(photoCursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Photo.SYNC2))
+ if (syncToken != lastSyncToken) {
+ val sync = ContentValues().apply {
+ put(ContactsContract.CommonDataKinds.Photo.PHOTO, imageBytes)
+ put(ContactsContract.CommonDataKinds.Photo.SYNC1, imageUrl)
+ put(ContactsContract.CommonDataKinds.Photo.SYNC2, syncToken)
+ }
+ provider.update(uri, sync, "${ContactsContract.Data._ID} = ?", arrayOf(dataId.toString()))
+ } else { }
+ } else {
+ val sync = ContentValues().apply {
+ put(ContactsContract.Data.RAW_CONTACT_ID, rawContactId)
+ put(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
+ put(ContactsContract.CommonDataKinds.Photo.PHOTO, imageBytes)
+ put(ContactsContract.CommonDataKinds.Photo.SYNC1, imageUrl)
+ put(ContactsContract.CommonDataKinds.Photo.SYNC2, syncToken)
+ }
+ provider.insert(uri, sync)
+ }
+ }
+ }
+ }
+ }
+
+ fun deleteContact(contactId: Long, account: Account, syncResult: SyncResult, provider: ContentProviderClient) {
+ if (contactId == -1L) return
+ val uri = SyncAdapterUtils.ContentUri.addQueryParameters(ContactsContract.RawContacts.CONTENT_URI, account)
+ val selection = "${ContactsContract.Data._ID}=?"
+ val selectionArgs = arrayOf(contactId.toString())
+ val delete = provider.delete(uri, selection, selectionArgs)
+ Log.d(TAG, "deleteContact: deleted -> $delete")
+ syncResult.stats.numDeletes++
+ }
+
+ fun saveSyncToken(account: Account, provider: ContentProviderClient, syncToken: String?) {
+ runCatching {
+ ContactsContract.SyncState.set(provider, account, syncToken?.toByteArray(Charsets.UTF_8))
+ }
+ }
+
+ fun lastSyncToken(account: Account, provider: ContentProviderClient) = runCatching {
+ ContactsContract.SyncState.get(provider, account)?.toString(Charsets.UTF_8)
+ }.getOrNull()
+
+ fun saveProfileSyncToken(account: Account, provider: ContentProviderClient, syncToken: String?) {
+ runCatching {
+ ContactsContract.ProfileSyncState.set(provider, account, syncToken?.toByteArray(Charsets.UTF_8))
+ }
+ }
+
+ fun lastProfileSyncToken(account: Account, provider: ContentProviderClient) = runCatching {
+ ContactsContract.ProfileSyncState.get(provider, account)?.toString(Charsets.UTF_8)
+ }.getOrNull()
+}
\ No newline at end of file
diff --git a/play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactSyncHelper.kt b/play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactSyncHelper.kt
new file mode 100644
index 0000000000..fc43498941
--- /dev/null
+++ b/play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactSyncHelper.kt
@@ -0,0 +1,244 @@
+/**
+ * SPDX-FileCopyrightText: 2024 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.people.contacts
+
+import android.provider.ContactsContract
+import android.util.Log
+import androidx.collection.ArraySet
+import androidx.collection.arraySetOf
+import org.microg.gms.people.ContactGroup
+import org.microg.gms.people.ContactGroupRequest
+import org.microg.gms.people.ContactGroupResponse
+import org.microg.gms.people.CreateGroupContent
+import org.microg.gms.people.CreateGroupRequest
+import org.microg.gms.people.CreateGroupResponse
+import org.microg.gms.people.DeleteGroup
+import org.microg.gms.people.DeleteGroupRequest
+import org.microg.gms.people.DeleteGroupResponse
+import org.microg.gms.people.DeletePhotoRequest
+import org.microg.gms.people.DeleteRequest
+import org.microg.gms.people.DeleteResponse
+import org.microg.gms.people.FieldMetadata
+import org.microg.gms.people.GroupId
+import org.microg.gms.people.GroupInfo
+import org.microg.gms.people.GroupSource
+import org.microg.gms.people.GroupType
+import org.microg.gms.people.InsertRequest
+import org.microg.gms.people.InsertResponse
+import org.microg.gms.people.Membership
+import org.microg.gms.people.Person
+import org.microg.gms.people.PersonData
+import org.microg.gms.people.PersonMetadata
+import org.microg.gms.people.Profile
+import org.microg.gms.people.ProfileMetadata
+import org.microg.gms.people.SyncAdapterUtils
+import org.microg.gms.people.SyncAdapterUtils.buildDeletePersonPhoto
+import org.microg.gms.people.SyncAdapterUtils.buildRequestForInsert
+import org.microg.gms.people.SyncAdapterUtils.buildRequestForUpdate
+import org.microg.gms.people.SyncAdapterUtils.buildUpdatePersonPhoto
+import org.microg.gms.people.SyncPeopleRequest
+import org.microg.gms.people.SyncPeopleResponse
+import org.microg.gms.people.UpdateGroupContent
+import org.microg.gms.people.UpdateGroupRequest
+import org.microg.gms.people.UpdatePhotoRequest
+import org.microg.gms.people.UpdatePhotoResponse
+import org.microg.gms.people.UpdateRequest
+import org.microg.gms.people.UpdateResponse
+import java.security.SecureRandom
+
+private const val TAG = "ContactSyncHelper"
+
+object ContactSyncHelper {
+
+ fun syncServerGroup(lastToken: () -> String?, load: (ContactGroupRequest) -> ContactGroupResponse, sync: (ContactGroup) -> Unit, saveToken: (String?) -> Unit) {
+ val response = load(SyncAdapterUtils.buildRequestForGroup(lastToken()))
+ Log.d(TAG, "syncServerGroup res -> $response")
+ response.contactGroup.forEach { sync(it) }
+ saveToken(response.syncToken)
+ Log.d(TAG, "syncServerGroup success")
+ }
+
+ fun insertGroupUpload(list: Set, upload: (CreateGroupRequest) -> CreateGroupResponse, sync: (ContactGroup) -> Unit) {
+ val contactGroupList = list.map {
+ CreateGroupContent.Builder().apply {
+ groupSource(GroupSource.Builder().apply {
+ groupInfo(GroupInfo(it.groupTitle, it.groupTitle))
+ groupType(GroupType(1))
+ }.build())
+ }.build()
+ }
+ val createGroupResponse = upload(SyncAdapterUtils.buildCreateForGroup(contactGroupList))
+ for (contactGroup in createGroupResponse.content) {
+ sync(contactGroup)
+ }
+ }
+
+ fun updateGroupUpload(list: Set, upload: (UpdateGroupRequest) -> CreateGroupResponse, sync: (ContactGroup) -> Unit) {
+ val contactGroupList = list.map {
+ UpdateGroupContent.Builder().apply {
+ groupId(GroupId(it.groupId))
+ groupSource(GroupSource.Builder().apply {
+ groupId(GroupId(it.groupId))
+ groupInfo(GroupInfo(it.groupTitle, it.groupTitle))
+ groupType(GroupType(1))
+ }.build())
+ syncStr(it.syncStr)
+ }.build()
+ }
+ val createGroupResponse = upload(SyncAdapterUtils.buildUpdateForGroup(contactGroupList))
+ for (contactGroup in createGroupResponse.content) {
+ sync(contactGroup)
+ }
+ }
+
+ fun deleteGroupUpload(list: Set, upload: (DeleteGroupRequest) -> DeleteGroupResponse, sync: (DeleteGroup) -> Unit) {
+ val sourceIdList = list.filter { it.groupId != null }.map { it.groupId!! }
+ val deleteGroupResponse = upload(SyncAdapterUtils.buildDeleteForGroup(sourceIdList))
+ for (contactGroup in deleteGroupResponse.delete) {
+ sync(contactGroup)
+ }
+ }
+
+ fun syncServerContact(lastToken: () -> String?, load: (SyncPeopleRequest) -> SyncPeopleResponse, sync: (Person) -> Unit, saveToken: (String?) -> Unit) {
+ var count: Int
+ do {
+ val res = load(SyncAdapterUtils.buildRequestForSyncPeople(lastToken()))
+ Log.d(TAG, "syncServerContact res -> ${res.person}")
+ for (person in res.person) {
+ sync(person)
+ }
+ count = res.person.size
+ saveToken(res.syncToken ?: res.token)
+ } while (count > 0)
+ Log.d(TAG, "syncServerContact success")
+ }
+
+ @OptIn(ExperimentalStdlibApi::class)
+ fun insertUpload(
+ insertList: Set,
+ upload: (InsertRequest) -> InsertResponse,
+ sync: (Long, Person) -> Unit,
+ uploadPhoto: (UpdatePhotoRequest) -> UpdatePhotoResponse?,
+ syncPhoto: (String, String?, String?, ByteArray?) -> Unit
+ ) {
+ val contactsMap = hashMapOf()
+ for (contactData in insertList) {
+ if (contactData.data == null) continue
+ val contactId = contactData.rowId ?: continue
+ val fieldMetadata = FieldMetadata(type = 2, primary = contactData.isPrimary == 1L)
+ val personBuilder = contactsMap[contactId] ?: Person.Builder()
+ ContactConverter.toPersonProperty(contactData, fieldMetadata, personBuilder)
+ contactsMap[contactId] = personBuilder
+ }
+ val sourceIdMap = hashMapOf()
+ val personDataList = mutableListOf()
+ for (entry in contactsMap) {
+ val sourceId = (SecureRandom().nextLong() and 0x7FFFFFFF87FFFFFFL or 0x8000000L)
+ sourceIdMap[sourceId] = entry.key
+ val personData = PersonData(person = entry.value.build(), sourceId = sourceId)
+ personDataList.add(personData)
+ }
+ val insertResponse = upload(buildRequestForInsert(personDataList))
+ val insertPhoto = hashMapOf()
+ for (contact in insertResponse.contact) {
+ val person = contact.content?.person ?: continue
+ val sourceId = person.metadata?.sourceId?.firstOrNull() ?: continue
+ val contactId = sourceIdMap[sourceId] ?: continue
+ sync(contactId, person)
+ for (contactData in insertList) {
+ if (contactData.isPhotoType && contactData.rowId == contactId) {
+ insertPhoto[sourceId] = contactData
+ }
+ }
+ }
+ insertPhoto.forEach {
+ val imageId = it.value.data?.getAsString(ContactsContract.CommonDataKinds.Photo.PHOTO_FILE_ID)
+ val bytes = it.value.data?.getAsByteArray(ContactsContract.CommonDataKinds.Photo.PHOTO)
+ if (imageId != null && bytes != null) {
+ val sourceId = it.key.toHexString().trimStart('0')
+ val photoResponse = uploadPhoto(buildUpdatePersonPhoto(sourceId, bytes))
+ syncPhoto(sourceId, photoResponse?.syncToken, photoResponse?.url, bytes)
+ }
+ }
+ Log.d(TAG, "syncServerContacts success")
+ }
+
+ fun dirtyUpload(
+ dirtyList: Set,
+ defaultGroup: ContactGroupInfo?,
+ upload: (UpdateRequest) -> UpdateResponse?,
+ sync: (Long, Person) -> Unit,
+ uploadPhoto: (UpdatePhotoRequest) -> UpdatePhotoResponse?,
+ deletePhoto: (DeletePhotoRequest) -> Unit,
+ syncPhoto: (String, String?, String?, ByteArray?) -> Unit
+ ) {
+ val contactsMap = hashMapOf()
+ val photoList = hashSetOf()
+ for (contactData in dirtyList) {
+ if (contactData.data == null) continue
+ val contactId = contactData.rowId ?: continue
+ val sourceId = contactData.sourceId ?: continue
+ if (contactData.isPhotoType) {
+ photoList.add(contactData)
+ continue
+ }
+ val fieldMetadata = FieldMetadata(sourceId = sourceId, type = 2, primary = contactData.isPrimary == 1L)
+ val personBuilder = contactsMap[contactId] ?: Person.Builder()
+ if (personBuilder.metadata == null) {
+ val longSourceId = sourceId.toLong(16)
+ personBuilder.eTag("c$longSourceId")
+ val profileMetadata = ProfileMetadata(arrayListOf(Profile(sourceId, contactData.syncTime, contactData.syncTag, 2)))
+ val personMetadata = PersonMetadata(profileMetadata = profileMetadata, objectType = 2, sourceId = arrayListOf(longSourceId))
+ personBuilder.metadata(personMetadata)
+ }
+ ContactConverter.toPersonProperty(contactData, fieldMetadata, personBuilder)
+ if (!personBuilder.membership.any { it.groupSourceId == "6" }) {
+ val membership = Membership(fieldMetadata, defaultGroup?.groupId)
+ personBuilder.membership(ArraySet(personBuilder.membership).apply {
+ add(membership)
+ }.toList())
+ }
+ contactsMap[contactId] = personBuilder
+ }
+ for (entry in contactsMap) {
+ val person = entry.value.build()
+ val res = upload(buildRequestForUpdate(person))
+ val localPerson = res?.content?.person ?: person
+ sync(entry.key, localPerson)
+ }
+ photoList.forEach {
+ val imageId = it.data?.getAsString(ContactsContract.CommonDataKinds.Photo.PHOTO_FILE_ID)
+ val bytes = it.data?.getAsByteArray(ContactsContract.CommonDataKinds.Photo.PHOTO)
+ val lastSyncToken = it.data?.getAsString(ContactsContract.CommonDataKinds.Photo.SYNC2)
+ if (imageId != null && bytes != null) {
+ val photoResponse = uploadPhoto(buildUpdatePersonPhoto(it.sourceId!!, bytes))
+ syncPhoto(it.sourceId, photoResponse?.syncToken, photoResponse?.url, bytes)
+ } else if (bytes == null && lastSyncToken != null) {
+ deletePhoto(buildDeletePersonPhoto(it.sourceId!!))
+ syncPhoto(it.sourceId, null, null, null)
+ }
+ }
+ Log.d(TAG, "dirtyUpload success")
+ }
+
+ fun deletedUpload(deleteList: Set, upload: (DeleteRequest) -> DeleteResponse, sync: (Long) -> Unit) {
+ val deletedContactRowIds = arraySetOf()
+ val deletedSourceIds = arraySetOf()
+ for (delValue in deleteList) {
+ if (!delValue.sourceId.isNullOrEmpty()) {
+ deletedSourceIds.add(delValue.sourceId)
+ }
+ deletedContactRowIds.add(delValue.rowId!!)
+ }
+ deletedContactRowIds.forEach { sync(it) }
+ if (deletedSourceIds.isNotEmpty()) {
+ val response = upload(SyncAdapterUtils.buildRequestForDelete(deletedSourceIds.toList()))
+ Log.d(TAG, "deleted Upload success $response")
+ }
+ Log.d(TAG, "deletedUpload success")
+ }
+
+}
\ No newline at end of file