diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index 4ce09bd44ae..158dca7059e 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 @@ -39,18 +41,11 @@ import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.databinding.ToastBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager -import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter -import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible import com.lagradost.cloudstream3.ui.player.Torrent -import com.lagradost.cloudstream3.ui.result.ActorAdaptor -import com.lagradost.cloudstream3.ui.result.EpisodeAdapter -import com.lagradost.cloudstream3.ui.result.ImageAdapter -import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.updateTv -import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Event @@ -241,6 +236,21 @@ object CommonActivity { setLocale(this, localeCode) } + + private var activityFileLauncher: ActivityResultLauncher>? = null + private var activityFileLauncherCallback: Pair Unit)>? = null + + // Ensure that previous launches do not trigger current callbacks. + private var fileLaunchCounter = 0 + + /** Use the ActivityResultContracts.OpenDocument() file picker to select a file. + * Note: Is only able to handle ONE callback at once. Multiple calls will overwrite the old calls. + */ + fun selectFile(mimetypes: Array, callback: (Uri?) -> Unit) { + activityFileLauncherCallback = ++fileLaunchCounter to callback + activityFileLauncher?.launch(mimetypes) + } + fun init(act: Activity) { setActivityInstance(act) ioSafe { Torrent.deleteAllFiles() } @@ -265,6 +275,16 @@ object CommonActivity { } } + activityFileLauncher = + componentActivity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + val callback = activityFileLauncherCallback ?: return@registerForActivityResult + activityFileLauncherCallback = null + + if (fileLaunchCounter == callback.first) { + callback.second.invoke(result) + } + } + // 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 c88f70a7395..49ec165d263 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,7 @@ package com.lagradost.cloudstream3.ui.account import android.app.Activity import android.content.Context import android.content.DialogInterface +import android.content.Intent import android.os.Bundle import android.text.Editable import android.view.LayoutInflater @@ -22,6 +23,7 @@ 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 import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R @@ -46,6 +48,42 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +private object ProfileImagePicker { + fun pickImage(context: Context, callback: (String) -> Unit) { + runCatching { + CommonActivity.selectFile(arrayOf("image/*")) { uri -> + if (uri == null) return@selectFile + // Ensure context lifecycle + val ctx = context.applicationContext + + try { + ctx.contentResolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (error: Exception) { + logError(error) + showToast(R.string.edit_profile_image_error_invalid) + } + + ImageLoader(ctx).enqueue( + ImageRequest.Builder(ctx).data(uri) + .allowHardware(false).size(512, 512).listener( + onSuccess = { _, _ -> + callback(uri.toString()) + showToast(R.string.edit_profile_image_success, Toast.LENGTH_SHORT) + }, + onError = { _, _ -> + showToast(R.string.edit_profile_image_error_invalid) + } + ).build() + ) + } + }.onFailure { + showToast(R.string.error) + } + } +} + object AccountHelper { fun showAccountEditDialog( context: Context, @@ -60,12 +98,6 @@ object AccountHelper { var currentEditAccount = account val dialog = builder.show() - binding.accountImageHolder.requestFocus() - - if (!isLayout(TV or EMULATOR)) { - binding.accountImageHolder.setRippleForeground() - binding.editProfilePhotoButtonHolder.setRippleForeground() - } if (!isNewAccount) binding.title.setText(R.string.edit_account) @@ -176,12 +208,12 @@ object AccountHelper { canSetPin = true - binding.editProfilePhotoButtonHolder.setOnClickListener { - val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom) + val showProfileImageUrlDialog = { callback: (String) -> Unit -> + val bottomSheetDialog = BottomSheetDialog(context) val sheetBinding = BottomInputDialogBinding.inflate(LayoutInflater.from(context)) - builder.setView(sheetBinding.root) - val dialog = builder.create() - dialog.show() + bottomSheetDialog.setContentView(sheetBinding.root) + bottomSheetDialog.show() + sheetBinding.apply { text1.text = context.getString(R.string.edit_profile_image_title) @@ -200,13 +232,14 @@ object AccountHelper { .allowHardware(false) .listener( onSuccess = { _, _ -> - currentEditAccount = currentEditAccount.copy(customImage = url) - binding.accountImage.loadImage(url) + + callback(url) showToast( R.string.edit_profile_image_success, Toast.LENGTH_SHORT ) - dialog.dismissSafe() + bottomSheetDialog.dismissSafe() + }, onError = { _, _ -> showToast( @@ -227,6 +260,31 @@ object AccountHelper { } } } + + 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) { + ProfileImagePicker.pickImage(context) { image -> + if (!dialog.isShowing) return@pickImage + currentEditAccount = currentEditAccount.copy(customImage = image) + binding.accountImage.loadImage(image) + } + } else { + showProfileImageUrlDialog { image -> + currentEditAccount = currentEditAccount.copy(customImage = image) + binding.accountImage.loadImage(image) + } + } + } + .show() + } } fun showPinInputDialog( @@ -289,7 +347,10 @@ object AccountHelper { val activity = context.getActivity() if (activity is AccountSelectActivity) { isPinValid = true - activity.accountViewModel.handleAccountSelect(getDefaultAccount(context), activity) + activity.accountViewModel.handleAccountSelect( + getDefaultAccount(context), + activity + ) } } } @@ -427,4 +488,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 eabac20d322..75af8fcd25e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -171,7 +171,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 ae91c16a504..7dba30a80de 100644 --- a/app/src/main/res/layout/account_edit_dialog.xml +++ b/app/src/main/res/layout/account_edit_dialog.xml @@ -49,8 +49,8 @@ android:layout_gravity="center" android:foreground="@drawable/outline_drawable_forced_round" android:defaultFocusHighlightEnabled="false" - android:nextFocusRight="@id/edit_profile_photo_button_holder" - android:nextFocusDown="@id/edit_profile_photo_button_holder" + android:nextFocusRight="@id/edit_profile_photo_button" + android:nextFocusDown="@id/edit_profile_photo_button" app:cardCornerRadius="100dp" app:cardElevation="0dp"> @@ -158,4 +158,5 @@ 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 66eb3533c4c..221753f0ddb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -763,6 +763,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