diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index 4ce09bd44ae..1a6412d157e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -8,6 +8,7 @@ import android.content.pm.PackageManager import android.content.res.Configuration import android.content.res.Resources import android.Manifest +import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper @@ -20,6 +21,7 @@ import android.view.View.NO_ID import android.view.ViewGroup import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.MainThread import androidx.annotation.StringRes @@ -28,7 +30,13 @@ import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.view.children import androidx.core.view.isNotEmpty +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import androidx.preference.PreferenceManager +import coil3.ImageLoader +import coil3.request.Disposable +import coil3.request.ImageRequest +import coil3.request.allowHardware import com.google.android.gms.cast.framework.CastSession import com.google.android.material.chip.ChipGroup import com.google.android.material.navigationrail.NavigationRailView @@ -59,6 +67,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UiText import java.lang.ref.WeakReference import java.util.Locale +import java.util.WeakHashMap import kotlin.math.max import kotlin.math.min import org.schabi.newpipe.extractor.NewPipe @@ -79,9 +88,129 @@ object CommonActivity { _activity = WeakReference(value) } + private class ProfileImagePicker { + private class Attempt(val callback: (String) -> Boolean) { + var validation: Disposable? = null + var isValid = true + + fun invalidate() { + isValid = false + validation?.dispose() + validation = null + } + + fun complete() { + isValid = false + validation = null + } + } + + lateinit var launcher: ActivityResultLauncher> + private var attempt: Attempt? = null + private var isDestroyed = false + + private fun cancelAttempt() { + val cancelledAttempt = attempt + attempt = null + cancelledAttempt?.invalidate() + } + + private fun completeAttempt(completedAttempt: Attempt): Boolean { + if (attempt !== completedAttempt || !completedAttempt.isValid) return false + attempt = null + completedAttempt.complete() + return true + } + + fun launch(callback: (String) -> Boolean) { + if (isDestroyed) return + if (attempt?.validation == null && attempt != null) return + cancelAttempt() + val newAttempt = Attempt(callback) + attempt = newAttempt + try { + launcher.launch(arrayOf("image/*")) + } catch (error: Exception) { + completeAttempt(newAttempt) + logError(error) + CommonActivity.showToast( + R.string.edit_profile_image_error_invalid, + Toast.LENGTH_SHORT + ) + } + } + + fun onImagePicked(context: Context, uri: Uri?) { + // Results restored after recreation have no live dialog callback and are ignored. + val currentAttempt = attempt ?: return + if (uri == null) { + completeAttempt(currentAttempt) + return + } + + val validation = ImageLoader(context).enqueue( + ImageRequest.Builder(context).data(uri) + .allowHardware(false).size(512, 512).listener( + onSuccess = { _, _ -> + if (!completeAttempt(currentAttempt)) return@listener + if (currentAttempt.callback(uri.toString())) { + CommonActivity.showToast( + R.string.edit_profile_image_success, + Toast.LENGTH_SHORT + ) + } + }, + onError = { _, _ -> + if (!completeAttempt(currentAttempt)) return@listener + CommonActivity.showToast(R.string.edit_profile_image_error_invalid) + }, + onCancel = { + completeAttempt(currentAttempt) + } + ).build() + ) + currentAttempt.validation = validation + if (attempt !== currentAttempt || !currentAttempt.isValid) { + currentAttempt.validation = null + validation.dispose() + } + } + + fun cancel() { + cancelAttempt() + } + + fun destroy() { + isDestroyed = true + cancelAttempt() + launcher.unregister() + } + } + + private val profileImagePickers = + WeakHashMap>() + private var profileImagePicker: WeakReference? = null + @MainThread fun setActivityInstance(newActivity: Activity?) { activity = newActivity + profileImagePicker = + (newActivity as? ComponentActivity)?.let(profileImagePickers::get) + } + + @MainThread + fun pickProfileImage(callback: (String) -> Boolean) { + val picker = profileImagePicker?.get() + if (picker == null) { + showToast(R.string.edit_profile_image_error_invalid, Toast.LENGTH_SHORT) + return + } + picker.launch(callback) + } + + @MainThread + fun cancelProfileImagePick() { + profileImagePicker?.get()?.cancel() } @MainThread @@ -265,6 +394,37 @@ object CommonActivity { } } + val picker = ProfileImagePicker() + val applicationContext = componentActivity.applicationContext + picker.launcher = + componentActivity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { + picker.onImagePicked(applicationContext, it) + } + profileImagePickers[componentActivity] = WeakReference(picker) + componentActivity.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + setActivityInstance(owner as? Activity) + } + + override fun onPause(owner: LifecycleOwner) { + if (profileImagePicker?.get() === picker) { + profileImagePicker = null + } + } + + override fun onDestroy(owner: LifecycleOwner) { + if (profileImagePicker?.get() === picker) { + profileImagePicker = null + } + val ownerActivity = owner as? ComponentActivity + ownerActivity?.let(profileImagePickers::remove) + if (activity === ownerActivity) { + setActivityInstance(null) + } + picker.destroy() + } + }) + // Ask for notification permissions on Android 13 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission( diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt index 7725bad9128..4bd513449c2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt @@ -3,6 +3,8 @@ package com.lagradost.cloudstream3.ui.account import android.app.Activity import android.content.Context import android.content.DialogInterface +import android.content.Intent +import android.net.Uri import android.os.Bundle import android.text.Editable import android.view.LayoutInflater @@ -11,6 +13,7 @@ import android.widget.TextView import android.widget.Toast import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged @@ -18,10 +21,13 @@ import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import coil3.ImageLoader +import coil3.request.Disposable import coil3.request.ImageRequest import coil3.request.allowHardware import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.lagradost.cloudstream3.CommonActivity.cancelProfileImagePick +import com.lagradost.cloudstream3.CommonActivity.pickProfileImage import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R @@ -42,6 +48,22 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod import com.lagradost.cloudstream3.utils.UIHelper.showProgress +private class ProfileImageUrlAttempt(val owner: Any) { + var validation: Disposable? = null + var isValid = true + + fun invalidate() { + isValid = false + validation?.dispose() + validation = null + } + + fun complete() { + isValid = false + validation = null + } +} + object AccountHelper { fun showAccountEditDialog( context: Context, @@ -55,7 +77,36 @@ object AccountHelper { .setView(binding.root) var currentEditAccount = account + var pendingProfileImageUri: Uri? = null + var imageSelectionId = 0 + var profileImageUrlAttempt: ProfileImageUrlAttempt? = null + var profileImageUrlDialog: BottomSheetDialog? = null + + fun cancelProfileImageUrlAttempt(owner: Any? = null) { + val cancelledAttempt = profileImageUrlAttempt + if (owner != null && cancelledAttempt?.owner !== owner) return + profileImageUrlAttempt = null + cancelledAttempt?.invalidate() + } + + fun completeProfileImageUrlAttempt( + completedAttempt: ProfileImageUrlAttempt + ): Boolean { + if (profileImageUrlAttempt !== completedAttempt || !completedAttempt.isValid) { + return false + } + profileImageUrlAttempt = null + completedAttempt.complete() + return true + } + val dialog = builder.show() + dialog.setOnDismissListener { + cancelProfileImagePick() + cancelProfileImageUrlAttempt() + profileImageUrlDialog?.dismissSafe() + profileImageUrlDialog = null + } if (!isNewAccount) binding.title.setText(R.string.edit_account) @@ -102,12 +153,76 @@ object AccountHelper { binding.accountImage.loadImage(account.image) binding.accountImage.setOnClickListener { // Roll the image forwards once + imageSelectionId++ + cancelProfileImagePick() + cancelProfileImageUrlAttempt() + pendingProfileImageUri = null currentEditAccount = currentEditAccount.copy(customImage = null) currentEditAccount = currentEditAccount.copy(defaultImageIndex = (currentEditAccount.defaultImageIndex + 1) % DataStoreHelper.profileImages.size) binding.accountImage.loadImage(currentEditAccount.image) } + fun applyAccountChanges() { + val uri = pendingProfileImageUri?.takeIf { + currentEditAccount.customImage == it.toString() + } + val resolver = context.applicationContext.contentResolver + var acquiredReadPermission = false + + if (uri != null) { + try { + val hadReadPermission = resolver.persistedUriPermissions.any { + it.uri == uri && it.isReadPermission + } + if (!hadReadPermission) { + resolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + acquiredReadPermission = true + } + } catch (error: Exception) { + logError(error) + showToast(R.string.edit_profile_image_error_invalid) + return + } + } + + try { + accountEditCallback.invoke(currentEditAccount) + } catch (error: Throwable) { + logError(error) + } + + val durableAccounts = try { + DataStoreHelper.accounts.toList() + } catch (error: Throwable) { + logError(error) + emptyList() + } + val accountWasStored = durableAccounts.any { it == currentEditAccount } + + if (uri != null && acquiredReadPermission && + durableAccounts.none { it.customImage == uri.toString() } + ) { + try { + resolver.releasePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (error: Exception) { + logError(error) + } + } + + if (!accountWasStored) { + showToast(R.string.edit_profile_image_error_invalid) + return + } + + pendingProfileImageUri = null + dialog.dismissSafe() + } + // Handle applying changes binding.applyBtt.setOnClickListener { if (currentEditAccount.lockPin != null) { @@ -115,13 +230,11 @@ object AccountHelper { showPinInputDialog(context, currentEditAccount.lockPin, false) { pin -> if (pin == null) return@showPinInputDialog // PIN is correct, proceed to update the account - accountEditCallback.invoke(currentEditAccount) - dialog.dismissSafe() + applyAccountChanges() } } else { // No lock PIN set, proceed to update the account - accountEditCallback.invoke(currentEditAccount) - dialog.dismissSafe() + applyAccountChanges() } } @@ -166,10 +279,21 @@ object AccountHelper { canSetPin = true - binding.editProfilePhotoButton.setOnClickListener { + val showProfileImageUrlDialog = { callback: (String) -> Boolean -> + cancelProfileImageUrlAttempt() + profileImageUrlDialog?.dismissSafe() + val bottomSheetDialog = BottomSheetDialog(context) + val owner = Any() val sheetBinding = BottomInputDialogBinding.inflate(LayoutInflater.from(context)) bottomSheetDialog.setContentView(sheetBinding.root) + profileImageUrlDialog = bottomSheetDialog + bottomSheetDialog.setOnDismissListener { + cancelProfileImageUrlAttempt(owner) + if (profileImageUrlDialog === bottomSheetDialog) { + profileImageUrlDialog = null + } + } bottomSheetDialog.show() sheetBinding.apply { @@ -182,6 +306,9 @@ object AccountHelper { showToast(R.string.edit_profile_image_error_empty, Toast.LENGTH_SHORT) return@setOnClickListener } + cancelProfileImageUrlAttempt() + val attempt = ProfileImageUrlAttempt(owner) + profileImageUrlAttempt = attempt applyBtt.showProgress() val imageLoader = ImageLoader(context) val request = ImageRequest.Builder(context) @@ -189,15 +316,21 @@ object AccountHelper { .allowHardware(false) .listener( onSuccess = { _, _ -> - currentEditAccount = currentEditAccount.copy(customImage = url) - binding.accountImage.loadImage(url) - showToast( - R.string.edit_profile_image_success, - Toast.LENGTH_SHORT - ) + if (!completeProfileImageUrlAttempt(attempt)) { + return@listener + } + if (callback(url)) { + showToast( + R.string.edit_profile_image_success, + Toast.LENGTH_SHORT + ) + } bottomSheetDialog.dismissSafe() }, onError = { _, _ -> + if (!completeProfileImageUrlAttempt(attempt)) { + return@listener + } showToast( R.string.edit_profile_image_error_invalid, Toast.LENGTH_SHORT @@ -205,17 +338,66 @@ object AccountHelper { applyBtt.hideProgress() }, onCancel = { + if (!completeProfileImageUrlAttempt(attempt)) { + return@listener + } applyBtt.hideProgress() } ) .build() - imageLoader.enqueue(request) + val validation = imageLoader.enqueue(request) + attempt.validation = validation + if (profileImageUrlAttempt !== attempt || !attempt.isValid) { + attempt.validation = null + validation.dispose() + } } sheetBinding.cancelBtt.setOnClickListener { bottomSheetDialog.dismissSafe() } } } + + binding.editProfilePhotoButton.setOnClickListener { + AlertDialog.Builder(context) + .setTitle(R.string.edit_profile_image_title) + .setItems(arrayOf( + context.getString(R.string.edit_profile_image_from_file), + context.getString(R.string.edit_profile_image_hint), + )) { _, selection -> + if (selection == 0) { + cancelProfileImageUrlAttempt() + val selectionId = ++imageSelectionId + pickProfileImage { image -> + if (!dialog.isShowing || selectionId != imageSelectionId) { + false + } else { + pendingProfileImageUri = image.toUri() + currentEditAccount = + currentEditAccount.copy(customImage = image) + binding.accountImage.loadImage(image) + true + } + } + } else { + cancelProfileImagePick() + cancelProfileImageUrlAttempt() + val selectionId = ++imageSelectionId + showProfileImageUrlDialog { image -> + if (!dialog.isShowing || selectionId != imageSelectionId) { + false + } else { + pendingProfileImageUri = null + currentEditAccount = + currentEditAccount.copy(customImage = image) + binding.accountImage.loadImage(image) + true + } + } + } + } + .show() + } } fun showPinInputDialog( @@ -416,4 +598,4 @@ object AccountHelper { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt index 96eaf52a773..aebe328d3de 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt @@ -1,12 +1,16 @@ package com.lagradost.cloudstream3.ui.account +import android.content.ContentResolver import android.content.Context +import android.content.Intent +import androidx.core.net.toUri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getAccounts @@ -14,6 +18,30 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount class AccountViewModel : ViewModel() { + private fun releaseUnusedProfileImagePermission( + context: Context, + image: String?, + accounts: List, + ) { + if (image == null || accounts.any { it.customImage == image }) return + val uri = image.toUri() + if (uri.scheme != ContentResolver.SCHEME_CONTENT) return + + try { + val resolver = context.applicationContext.contentResolver + val hasReadPermission = resolver.persistedUriPermissions.any { + it.uri == uri && it.isReadPermission + } + if (hasReadPermission) { + resolver.releasePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + } catch (error: Exception) { + logError(error) + } + } + private fun getAllAccounts(): List { return context?.let { getAccounts(it) } ?: DataStoreHelper.accounts.toList() } @@ -49,6 +77,7 @@ class AccountViewModel : ViewModel() { val currentAccounts = getAccounts(context).toMutableList() val overrideIndex = currentAccounts.indexOfFirst { it.keyIndex == account.keyIndex } + val oldCustomImage = currentAccounts.getOrNull(overrideIndex)?.customImage if (overrideIndex != -1) { currentAccounts[overrideIndex] = account @@ -60,6 +89,7 @@ class AccountViewModel : ViewModel() { DataStoreHelper.currentHomePage = currentHomePage DataStoreHelper.accounts = currentAccounts.toTypedArray() + releaseUnusedProfileImagePermission(context, oldCustomImage, currentAccounts) _accounts.postValue(getAccounts(context)) _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) @@ -73,10 +103,13 @@ class AccountViewModel : ViewModel() { removeKeys(account.keyIndex.toString()) val currentAccounts = getAccounts(context).toMutableList() + val deletedCustomImage = + currentAccounts.firstOrNull { it.keyIndex == account.keyIndex }?.customImage currentAccounts.removeIf { it.keyIndex == account.keyIndex } DataStoreHelper.accounts = currentAccounts.toTypedArray() + releaseUnusedProfileImagePermission(context, deletedCustomImage, currentAccounts) if (account.keyIndex == DataStoreHelper.selectedKeyIndex) { setAccount(getDefaultAccount(context)) @@ -126,4 +159,4 @@ class AccountViewModel : ViewModel() { MainActivity.reloadAccountEvent(true) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 340266f6c52..37bfb37ba81 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -169,7 +169,10 @@ object DataStoreHelper { @JsonProperty("lockPin") @SerialName("lockPin") val lockPin: String? = null, ) { @get:JsonIgnore - val image get() = customImage?.let { UiImage.Image(it) } ?: + val image get() = customImage?.takeUnless { image -> + image.startsWith("content://") && context?.contentResolver?.persistedUriPermissions + ?.none { it.uri.toString() == image } != false + }?.let { UiImage.Image(it) } ?: profileImages.getOrNull(defaultImageIndex)?.let { UiImage.Drawable(it) } ?: UiImage.Drawable(profileImages.first()) diff --git a/app/src/main/res/layout/account_edit_dialog.xml b/app/src/main/res/layout/account_edit_dialog.xml index f52c8ea5196..df8f9e8c002 100644 --- a/app/src/main/res/layout/account_edit_dialog.xml +++ b/app/src/main/res/layout/account_edit_dialog.xml @@ -51,6 +51,7 @@ android:contentDescription="@string/preview_background_img_des" android:focusable="true" android:foreground="@drawable/outline_drawable_forced_round" + android:nextFocusRight="@id/edit_profile_photo_button" android:scaleType="centerCrop" android:src="@drawable/profile_bg_blue" /> @@ -60,6 +61,11 @@ android:layout_height="wrap_content" android:layout_gravity="bottom|end" android:background="@color/skipOpTransparent" + android:contentDescription="@string/edit_profile_image_title" + android:focusable="true" + android:foreground="@drawable/outline_drawable_forced_round" + android:nextFocusLeft="@id/account_image" + android:nextFocusDown="@id/account_name" android:padding="5dp" android:src="@drawable/ic_baseline_edit_24" /> @@ -127,4 +133,4 @@ android:text="@string/sort_cancel" /> - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 31cf951cf5f..aae373930cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -749,6 +749,7 @@ LongPress Speed Toggle Hold to get 2x speed Edit Profile Image + @string/player_load_subtitles Enter Profile Image URL No URL Found Invalid URL or Image