From 6d21c0f31083860a771472a85345faa99f5f62c3 Mon Sep 17 00:00:00 2001 From: davinci9196 Date: Mon, 6 Jan 2025 16:19:54 +0800 Subject: [PATCH 1/2] Google Contacts Sync Service Content Supplement --- .../gms/common/images/ImageManager.java | 15 + .../src/main/proto/contacts.proto | 536 ++++++++++++++ .../src/main/AndroidManifest.xml | 1 + .../microg/gms/people/ContactSyncService.java | 44 -- .../microg/gms/people/ContactSyncService.kt | 64 ++ .../org/microg/gms/people/SyncAdapterProxy.kt | 227 ++++++ .../org/microg/gms/people/SyncAdapterUtils.kt | 169 +++++ .../gms/people/contacts/ContactConverter.kt | 677 ++++++++++++++++++ .../gms/people/contacts/ContactGroupInfo.kt | 8 + .../people/contacts/ContactProviderHelper.kt | 327 +++++++++ .../gms/people/contacts/ContactSyncHelper.kt | 244 +++++++ 11 files changed, 2268 insertions(+), 44 deletions(-) create mode 100644 play-services-core-proto/src/main/proto/contacts.proto delete mode 100644 play-services-core/src/main/java/org/microg/gms/people/ContactSyncService.java create mode 100644 play-services-core/src/main/kotlin/org/microg/gms/people/ContactSyncService.kt create mode 100644 play-services-core/src/main/kotlin/org/microg/gms/people/SyncAdapterProxy.kt create mode 100644 play-services-core/src/main/kotlin/org/microg/gms/people/SyncAdapterUtils.kt create mode 100644 play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactConverter.kt create mode 100644 play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactGroupInfo.kt create mode 100644 play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactProviderHelper.kt create mode 100644 play-services-core/src/main/kotlin/org/microg/gms/people/contacts/ContactSyncHelper.kt 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 cfcfc004cf..1f1883f3db 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..731af7db3a --- /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.size > 0 }?.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.size > 0 }?.run { + ContactSyncHelper.deletedUpload(this, upload = { + peopleClient.DeletePeople().executeBlocking(it) + }, sync = { + ContactProviderHelper.get(context).deleteContact(it, account, syncResult, provider) + }) + } + updateList.takeIf { it.size > 0 }?.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.size > 0 }?.run { + ContactSyncHelper.insertGroupUpload(this, upload = { + peopleClient.CreateContactGroups().executeBlocking(it) + }, sync = { + ContactProviderHelper.get(context).syncPersonGroup(it, allGroupList, account, provider) + }) + } + updatedGroups.takeIf { it.size > 0 }?.run { + ContactSyncHelper.updateGroupUpload(this, upload = { + peopleClient.UpdateContactGroups().executeBlocking(it) + }, sync = { + ContactProviderHelper.get(context).syncPersonGroup(it, allGroupList, account, provider) + }) + } + deletedGroups.takeIf { it.size > 0 }?.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 From d594ed5bfd5b62f922799c17efaa1d4dd28dd050 Mon Sep 17 00:00:00 2001 From: davinci9196 Date: Tue, 7 Jan 2025 16:04:24 +0800 Subject: [PATCH 2/2] cleanup --- .../kotlin/org/microg/gms/people/SyncAdapterProxy.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 index 731af7db3a..a352aab00b 100644 --- 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 @@ -104,7 +104,7 @@ class SyncAdapterProxy(context: Context) : AbstractThreadedSyncAdapter(context, updateList.add(values) } } - insertList.takeIf { it.size > 0 }?.run { + insertList.takeIf { it.isNotEmpty() }?.run { ContactSyncHelper.insertUpload(this, upload = { peopleClient.BulkInsertContacts().executeBlocking(it) }, sync = { contactId, person -> @@ -115,14 +115,14 @@ class SyncAdapterProxy(context: Context) : AbstractThreadedSyncAdapter(context, ContactProviderHelper.get(context).syncContactPhoto(sourceId, account, provider, url, syncToken, bytes) }) } - deleteList.takeIf { it.size > 0 }?.run { + 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.size > 0 }?.run { + 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() @@ -162,21 +162,21 @@ class SyncAdapterProxy(context: Context) : AbstractThreadedSyncAdapter(context, updatedGroups.add(groupInfo) } } - createdGroups.takeIf { it.size > 0 }?.run { + 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.size > 0 }?.run { + 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.size > 0 }?.run { + deletedGroups.takeIf { it.isNotEmpty() }?.run { ContactSyncHelper.deleteGroupUpload(this, upload = { peopleClient.DeleteContactGroups().executeBlocking(it) }, sync = {