From e06cdf3f60219db06b1d588e5c1889cbd6e69327 Mon Sep 17 00:00:00 2001 From: Davinci9196 Date: Mon, 18 Aug 2025 10:17:12 +0800 Subject: [PATCH] Family:Managing Family Sharing Settings --- .../gms/family/model/MemberDataModel.java | 79 ++++ .../gms/family/v2/model/BulletPoint.java | 68 ++++ .../android/gms/family/v2/model/HelpData.java | 45 +++ .../android/gms/family/v2/model/PageData.java | 61 +++ .../src/main/proto/familymanagement.proto | 278 ++++++++++++++ .../src/main/AndroidManifest.xml | 24 ++ .../family/v2/manage/DeleteMemberActivity.kt | 61 +++ .../gms/family/v2/manage/FamilyApiClient.kt | 162 ++++++++ .../gms/family/v2/manage/FamilyExtensions.kt | 266 +++++++++++++ .../v2/manage/FamilyManagementActivity.kt | 155 ++++++++ .../manage/fragment/FamilyDeleteFragment.kt | 179 +++++++++ .../fragment/FamilyManagementFragment.kt | 127 +++++++ .../manage/fragment/MemberDetailFragment.kt | 100 +++++ .../family/v2/manage/model/FamilyViewModel.kt | 258 +++++++++++++ .../gms/family/v2/manage/ui/DeleteUI.kt | 235 ++++++++++++ .../gms/family/v2/manage/ui/FamilyUI.kt | 358 ++++++++++++++++++ .../gms/family/v2/manage/ui/ManagementUI.kt | 211 +++++++++++ .../gms/accountsettings/ui/MainActivity.kt | 4 + .../gms/accountsettings/ui/extensions.kt | 1 + .../ui/GoogleHelpRedirectActivity.kt | 8 +- .../src/main/res/drawable/ic_arrow_back.xml | 10 + .../src/main/res/drawable/ic_more_vert.xml | 10 + .../src/main/res/values-zh-rCN/strings.xml | 28 ++ .../src/main/res/values-zh-rTW/strings.xml | 28 ++ .../src/main/res/values/strings.xml | 29 ++ 25 files changed, 2783 insertions(+), 2 deletions(-) create mode 100644 play-services-api/src/main/java/com/google/android/gms/family/model/MemberDataModel.java create mode 100644 play-services-api/src/main/java/com/google/android/gms/family/v2/model/BulletPoint.java create mode 100644 play-services-api/src/main/java/com/google/android/gms/family/v2/model/HelpData.java create mode 100644 play-services-api/src/main/java/com/google/android/gms/family/v2/model/PageData.java create mode 100644 play-services-core-proto/src/main/proto/familymanagement.proto create mode 100644 play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/DeleteMemberActivity.kt create mode 100644 play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/FamilyApiClient.kt create mode 100644 play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/FamilyExtensions.kt create mode 100644 play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/FamilyManagementActivity.kt create mode 100644 play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/fragment/FamilyDeleteFragment.kt create mode 100644 play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/fragment/FamilyManagementFragment.kt create mode 100644 play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/fragment/MemberDetailFragment.kt create mode 100644 play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/model/FamilyViewModel.kt create mode 100644 play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/ui/DeleteUI.kt create mode 100644 play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/ui/FamilyUI.kt create mode 100644 play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/ui/ManagementUI.kt create mode 100644 play-services-core/src/main/res/drawable/ic_arrow_back.xml create mode 100644 play-services-core/src/main/res/drawable/ic_more_vert.xml diff --git a/play-services-api/src/main/java/com/google/android/gms/family/model/MemberDataModel.java b/play-services-api/src/main/java/com/google/android/gms/family/model/MemberDataModel.java new file mode 100644 index 0000000000..500a50903e --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/family/model/MemberDataModel.java @@ -0,0 +1,79 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.model; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +@SafeParcelable.Class +public class MemberDataModel extends AbstractSafeParcelable { + @Field(1) + public String memberId = ""; + @Field(2) + public String email = ""; + @Field(3) + public String displayName = ""; + @Field(4) + public String hohGivenName = ""; + @Field(5) + public String profilePhotoUrl = ""; + @Field(6) + public String roleName = ""; + @Field(7) + public int role = 0; + @Field(8) + public boolean isActive = false; + @Field(9) + public int supervisionType = 0; + @Field(10) + public long timestamp = 0; + @Field(11) + public boolean isInviteEntry = false; + @Field(12) + public int inviteSlots = 0; + @Field(13) + public boolean isInvited = false; + @Field(14) + public String invitationId = ""; + @Field(15) + public long inviteState = 0; + @Field(16) + public String inviteSentDate = ""; + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(MemberDataModel.class); + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + @Override + public String toString() { + return "MemberDataModel{" + + "memberId='" + memberId + '\'' + + ", email='" + email + '\'' + + ", displayName='" + displayName + '\'' + + ", hohGivenName='" + hohGivenName + '\'' + + ", profilePhotoUrl='" + profilePhotoUrl + '\'' + + ", roleName='" + roleName + '\'' + + ", role=" + role + + ", isActive=" + isActive + + ", supervisionType=" + supervisionType + + ", timestamp=" + timestamp + + ", isInviteEntry=" + isInviteEntry + + ", inviteSlots=" + inviteSlots + + ", isInvited=" + isInvited + + ", invitationId='" + invitationId + '\'' + + ", inviteState=" + inviteState + + ", inviteSentDate='" + inviteSentDate + '\'' + + '}'; + } +} diff --git a/play-services-api/src/main/java/com/google/android/gms/family/v2/model/BulletPoint.java b/play-services-api/src/main/java/com/google/android/gms/family/v2/model/BulletPoint.java new file mode 100644 index 0000000000..f1ef313a73 --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/family/v2/model/BulletPoint.java @@ -0,0 +1,68 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.v2.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +public class BulletPoint implements Parcelable { + + public HashMap contentMap = new HashMap<>(); + + public BulletPoint() { + } + + public BulletPoint(HashMap contentMap) { + this.contentMap = contentMap; + } + + public BulletPoint(Parcel parcel) { + int readInt = parcel.readInt(); + for (int i = 0; i < readInt; i++) { + this.contentMap.put(parcel.readInt(), parcel.readString()); + } + } + + public final boolean equals(Object obj) { + return (obj instanceof BulletPoint) && ((BulletPoint) obj).contentMap.equals(this.contentMap); + } + + public final int hashCode() { + return Arrays.hashCode(new Object[]{this.contentMap}); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeInt(this.contentMap.size()); + for (Map.Entry entry : this.contentMap.entrySet()) { + dest.writeInt((Integer) entry.getKey()); + dest.writeString((String) entry.getValue()); + } + } + + public static final Creator CREATOR = new Creator() { + @Override + public BulletPoint createFromParcel(Parcel source) { + return new BulletPoint(source); + } + + @Override + public BulletPoint[] newArray(int size) { + return new BulletPoint[size]; + } + }; +} diff --git a/play-services-api/src/main/java/com/google/android/gms/family/v2/model/HelpData.java b/play-services-api/src/main/java/com/google/android/gms/family/v2/model/HelpData.java new file mode 100644 index 0000000000..21c1af75bb --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/family/v2/model/HelpData.java @@ -0,0 +1,45 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.v2.model; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +@SafeParcelable.Class +public class HelpData extends AbstractSafeParcelable { + @Field(1) + public String linkUrl; + @Field(2) + public String appContext; + + public HelpData() { + } + + public HelpData(String linkUrl, String appContext) { + this.linkUrl = linkUrl; + this.appContext = appContext; + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(HelpData.class); + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + @Override + public String toString() { + return "HelpData{" + + "linkUrl='" + linkUrl + '\'' + + ", appContext='" + appContext + '\'' + + '}'; + } +} diff --git a/play-services-api/src/main/java/com/google/android/gms/family/v2/model/PageData.java b/play-services-api/src/main/java/com/google/android/gms/family/v2/model/PageData.java new file mode 100644 index 0000000000..5a0cb1145b --- /dev/null +++ b/play-services-api/src/main/java/com/google/android/gms/family/v2/model/PageData.java @@ -0,0 +1,61 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.v2.model; + +import android.os.Parcelable; + +import org.microg.safeparcel.AutoSafeParcelable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Objects; + +public class PageData extends AutoSafeParcelable { + @Field(1) + public int version = 1; + @Field(2) + public HashMap sectionMap = new HashMap<>(); + @Field(3) + public HashMap helpMap = new HashMap<>(); + @Field(4) + public ArrayList bulletPoints = new ArrayList<>(); + + public PageData() {} + + public PageData(HashMap sectionMap, HashMap helpMap, ArrayList bulletPoints) { + this.sectionMap = sectionMap; + this.helpMap = helpMap; + this.bulletPoints = bulletPoints; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof PageData)) return false; + PageData other = (PageData) obj; + return version == other.version && + Objects.equals(sectionMap, other.sectionMap) && + Objects.equals(helpMap, other.helpMap) && + Objects.equals(bulletPoints, other.bulletPoints); + } + + @Override + public int hashCode() { + return Objects.hash(version, sectionMap, helpMap, bulletPoints); + } + + @Override + public String toString() { + return "PageData{" + + "version=" + version + + ", sectionMap=" + sectionMap + + ", helpMap=" + helpMap + + ", bulletPoints=" + bulletPoints + + '}'; + } + + public static final Parcelable.Creator CREATOR = findCreator(PageData.class); +} diff --git a/play-services-core-proto/src/main/proto/familymanagement.proto b/play-services-core-proto/src/main/proto/familymanagement.proto new file mode 100644 index 0000000000..91f3380157 --- /dev/null +++ b/play-services-core-proto/src/main/proto/familymanagement.proto @@ -0,0 +1,278 @@ +/* + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package google.familymanagement.v1; + +option java_outer_classname = "FamilyManagementProto"; + +option java_package = "org.microg.gms.family"; +option java_multiple_files = true; + +service FamilyManagementService { + // Get the home group management configuration interface + rpc GetFamilyManagementConfig(GetFamilyManagementConfigRequest) returns (GetFamilyManagementConfigResponse); + + // Get the family group member list interface + rpc GetFamily(GetFamilyRequest) returns (GetFamilyResponse); + + // When exiting the family group, the page content displays + rpc GetFamilyManagementPageContent(GetFamilyRequest) returns (GetFamilyManagementPageContentResponse); + + // Delete invitation interface + rpc DeleteInvitation(DeleteOperationRequest) returns (DeleteOperationResponse); + + // Delete member interface + rpc DeleteMember(DeleteOperationRequest) returns (DeleteOperationResponse); + + // Delete family interface + rpc DeleteFamily(DeleteOperationRequest) returns (DeleteOperationResponse); +} + +// Responses to operations such as canceling invitations and removing members +message DeleteOperationResponse { + optional DeleteOperationResult result = 1; +} + +message DeleteOperationResult { + optional string consistencyToken = 1; + optional int32 expireTime = 2; +} + +// When jumping to WebView when removing family members, etc., the page configuration loaded +message OctarineWebViewPageConfiguration { + optional string requestUrl = 1; + optional string accountName = 2; + optional int32 initialTitleType = 3; + optional int32 initialAccountDisplay = 4; + optional int32 theme = 5; + optional string callingPackageName = 6; + optional string consistencyToken = 7; + optional bool disableClearCut = 8; +} + +// Get home group management configuration +message GetFamilyManagementConfigRequest { + optional RequestContext context = 1; + optional bool unknownBool2 = 2 [default = true]; + optional bool unknownBool3 = 3 [default = true]; + optional bool directAdd = 4; +} + +message GetFamilyManagementConfigResponse { + optional FamilyConfigExtra configExtra = 1; + optional FamilyConfigMain configMain = 2; +} + +message FamilyConfigExtra { + +} + +message FamilyConfigMain { + optional FamilyTypeList familyTypeList = 2; + repeated FamilyOption familyOption = 5; + optional FamilyInviteConfig familyInviteConfig = 6; +} + +message FamilyTypeList { + repeated int32 type = 1; + repeated FamilyMemberIdList memberIdList = 2; +} + +message FamilyOption { + optional int32 optionId = 1; + repeated FamilyOptionContent optionContents = 2; +} + +message FamilyInviteConfig { + optional FamilyInviteText content = 5; +} + +message FamilyInviteText { + optional string cpTitle = 1; + optional string addRecipientEmail = 2; + optional string addOthersEmail = 3; + optional string contacts = 4; + optional string send = 5; + optional string smsFeeNotice = 6; + optional string invalidEmailNotice = 7; + optional string validEmailReminderInfo = 8; + optional string selectedInviteesInfo = 9; +} + +message FamilyMemberIdList { + optional string memberId = 1; + repeated int32 unKnownInt2 = 2; +} + +message FamilyOptionContent { + optional int32 optId = 1; + optional string content = 2; +} + +// Return value when exiting a family group +message GetFamilyManagementPageContentResponse { + optional FamilyPageHeader header = 1; + optional FamilyPageBody body = 2; +} + +message FamilyPageHeader { + optional FamilyHeaderInfo info = 1; +} + +message FamilyHeaderInfo { + optional string title = 1; + optional int32 type = 2; +} + +message FamilyPageBody { + optional int32 status = 1; + repeated FamilySection sections = 2; + repeated FamilyHelpLink helpLinks = 3; + repeated FamilyBulletPoint bulletPoints = 4; +} + +message FamilySection { + optional int32 sectionId = 1; + optional string content = 2; +} + +message FamilyHelpLink { + optional string tag = 1; + optional string appContext = 2; + optional string url = 4; +} + +message FamilyBulletPoint { + repeated FamilySection items = 1; +} + +message GetFamilyRequest { + optional RequestContext context = 1; + optional uint32 flag = 2; + optional PlaceHolder placeHolder = 3; +// optional PlaceHolder placeHolder2 = 4; + optional MemberInfo memberInfo = 5; +} + +message DeleteOperationRequest { + optional RequestContext context = 1; + optional string memberId = 2; + optional PlaceHolder placeHolder = 3; +// optional PlaceHolder2 placeHolder2 = 4; + optional MemberInfo memberInfo = 5; +} + +message MemberInfo { + optional string memberId = 1; +} + +message PlaceHolder { +} + +message RequestContext { + required string familyExperimentOverrides = 1; + optional DeviceInfo deviceInfo = 3; + optional string moduleSet = 4; +} + +message DeviceInfo { + optional string moduleVersion = 1; + optional int32 clientType = 2; + optional CallerInfo moduleInfo = 4; +// required ScreenDensity screenDensity = 5; +} + +message CallerInfo { + optional string appId = 1; +} + +message ScreenDensity { + required int32 densityLevel = 1; +} + +message GetFamilyResponse { + optional string userId = 2; + optional bool isActive = 3; + optional FamilyRole familyRole = 4; + optional JoinMethod joinType = 5; + optional int32 maxAvailableSlots = 7; + repeated FamilyMember memberDataList = 8; + repeated FamilyInvite invitationList = 9; +} + +message FamilyInvite { + optional string invitationId = 1; + optional InviteInfo inviteInfo = 2; + optional MemberProfile profile = 3; + optional FamilyRole role = 4; + optional int64 inviteState = 5; + optional string appId = 7; + optional string invitationMessage = 8; + optional string contactId = 11; +} + +message InviteInfo { + optional string emailAddress = 1; + optional string phoneNumber = 2; +} + +// Family group member roles +enum FamilyRole { + UNKNOWN_FAMILY_ROLE = 0; + HEAD_OF_HOUSEHOLD = 1; + PARENT = 2; + MEMBER = 3; + CHILD = 4; + UNCONFIRMED_MEMBER = 5; +} + +// The method of joining the group, that is, by invitation or other means +enum JoinMethod { + UNKNOWN_JOIN_METHOD = 0; + INVITED_BY_ADMIN = 1; + JOINED_BY_LINK = 2; + JOINED_BY_EMAIL = 3; + MIGRATED = 4; +} + +message FamilyMember { + optional string memberId = 1; + optional FamilyRole role = 3; + optional MemberProfile profile = 4; + optional string hohGivenName = 6; + repeated int32 tags = 7; +} + +message MemberProfile { + optional string displayName = 1; + optional string profilePhotoUrl = 3; + optional string email = 4; + optional string familyName = 6; + optional string defaultPhotoUrl = 9; +} + +// reAuthProofTokensRequest +message ReAuthProofTokensRequest { + optional int32 type = 2; + optional string password = 4; +} + +// reAuth settings response +message ReAuthSettingsResponse { + optional ReAuthSettings settings = 1; +} + +message ReAuthSettings { + optional ReAuthSettingsOption option1 = 1; + optional ReAuthSettingsOption option2 = 2; +} + +message ReAuthSettingsOption { + optional int32 type = 1; + optional string resetPinUrlPart1 = 2; + optional string resetPinUrlPart2 = 3; + optional string resetPinUrlPart3 = 5; + optional string recoveryUrl = 6; +} diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index 046200b692..8b3b024ecd 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -983,6 +983,30 @@ + + + + + + + + + + + + + + + + diff --git a/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/DeleteMemberActivity.kt b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/DeleteMemberActivity.kt new file mode 100644 index 0000000000..d3027c280b --- /dev/null +++ b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/DeleteMemberActivity.kt @@ -0,0 +1,61 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.v2.manage + +import android.os.Bundle +import android.view.View +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.commit +import com.google.android.gms.family.v2.manage.fragment.FamilyDeleteFragment +import com.google.android.gms.family.v2.manage.model.FamilyViewModel +import com.google.android.gms.family.v2.manage.ui.FamilyActivityScreen + +class DeleteMemberActivity : AppCompatActivity() { + + private val familyViewModel by viewModels() + + private val themeType: String? + get() = intent?.getStringExtra(EXTRA_KEY_PREDEFINED_THEME) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val callingPackageName = callingPackage ?: callingActivity?.packageName + if (callingPackageName.isNullOrEmpty()) { + errorResult("DeleteMemberActivity: callingPackageName is empty", -3) + return finish() + } + val extras = intent.extras + if (extras == null) { + errorResult("DeleteMemberActivity: extras is empty", -3) + return finish() + } + setContent { + FamilyActivityScreen( + viewModel = familyViewModel, + type = themeType, + addFragment = { addDeleteFragment(it, extras) }, + onBackClick = { onBackPressed() } + ) + } + } + + private fun addDeleteFragment(container: View, bundle: Bundle) { + val activity = container.context as? AppCompatActivity + val fragmentManager = activity?.supportFragmentManager + val containerId = container.id + fragmentManager?.apply { + if (findFragmentByTag(FamilyDeleteFragment.TAG) == null) { + val deleteFragment = FamilyDeleteFragment.newInstance(bundle) + commit { + setReorderingAllowed(true) + replace(containerId, deleteFragment, FamilyDeleteFragment.TAG) + } + } + } + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/FamilyApiClient.kt b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/FamilyApiClient.kt new file mode 100644 index 0000000000..d28bf34bc3 --- /dev/null +++ b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/FamilyApiClient.kt @@ -0,0 +1,162 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.v2.manage + +import android.content.Context +import android.util.Log +import com.google.android.gms.family.model.MemberDataModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.microg.gms.common.Constants +import org.microg.gms.family.DeleteOperationRequest +import org.microg.gms.family.DeleteOperationResponse +import org.microg.gms.family.FamilyRole +import org.microg.gms.family.GetFamilyManagementConfigRequest +import org.microg.gms.family.GetFamilyManagementConfigResponse +import org.microg.gms.family.GetFamilyManagementPageContentResponse +import org.microg.gms.family.GetFamilyRequest +import org.microg.gms.family.GetFamilyResponse +import org.microg.gms.family.MemberInfo +import org.microg.gms.family.PlaceHolder +import org.microg.gms.family.ReAuthProofTokensRequest +import org.microg.gms.profile.Build + +object FamilyApiClient { + + private val FAMILY_RE_AUTH_PROOF_TOKENS_USER_AGENT = "${Constants.GMS_PACKAGE_NAME}/ ${Constants.GMS_VERSION_CODE} (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL} Build/${Build.ID};)" + + suspend fun loadFamilyData( + context: Context, + oauthToken: String?, + appId: String, + flag: Int + ): GetFamilyResponse? = withContext(Dispatchers.IO) { + val requestContext = buildRequestContext(appId) + val request = GetFamilyRequest.build { + context(requestContext) + flag(flag) + } + Log.d(TAG, "getFamily request: $request") + familyGrpcClient(context, oauthToken).GetFamily().executeBlocking(request) + } + + suspend fun loadFamilyManagementPageContent( + context: Context, + oauthToken: String?, + appId: String, + memberId: String, + currentMember: MemberDataModel, + leaveFamily: Boolean = false + ): GetFamilyManagementPageContentResponse? = withContext(Dispatchers.IO) { + val requestContext = buildRequestContext(appId) + val request = GetFamilyRequest.build { + val type = if (leaveFamily) { + FAMILY_FLAG_PAGE_CONTENT_REMOVE_MEMBER + } else if (currentMember.role == FamilyRole.HEAD_OF_HOUSEHOLD.value) { + FAMILY_FLAG_PAGE_CONTENT_DELETE_FAMILY + } else { + FAMILY_FLAG_PAGE_CONTENT_LEAVE_FAMILY + } + context(requestContext) + flag(type) + if (leaveFamily && memberId.isNotEmpty()) { + memberInfo(MemberInfo.build { memberId(memberId) }) + } else { + placeHolder(PlaceHolder()) + } + } + Log.d(TAG, "getFamilyManagementPageContent request: $request") + familyGrpcClient(context, oauthToken).GetFamilyManagementPageContent().executeBlocking(request) + } + + suspend fun loadFamilyManagementConfig( + context: Context, + oauthToken: String?, + appId: String, + directAdd: Boolean = false + ): GetFamilyManagementConfigResponse? = withContext(Dispatchers.IO) { + val requestContext = buildRequestContext(appId) + val request = GetFamilyManagementConfigRequest.build { + context(requestContext) + directAdd(directAdd) + } + Log.d(TAG, "getFamilyManagementConfig request: $request") + familyGrpcClient(context, oauthToken).GetFamilyManagementConfig().executeBlocking(request) + } + + suspend fun deleteInvitationMember( + context: Context, + oauthToken: String?, + appId: String, + memberId: String + ): DeleteOperationResponse? = withContext(Dispatchers.IO) { + val requestContext = buildRequestContext(appId) + val request = DeleteOperationRequest.build { + context(requestContext) + placeHolder(PlaceHolder()) + memberId(memberId) + } + Log.d(TAG, "deleteInvitation request: $request") + familyGrpcClient(context, oauthToken).DeleteInvitation().executeBlocking(request) + } + + suspend fun deleteMember( + context: Context, + oauthToken: String?, + appId: String, + memberId: String + ): DeleteOperationResponse? = withContext(Dispatchers.IO) { + val requestContext = buildRequestContext(appId) + val request = DeleteOperationRequest.build { + context(requestContext) + memberId(memberId) + } + Log.d(TAG, "deleteMember request: $request") + familyGrpcClient(context, oauthToken).DeleteMember().executeBlocking(request) + } + + suspend fun deleteFamily( + context: Context, + oauthToken: String?, + appId: String, + ): DeleteOperationResponse? = withContext(Dispatchers.IO) { + val requestContext = buildRequestContext(appId) + val request = DeleteOperationRequest.build { context(requestContext) } + Log.d(TAG, "deleteFamily request: $request") + familyGrpcClient(context, oauthToken).DeleteFamily().executeBlocking(request) + } + + suspend fun validatePassword(oauthToken: String?, password: String) = withContext(Dispatchers.IO) { + runCatching { + val client = OkHttpClient() + val mediaType = "application/x-protobuf".toMediaTypeOrNull() + val reAuthRequest = ReAuthProofTokensRequest.build { + type(2) + password(password) + }.encode() + val requestBody = reAuthRequest.toRequestBody(mediaType) + + val request = Request.Builder() + .url(FAMILY_RE_AUTH_PROOF_TOKENS_URL) + .post(requestBody) + .addHeader("Authorization", "Bearer $oauthToken") + .addHeader("Content-Type", "application/x-protobuf") + .addHeader("User-Agent", FAMILY_RE_AUTH_PROOF_TOKENS_USER_AGENT) + .build() + val response = client.newCall(request).execute() + if (response.code != 200) { + throw RuntimeException("Invalid response code: ${response.code} body: ${response.body?.string()}") + } + true + }.onFailure { + Log.d(TAG, "requestReAuthProofTokens: failed ", it) + }.getOrDefault(false) + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/FamilyExtensions.kt b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/FamilyExtensions.kt new file mode 100644 index 0000000000..6b7021b4a3 --- /dev/null +++ b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/FamilyExtensions.kt @@ -0,0 +1,266 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.v2.manage + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.LocaleList +import android.util.Log +import android.widget.Toast +import androidx.compose.ui.unit.Dp +import androidx.core.net.toUri +import com.google.android.gms.R +import com.google.android.gms.family.model.MemberDataModel +import com.google.android.gms.family.v2.model.BulletPoint +import com.google.android.gms.family.v2.model.HelpData +import com.google.android.gms.family.v2.model.PageData +import com.squareup.wire.GrpcClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import org.microg.gms.auth.AuthConstants +import org.microg.gms.auth.AuthManager +import org.microg.gms.checkin.LastCheckinInfo +import org.microg.gms.common.Constants +import org.microg.gms.family.CallerInfo +import org.microg.gms.family.DeviceInfo +import org.microg.gms.family.FamilyBulletPoint +import org.microg.gms.family.FamilyHelpLink +import org.microg.gms.family.FamilyPageBody +import org.microg.gms.family.FamilyRole +import org.microg.gms.family.GetFamilyManagementConfigResponse +import org.microg.gms.family.GetFamilyResponse +import org.microg.gms.family.GrpcFamilyManagementServiceClient +import org.microg.gms.family.RequestContext +import org.microg.gms.profile.Build +import java.text.DateFormat +import java.util.Date +import java.util.Locale + +const val TAG = "FamilyManagement" + +const val ACTION_FAMILY_MANAGEMENT = "com.google.android.gms.family.v2.MANAGE" + +const val EXTRA_KEY_APP_ID = "appId" +const val EXTRA_KEY_PREDEFINED_THEME = "predefinedTheme" +const val EXTRA_KEY_ACCOUNT_NAME = "accountName" +const val EXTRA_KEY_ERROR_CODE = "errorCode" +const val EXTRA_KEY_FAMILY_CHANGED = "familyChanged" +const val EXTRA_KEY_CONSISTENCY_TOKEN = "consistencyToken" +const val EXTRA_KEY_TOKEN_EXPIRATION_TIME_SECS = "tokenExpirationTimeSecs" +const val EXTRA_KEY_CALLING_PACKAGE_NAME = "callingPackageName" +const val EXTRA_KEY_MEMBER_MODEL = "memberDataModel" +const val EXTRA_KEY_MEMBER_ID = "memberId" +const val EXTRA_KEY_MEMBER_GIVEN_NAME= "memberGivenName" +const val EXTRA_KEY_MEMBER_LEAVE_FAMILY= "leaveFamily" +const val EXTRA_KEY_MEMBER_HOH_GIVEN_NAME = "hohGivenName" +const val EXTRA_KEY_CLIENT_CALLING_PACKAGE = "clientCallingPackage" + +const val FAMILY_MANAGEMENT_MODULE_VERSION = "228" +const val FAMILY_MANAGEMENT_MODULE_DASHBOARD = "family_module_management_dashboard" +const val FAMILY_MANAGEMENT_BASE_URL = "https://familymanagement-pa.googleapis.com/" +const val FAMILY_LINK_MEMBER_BASE_URL = "https://familylink.google.com/member/" +const val FAMILY_MANAGEMENT_DEFAULT_USER_AGENT = "grpc-java-okhttp/1.66.0-SNAPSHOT" + +const val FAMILY_PAGE_CONTENT_TEXT_INDEX = 3 +const val FAMILY_PAGE_CONTENT_POSITIVE_BUTTON_INDEX = 4 +const val FAMILY_PAGE_CONTENT_NEGATIVE_BUTTON_INDEX = 5 +const val FAMILY_PAGE_CONTENT_TITLE_INDEX = 28 + +const val FAMILY_PAGE_CONTENT_FLAG_MEMBER_LIST = 1 +const val FAMILY_FLAG_PAGE_CONTENT_DELETE_FAMILY = 9 +const val FAMILY_FLAG_PAGE_CONTENT_REMOVE_MEMBER = 10 +const val FAMILY_FLAG_PAGE_CONTENT_LEAVE_FAMILY = 11 + +const val FAMILY_OPTION_INVITE_TITLE_ID = 19 +const val FAMILY_OPTION_INVITE_ID = 20 + +const val FAMILY_RE_AUTH_PROOF_TOKENS_URL = "https://www.googleapis.com/reauth/v1beta/users/me/reauthProofTokens?alt=proto" +const val FAMILY_INVITE_MEMBER_URL = "https://myaccount.google.com/embedded/family/invitemembers" + +private const val FAMILY_SCOPE = "https://www.googleapis.com/auth/kid.family" +val SERVICE_FAMILY_SCOPE: String + get() = "${AuthConstants.SCOPE_OAUTH2}${FAMILY_SCOPE}" +private const val FAMILY_RE_AUTH_SCOPE = "https://www.googleapis.com/auth/accounts.reauth" +val SERVICE_FAMILY_RE_AUTH_SCOPE: String + get() = "${AuthConstants.SCOPE_OAUTH2}${FAMILY_RE_AUTH_SCOPE}" + +fun Dp.toPx(context: Context): Int = (this.value * context.resources.displayMetrics.density).toInt() + +fun familyGrpcClient(context: Context, oauthToken: String?): GrpcFamilyManagementServiceClient { + val okHttpClient = OkHttpClient.Builder().addInterceptor { chain -> + val original = chain.request() + val requestBuilder = original.newBuilder().header("te", "trailers").header("x-device-id", LastCheckinInfo.read(context).androidId.toString(16)).header("authorization", "Bearer $oauthToken") + .header("user-agent", FAMILY_MANAGEMENT_DEFAULT_USER_AGENT) + .header("accept-language", if (Build.VERSION.SDK_INT >= 24) LocaleList.getDefault().toLanguageTags() else Locale.getDefault().language).removeHeader("grpc-trace-bin") + val request = requestBuilder.build() + chain.proceed(request) + }.build() + val grpcClient = GrpcClient.Builder().client(okHttpClient).baseUrl(FAMILY_MANAGEMENT_BASE_URL).minMessageToCompress(Long.MAX_VALUE).build() + return GrpcFamilyManagementServiceClient(grpcClient) +} + +fun buildRequestContext(appId: String): RequestContext { + val deviceInfo = DeviceInfo.build { + moduleVersion(FAMILY_MANAGEMENT_MODULE_VERSION) + clientType(7) + moduleInfo(CallerInfo.build { appId(appId) }) + } + return RequestContext.build { + deviceInfo(deviceInfo) + familyExperimentOverrides("") + moduleSet("") + } +} + +suspend fun requestOauthToken(context: Context, accountName: String, service: String): String { + val authResponse = withContext(Dispatchers.IO) { + AuthManager( + context, accountName, Constants.GMS_PACKAGE_NAME, service + ).apply { isPermitted = true }.requestAuth(true) + } + return authResponse.auth ?: throw RuntimeException("oauthToken is null") +} + +fun GetFamilyResponse.parseToMemberDataModels(context: Context, accountName: String, configResponse: GetFamilyManagementConfigResponse?): MutableList { + val inviteSlotSize = configResponse?.let { + val inviteOption = it.configMain?.familyOption?.find { option -> option.optionId == FAMILY_OPTION_INVITE_ID } + val inviteSlotsContent = inviteOption?.optionContents?.find { c -> c.optId == FAMILY_OPTION_INVITE_TITLE_ID }?.content + inviteSlotsContent?.let { content -> Regex("\\d+").find(content)?.value?.toIntOrNull() ?: 0 } ?: 0 + } ?: 0 + val memberDataModels = mutableListOf() + memberDataList.map { + MemberDataModel().apply { + memberId = it.memberId ?: "" + profilePhotoUrl = it.profile?.profilePhotoUrl ?: it.profile?.defaultPhotoUrl ?: "" + displayName = it.profile?.displayName ?: it.profile?.email ?: "" + email = it.profile?.email ?: "" + hohGivenName = it.hohGivenName ?: "" + role = it.role?.value ?: FamilyRole.UNCONFIRMED_MEMBER.value + roleName = it.role?.name ?: FamilyRole.UNCONFIRMED_MEMBER.name + } + }.forEach { memberDataModels.add(it) } + invitationList.map { + MemberDataModel().apply { + memberId = it.invitationId ?: "" + profilePhotoUrl = it.profile?.profilePhotoUrl ?: it.profile?.defaultPhotoUrl ?: "" + displayName = it.profile?.displayName ?: it.profile?.email ?: "" + email = it.profile?.email ?: "" + hohGivenName = context.resources.getString(R.string.family_management_invite_send) + role = it.role?.value ?: FamilyRole.UNCONFIRMED_MEMBER.value + roleName = it.role?.name ?: FamilyRole.UNCONFIRMED_MEMBER.name + isInvited = true + invitationId = it.invitationId ?: "" + inviteState = it.inviteState ?: 0 + inviteSentDate = when { + inviteState <= 0 -> "" + else -> runCatching { + val locale = if (Build.VERSION.SDK_INT >= 24) { + context.resources.configuration.locales[0] + } else { + context.resources.configuration.locale + } + context.getString( + R.string.family_management_invite_sent_date_format, DateFormat.getDateInstance(DateFormat.MEDIUM, locale).format(Date(inviteState)) + ) + }.getOrDefault("") + } + } + }.forEach { memberDataModels.add(it) } + if (memberDataModels.any { it.email == accountName && it.role == FamilyRole.HEAD_OF_HOUSEHOLD.value } && inviteSlotSize > 0) { + memberDataModels.add( + MemberDataModel().apply { + isInviteEntry = true + inviteSlots = inviteSlotSize + } + ) + } + return memberDataModels +} + +fun FamilyPageBody.parseToPageData(): PageData { + val sectionMap = HashMap() + val helpMap = HashMap() + val bps = ArrayList() + for (section in sections) { + sectionMap.put(section.sectionId, section.content) + } + for (link in helpLinks) { + helpMap.put(link.tag, link.parseToHelpData()) + } + for (bp in bulletPoints) { + bps.add(bp.parseToBulletPoint()) + } + return PageData(sectionMap, helpMap, bps) +} + +private fun FamilyBulletPoint.parseToBulletPoint(): BulletPoint { + val contentMap = HashMap() + if (items.isNotEmpty()) { + items.filter { + it.sectionId != null && it.content != null + }.forEach { + contentMap.put(it.sectionId!!, it.content!!) + } + } + return BulletPoint(contentMap) +} + +private fun FamilyHelpLink.parseToHelpData(): HelpData { + return HelpData(url, appContext) +} + +fun buildFamilyInviteUrl(callerAppId: String?): String { + return FAMILY_INVITE_MEMBER_URL.toUri().buildUpon() + .appendQueryParameter("app_id", callerAppId?.let { (it.transformToInt() - 2).toString() }) + .appendQueryParameter("referrer_flow", FAMILY_MANAGEMENT_MODULE_DASHBOARD) + .build().toString() +} + +private fun String.transformToInt() = when (this) { + "ytu" -> 4 + "g1" -> 5 + "pfl" -> 6 + "pfpp" -> 7 + "agsa" -> 8 + "asm" -> 9 + "calendar" -> 10 + "ytm" -> 11 + "ytr" -> 12 + "famlink" -> 13 + "com.google.android.gms" -> 14 + "yt-main" -> 15 + "yt-fc" -> 17 + "yt-tandem" -> 18 + else -> 2 +} + +fun Activity.showToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() +} + +fun Activity.errorResult(msg: String, code: Int? = null, accountName: String? = null) { + Log.d(TAG, "errorResult: $msg") + if (code != null) { + setResult(4, Intent().apply { + putExtra(EXTRA_KEY_ACCOUNT_NAME, accountName) + putExtra(EXTRA_KEY_ERROR_CODE, code) + }) + } else setResult(4) +} + +fun Activity.onResult(accountName: String?, consistencyToken: String? = null) { + val result = Intent().apply { + putExtra(EXTRA_KEY_ACCOUNT_NAME, accountName) + putExtra(EXTRA_KEY_FAMILY_CHANGED, true) + } + consistencyToken?.let { + result.putExtra(EXTRA_KEY_CONSISTENCY_TOKEN, it) + result.putExtra(EXTRA_KEY_TOKEN_EXPIRATION_TIME_SECS, 300) + } + setResult(3, result) +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/FamilyManagementActivity.kt b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/FamilyManagementActivity.kt new file mode 100644 index 0000000000..3e27ed9785 --- /dev/null +++ b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/FamilyManagementActivity.kt @@ -0,0 +1,155 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.v2.manage + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope +import com.google.android.gms.family.model.MemberDataModel +import com.google.android.gms.family.v2.manage.fragment.FamilyManagementFragment +import com.google.android.gms.family.v2.manage.fragment.MemberDetailFragment +import com.google.android.gms.family.v2.manage.model.FamilyViewModel +import com.google.android.gms.family.v2.manage.ui.FamilyActivityScreen +import kotlinx.coroutines.launch +import org.microg.gms.profile.ProfileManager + +class FamilyManagementActivity : AppCompatActivity() { + private val familyViewModel by viewModels() + private var deleteFamily = false + private val callerAppId: String? + get() = intent?.getStringExtra(EXTRA_KEY_APP_ID) + private val accountName: String? + get() = intent?.getStringExtra(EXTRA_KEY_ACCOUNT_NAME) + private val themeType: String? + get() = intent?.getStringExtra(EXTRA_KEY_PREDEFINED_THEME) + + private val deletedLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + val consistencyToken = result.data?.getStringExtra(EXTRA_KEY_CONSISTENCY_TOKEN) + Log.d(TAG, "consistencyToken: $consistencyToken") + if (consistencyToken != null) { + if (!deleteFamily && backToManagement()) { + familyViewModel.refreshData() + return@registerForActivityResult + } + onResult(accountName, consistencyToken) + finish() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent?.action != null && intent.action != ACTION_FAMILY_MANAGEMENT) { + errorResult("FamilyManagementActivity: Intent has unexpected action") + return finish() + } + + val callingPackageName = callingPackage ?: callingActivity?.packageName + + if (callingPackageName.isNullOrEmpty()) { + errorResult("FamilyManagementActivity: callingPackageName is empty", -3) + return finish() + } + + if (accountName.isNullOrEmpty()) { + errorResult("FamilyManagementActivity: accountName is empty", -2) + return finish() + } + + ProfileManager.ensureInitialized(this) + setContent { + FamilyActivityScreen( + viewModel = familyViewModel, + type = themeType, + addFragment = { addManagementFragment(it, callingPackageName) }, + onBackClick = { onBackPressed() }, + onMoreClick = { currentMember, leave -> executeFamilyGroupByAction(currentMember, callingPackageName, leave) } + ) + } + lifecycleScope.launch { + familyViewModel.selectedMember.collect { + val containerId = it.first + val member = it.second + if (containerId != null) { + val detailFragment = MemberDetailFragment.newInstance(member, accountName!!, callerAppId!!) + val managementFragment = supportFragmentManager.findFragmentByTag(FamilyManagementFragment.TAG) + val transaction = supportFragmentManager.beginTransaction() + if (managementFragment != null) { + transaction.hide(managementFragment).add(containerId, detailFragment, MemberDetailFragment.TAG) + } else{ + transaction.replace(containerId, detailFragment, MemberDetailFragment.TAG) + } + transaction.commit() + return@collect + } + executeFamilyGroupByAction(member, callingPackageName, true) + } + } + } + + override fun onBackPressed() { + if (backToManagement()) { + return + } + super.onBackPressed() + } + + private fun backToManagement(): Boolean { + try { + val memberDetailFragment = supportFragmentManager.findFragmentByTag(MemberDetailFragment.TAG) + if (memberDetailFragment != null) { + val managementFragment = supportFragmentManager.findFragmentByTag(FamilyManagementFragment.TAG) + if (managementFragment != null) { + supportFragmentManager.beginTransaction().show(managementFragment).remove(memberDetailFragment).commit() + return true + } + } + } catch (e: Exception){ + Log.d(TAG, "backToManagement: ", e) + } + return false + } + + private fun addManagementFragment(container: View, callingPackage: String) { + val activity = container.context as? AppCompatActivity + val fragmentManager = activity?.supportFragmentManager + val containerId = container.id + + fragmentManager?.apply { + if (findFragmentByTag(FamilyManagementFragment.TAG) == null) { + val managementFragment = FamilyManagementFragment.newInstance(accountName!!, callerAppId!!, callingPackage) + commit { + setReorderingAllowed(true) + replace(containerId, managementFragment, FamilyManagementFragment.TAG) + } + } + } + } + + private fun executeFamilyGroupByAction(member: MemberDataModel, callingPackageName: String, leaveFamily: Boolean) { + deleteFamily = !leaveFamily + deletedLauncher.launch(Intent(this, DeleteMemberActivity::class.java).apply { + putExtra(EXTRA_KEY_ACCOUNT_NAME, accountName) + putExtra(EXTRA_KEY_APP_ID, callerAppId) + putExtra(EXTRA_KEY_PREDEFINED_THEME, themeType) + putExtra(EXTRA_KEY_CLIENT_CALLING_PACKAGE, callingPackageName) + putExtra(EXTRA_KEY_MEMBER_ID, member.memberId) + putExtra(EXTRA_KEY_MEMBER_GIVEN_NAME, member.displayName.ifEmpty { member.email }) + putExtra(EXTRA_KEY_MEMBER_HOH_GIVEN_NAME, member.hohGivenName.ifEmpty { member.roleName }) + putExtra(EXTRA_KEY_MEMBER_LEAVE_FAMILY, leaveFamily) + }) + } + +} + diff --git a/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/fragment/FamilyDeleteFragment.kt b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/fragment/FamilyDeleteFragment.kt new file mode 100644 index 0000000000..b9b7dda1f6 --- /dev/null +++ b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/fragment/FamilyDeleteFragment.kt @@ -0,0 +1,179 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.v2.manage.fragment + +import android.accounts.AccountManager +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.core.net.toUri +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import com.google.android.gms.R +import com.google.android.gms.family.model.MemberDataModel +import com.google.android.gms.family.v2.manage.EXTRA_KEY_ACCOUNT_NAME +import com.google.android.gms.family.v2.manage.EXTRA_KEY_APP_ID +import com.google.android.gms.family.v2.manage.EXTRA_KEY_MEMBER_GIVEN_NAME +import com.google.android.gms.family.v2.manage.EXTRA_KEY_MEMBER_ID +import com.google.android.gms.family.v2.manage.EXTRA_KEY_MEMBER_LEAVE_FAMILY +import com.google.android.gms.family.v2.manage.errorResult +import com.google.android.gms.family.v2.manage.model.FamilyChangedState +import com.google.android.gms.family.v2.manage.model.FamilyViewModel +import com.google.android.gms.family.v2.manage.onResult +import com.google.android.gms.family.v2.manage.showToast +import com.google.android.gms.family.v2.manage.ui.FamilyDeleteFragmentScreen +import com.google.android.gms.family.v2.model.HelpData +import com.google.android.gms.googlehelp.GoogleHelp +import kotlinx.coroutines.launch +import org.microg.gms.auth.AuthConstants +import org.microg.gms.family.FamilyRole +import org.microg.gms.googlehelp.ui.GoogleHelpRedirectActivity + +class FamilyDeleteFragment : Fragment() { + private val familyViewModel by activityViewModels() + + private val callerAppId: String? + get() = arguments?.getString(EXTRA_KEY_APP_ID) + private val accountName: String? + get() = arguments?.getString(EXTRA_KEY_ACCOUNT_NAME) + private val memberId: String? + get() = arguments?.getString(EXTRA_KEY_MEMBER_ID) + private val memberGivenName: String? + get() = arguments?.getString(EXTRA_KEY_MEMBER_GIVEN_NAME) + private val leaveFamily: Boolean + get() = arguments?.getBoolean(EXTRA_KEY_MEMBER_LEAVE_FAMILY, false) ?: false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (accountName == null) { + requireActivity().errorResult("FamilyDeleteFragment: accountName is empty", -2) + return requireActivity().finish() + } + if (AccountManager.get(requireContext()).getAccountsByType(AuthConstants.DEFAULT_ACCOUNT_TYPE).find { it.name == accountName } == null) { + requireActivity().errorResult("FamilyDeleteFragment: accountName is invalid", -2) + return requireActivity().finish() + } + if (callerAppId == null) { + requireActivity().errorResult("FamilyDeleteFragment: callerAppId is empty", -2) + return requireActivity().finish() + } + if (memberId == null) { + requireActivity().errorResult("FamilyDeleteFragment: memberId is null", -2) + return requireActivity().finish() + } + if (memberGivenName.isNullOrEmpty()) { + requireActivity().errorResult("FamilyDeleteFragment: memberGivenName is empty", -2) + return requireActivity().finish() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + lifecycleScope.launch { + familyViewModel.familyChangedStateState.collect { + when (it) { + is FamilyChangedState.Idle -> Unit + is FamilyChangedState.Changed -> onMemberChanged(it.token) + is FamilyChangedState.Error -> requireActivity().errorResult(it.message, it.code, accountName) + } + } + } + lifecycleScope.launch { familyViewModel.refreshing.collect { loadContentData() } } + } + + private fun onMemberChanged(consistencyToken: String) { + Log.d(TAG, "onMemberChanged: consistencyToken: $consistencyToken") + requireActivity().onResult(accountName, consistencyToken) + } + + private fun loadContentData() { + familyViewModel.loadFamilyManagementPageContent( + requireContext(), accountName!!, callerAppId!!, memberId!!, leaveFamily + ) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return ComposeView(requireContext()).apply { + setContent { + FamilyDeleteFragmentScreen( + viewModel = familyViewModel, + onHelpClick = ::showHelpPage, + displayName = memberGivenName!!, + leaveFamily = leaveFamily, + onCancelDelete = { requireActivity().onBackPressed() }, + onValidatePassword = ::validatePassword, + onCheckPasswordSuccess = ::executeDeleteOperation + ) + } + } + } + + private fun executeDeleteOperation(currentMember: MemberDataModel) { + lifecycleScope.launch { + if (leaveFamily) { + val deleted = familyViewModel.deleteMember(requireContext(), accountName!!, callerAppId!!, memberId!!) + if (deleted) { + requireActivity().also { + val message = getString(R.string.family_management_member_removed_success, memberGivenName) + it.showToast(message) + }.finish() + return@launch + } + requireActivity().showToast(getString(R.string.family_management_member_remove_failed, memberGivenName)) + return@launch + } + if (currentMember.role == FamilyRole.HEAD_OF_HOUSEHOLD.value) { + val deleted = familyViewModel.deleteFamily(requireContext(), accountName!!, callerAppId!!) + if (deleted) { + requireActivity().also { + it.showToast(getString(R.string.family_management_delete_group_success)) + }.finish() + return@launch + } + requireActivity().showToast(getString(R.string.family_management_delete_group_failure)) + return@launch + } + val deleted = familyViewModel.deleteMember(requireContext(), accountName!!, callerAppId!!, memberId!!) + if (deleted) { + requireActivity().also { + it.showToast(getString(R.string.family_management_exist_group_success)) + }.finish() + return@launch + } + requireActivity().showToast(getString(R.string.family_management_leave_family_error_message)) + } + } + + private fun showHelpPage(helpData: HelpData) { + Intent(requireActivity(), GoogleHelpRedirectActivity::class.java).apply { + val googleHelp = GoogleHelp().apply { + appContext = helpData.appContext + uri = helpData.linkUrl.toUri() + } + putExtra(GoogleHelpRedirectActivity.GOOGLE_HELP_KEY, googleHelp) + putExtra(GoogleHelpRedirectActivity.KEY_PACKAGE_NAME, requireActivity().packageName) + }.let { requireActivity().startActivity(it) } + } + + private fun validatePassword(password: String, member: MemberDataModel) { + familyViewModel.validatePassword(requireContext(), accountName!!, password, member) + } + + companion object { + const val TAG = "FamilyDeleteFragment" + fun newInstance(bundle: Bundle): FamilyDeleteFragment { + val fragment = FamilyDeleteFragment().apply { + arguments = bundle + } + return fragment + } + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/fragment/FamilyManagementFragment.kt b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/fragment/FamilyManagementFragment.kt new file mode 100644 index 0000000000..bdf7245528 --- /dev/null +++ b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/fragment/FamilyManagementFragment.kt @@ -0,0 +1,127 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.v2.manage.fragment + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import com.google.android.gms.R +import com.google.android.gms.common.images.ImageManager +import com.google.android.gms.family.model.MemberDataModel +import com.google.android.gms.family.v2.manage.EXTRA_KEY_ACCOUNT_NAME +import com.google.android.gms.family.v2.manage.EXTRA_KEY_APP_ID +import com.google.android.gms.family.v2.manage.EXTRA_KEY_CALLING_PACKAGE_NAME +import com.google.android.gms.family.v2.manage.FAMILY_LINK_MEMBER_BASE_URL +import com.google.android.gms.family.v2.manage.buildFamilyInviteUrl +import com.google.android.gms.family.v2.manage.model.FamilyViewModel +import com.google.android.gms.family.v2.manage.ui.FamilyManagementFragmentScreen +import kotlinx.coroutines.launch +import org.microg.gms.accountsettings.ui.EXTRA_ACCOUNT_NAME +import org.microg.gms.accountsettings.ui.EXTRA_CALLING_PACKAGE_NAME +import org.microg.gms.accountsettings.ui.EXTRA_URL +import org.microg.gms.accountsettings.ui.MainActivity +import org.microg.gms.family.FamilyRole + +class FamilyManagementFragment : Fragment() { + private val familyViewModel by activityViewModels() + + private val callerAppId: String? + get() = arguments?.getString(EXTRA_KEY_APP_ID) + private val accountName: String? + get() = arguments?.getString(EXTRA_KEY_ACCOUNT_NAME) + private val callingPackageName: String? + get() = arguments?.getString(EXTRA_KEY_CALLING_PACKAGE_NAME) + + private val resultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + loadFamilyData() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return ComposeView(requireContext()).apply { + setContent { + FamilyManagementFragmentScreen( + viewModel = familyViewModel, + onMemberClick = ::onClickFamilyMember, + loadImage = ::loadMemberAvatar + ) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + lifecycleScope.launch { familyViewModel.refreshing.collect { loadFamilyData() } } + familyViewModel.updateUIState(true, getString(R.string.family_management_title)) + } + + override fun onHiddenChanged(hidden: Boolean) { + super.onHiddenChanged(hidden) + if (!hidden) { + familyViewModel.updateUIState(true, getString(R.string.family_management_title)) + familyViewModel.refreshData() + } + } + + private fun loadFamilyData() { + familyViewModel.loadFamilyMembers( + requireContext(), + accountName!!, + callerAppId!! + ) + } + + private fun onClickFamilyMember(member: MemberDataModel) { + if (member.isInviteEntry) { + resultLauncher.launch(Intent(requireContext(), MainActivity::class.java).apply { + putExtra(EXTRA_URL, buildFamilyInviteUrl(callerAppId)) + putExtra(EXTRA_ACCOUNT_NAME, accountName) + putExtra(EXTRA_CALLING_PACKAGE_NAME, callingPackageName) + }) + return + } + if (member.role == FamilyRole.CHILD.value) { + resultLauncher.launch(Intent(requireContext(), MainActivity::class.java).apply { + putExtra(EXTRA_URL, "$FAMILY_LINK_MEMBER_BASE_URL${member.memberId}") + putExtra(EXTRA_ACCOUNT_NAME, accountName) + putExtra(EXTRA_CALLING_PACKAGE_NAME, callingPackageName) + }) + return + } + lifecycleScope.launch { + familyViewModel.selectMember((view?.parent as View).id, member) + } + } + + private fun loadMemberAvatar(url: String?, view: ImageView) { + runCatching { + ImageManager.create(requireContext()).loadImage(url, view) + } + } + + companion object { + const val TAG = "FamilyManagementFragment" + fun newInstance(accountName: String, appId: String, callingPackageName: String): FamilyManagementFragment { + val fragment = FamilyManagementFragment().apply { + arguments = Bundle().apply { + putString(EXTRA_KEY_ACCOUNT_NAME, accountName) + putString(EXTRA_KEY_APP_ID, appId) + putString(EXTRA_KEY_CALLING_PACKAGE_NAME, callingPackageName) + } + } + return fragment + } + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/fragment/MemberDetailFragment.kt b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/fragment/MemberDetailFragment.kt new file mode 100644 index 0000000000..dfae625528 --- /dev/null +++ b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/fragment/MemberDetailFragment.kt @@ -0,0 +1,100 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.v2.manage.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import com.google.android.gms.R +import com.google.android.gms.common.images.ImageManager +import com.google.android.gms.family.model.MemberDataModel +import com.google.android.gms.family.v2.manage.EXTRA_KEY_ACCOUNT_NAME +import com.google.android.gms.family.v2.manage.EXTRA_KEY_APP_ID +import com.google.android.gms.family.v2.manage.EXTRA_KEY_MEMBER_MODEL +import com.google.android.gms.family.v2.manage.model.FamilyViewModel +import com.google.android.gms.family.v2.manage.showToast +import com.google.android.gms.family.v2.manage.ui.MemberDetailItem +import kotlinx.coroutines.launch + +class MemberDetailFragment : Fragment() { + private val familyViewModel by activityViewModels() + + private val callerAppId: String? + get() = arguments?.getString(EXTRA_KEY_APP_ID) + private val accountName: String? + get() = arguments?.getString(EXTRA_KEY_ACCOUNT_NAME) + private val memberModel: MemberDataModel + get() = arguments?.getParcelable(EXTRA_KEY_MEMBER_MODEL) ?: MemberDataModel() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return ComposeView(requireContext()).apply { + setContent { + MemberDetailItem( + viewModel = familyViewModel, + member = memberModel, + onMemberClick = ::onMemberClick, + loadImage = ::loadMemberAvatar + ) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + familyViewModel.updateUIState(false, memberModel.displayName) + } + + private fun onMemberClick(member: MemberDataModel) { + if (member.isInvited) { + lifecycleScope.launch { + val deleteInvitationState = familyViewModel.deleteInvitationMember( + requireContext(), + accountName!!, + callerAppId!!, + member.invitationId + ) + runCatching { + if (deleteInvitationState){ + requireActivity().showToast(getString(R.string.family_management_cancel_invite_success)) + requireActivity().onBackPressed() + return@launch + } + requireActivity().showToast(getString(R.string.family_management_cancel_invite_error)) + } + } + return + } + lifecycleScope.launch { + familyViewModel.selectMember(null, member) + } + } + + private fun loadMemberAvatar(url: String?, view: ImageView) { + runCatching { + ImageManager.create(requireContext()).loadImage(url, view) + } + } + + companion object { + const val TAG = "MemberDetailFragment" + fun newInstance(member: MemberDataModel, accountName: String, appId: String): MemberDetailFragment { + val memberDetailFragment = MemberDetailFragment().apply { + arguments = Bundle().apply { + putParcelable(EXTRA_KEY_MEMBER_MODEL, member) + putString(EXTRA_KEY_ACCOUNT_NAME, accountName) + putString(EXTRA_KEY_APP_ID, appId) + } + } + return memberDetailFragment + } + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/model/FamilyViewModel.kt b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/model/FamilyViewModel.kt new file mode 100644 index 0000000000..e5ea697cb5 --- /dev/null +++ b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/model/FamilyViewModel.kt @@ -0,0 +1,258 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.v2.manage.model + +import android.content.Context +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.gms.R +import com.google.android.gms.family.model.MemberDataModel +import com.google.android.gms.family.v2.manage.FAMILY_PAGE_CONTENT_FLAG_MEMBER_LIST +import com.google.android.gms.family.v2.manage.FAMILY_PAGE_CONTENT_TITLE_INDEX +import com.google.android.gms.family.v2.manage.FamilyApiClient +import com.google.android.gms.family.v2.manage.SERVICE_FAMILY_RE_AUTH_SCOPE +import com.google.android.gms.family.v2.manage.SERVICE_FAMILY_SCOPE +import com.google.android.gms.family.v2.manage.TAG +import com.google.android.gms.family.v2.manage.parseToMemberDataModels +import com.google.android.gms.family.v2.manage.parseToPageData +import com.google.android.gms.family.v2.manage.requestOauthToken +import com.google.android.gms.family.v2.model.PageData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext + +data class UiState( + val title: String = "", + val isError: Boolean = false, + val isLoading: Boolean = false, + val showMoreAction: Boolean = false, + val currentMember: MemberDataModel = MemberDataModel(), + val memberList: List = emptyList(), + val pageData: PageData? = null, +) + +sealed class PasswordCheckState { + object Idle : PasswordCheckState() + object Checking : PasswordCheckState() + data class Success(val member: MemberDataModel) : PasswordCheckState() + data class Error(val message: String) : PasswordCheckState() +} + +sealed class FamilyChangedState { + object Idle : FamilyChangedState() + data class Changed(val token: String, val deleteFamily: Boolean) : FamilyChangedState() + data class Error(val message: String, val code: Int) : FamilyChangedState() +} + +class FamilyViewModel : ViewModel() { + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState.asStateFlow() + private val _passwordCheckState = MutableStateFlow(PasswordCheckState.Idle) + val passwordCheckState: StateFlow = _passwordCheckState.asStateFlow() + private val _familyChangedStateState = MutableStateFlow(FamilyChangedState.Idle) + val familyChangedStateState: StateFlow = _familyChangedStateState.asStateFlow() + private val _refreshData = MutableStateFlow(true) + val refreshing: StateFlow = _refreshData.asStateFlow() + private val _selectedMember = MutableSharedFlow>() + val selectedMember = _selectedMember.asSharedFlow() + + suspend fun selectMember(viewId: Int?, member: MemberDataModel) { + _selectedMember.emit(Pair(viewId, member)) + } + + fun updateUIState(showAction: Boolean, title: String = "") { + _uiState.update { it.copy(showMoreAction = showAction, title = title) } + } + + fun refreshData() { + _refreshData.value = !_refreshData.value + } + + fun resetPasswordState() { + _passwordCheckState.value = PasswordCheckState.Idle + } + + fun loadFamilyMembers(context: Context, accountName: String, appId: String, flag: Int = FAMILY_PAGE_CONTENT_FLAG_MEMBER_LIST) { + viewModelScope.launch { + supervisorScope { + runCatching { + _uiState.update { it.copy(isLoading = true, isError = false) } + val oauthToken = requestOauthToken(context, accountName, SERVICE_FAMILY_SCOPE) + val familyResponseDeferred = async { + FamilyApiClient.loadFamilyData(context, oauthToken, appId, flag) + } + val configResponseDeferred = async { + FamilyApiClient.loadFamilyManagementConfig(context, oauthToken, appId, false) + } + val familyResponse = familyResponseDeferred.await() + val configResponse = configResponseDeferred.await() + Log.d(TAG, "loadFamilyMembers: familyResponse: $familyResponse") + Log.d(TAG, "loadFamilyMembers: configResponse: $configResponse") + familyResponse?.parseToMemberDataModels(context, accountName, configResponse) + ?: throw RuntimeException("familyResponse is null") + }.onFailure { throwable -> + _familyChangedStateState.value = FamilyChangedState.Error(throwable.message ?: "", 4) + _uiState.update { it.copy(isLoading = false, isError = true) } + Log.d(TAG, "loadFamilyMembers error", throwable) + }.onSuccess { list -> + _uiState.update { + it.copy( + isLoading = false, + memberList = list, + currentMember = list.firstOrNull { m -> m.email == accountName } ?: MemberDataModel() + ) + } + } + } + } + } + + fun loadFamilyManagementPageContent( + context: Context, + accountName: String, + appId: String, + memberId: String, + leaveFamily: Boolean = false + ) { + viewModelScope.launch { + runCatching { + _uiState.update { it.copy(isLoading = true, isError = false) } + val oauthToken = requestOauthToken(context, accountName, SERVICE_FAMILY_SCOPE) + val currentMember = getCurrentMember(context, accountName, appId, oauthToken) + val pageContent = FamilyApiClient.loadFamilyManagementPageContent(context, oauthToken, appId, memberId, currentMember, leaveFamily) + pageContent?.body?.parseToPageData() ?: throw RuntimeException("pageContent is null") + }.onFailure { throwable -> + _uiState.update { it.copy(isLoading = false, isError = true) } + _familyChangedStateState.value = FamilyChangedState.Error(throwable.message ?: "", 4) + Log.d(TAG, "loadFamilyManagementPageContent error", throwable) + }.onSuccess { pageData -> + _uiState.update { + it.copy( + isLoading = false, + title = pageData.sectionMap.getValue(FAMILY_PAGE_CONTENT_TITLE_INDEX), + pageData = pageData + ) + } + } + } + } + + fun validatePassword( + context: Context, + accountName: String, + password: String, + currentMember: MemberDataModel + ) { + viewModelScope.launch { + _passwordCheckState.value = PasswordCheckState.Checking + runCatching { + val oauthToken = requestOauthToken(context, accountName, SERVICE_FAMILY_RE_AUTH_SCOPE) + FamilyApiClient.validatePassword(oauthToken, password) + }.onFailure { + _passwordCheckState.value = + PasswordCheckState.Error(context.getString(R.string.family_management_pwd_error)) + _familyChangedStateState.value = FamilyChangedState.Error(it.message ?: "", 4) + Log.d(TAG, "validatePassword error", it) + }.onSuccess { success -> + if (success) { + _passwordCheckState.value = PasswordCheckState.Success(currentMember) + } else { + _passwordCheckState.value = + PasswordCheckState.Error(context.getString(R.string.family_management_pwd_error)) + } + } + } + } + + suspend fun deleteInvitationMember( + context: Context, + accountName: String, + appId: String, + memberId: String + ) = withContext(Dispatchers.IO) { + runCatching { + val oauthToken = requestOauthToken(context, accountName, SERVICE_FAMILY_SCOPE) + val operationResponse = + FamilyApiClient.deleteInvitationMember(context, oauthToken, appId, memberId) + val consistencyToken = operationResponse?.result?.consistencyToken + consistencyToken?.takeIf { it.isNotEmpty() }?.let { + _familyChangedStateState.value = FamilyChangedState.Changed(it, false) + } + Log.d(TAG, "deleteInvitationMember: operationResponse: $operationResponse") + !consistencyToken.isNullOrEmpty() + }.onFailure { + _familyChangedStateState.value = FamilyChangedState.Error(it.message ?: "", 4) + Log.d(TAG, "deleteInvitationMember error", it) + }.getOrDefault(false) + } + + + suspend fun deleteMember( + context: Context, + accountName: String, + appId: String, + memberId: String + ) = withContext(Dispatchers.IO) { + runCatching { + val oauthToken = requestOauthToken(context, accountName, SERVICE_FAMILY_SCOPE) + val operationResponse = FamilyApiClient.deleteMember(context, oauthToken, appId, memberId) + val consistencyToken = operationResponse?.result?.consistencyToken + consistencyToken?.takeIf { it.isNotEmpty() }?.let { + _familyChangedStateState.value = FamilyChangedState.Changed(it, false) + } + Log.d(TAG, "deleteMember: operationResponse: $operationResponse") + !consistencyToken.isNullOrEmpty() + }.onFailure { + _familyChangedStateState.value = FamilyChangedState.Error(it.message ?: "", 4) + Log.d(TAG, "deleteMember error", it) + }.getOrDefault(false) + } + + suspend fun deleteFamily( + context: Context, + accountName: String, + appId: String + ) = withContext(Dispatchers.IO) { + runCatching { + val oauthToken = requestOauthToken(context, accountName, SERVICE_FAMILY_SCOPE) + val operationResponse = FamilyApiClient.deleteFamily(context, oauthToken, appId) + val consistencyToken = operationResponse?.result?.consistencyToken + consistencyToken?.takeIf { it.isNotEmpty() }?.let { + _familyChangedStateState.value = FamilyChangedState.Changed(it, true) + } + Log.d(TAG, "deleteFamily: operationResponse: $operationResponse") + !consistencyToken.isNullOrEmpty() + }.onFailure { + _familyChangedStateState.value = FamilyChangedState.Error(it.message ?: "", 4) + Log.d(TAG, "deleteFamily error", it) + }.getOrDefault(false) + } + + suspend fun getCurrentMember(context: Context, accountName: String, appId: String, oauthToken: String? = null): MemberDataModel { + val currentMember = uiState.value.currentMember + if (!currentMember.memberId.isNullOrEmpty()) { + return currentMember + } + val memberDataModels = withContext(Dispatchers.IO) { + val oauthToken = oauthToken ?: requestOauthToken(context, accountName, SERVICE_FAMILY_SCOPE) + val familyResponse = FamilyApiClient.loadFamilyData(context, oauthToken, appId, FAMILY_PAGE_CONTENT_FLAG_MEMBER_LIST) + familyResponse?.parseToMemberDataModels(context, accountName, null) + } + return memberDataModels?.firstOrNull { m -> m.email == accountName }?.also { + _uiState.update { state -> state.copy(currentMember = it) } + } ?: MemberDataModel() + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/ui/DeleteUI.kt b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/ui/DeleteUI.kt new file mode 100644 index 0000000000..d7c7109b83 --- /dev/null +++ b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/ui/DeleteUI.kt @@ -0,0 +1,235 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.v2.manage.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.text.HtmlCompat +import com.google.android.gms.R +import com.google.android.gms.family.model.MemberDataModel +import com.google.android.gms.family.v2.manage.FAMILY_PAGE_CONTENT_NEGATIVE_BUTTON_INDEX +import com.google.android.gms.family.v2.manage.FAMILY_PAGE_CONTENT_POSITIVE_BUTTON_INDEX +import com.google.android.gms.family.v2.manage.FAMILY_PAGE_CONTENT_TEXT_INDEX +import com.google.android.gms.family.v2.manage.model.FamilyViewModel +import com.google.android.gms.family.v2.manage.model.PasswordCheckState +import com.google.android.gms.family.v2.model.HelpData +import org.microg.gms.family.FamilyRole + +@Composable +fun FamilyDeleteFragmentScreen( + viewModel: FamilyViewModel, + displayName: String, + leaveFamily: Boolean, + onHelpClick: (HelpData) -> Unit, + onCancelDelete: () -> Unit, + onValidatePassword: (String, MemberDataModel) -> Unit, + onCheckPasswordSuccess: (MemberDataModel) -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + val passwordState by viewModel.passwordCheckState.collectAsState() + + var showDialog by remember { mutableStateOf(false) } + var password by remember { mutableStateOf("") } + + LaunchedEffect(passwordState) { + if (passwordState is PasswordCheckState.Success) { + onCheckPasswordSuccess((passwordState as PasswordCheckState.Success).member) + viewModel.resetPasswordState() + showDialog = false + password = "" + } + } + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + uiState.pageData?.let { pageData -> + HtmlTextWithHelpLinks( + htmlContent = pageData.sectionMap[FAMILY_PAGE_CONTENT_TEXT_INDEX] ?: "", + helpMap = pageData.helpMap ?: emptyMap(), + textColor = MaterialTheme.colorScheme.onBackground, + onHelpClick = onHelpClick + ) + } + + Spacer(Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onCancelDelete) { + Text(text = uiState.pageData?.sectionMap?.get(FAMILY_PAGE_CONTENT_NEGATIVE_BUTTON_INDEX) ?: "", fontSize = 13.sp) + } + Spacer(Modifier.width(12.dp)) + TextButton(onClick = { showDialog = true }) { + Text(text = uiState.pageData?.sectionMap?.get(FAMILY_PAGE_CONTENT_POSITIVE_BUTTON_INDEX) ?: "", fontSize = 13.sp) + } + } + } + + if (showDialog) { + PasswordDialog( + currentModel = uiState.currentMember, + displayName = displayName, + leaveFamily = leaveFamily, + password = password, + isChecking = passwordState is PasswordCheckState.Checking, + errorMessage = if (passwordState is PasswordCheckState.Error) (passwordState as PasswordCheckState.Error).message else null, + onPasswordChange = { password = it }, + onConfirm = { + if (password.isNotEmpty()) { + onValidatePassword(password, uiState.currentMember) + } + }, + onCancel = { + viewModel.resetPasswordState() + password = "" + showDialog = false + }) + } + } +} + +@Composable +fun HtmlTextWithHelpLinks( + htmlContent: String, helpMap: Map, textColor: Color, onHelpClick: (HelpData) -> Unit +) { + val annotatedString = remember(htmlContent, helpMap) { + buildAnnotatedString { + val spanned = HtmlCompat.fromHtml(htmlContent, HtmlCompat.FROM_HTML_MODE_LEGACY) + + val plainText = spanned.toString() + append(plainText) + + val regex = Regex("(.*?)", RegexOption.IGNORE_CASE) + regex.findAll(htmlContent).forEach { match -> + val helpKey = match.groupValues[1] + val displayText = match.groupValues[2] + val startIndex = plainText.indexOf(displayText) + if (startIndex >= 0) { + val endIndex = startIndex + displayText.length + addStyle( + style = SpanStyle( + color = Color.Blue, textDecoration = TextDecoration.None + ), start = startIndex, end = endIndex + ) + addStringAnnotation( + tag = "HELP_TAG", annotation = helpKey, start = startIndex, end = endIndex + ) + } + } + } + } + + ClickableText( + text = annotatedString, + style = LocalTextStyle.current.copy( + color = textColor, + fontSize = 14.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + onClick = { offset -> + annotatedString.getStringAnnotations("HELP_TAG", offset, offset).firstOrNull()?.let { annotation -> + helpMap[annotation.item]?.let { helpData -> + onHelpClick(helpData) + } + } + }) +} + +@Composable +private fun PasswordDialog( + currentModel: MemberDataModel, + displayName: String, + password: String, + leaveFamily: Boolean, + isChecking: Boolean, + errorMessage: String?, + onPasswordChange: (String) -> Unit, + onConfirm: () -> Unit, + onCancel: () -> Unit +) { + AlertDialog( + onDismissRequest = onCancel, + title = { + val title = if (leaveFamily) { + stringResource(id = R.string.family_management_remove_member_password_title, displayName) + } else if (currentModel.role == FamilyRole.HEAD_OF_HOUSEHOLD.value) { + stringResource(id = R.string.family_management_delete_family_password_title) + } else { + stringResource(id = R.string.family_management_leave_family_password_title) + } + Text(text = title) + }, + text = { + Column { + Text(currentModel.email) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = password, + onValueChange = onPasswordChange, + placeholder = { Text(stringResource(R.string.family_management_input_pwd)) }, + isError = errorMessage != null, + supportingText = { + errorMessage?.let { Text(it, color = Color.Red) } + }) + } + }, + confirmButton = { + TextButton( + onClick = onConfirm, enabled = !isChecking + ) { + if (isChecking) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), strokeWidth = 2.dp + ) + } else { + Text(stringResource(id = R.string.family_management_delete_group_confirm), fontSize = 13.sp) + } + } + }, + dismissButton = { + TextButton(onClick = onCancel) { + Text(stringResource(id = R.string.family_management_delete_group_cancel), fontSize = 13.sp) + } + }) +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/ui/FamilyUI.kt b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/ui/FamilyUI.kt new file mode 100644 index 0000000000..2a13087590 --- /dev/null +++ b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/ui/FamilyUI.kt @@ -0,0 +1,358 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.v2.manage.ui + +import android.app.Activity +import android.view.View +import android.widget.FrameLayout +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.WindowCompat +import com.google.android.gms.R +import com.google.android.gms.family.model.MemberDataModel +import com.google.android.gms.family.v2.manage.model.FamilyViewModel +import com.google.android.gms.family.v2.manage.model.UiState +import kotlinx.coroutines.launch +import org.microg.gms.family.FamilyRole +import org.microg.gms.profile.Build + +@Composable +fun FamilyActivityScreen( + viewModel: FamilyViewModel, + type: String?, + addFragment: (View) -> Unit, + onBackClick: () -> Unit, + onMoreClick: ((MemberDataModel, Boolean) -> Unit)? = null +) { + val uiState by viewModel.uiState.collectAsState() + val snackHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + FamilyTheme(familyThemeType = FamilyThemeType.from(type)) { + Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackHostState) { data -> + Snackbar( + shape = RectangleShape, + action = { + TextButton(onClick = { + scope.launch { + snackHostState.currentSnackbarData?.dismiss() + viewModel.refreshData() + } + }) { + Text(stringResource(R.string.family_management_retry)) + } + } + ) { + Text(data.visuals.message) + } + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + FamilyToolbar( + uiState = uiState, + currentMember = uiState.currentMember, + onBackClick = onBackClick, + onMoreClick = onMoreClick + ) + Box(modifier = Modifier.fillMaxSize()) { + val containerId = remember { View.generateViewId() } + AndroidView( + factory = { context -> FrameLayout(context).apply { id = containerId } }, + update = { addFragment(it) } + ) + if (uiState.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + } + } + } + + LaunchedEffect(uiState.isError) { + if (uiState.isError) { + snackHostState.showSnackbar(context.getString(R.string.family_management_load_error)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FamilyToolbar( + uiState: UiState, + currentMember: MemberDataModel?, + onBackClick: () -> Unit, + onMoreClick: ((MemberDataModel, Boolean) -> Unit)? = null +) { + val title = uiState.title + val showMore = uiState.showMoreAction + TopAppBar( + title = { Text(title, color = MaterialTheme.colorScheme.onPrimary, fontSize = 18.sp) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back), + contentDescription = stringResource(id = R.string.family_management_toolbar_back), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + }, + actions = { + currentMember?.let { member -> + onMoreClick?.let { moreClick -> + val action = when (member.role) { + FamilyRole.HEAD_OF_HOUSEHOLD.value -> stringResource(R.string.family_management_delete_family_group) + FamilyRole.MEMBER.value -> stringResource(R.string.family_management_exit_family_group) + else -> null + } + action?.let { text -> + if (showMore) { + MoreOptionsMenu( + menuItems = listOf(text), + member = member, + onMenuClick = moreClick + ) + } + } + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) +} + +@Composable +private fun MoreOptionsMenu( + menuItems: List, + member: MemberDataModel, + onMenuClick: (MemberDataModel, Boolean) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Box { + IconButton(onClick = { expanded = true }) { + Icon( + painter = painterResource(id = R.drawable.ic_more_vert), + contentDescription = stringResource(id = R.string.family_management_toolbar_more), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + menuItems.forEach { title -> + DropdownMenuItem( + text = { Text(title) }, + onClick = { + onMenuClick(member, member.role != FamilyRole.HEAD_OF_HOUSEHOLD.value) + expanded = false + } + ) + } + } + } +} + +private data class ThemeColors( + val primary: Color, + val onPrimary: Color, + val background: Color, + val onBackground: Color, +) + +private enum class FamilyThemeType( + val typeName: String, val lightColorScheme: ThemeColors, val darkColorScheme: ThemeColors? = null +) { + FAMILY_MANAGEMENT( + "family_management", lightColorScheme = ThemeColors( + primary = Color(0xFF1A73E8), + onPrimary = Color.White, + background = Color.White, + onBackground = Color(0xFF5F6368), + ), darkColorScheme = ThemeColors( + primary = Color(0xFF89B4F8), + onPrimary = Color.Black, + background = Color(0xFF202124), + onBackground = Color(0xFFEEEEEE), + ) + ), + + PLAY_PASS( + "play_pass", lightColorScheme = ThemeColors( + primary = Color(0xFF01875F), + onPrimary = Color.White, + background = Color.White, + onBackground = Color.Black, + ), darkColorScheme = ThemeColors( + primary = Color(0xFF00A173), + onPrimary = Color.Black, + background = Color(0xFF202124), + onBackground = Color(0xFFEEEEEE), + ) + ), + + PLAY_MUSIC( + "play_music", lightColorScheme = ThemeColors( + primary = Color(0xFFEF6C00), + onPrimary = Color.White, + background = Color.White, + onBackground = Color.Black, + ) + ), + + YOUTUBE( + "youtube", lightColorScheme = ThemeColors( + primary = Color(0xFFE62117), + onPrimary = Color.White, + background = Color.White, + onBackground = Color.Black, + ) + ), + + ASSISTANT( + "assistant", lightColorScheme = ThemeColors( + primary = Color(0xFFFFFFFF), + onPrimary = Color.Black, + background = Color.White, + onBackground = Color.Black, + ) + ), + + G1( + "g1", lightColorScheme = ThemeColors( + primary = Color(0xFFFFFFFF), + onPrimary = Color.Black, + background = Color.White, + onBackground = Color.Black, + ) + ), + + PLAY( + "play", lightColorScheme = ThemeColors( + primary = Color(0xFF455A64), onPrimary = Color.White, background = Color.White, onBackground = Color.Black + ) + ); + + companion object { + fun from(type: String?) = FamilyThemeType.entries.firstOrNull { + it.typeName.equals(type, ignoreCase = true) + } ?: FAMILY_MANAGEMENT + } +} + +@Composable +private fun FamilyTheme( + familyThemeType: FamilyThemeType, darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit +) { + val colors = if (darkTheme && familyThemeType.darkColorScheme != null) { + familyThemeType.darkColorScheme + } else { + familyThemeType.lightColorScheme + } + + val colorScheme = ColorScheme( + primary = colors.primary, + onPrimary = colors.onPrimary, + background = colors.background, + onBackground = colors.onBackground, + surface = colors.background, + onSurface = colors.onBackground, + primaryContainer = colors.primary.copy(alpha = 0.2f), + onPrimaryContainer = colors.onPrimary, + secondary = colors.primary, + onSecondary = colors.onPrimary, + secondaryContainer = colors.primary.copy(alpha = 0.2f), + onSecondaryContainer = colors.onPrimary, + error = Color(0xFFB00020), + onError = Color.White, + errorContainer = Color(0xFFCF6679), + onErrorContainer = Color.White, + outline = Color.Gray, + inverseOnSurface = Color.White, + inverseSurface = Color.DarkGray, + inversePrimary = colors.primary, + surfaceVariant = colors.background, + onSurfaceVariant = colors.onBackground, + scrim = Color.Black.copy(alpha = 0.5f), + tertiary = colors.primary, + onTertiary = colors.onPrimary, + tertiaryContainer = colors.primary, + onTertiaryContainer = colors.onPrimary, + surfaceTint = Color.White, + outlineVariant = Color.White + ) + + val view = LocalView.current + val activity = view.context as? Activity + + SideEffect { + activity?.window?.apply { + if (Build.VERSION.SDK_INT >= 21) { + statusBarColor = colorScheme.primary.toArgb() + navigationBarColor = colorScheme.primary.toArgb() + } + WindowCompat.getInsetsController(this, decorView).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, typography = MaterialTheme.typography, shapes = MaterialTheme.shapes, content = content + ) +} diff --git a/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/ui/ManagementUI.kt b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/ui/ManagementUI.kt new file mode 100644 index 0000000000..7a70693a4f --- /dev/null +++ b/play-services-core/src/main/kotlin/com/google/android/gms/family/v2/manage/ui/ManagementUI.kt @@ -0,0 +1,211 @@ +/** + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.family.v2.manage.ui + +import android.view.ViewGroup +import android.widget.ImageView +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import com.google.android.gms.R +import com.google.android.gms.family.model.MemberDataModel +import com.google.android.gms.family.v2.manage.model.FamilyViewModel +import com.google.android.gms.family.v2.manage.toPx +import de.hdodenhof.circleimageview.CircleImageView +import org.microg.gms.family.FamilyRole + +@Composable +fun FamilyManagementFragmentScreen( + viewModel: FamilyViewModel, + onMemberClick: (MemberDataModel) -> Unit, + loadImage: (String?, ImageView) -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(uiState.memberList, key = { it.memberId }) { member -> + if (member.isInviteEntry) { + InviteItem( + member = member, + onMemberClick = onMemberClick + ) + } else { + MemberItem( + member = member, + currentMember = uiState.currentMember, + onMemberClick = onMemberClick, + imageLoader = loadImage + ) + } + } + } +} + +@Composable +fun MemberDetailItem( + viewModel: FamilyViewModel, + member: MemberDataModel, + onMemberClick: (MemberDataModel) -> Unit, + loadImage: (String?, ImageView) -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + val isHeadOfHousehold = uiState.currentMember.role == FamilyRole.HEAD_OF_HOUSEHOLD.value + val actionTextId = if (member.isInvited) R.string.family_management_cancel_invite else R.string.family_management_remove_member + + Column( + modifier = Modifier.fillMaxWidth() + ) { + MemberItem( + member = member, + currentMember = uiState.currentMember, + imageLoader = loadImage, + isDetail = true + ) + + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ) + + Text( + text = stringResource(id = actionTextId), + fontSize = 14.sp, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(16.dp) + .clickable(isHeadOfHousehold) { onMemberClick.invoke(member) } + ) + } +} + +@Composable +fun MemberItem( + member: MemberDataModel, + currentMember: MemberDataModel, + onMemberClick: ((MemberDataModel) -> Unit)? = null, + imageLoader: (String?, ImageView) -> Unit, + isDetail: Boolean = false +) { + val isSame = member.memberId == currentMember.memberId + val isHeadOfHousehold = currentMember.role == FamilyRole.HEAD_OF_HOUSEHOLD.value + val isInvited = member.isInvited + var roleColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + val roleName = if (isInvited && !member.inviteSentDate.isNullOrEmpty()) { + if (isDetail) member.inviteSentDate else member.hohGivenName.also { roleColor = Color.Green } + } else member.hohGivenName + + Row( + modifier = Modifier + .fillMaxWidth() + .height(66.dp) + .padding(horizontal = 16.dp) + .clickable(enabled = isHeadOfHousehold && !isSame && onMemberClick != null) { + onMemberClick?.invoke(member) + }, + verticalAlignment = Alignment.CenterVertically + ) { + AndroidView( + factory = { context -> + CircleImageView(context).apply { + layoutParams = ViewGroup.LayoutParams(36.dp.toPx(context), 36.dp.toPx(context)) + scaleType = ImageView.ScaleType.CENTER_CROP + } + }, + modifier = Modifier.size(36.dp), + update = { imageView -> + if (member.profilePhotoUrl.isNullOrEmpty()) { + imageView.setImageResource(R.drawable.ic_generic_man) + } else { + imageLoader.invoke(member.profilePhotoUrl, imageView) + } + } + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) + ) { + Text( + text = if (isDetail) member.email else member.displayName, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 14.sp, + ) + if (!roleName.isNullOrEmpty()) { + Text( + text = roleName, + color = roleColor, + fontSize = 12.sp, + ) + } + } + } +} + +@Composable +private fun InviteItem( + member: MemberDataModel, + onMemberClick: (MemberDataModel) -> Unit, + modifier: Modifier = Modifier, + iconRes: Int = android.R.drawable.ic_menu_add, + title: String = stringResource(id = R.string.family_management_invite_family_member), + subTitle: String = stringResource(id = R.string.family_management_invite_slots_left, member.inviteSlots) +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(66.dp) + .padding(horizontal = 16.dp) + .clickable { onMemberClick(member) }, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = iconRes), + contentDescription = title, + modifier = Modifier.size(36.dp) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) + ) { + Text( + text = title, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = subTitle, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + } + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/MainActivity.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/MainActivity.kt index 6cc059a487..f94d2d369d 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/MainActivity.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/MainActivity.kt @@ -123,6 +123,7 @@ private val SCREEN_ID_TO_URL = hashMapOf( 10706 to "https://myaccount.google.com/profile/profiles-summary", 10728 to "https://myaccount.google.com/data-and-privacy/how-data-improves-experience", 10729 to "https://myaccount.google.com/data-and-privacy/data-visibility", + 10731 to "https://myaccount.google.com/embedded/family/createconfirmation", 10759 to "https://myaccount.google.com/address/home", 10760 to "https://myaccount.google.com/address/work", 14500 to "https://profilewidgets.google.com/alternate-profile/edit?interop=o&opts=sb", @@ -180,6 +181,7 @@ class MainActivity : AppCompatActivity() { val product = intent?.getStringExtra(EXTRA_SCREEN_MY_ACTIVITY_PRODUCT) val kidOnboardingParams = intent?.getStringExtra(EXTRA_SCREEN_KID_ONBOARDING_PARAMS) val screenUrl = intent?.getStringExtra(EXTRA_URL) + val familyAppId = intent?.getStringExtra(EXTRA_SCREEN_FAMILY_APP_ID) val screenOptions = intent.extras?.keySet().orEmpty() .filter { it.startsWith(EXTRA_SCREEN_OPTIONS_PREFIX) } @@ -205,6 +207,8 @@ class MainActivity : AppCompatActivity() { replace("search", product) } else if (screenId == 580 && !kidOnboardingParams.isNullOrEmpty()){ "$this?params=$kidOnboardingParams" + } else if (screenId == 10731 && !familyAppId.isNullOrEmpty()) { + "$this?app_id=$familyAppId" } else { this } } val layout = RelativeLayout(this) diff --git a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/extensions.kt b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/extensions.kt index 660fbda869..e5d4b9b2b9 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/extensions.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/accountsettings/ui/extensions.kt @@ -27,6 +27,7 @@ const val EXTRA_FALLBACK_AUTH = "extra.fallbackAuth" const val EXTRA_THEME_CHOICE = "extra.themeChoice" const val EXTRA_SCREEN_MY_ACTIVITY_PRODUCT = "extra.screen.myactivityProduct" const val EXTRA_SCREEN_KID_ONBOARDING_PARAMS = "extra.screen.kidOnboardingParams" +const val EXTRA_SCREEN_FAMILY_APP_ID = "extra.screen.family-app_id" const val EXTRA_URL = "extra.url" const val QUERY_WC_ACTION = "wv_action" diff --git a/play-services-core/src/main/kotlin/org/microg/gms/googlehelp/ui/GoogleHelpRedirectActivity.kt b/play-services-core/src/main/kotlin/org/microg/gms/googlehelp/ui/GoogleHelpRedirectActivity.kt index 915c261003..510aae7c24 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/googlehelp/ui/GoogleHelpRedirectActivity.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/googlehelp/ui/GoogleHelpRedirectActivity.kt @@ -31,13 +31,17 @@ import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine private const val TAG = "GoogleHelpRedirect" -private const val GOOGLE_HELP_KEY = "EXTRA_GOOGLE_HELP" private const val PRODUCT_HELP_KEY = "EXTRA_IN_PRODUCT_HELP" private const val HELP_URL = "https://www.google.com/tools/feedback/mobile/help-suggestions" class GoogleHelpRedirectActivity : AppCompatActivity() { + companion object { + const val GOOGLE_HELP_KEY = "EXTRA_GOOGLE_HELP" + const val KEY_PACKAGE_NAME = "EXTRA_PACKAGE" + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Log.d(TAG, "onCreate begin") @@ -46,7 +50,7 @@ class GoogleHelpRedirectActivity : AppCompatActivity() { finish() return } - val callingPackage = callingPackage ?: callingActivity?.packageName ?: return finish() + val callingPackage = intent.getStringExtra(KEY_PACKAGE_NAME) ?: callingPackage ?: callingActivity?.packageName ?: return finish() Log.d(TAG, "onCreate callingPackage: $callingPackage") val googleHelp = intent.getParcelableExtra(GOOGLE_HELP_KEY) var inProductHelp: InProductHelp? = null diff --git a/play-services-core/src/main/res/drawable/ic_arrow_back.xml b/play-services-core/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000000..aa0abb3d09 --- /dev/null +++ b/play-services-core/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/drawable/ic_more_vert.xml b/play-services-core/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000000..9a51b8e4a3 --- /dev/null +++ b/play-services-core/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/values-zh-rCN/strings.xml b/play-services-core/src/main/res/values-zh-rCN/strings.xml index 9eda431379..5009582c12 100644 --- a/play-services-core/src/main/res/values-zh-rCN/strings.xml +++ b/play-services-core/src/main/res/values-zh-rCN/strings.xml @@ -293,6 +293,34 @@ microG GmsCore 内置一套自由的 SafetyNet 实现,但是官方服务器要 取消 确定 删除失败,请稍后重试 + 家庭 + 重试 + 内容加载失败 + 返回 + 更多 + 退出家庭群组 + 删除家庭群组 + 确认密码以删除家庭群组 + 确认密码以退出家庭群组 + 确认密码以移除“%s” + 邀请家庭成员 + 剩余%1$d个邀请名额 + 取消邀请 + 取消邀请成功 + 取消邀请失败,请重试 + 移除成员 + %s已从您的家庭群组中移除 + 移除%s失败,请重试 + 无法退出家庭群组。请重试。 + 您已成功退出家庭群组 + 家庭群组已删除 + 删除家庭群组失败 + 取消 + 确认 + 请输入密码 + 邀请已于%s发送 + 邀请已发送 + 密码错误,请重新输入 取消 继续 正在登录 diff --git a/play-services-core/src/main/res/values-zh-rTW/strings.xml b/play-services-core/src/main/res/values-zh-rTW/strings.xml index c5b3574768..2d1b451be5 100644 --- a/play-services-core/src/main/res/values-zh-rTW/strings.xml +++ b/play-services-core/src/main/res/values-zh-rTW/strings.xml @@ -254,6 +254,34 @@ 刪除 取消 刪除失敗,請稍後再試 + 家庭 + 重試 + 內容載入失敗 + 返回 + 更多 + 退出家庭群組 + 刪除家庭群組 + 確認密碼以退出家庭群組 + 確認密碼以刪除家庭群組 + 確認密碼以移除“%s” + 邀請家庭成員 + 剩餘%1$d個邀請名額 + 取消邀請 + 已成功取消邀請 + 取消邀請失敗,請重試 + 移除成員 + 已將%s從您的家庭群組中移除 + 移除%s失敗,請再試一次 + 退出家庭群組時發生問題,請再試一次。 + 您已成功退出家庭群組⋯ + 家庭群組已刪除 + 刪除家庭群組失敗 + 取消 + 確定 + 請輸入密碼 + 已於%s發送邀請 + 已發送邀請 + 密碼錯誤,請重新輸入 已由 microG 代表 %1$s 掃描 需要相機使用權限 自動添加免費應用程式到媒體庫 diff --git a/play-services-core/src/main/res/values/strings.xml b/play-services-core/src/main/res/values/strings.xml index 6dc8915f0c..3fb9d08bea 100644 --- a/play-services-core/src/main/res/values/strings.xml +++ b/play-services-core/src/main/res/values/strings.xml @@ -398,6 +398,35 @@ Please set up a password, PIN, or pattern lock screen." Permanently deleting your data for %1$s will remove your scores, progress (saved games), and game settings in Google Play Games. Hey there, %1$s + Family + Retry + Content loading failed + Back + More + Leave the family group + Delete a family group + Confirm password to leave the family group + Confirm password to delete a family group + Confirm password to remove "%s" + Invite family members + %1$d invitations remaining + Cancel invitation + Cancel invitation successfully + Trouble cancelling invitation. Try again. + Remove Member + %s was removed from your family group + Trouble removing %s. Try again. + Trouble leaving the family group. Try again. + You have left your family group… + Family group deleted + Failed to delete home group + Cancel + OK + Please enter your password + Invitation sent on %s + Invitation sent + Wrong password, please re-enter + Scanned by microG on behalf of %1$s OK microG services needs to access your device\'s camera to scan a code for %1$s.\n\nTo enable, please grant camera permission to microG services in Settings.