diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index 1a6412d157e..158dca7059e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -30,13 +30,7 @@ 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 @@ -47,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 @@ -67,7 +54,6 @@ 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 @@ -88,129 +74,9 @@ 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 @@ -370,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() } @@ -394,36 +275,15 @@ 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 - } - } + activityFileLauncher = + componentActivity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + val callback = activityFileLauncherCallback ?: return@registerForActivityResult + activityFileLauncherCallback = 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) + if (fileLaunchCounter == callback.first) { + callback.second.invoke(result) } - picker.destroy() } - }) // Ask for notification permissions on Android 13 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && 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 4bd513449c2..a5b2f19c0a9 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 @@ -4,7 +4,6 @@ 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 @@ -13,7 +12,6 @@ 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 @@ -21,13 +19,11 @@ 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 import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R @@ -48,19 +44,39 @@ 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 +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 - fun invalidate() { - isValid = false - validation?.dispose() - validation = null - } + try { + ctx.contentResolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (error: Exception) { + logError(error) + showToast(R.string.edit_profile_image_error_invalid) + } - fun complete() { - isValid = false - validation = null + 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) + } } } @@ -77,36 +93,7 @@ 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) @@ -153,76 +140,12 @@ 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) { @@ -230,11 +153,13 @@ object AccountHelper { showPinInputDialog(context, currentEditAccount.lockPin, false) { pin -> if (pin == null) return@showPinInputDialog // PIN is correct, proceed to update the account - applyAccountChanges() + accountEditCallback.invoke(currentEditAccount) + dialog.dismissSafe() } } else { // No lock PIN set, proceed to update the account - applyAccountChanges() + accountEditCallback.invoke(currentEditAccount) + dialog.dismissSafe() } } @@ -279,21 +204,10 @@ object AccountHelper { canSetPin = true - val showProfileImageUrlDialog = { callback: (String) -> Boolean -> - cancelProfileImageUrlAttempt() - profileImageUrlDialog?.dismissSafe() - + val showProfileImageUrlDialog = { callback: (String) -> Unit -> 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 { @@ -306,9 +220,6 @@ 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) @@ -316,21 +227,14 @@ object AccountHelper { .allowHardware(false) .listener( onSuccess = { _, _ -> - if (!completeProfileImageUrlAttempt(attempt)) { - return@listener - } - if (callback(url)) { - showToast( - R.string.edit_profile_image_success, - Toast.LENGTH_SHORT - ) - } + 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 @@ -338,19 +242,11 @@ object AccountHelper { applyBtt.hideProgress() }, onCancel = { - if (!completeProfileImageUrlAttempt(attempt)) { - return@listener - } applyBtt.hideProgress() } ) .build() - val validation = imageLoader.enqueue(request) - attempt.validation = validation - if (profileImageUrlAttempt !== attempt || !attempt.isValid) { - attempt.validation = null - validation.dispose() - } + imageLoader.enqueue(request) } sheetBinding.cancelBtt.setOnClickListener { bottomSheetDialog.dismissSafe() @@ -361,38 +257,22 @@ 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 -> + .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 - } + ProfileImagePicker.pickImage(context) { image -> + if (!dialog.isShowing) return@pickImage + currentEditAccount = currentEditAccount.copy(customImage = image) + binding.accountImage.loadImage(image) } } 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 - } + currentEditAccount = currentEditAccount.copy(customImage = image) + binding.accountImage.loadImage(image) } } } @@ -460,7 +340,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 + ) } } }