diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ExtensionOpenerActivity.kt b/app/src/main/java/dev/brahmkshatriya/echo/ExtensionOpenerActivity.kt index ab2fb987..35b3c431 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ExtensionOpenerActivity.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ExtensionOpenerActivity.kt @@ -3,30 +3,18 @@ package dev.brahmkshatriya.echo import android.app.Activity import android.content.Context import android.content.Intent -import android.content.pm.PackageInfo import android.net.Uri import android.widget.Toast import androidx.activity.viewModels -import androidx.core.content.FileProvider import androidx.core.net.toFile import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope -import dev.brahmkshatriya.echo.common.helpers.ExtensionType -import dev.brahmkshatriya.echo.common.helpers.ImportType -import dev.brahmkshatriya.echo.extensions.ExtensionRepo.Companion.FEATURE -import dev.brahmkshatriya.echo.extensions.ExtensionRepo.Companion.getPluginFileDir -import dev.brahmkshatriya.echo.extensions.plugger.ApkManifestParser -import dev.brahmkshatriya.echo.extensions.plugger.ApkPluginSource -import dev.brahmkshatriya.echo.extensions.plugger.FileChangeListener import dev.brahmkshatriya.echo.ui.extension.ExtensionInstallerBottomSheet -import dev.brahmkshatriya.echo.viewmodels.LoginUserViewModel -import dev.brahmkshatriya.echo.viewmodels.SnackBar -import dev.brahmkshatriya.echo.viewmodels.SnackBar.Companion.createSnack +import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel import kotlinx.coroutines.launch import java.io.File - class ExtensionOpenerActivity : Activity() { override fun onStart() { super.onStart() @@ -75,61 +63,13 @@ class ExtensionOpenerActivity : Activity() { val install = b.getBoolean("install") val installAsApk = b.getBoolean("installAsApk") val context = this - if (install && file != null) lifecycleScope.launch { - val installation = if (installAsApk) openApk(context, file) - else { - val viewModel by viewModels() - val extensionLoader = viewModel.extensionLoader - installAsFile(context, file, extensionLoader.fileListener) + if (install && file != null) { + val extensionViewModel by viewModels() + lifecycleScope.launch { + extensionViewModel.install(context, file, installAsApk) } - val exception = installation.exceptionOrNull() - if (exception != null) { - val viewModel by viewModels() - viewModel.throwableFlow.emit(exception) - } else if (!installAsApk) - createSnack(getString(R.string.extension_installed_successfully)) } } } - - private fun openApk(context: Context, file: File) = runCatching { - val contentUri = FileProvider.getUriForFile( - context, context.packageName + ".provider", file - ) - val installIntent = Intent(Intent.ACTION_VIEW).apply { - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) - data = contentUri - } - context.startActivity(installIntent) - } - - private suspend fun installAsFile( - context: Context, file: File, fileChangeListener: FileChangeListener - ) = runCatching { - val packageInfo = context.packageManager.getPackageArchiveInfo( - file.path, ApkPluginSource.PACKAGE_FLAGS - ) - val type = getType(packageInfo!!) - val metadata = ApkManifestParser(ImportType.File) - .parseManifest(packageInfo.applicationInfo!!) - val dir = context.getPluginFileDir(type) - dir.setWritable(true) - val newFile = File(dir, "${metadata.id}.apk") - val flow = fileChangeListener.getFlow(type) - flow.emit(newFile) - newFile.setWritable(true) - if (newFile.exists()) newFile.delete() - file.copyTo(newFile, true) - dir.setReadOnly() - flow.emit(null) - } - - fun getType(appInfo: PackageInfo) = appInfo.reqFeatures?.find { featureInfo -> - ExtensionType.entries.any { featureInfo.name == "$FEATURE${it.feature}" } - }?.let { featureInfo -> - ExtensionType.entries.first { it.feature == featureInfo.name.removePrefix(FEATURE) } - } ?: error("Extension type not found for ${appInfo.packageName}") } } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt index e9d14a75..e2669a93 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoader.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -68,6 +69,12 @@ class ExtensionLoader( val current = extensionFlow val currentWithUser = MutableStateFlow>(null to null) + val priorityMap = ExtensionType.entries.associateWith { + val key = it.priorityKey() + val list = settings.getString(key, null).orEmpty().split(',') + MutableStateFlow(list) + } + fun initialize() { scope.launch { getAllPlugins(scope) @@ -199,17 +206,23 @@ class ExtensionLoader( private suspend fun ExtensionRepo.getPlugins( collector: FlowCollector>>>> - ) = getAllPlugins().catchWith(throwableFlow).map { list -> - list.mapNotNull { result -> - val (metadata, client) = result.getOrElse { - val error = it.cause ?: it - throwableFlow.emit(ExtensionException(type, error)) - null - } ?: return@mapNotNull null - val metadataEnabled = isExtensionEnabled(type, metadata) - Pair(metadataEnabled, client) + ) { + val pluginFlow = getAllPlugins().catchWith(throwableFlow).map { list -> + list.mapNotNull { result -> + val (metadata, client) = result.getOrElse { + val error = it.cause ?: it + throwableFlow.emit(ExtensionLoadingException(type, error)) + null + } ?: return@mapNotNull null + val metadataEnabled = isExtensionEnabled(type, metadata) + Pair(metadataEnabled, client) + } } - }.collect(collector) + val priorityFlow = priorityMap[type]!! + pluginFlow.combine(priorityFlow) { list, set -> + list.sortedBy { set.indexOf(it.first.id) } + }.collect(collector) + } private suspend fun List>.setExtensions() = coroutineScope { map { @@ -229,6 +242,8 @@ class ExtensionLoader( const val LAST_EXTENSION_KEY = "last_extension" private const val TIMEOUT = 5000L + fun ExtensionType.priorityKey() = "priority_$this" + fun setupMusicExtension( scope: CoroutineScope, settings: SharedPreferences, diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionException.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoadingException.kt similarity index 87% rename from app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionException.kt rename to app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoadingException.kt index 09f6e59d..eec87aec 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionException.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionLoadingException.kt @@ -2,7 +2,7 @@ package dev.brahmkshatriya.echo.extensions import dev.brahmkshatriya.echo.common.helpers.ExtensionType -class ExtensionException( +class ExtensionLoadingException( val type: ExtensionType, override val cause: Throwable ) : Exception("Failed to load extension of type: $type") \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt index 9482aac5..0726bf09 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/ExtensionRepo.kt @@ -17,7 +17,8 @@ import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepo import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepoImpl import dev.brahmkshatriya.echo.extensions.plugger.LazyRepoComposer import dev.brahmkshatriya.echo.extensions.plugger.PackageChangeListener -import dev.brahmkshatriya.echo.extensions.plugger.lazily +import dev.brahmkshatriya.echo.extensions.plugger.catchLazy +import dev.brahmkshatriya.echo.utils.getSettings import tel.jeelpa.plugger.utils.mapState import java.io.File @@ -49,9 +50,9 @@ sealed class ExtensionRepo( list.map { runCatching { val plugin = it.getOrThrow() - val metadata = plugin.first - metadata to lazily { - val instance = plugin.second.value.getOrThrow() + val (metadata, resultLazy) = plugin + metadata to catchLazy { + val instance = resultLazy.value.getOrThrow() //Injection instance.setSettings(getSettings(context, type, metadata)) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/InstallationUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/InstallationUtils.kt new file mode 100644 index 00000000..34f4085f --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/InstallationUtils.kt @@ -0,0 +1,104 @@ +package dev.brahmkshatriya.echo.extensions + +import android.app.Activity.RESULT_OK +import android.content.Intent +import android.content.pm.PackageInfo +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import androidx.fragment.app.FragmentActivity +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.helpers.ExtensionType +import dev.brahmkshatriya.echo.common.helpers.ImportType +import dev.brahmkshatriya.echo.extensions.ExtensionRepo.Companion.FEATURE +import dev.brahmkshatriya.echo.extensions.ExtensionRepo.Companion.getPluginFileDir +import dev.brahmkshatriya.echo.extensions.plugger.ApkManifestParser +import dev.brahmkshatriya.echo.extensions.plugger.ApkPluginSource +import dev.brahmkshatriya.echo.utils.registerActivityResultLauncher +import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel +import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +suspend fun installExtension(context: FragmentActivity, file: File, apk: Boolean) = runCatching { + if (apk) { + val contentUri = FileProvider.getUriForFile( + context, context.packageName + ".provider", file + ) + val installIntent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + putExtra(Intent.EXTRA_RETURN_RESULT, true) + data = contentUri + } + context.waitForResult(installIntent) + } else { + val viewModel by context.viewModels() + val extensionLoader = viewModel.extensionLoader + val fileChangeListener = extensionLoader.fileListener + val packageInfo = context.packageManager.getPackageArchiveInfo( + file.path, ApkPluginSource.PACKAGE_FLAGS + ) + val type = getType(packageInfo!!) + val metadata = ApkManifestParser(ImportType.File) + .parseManifest(packageInfo.applicationInfo!!) + val flow = fileChangeListener.getFlow(type) + val dir = context.getPluginFileDir(type) + val newFile = File(dir, "${metadata.id}.apk") + flow.emit(newFile) + dir.setWritable(true) + newFile.setWritable(true) + if (newFile.exists()) newFile.delete() + file.copyTo(newFile, true) + dir.setReadOnly() + flow.emit(null) + true + } +} + +suspend fun uninstallExtension(context: FragmentActivity, extension: Extension<*>) = runCatching { + when (extension.metadata.importType) { + ImportType.BuiltIn -> throw UnsupportedOperationException() + ImportType.File -> { + val file = File(extension.metadata.path) + val viewModel by context.viewModels() + val extensionLoader = viewModel.extensionLoader + val flow = extensionLoader.fileListener.getFlow(extension.type) + flow.emit(file) + file.parentFile!!.setWritable(true) + file.setWritable(true) + val delete = file.delete() + flow.emit(null) + delete + } + + ImportType.App -> { + val packageInfo = context.packageManager.getPackageArchiveInfo( + extension.metadata.path, ApkPluginSource.PACKAGE_FLAGS + )!! + val packageName = packageInfo.packageName + val intent = Intent(Intent.ACTION_DELETE).apply { + data = "package:$packageName".toUri() + putExtra(Intent.EXTRA_RETURN_RESULT, true) + } + context.waitForResult(intent) + } + } +} + +private suspend fun FragmentActivity.waitForResult(intent: Intent) = suspendCoroutine { cont -> + val contract = ActivityResultContracts.StartActivityForResult() + val activityResultLauncher = registerActivityResultLauncher(contract) { + cont.resume(it.resultCode == RESULT_OK) + } + activityResultLauncher.launch(intent) +} + + +fun getType(appInfo: PackageInfo) = appInfo.reqFeatures?.find { featureInfo -> + ExtensionType.entries.any { featureInfo.name == "$FEATURE${it.feature}" } +}?.let { featureInfo -> + ExtensionType.entries.first { it.feature == featureInfo.name.removePrefix(FEATURE) } +} ?: error("Extension type not found for ${appInfo.packageName}") \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/AndroidPluginLoader.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/AndroidPluginLoader.kt index 89ccb1a0..27437dbc 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/AndroidPluginLoader.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/AndroidPluginLoader.kt @@ -10,7 +10,6 @@ class AndroidPluginLoader( ) : PluginLoader { constructor(context: Context): this(GetClassLoaderWithPathUseCase(context.classLoader)) - // for loading classes.dex from dex, jar or apk @Suppress("UNCHECKED_CAST") override fun loadPlugin(pluginMetadata: Metadata): TPlugin { return getClassLoader.getWithPath(pluginMetadata.path) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepo.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepo.kt index 873c0d22..87a016a7 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepo.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepo.kt @@ -12,4 +12,4 @@ class LazyRepoComposer( .reduce { a, b -> combineStates(a, b) { x, y -> x + y } } } -fun lazily(function: () -> T) = lazy { runCatching { function() } } \ No newline at end of file +fun catchLazy(function: () -> T) = lazy { runCatching { function() } } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepoImpl.kt b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepoImpl.kt index fff7218e..34cbf24f 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepoImpl.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/extensions/plugger/LazyPluginRepoImpl.kt @@ -18,7 +18,7 @@ data class LazyPluginRepoImpl( }.mapState { metadata -> metadata.map { resultMetadata -> resultMetadata.mapCatching { - it to lazily { pluginLoader.loadPlugin(it) } + it to catchLazy { pluginLoader.loadPlugin(it) } } } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/offline/BuiltInExtensionRepo.kt b/app/src/main/java/dev/brahmkshatriya/echo/offline/BuiltInExtensionRepo.kt index 5a663acb..3098880f 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/offline/BuiltInExtensionRepo.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/offline/BuiltInExtensionRepo.kt @@ -3,7 +3,7 @@ package dev.brahmkshatriya.echo.offline import dev.brahmkshatriya.echo.common.clients.ExtensionClient import dev.brahmkshatriya.echo.common.models.Metadata import dev.brahmkshatriya.echo.extensions.plugger.LazyPluginRepo -import dev.brahmkshatriya.echo.extensions.plugger.lazily +import dev.brahmkshatriya.echo.extensions.plugger.catchLazy import kotlinx.coroutines.flow.MutableStateFlow class BuiltInExtensionRepo( @@ -18,5 +18,5 @@ class BuiltInExtensionRepo( ) private fun getLazy(metadata: Metadata, extension: ExtensionClient) = - Result.success(Pair(metadata, lazily { extension })) + Result.success(Pair(metadata, catchLazy { extension })) } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/offline/OfflineExtension.kt b/app/src/main/java/dev/brahmkshatriya/echo/offline/OfflineExtension.kt index 7ba11cc8..5afa2025 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/offline/OfflineExtension.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/offline/OfflineExtension.kt @@ -40,7 +40,7 @@ import dev.brahmkshatriya.echo.common.settings.SettingMultipleChoice import dev.brahmkshatriya.echo.common.settings.SettingSlider import dev.brahmkshatriya.echo.common.settings.SettingSwitch import dev.brahmkshatriya.echo.common.settings.Settings -import dev.brahmkshatriya.echo.extensions.getSettings +import dev.brahmkshatriya.echo.utils.getSettings import dev.brahmkshatriya.echo.offline.MediaStoreUtils.addSongToPlaylist import dev.brahmkshatriya.echo.offline.MediaStoreUtils.createPlaylist import dev.brahmkshatriya.echo.offline.MediaStoreUtils.deletePlaylist diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistFragment.kt index b59a6635..e6824fbb 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistFragment.kt @@ -22,7 +22,7 @@ import dev.brahmkshatriya.echo.databinding.FragmentEditPlaylistBinding import dev.brahmkshatriya.echo.playback.MediaItemUtils import dev.brahmkshatriya.echo.extensions.getExtension import dev.brahmkshatriya.echo.extensions.isClient -import dev.brahmkshatriya.echo.ui.adapter.PlaylistAdapter +import dev.brahmkshatriya.echo.ui.player.PlaylistAdapter import dev.brahmkshatriya.echo.ui.common.openFragment import dev.brahmkshatriya.echo.ui.editplaylist.EditPlaylistViewModel.ListAction.Add import dev.brahmkshatriya.echo.ui.editplaylist.EditPlaylistViewModel.ListAction.Move diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionAdapter.kt index e20cc280..497ad9a2 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionAdapter.kt @@ -11,29 +11,28 @@ import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder import dev.brahmkshatriya.echo.databinding.ItemExtensionBinding -import dev.brahmkshatriya.echo.common.models.Metadata import dev.brahmkshatriya.echo.ui.adapter.ShelfEmptyAdapter import dev.brahmkshatriya.echo.utils.loadWith class ExtensionAdapter( val listener: Listener -) : PagingDataAdapter(DiffCallback) { +) : PagingDataAdapter, ExtensionAdapter.ViewHolder>(DiffCallback) { - fun interface Listener { - fun onClick(metadata: Metadata, view: View) + interface Listener { + fun onClick(extension: Extension<*>, view: View) + fun onDragHandleTouched(viewHolder: ViewHolder) } - object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: Metadata, newItem: Metadata - ) = oldItem.id == newItem.id + object DiffCallback : DiffUtil.ItemCallback>() { + override fun areItemsTheSame(oldItem: Extension<*>, newItem: Extension<*>) = + oldItem.id == newItem.id - override fun areContentsTheSame( - oldItem: Metadata, newItem: Metadata - ) = oldItem == newItem + override fun areContentsTheSame(oldItem: Extension<*>, newItem: Extension<*>) = + oldItem == newItem } private val empty = ShelfEmptyAdapter() @@ -42,9 +41,11 @@ ExtensionAdapter( class ViewHolder(val binding: ItemExtensionBinding, val listener: Listener) : RecyclerView.ViewHolder(binding.root) { @SuppressLint("SetTextI18n") - fun bind(metadata: Metadata) { + fun bind(extension: Extension<*>) { + val metadata = extension.metadata binding.root.transitionName = metadata.id - binding.root.setOnClickListener { listener.onClick(metadata, binding.root) } + binding.root.alpha = if (extension.instance.value.isSuccess) 1f else 0.5f + binding.root.setOnClickListener { listener.onClick(extension, binding.root) } binding.extensionName.apply { text = if (metadata.enabled) metadata.name else context.getString(R.string.extension_disabled, metadata.name) @@ -55,6 +56,9 @@ ExtensionAdapter( setImageDrawable(it) } } + binding.extensionDrag.setOnClickListener { + listener.onDragHandleTouched(this) + } } } @@ -68,7 +72,7 @@ ExtensionAdapter( holder.bind(download) } - suspend fun submit(list: List) { + suspend fun submit(list: List>) { empty.loadState = if (list.isEmpty()) LoadState.Loading else LoadState.NotLoading(true) submitData(PagingData.from(list)) } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInfoFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInfoFragment.kt index e37cfa50..3512027c 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInfoFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInfoFragment.kt @@ -9,11 +9,13 @@ import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension import dev.brahmkshatriya.echo.common.clients.LoginClient import dev.brahmkshatriya.echo.common.helpers.ExtensionType +import dev.brahmkshatriya.echo.common.helpers.ImportType import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder -import dev.brahmkshatriya.echo.common.models.Metadata import dev.brahmkshatriya.echo.databinding.FragmentExtensionBinding import dev.brahmkshatriya.echo.extensions.getExtension import dev.brahmkshatriya.echo.extensions.isClient @@ -31,6 +33,7 @@ import dev.brahmkshatriya.echo.viewmodels.SnackBar.Companion.createSnack import dev.brahmkshatriya.echo.viewmodels.UiViewModel.Companion.applyBackPressCallback import dev.brahmkshatriya.echo.viewmodels.UiViewModel.Companion.applyContentInsets import dev.brahmkshatriya.echo.viewmodels.UiViewModel.Companion.applyInsets +import kotlinx.coroutines.launch class ExtensionInfoFragment : Fragment() { companion object { @@ -44,8 +47,8 @@ class ExtensionInfoFragment : Fragment() { } } - fun newInstance(metadata: Metadata, extensionType: Int) = - newInstance(metadata.id, metadata.name, ExtensionType.entries[extensionType]) + fun newInstance(extension: Extension<*>) = + newInstance(extension.id, extension.name, extension.type) } private var binding by autoCleared() @@ -83,7 +86,7 @@ class ExtensionInfoFragment : Fragment() { val extension = when (extensionType) { ExtensionType.MUSIC -> viewModel.extensionListFlow.getExtension(clientId) - ExtensionType.TRACKER -> viewModel.trackerListFlow.getExtension(clientId) + ExtensionType.TRACKER -> viewModel.trackerListFlow.getExtension(clientId) ExtensionType.LYRICS -> viewModel.lyricsListFlow.getExtension(clientId) } @@ -95,6 +98,24 @@ class ExtensionInfoFragment : Fragment() { val metadata = extension.metadata + if (metadata.importType != ImportType.BuiltIn) { + binding.toolBar.inflateMenu(R.menu.extensions_menu) + binding.toolBar.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.menu_uninstall -> { + lifecycleScope.launch { + viewModel.uninstall(requireActivity(), extension) { + parentFragmentManager.popBackStack() + } + } + true + } + + else -> false + } + } + } + metadata.iconUrl?.toImageHolder().loadWith(binding.extensionIcon, R.drawable.ic_extension) { binding.extensionIcon.setImageDrawable(it) } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt index d95e91cf..121198ec 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionInstallerBottomSheet.kt @@ -11,12 +11,12 @@ import androidx.core.net.toUri import androidx.core.view.isVisible import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dev.brahmkshatriya.echo.ExtensionOpenerActivity.Companion.EXTENSION_INSTALLER -import dev.brahmkshatriya.echo.ExtensionOpenerActivity.Companion.getType import dev.brahmkshatriya.echo.R import dev.brahmkshatriya.echo.common.helpers.ExtensionType import dev.brahmkshatriya.echo.common.helpers.ImportType import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder import dev.brahmkshatriya.echo.databinding.DialogExtensionInstallerBinding +import dev.brahmkshatriya.echo.extensions.getType import dev.brahmkshatriya.echo.extensions.plugger.ApkManifestParser import dev.brahmkshatriya.echo.extensions.plugger.ApkPluginSource import dev.brahmkshatriya.echo.utils.ApkLinkParser diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ManageExtensionsFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ManageExtensionsFragment.kt index d38aed23..ed9d1f9e 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ManageExtensionsFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ManageExtensionsFragment.kt @@ -7,8 +7,12 @@ import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.tabs.TabLayout import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.Extension +import dev.brahmkshatriya.echo.common.helpers.ExtensionType import dev.brahmkshatriya.echo.databinding.FragmentManageExtensionsBinding import dev.brahmkshatriya.echo.ui.common.openFragment import dev.brahmkshatriya.echo.utils.FastScrollerHelper @@ -20,7 +24,7 @@ import dev.brahmkshatriya.echo.utils.setupTransition import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel import dev.brahmkshatriya.echo.viewmodels.UiViewModel.Companion.applyBackPressCallback import dev.brahmkshatriya.echo.viewmodels.UiViewModel.Companion.applyInsetsMain -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.Job class ManageExtensionsFragment : Fragment() { var binding by autoCleared() @@ -50,35 +54,62 @@ class ManageExtensionsFragment : Fragment() { refresh.setOnClickListener { viewModel.refresh() } binding.swipeRefresh.configure { viewModel.refresh() } - val flow = MutableStateFlow( - viewModel.extensionListFlow.value?.map { it.metadata } - ) + var type = ExtensionType.entries[binding.tabLayout.selectedTabPosition] + val callback = object : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0 + ) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val fromPos = viewHolder.bindingAdapterPosition + val toPos = target.bindingAdapterPosition + viewModel.moveExtensionItem(type, toPos, fromPos) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} + + override fun getMovementFlags( + recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder + ) = makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) + + } + + val touchHelper = ItemTouchHelper(callback) + val extensionAdapter = ExtensionAdapter(object : ExtensionAdapter.Listener { + override fun onClick(extension: Extension<*>, view: View) { + openFragment(ExtensionInfoFragment.newInstance(extension), view) + } + + override fun onDragHandleTouched(viewHolder: ExtensionAdapter.ViewHolder) { + touchHelper.startDrag(viewHolder) + } + }) - fun change(pos: Int) { - when (pos) { - 0 -> flow.value = viewModel.extensionListFlow.value?.map { it.metadata } - 1 -> flow.value = viewModel.trackerListFlow.value?.map { it.metadata } - 2 -> flow.value = viewModel.lyricsListFlow.value?.map { it.metadata } + fun change(pos: Int): Job { + type = ExtensionType.entries[pos] + val flow = viewModel.getExtensionListFlow(type) + return observe(flow) { list -> + extensionAdapter.submit(list ?: emptyList()) } } binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + var job: Job? = null override fun onTabSelected(tab: TabLayout.Tab) { - change(tab.position) + job?.cancel() + job = change(tab.position) } override fun onTabUnselected(tab: TabLayout.Tab) {} override fun onTabReselected(tab: TabLayout.Tab) {} }) - val extensionAdapter = ExtensionAdapter { metadata, view1 -> - openFragment( - ExtensionInfoFragment.newInstance(metadata, binding.tabLayout.selectedTabPosition), - view1 - ) - } binding.recyclerView.adapter = extensionAdapter.withEmptyAdapter() - observe(flow) { extensionAdapter.submit(it ?: emptyList()) } + touchHelper.attachToRecyclerView(binding.recyclerView) + observe(viewModel.refresher) { change(binding.tabLayout.selectedTabPosition) binding.swipeRefresh.isRefreshing = it diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/PlaylistAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlaylistAdapter.kt similarity index 97% rename from app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/PlaylistAdapter.kt rename to app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlaylistAdapter.kt index 9d6029eb..35ddbe91 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapter/PlaylistAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlaylistAdapter.kt @@ -1,4 +1,4 @@ -package dev.brahmkshatriya.echo.ui.adapter +package dev.brahmkshatriya.echo.ui.player import android.annotation.SuppressLint import android.graphics.drawable.Animatable @@ -14,6 +14,7 @@ import dev.brahmkshatriya.echo.databinding.ItemPlaylistItemBinding import dev.brahmkshatriya.echo.databinding.SkeletonItemQueueBinding import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLoaded import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.ui.adapter.LifeCycleListAdapter import dev.brahmkshatriya.echo.utils.loadInto import dev.brahmkshatriya.echo.utils.toTimeString diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/player/QueueFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/QueueFragment.kt index 4e57dda6..7bfe012d 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/player/QueueFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/QueueFragment.kt @@ -14,7 +14,6 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDE import dev.brahmkshatriya.echo.databinding.FragmentPlaylistBinding import dev.brahmkshatriya.echo.playback.MediaItemUtils import dev.brahmkshatriya.echo.playback.listeners.Radio -import dev.brahmkshatriya.echo.ui.adapter.PlaylistAdapter import dev.brahmkshatriya.echo.utils.autoCleared import dev.brahmkshatriya.echo.utils.dpToPx import dev.brahmkshatriya.echo.utils.observe diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/ExtensionFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/ExtensionFragment.kt index e178f5a3..6379b952 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/ExtensionFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/ExtensionFragment.kt @@ -26,7 +26,7 @@ import dev.brahmkshatriya.echo.common.settings.SettingSwitch import dev.brahmkshatriya.echo.common.settings.SettingTextInput import dev.brahmkshatriya.echo.extensions.getExtension import dev.brahmkshatriya.echo.extensions.run -import dev.brahmkshatriya.echo.extensions.toSettings +import dev.brahmkshatriya.echo.utils.toSettings import dev.brahmkshatriya.echo.utils.prefs.MaterialListPreference import dev.brahmkshatriya.echo.utils.prefs.MaterialMultipleChoicePreference import dev.brahmkshatriya.echo.utils.prefs.MaterialSliderPreference diff --git a/app/src/main/java/dev/brahmkshatriya/echo/utils/CheckPermissions.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/CheckPermissions.kt deleted file mode 100644 index 583ca215..00000000 --- a/app/src/main/java/dev/brahmkshatriya/echo/utils/CheckPermissions.kt +++ /dev/null @@ -1,64 +0,0 @@ -package dev.brahmkshatriya.echo.utils - -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.provider.Settings -import android.widget.Toast -import androidx.activity.result.ActivityResultCaller -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import dev.brahmkshatriya.echo.R - - -fun AppCompatActivity.checkAudioPermissions() { - val perm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - Manifest.permission.READ_MEDIA_AUDIO - else - Manifest.permission.READ_EXTERNAL_STORAGE - checkPermissions( - this, - perm, - R.string.permission_required, - R.string.music_permission_required_summary, - { finish() } - )?.launch(perm) -} - -fun ActivityResultCaller.checkPermissions( - context: Context, - perm: String, - title: Int, - message: Int, - onCancel: () -> Unit, - onGranted: () -> Unit = {}, - onRequest: () -> Unit = { - Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - context.startActivity(this) - } - } -): ActivityResultLauncher? = with(context) { - val permStatus = ContextCompat.checkSelfPermission(this, perm) - val contract = ActivityResultContracts.RequestPermission() - return if (permStatus != PackageManager.PERMISSION_GRANTED) registerForActivityResult(contract) { - if (!it) AlertDialog.Builder(this) - .setTitle(getString(title)) - .setMessage(getString(message)) - .setPositiveButton(getString(R.string.ok)) { _, _ -> onRequest() } - .setNegativeButton(getString(R.string.cancel)) { _, _ -> - Toast.makeText( - this, getString(R.string.permission_denied), Toast.LENGTH_SHORT - ).show() - onCancel() - } - .show() - else onGranted() - } else null -} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/utils/FlowUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/FlowUtils.kt index af74bcee..92fcfdf4 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/utils/FlowUtils.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/utils/FlowUtils.kt @@ -11,14 +11,12 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -fun Fragment.observe(flow: Flow, callback: suspend (T) -> Unit) { +fun Fragment.observe(flow: Flow, callback: suspend (T) -> Unit) = viewLifecycleOwner.observe(flow, callback) -} -fun LifecycleOwner.observe(flow: Flow, block: suspend (T) -> Unit) { - lifecycleScope.launch { - flow.flowWithLifecycle(lifecycle).collectLatest(block) - } + +fun LifecycleOwner.observe(flow: Flow, block: suspend (T) -> Unit) = lifecycleScope.launch { + flow.flowWithLifecycle(lifecycle).collectLatest(block) } fun LifecycleOwner.collect(flow: Flow, block: suspend (T) -> Unit) { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/utils/Permissions.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/Permissions.kt new file mode 100644 index 00000000..225648f0 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/utils/Permissions.kt @@ -0,0 +1,88 @@ +package dev.brahmkshatriya.echo.utils + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import dev.brahmkshatriya.echo.R +import java.util.UUID + + +fun ComponentActivity.checkAudioPermissions() { + val perm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + Manifest.permission.READ_MEDIA_AUDIO + else + Manifest.permission.READ_EXTERNAL_STORAGE + checkPermissions( + this, + perm, + R.string.permission_required, + R.string.music_permission_required_summary, + { finish() } + )?.launch(perm) +} + +fun ComponentActivity.checkPermissions( + context: Context, + perm: String, + title: Int, + message: Int, + onCancel: () -> Unit, + onGranted: () -> Unit = {}, + onRequest: () -> Unit = { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + context.startActivity(this) + } + } +): ActivityResultLauncher? = with(context) { + val permStatus = ContextCompat.checkSelfPermission(this, perm) + val contract = ActivityResultContracts.RequestPermission() + return if (permStatus != PackageManager.PERMISSION_GRANTED) + registerActivityResultLauncher(contract) { + if (!it) AlertDialog.Builder(this) + .setTitle(getString(title)) + .setMessage(getString(message)) + .setPositiveButton(getString(R.string.ok)) { _, _ -> onRequest() } + .setNegativeButton(getString(R.string.cancel)) { _, _ -> + Toast.makeText( + this, getString(R.string.permission_denied), Toast.LENGTH_SHORT + ).show() + onCancel() + } + .show() + else onGranted() + } else null +} + +fun ComponentActivity.registerActivityResultLauncher( + contract: ActivityResultContract, + block: (O) -> Unit +): ActivityResultLauncher { + val key = UUID.randomUUID().toString() + var launcher: ActivityResultLauncher? = null + val callback = ActivityResultCallback { + block.invoke(it) + launcher?.unregister() + } + + lifecycle.addObserver(object : androidx.lifecycle.DefaultLifecycleObserver { + override fun onDestroy(owner: androidx.lifecycle.LifecycleOwner) { + launcher?.unregister() + } + }) + + launcher = activityResultRegistry.register(key, contract, callback) + return launcher +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/extensions/SettingsInjector.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/SettingsUtils.kt similarity index 96% rename from app/src/main/java/dev/brahmkshatriya/echo/extensions/SettingsInjector.kt rename to app/src/main/java/dev/brahmkshatriya/echo/utils/SettingsUtils.kt index 173d75bc..73a7b8cc 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/extensions/SettingsInjector.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/utils/SettingsUtils.kt @@ -1,4 +1,4 @@ -package dev.brahmkshatriya.echo.extensions +package dev.brahmkshatriya.echo.utils import android.content.Context import android.content.SharedPreferences diff --git a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/ExtensionViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/ExtensionViewModel.kt index d4af23ef..4db715da 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/ExtensionViewModel.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/ExtensionViewModel.kt @@ -1,7 +1,10 @@ package dev.brahmkshatriya.echo.viewmodels +import android.app.Application import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.viewModelScope import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.lifecycle.HiltViewModel @@ -17,9 +20,13 @@ import dev.brahmkshatriya.echo.common.models.Metadata import dev.brahmkshatriya.echo.common.settings.Settings import dev.brahmkshatriya.echo.db.models.ExtensionEntity import dev.brahmkshatriya.echo.db.models.UserEntity +import dev.brahmkshatriya.echo.extensions.ExtensionLoader +import dev.brahmkshatriya.echo.extensions.ExtensionLoader.Companion.priorityKey import dev.brahmkshatriya.echo.extensions.ExtensionLoader.Companion.setupMusicExtension import dev.brahmkshatriya.echo.extensions.get import dev.brahmkshatriya.echo.extensions.getExtension +import dev.brahmkshatriya.echo.extensions.installExtension +import dev.brahmkshatriya.echo.extensions.uninstallExtension import dev.brahmkshatriya.echo.ui.common.ClientLoadingAdapter import dev.brahmkshatriya.echo.ui.common.ClientNotSupportedAdapter import dev.brahmkshatriya.echo.ui.extension.ClientSelectionViewModel @@ -29,11 +36,15 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import tel.jeelpa.plugger.utils.mapState +import java.io.File import javax.inject.Inject @HiltViewModel class ExtensionViewModel @Inject constructor( throwableFlow: MutableSharedFlow, + val app: Application, + val extensionLoader: ExtensionLoader, + val messageFlow: MutableSharedFlow, val extensionListFlow: MutableStateFlow?>, val trackerListFlow: MutableStateFlow?>, val lyricsListFlow: MutableStateFlow?>, @@ -81,6 +92,42 @@ class ExtensionViewModel @Inject constructor( } } + suspend fun install(context: FragmentActivity, file: File, installAsApk: Boolean) { + val result = installExtension(context, file, installAsApk).getOrElse { + throwableFlow.emit(it) + false + } + if (result) messageFlow.emit(SnackBar.Message(app.getString(R.string.extension_installed_successfully))) + } + + suspend fun uninstall( + context: FragmentActivity, extension: Extension<*>, function: (Boolean) -> Unit + ) { + val result = uninstallExtension(context, extension).getOrElse { + throwableFlow.emit(it) + false + } + if (result) messageFlow.emit(SnackBar.Message(app.getString(R.string.extension_uninstalled_successfully))) + function(result) + } + + fun moveExtensionItem(type: ExtensionType, toPos: Int, fromPos: Int) { + settings.edit { + val flow = extensionLoader.priorityMap[type]!! + val list = flow.value.toMutableList() + list.add(toPos, list.removeAt(fromPos)) + flow.value = list + putString(type.priorityKey(), list.joinToString(",")) + } + } + + fun getExtensionListFlow(type: ExtensionType) = when (type) { + ExtensionType.MUSIC -> extensionListFlow + ExtensionType.TRACKER -> trackerListFlow + ExtensionType.LYRICS -> lyricsListFlow + } + + companion object { fun Context.noClient() = SnackBar.Message( getString(R.string.error_no_client) diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml index 61231e8e..ba8815fd 100644 --- a/app/src/main/res/drawable/ic_delete.xml +++ b/app/src/main/res/drawable/ic_delete.xml @@ -1,6 +1,7 @@ - - - - + app:icon="@drawable/ic_drag_20dp" + app:iconSize="24dp" + app:iconTint="@color/button_player" /> \ No newline at end of file diff --git a/app/src/main/res/menu/extensions_menu.xml b/app/src/main/res/menu/extensions_menu.xml new file mode 100644 index 00000000..9b85ad14 --- /dev/null +++ b/app/src/main/res/menu/extensions_menu.xml @@ -0,0 +1,12 @@ + + + + + \ 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 5c378f91..39b0a830 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -247,8 +247,10 @@ Not available if you install the extension as a File. Could not find the file. Extension Installer - Extension installed successfully. + Uninstall Invalid Extension + Extension installed successfully. + Extension uninstalled successfully. Highest Medium