From 549eac7956054200910d2ccdcd0a3c03d5658d68 Mon Sep 17 00:00:00 2001 From: brahmkshatriya <69040506+brahmkshatriya@users.noreply.github.com> Date: Tue, 20 Feb 2024 18:25:56 +0530 Subject: [PATCH] Add support for extensions working with flows --- app/build.gradle.kts | 4 +- .../dev/brahmkshatriya/echo/MainActivity.kt | 46 ++++++++++-- .../data/extensions/LocalExtensionRepo.kt | 9 +-- .../echo/data/extensions/OfflineExtension.kt | 12 +-- .../echo/di/ApkManifestParser.kt | 16 ++++ .../echo/di/ContextProviderForRepo.kt | 17 ----- .../dev/brahmkshatriya/echo/di/FlowModels.kt | 18 +++++ .../brahmkshatriya/echo/di/PluginModule.kt | 37 +++++---- .../brahmkshatriya/echo/player/InitPlayer.kt | 17 +++-- .../echo/player/PlaybackService.kt | 16 ++-- .../echo/player/PlayerListener.kt | 2 + .../echo/player/PlayerSessionCallback.kt | 20 ++++- .../echo/player/PlayerViewModel.kt | 28 +++++-- .../echo/ui/adapters/NotSupportedAdapter.kt | 44 +++++++++++ .../ui/dialogs/ExtensionDialogFragment.kt | 55 -------------- .../ui/extension/ExtensionDialogFragment.kt | 75 +++++++++++++++++++ .../echo/ui/extension/ExtensionViewModel.kt | 29 +++++++ .../echo/ui/home/HomeFragment.kt | 23 ++++-- .../echo/ui/home/HomeViewModel.kt | 14 +++- .../echo/ui/search/SearchFragment.kt | 18 ++++- .../echo/ui/search/SearchViewModel.kt | 15 +++- .../brahmkshatriya/echo/ui/utils/FlowUtils.kt | 5 ++ .../echo/ui/utils/ImageLoading.kt | 46 ++++++++---- .../main/res/layout-land/activity_main.xml | 1 + app/src/main/res/layout/activity_main.xml | 1 + app/src/main/res/layout/button_extension.xml | 2 +- app/src/main/res/layout/dialog_extension.xml | 7 +- .../main/res/layout/item_not_supported.xml | 30 ++++++++ .../main/res/navigation/mobile_navigation.xml | 2 +- app/src/main/res/values-land/dimens.xml | 1 + app/src/main/res/values/dimens.xml | 1 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/values/styles.xml | 2 - 33 files changed, 439 insertions(+), 175 deletions(-) create mode 100644 app/src/main/java/dev/brahmkshatriya/echo/di/ApkManifestParser.kt delete mode 100644 app/src/main/java/dev/brahmkshatriya/echo/di/ContextProviderForRepo.kt create mode 100644 app/src/main/java/dev/brahmkshatriya/echo/di/FlowModels.kt create mode 100644 app/src/main/java/dev/brahmkshatriya/echo/ui/adapters/NotSupportedAdapter.kt delete mode 100644 app/src/main/java/dev/brahmkshatriya/echo/ui/dialogs/ExtensionDialogFragment.kt create mode 100644 app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionDialogFragment.kt create mode 100644 app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionViewModel.kt create mode 100644 app/src/main/res/layout/item_not_supported.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 582cb3cb..cf00da8b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -46,8 +46,8 @@ android { dependencies { - implementation("com.github.brahmkshatriya:echo-common:0.0.1") - implementation("com.github.JeelPatel231:plugger:1.0.1") + implementation("com.github.brahmkshatriya:echo-common:1.0.2") + implementation("com.github.brahmkshatriya:plugger:1.0.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.7.1") diff --git a/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt b/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt index ca782d7c..21ff0c8c 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.ComponentName import android.content.Intent import android.os.Bundle import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat @@ -12,24 +13,38 @@ import androidx.media3.session.MediaBrowser import androidx.media3.session.SessionToken import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.setupWithNavController +import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.navigation.NavigationBarView +import com.google.android.material.snackbar.Snackbar +import com.google.common.util.concurrent.ListenableFuture import dagger.hilt.android.AndroidEntryPoint +import dev.brahmkshatriya.echo.common.clients.ExtensionClient import dev.brahmkshatriya.echo.databinding.ActivityMainBinding import dev.brahmkshatriya.echo.player.PlaybackService +import dev.brahmkshatriya.echo.player.PlayerViewModel import dev.brahmkshatriya.echo.player.initPlayer +import dev.brahmkshatriya.echo.ui.extension.ExtensionViewModel import dev.brahmkshatriya.echo.ui.utils.checkPermissions import dev.brahmkshatriya.echo.ui.utils.emit import dev.brahmkshatriya.echo.ui.utils.updateBottomMarginWithSystemInsets -import kotlinx.coroutines.flow.MutableSharedFlow +import tel.jeelpa.plugger.PluginRepo +import javax.inject.Inject @AndroidEntryPoint class MainActivity : AppCompatActivity() { + + @Inject + lateinit var pluginRepo: PluginRepo + val binding by lazy(LazyThreadSafetyMode.NONE) { ActivityMainBinding.inflate(layoutInflater) } - var fromNotification: MutableSharedFlow = MutableSharedFlow() + private var controllerFuture: ListenableFuture? = null + + private val playerViewModel: PlayerViewModel by viewModels() + private val extensionViewModel: ExtensionViewModel by viewModels() @SuppressLint("RestrictedApi") override fun onCreate(savedInstanceState: Bundle?) { @@ -46,16 +61,35 @@ class MainActivity : AppCompatActivity() { navView.setupWithNavController(navHostFragment.navController) updateBottomMarginWithSystemInsets(binding.navHostFragment) + if (extensionViewModel.extensionListFlow == null) { + extensionViewModel.extensionListFlow = pluginRepo.getAllPlugins { e -> + e.message?.let { + val snack = Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT) + if (binding.navView is BottomNavigationView) + snack.setAnchorView(binding.navView) + snack.show() + } + } + } + val sessionToken = SessionToken(this, ComponentName(this, PlaybackService::class.java)) - val controllerFuture = MediaBrowser.Builder(this, sessionToken).buildAsync() - val listener = Runnable { initPlayer(this, controllerFuture.get()) } - controllerFuture.addListener(listener, ContextCompat.getMainExecutor(this)) + MediaBrowser.Builder(this, sessionToken).buildAsync().also { + controllerFuture = it + val listener = Runnable { initPlayer(this, it.get()) } + it.addListener(listener, ContextCompat.getMainExecutor(this)) + } + } override fun onNewIntent(intent: Intent?) { intent?.hasExtra("fromNotification")?.let { - emit(fromNotification) { it } + emit(playerViewModel.fromNotification) { it } } super.onNewIntent(intent) } + + override fun onDestroy() { + controllerFuture?.let { MediaBrowser.releaseFuture(it) } + super.onDestroy() + } } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/data/extensions/LocalExtensionRepo.kt b/app/src/main/java/dev/brahmkshatriya/echo/data/extensions/LocalExtensionRepo.kt index d99fcdfd..a58d07be 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/data/extensions/LocalExtensionRepo.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/data/extensions/LocalExtensionRepo.kt @@ -1,12 +1,11 @@ package dev.brahmkshatriya.echo.data.extensions +import android.content.Context import dev.brahmkshatriya.echo.common.clients.ExtensionClient -import dev.brahmkshatriya.echo.common.data.extensions.OfflineExtension -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import tel.jeelpa.plugger.PluginRepo -class LocalExtensionRepo : PluginRepo { - override fun getAllPlugins(): Flow> = - flowOf(listOf(OfflineExtension())) +class LocalExtensionRepo(val context: Context) : PluginRepo { + override fun getAllPlugins(exceptionHandler: (Exception) -> Unit) = + flowOf(listOf(OfflineExtension(context))) } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/data/extensions/OfflineExtension.kt b/app/src/main/java/dev/brahmkshatriya/echo/data/extensions/OfflineExtension.kt index dd3f878c..a56f2cbd 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/data/extensions/OfflineExtension.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/data/extensions/OfflineExtension.kt @@ -1,4 +1,4 @@ -package dev.brahmkshatriya.echo.common.data.extensions +package dev.brahmkshatriya.echo.data.extensions import android.content.Context import android.net.Uri @@ -11,6 +11,10 @@ import dev.brahmkshatriya.echo.common.clients.ExtensionClient import dev.brahmkshatriya.echo.common.clients.HomeFeedClient import dev.brahmkshatriya.echo.common.clients.SearchClient import dev.brahmkshatriya.echo.common.clients.TrackClient +import dev.brahmkshatriya.echo.common.data.offline.LocalAlbum +import dev.brahmkshatriya.echo.common.data.offline.LocalArtist +import dev.brahmkshatriya.echo.common.data.offline.LocalStream +import dev.brahmkshatriya.echo.common.data.offline.LocalTrack import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Companion.toMediaItem import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Companion.toMediaItemsContainer import dev.brahmkshatriya.echo.common.models.ExtensionMetadata @@ -19,15 +23,11 @@ import dev.brahmkshatriya.echo.common.models.QuickSearchItem import dev.brahmkshatriya.echo.common.models.StreamableAudio import dev.brahmkshatriya.echo.common.models.StreamableAudio.Companion.toAudio import dev.brahmkshatriya.echo.common.models.Track -import dev.brahmkshatriya.echo.common.data.offline.LocalAlbum -import dev.brahmkshatriya.echo.common.data.offline.LocalArtist -import dev.brahmkshatriya.echo.common.data.offline.LocalStream -import dev.brahmkshatriya.echo.common.data.offline.LocalTrack import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import java.io.IOException -class OfflineExtension : ExtensionClient(), SearchClient, TrackClient, HomeFeedClient { +class OfflineExtension(val context: Context) : ExtensionClient, SearchClient, TrackClient, HomeFeedClient { override fun getMetadata() = ExtensionMetadata( name = "Offline", diff --git a/app/src/main/java/dev/brahmkshatriya/echo/di/ApkManifestParser.kt b/app/src/main/java/dev/brahmkshatriya/echo/di/ApkManifestParser.kt new file mode 100644 index 00000000..c93e3177 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/di/ApkManifestParser.kt @@ -0,0 +1,16 @@ +package dev.brahmkshatriya.echo.di + +import android.content.pm.ApplicationInfo +import tel.jeelpa.plugger.ManifestParser +import tel.jeelpa.plugger.models.PluginMetadata + +class ApkManifestParser: ManifestParser { + override fun parseManifest(data: ApplicationInfo): PluginMetadata { + + return PluginMetadata( + path = data.sourceDir, + className = data.metaData.getString("class") + ?: error("Class Name not found in Metadata for ${data.packageName}"), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/di/ContextProviderForRepo.kt b/app/src/main/java/dev/brahmkshatriya/echo/di/ContextProviderForRepo.kt deleted file mode 100644 index 4759e733..00000000 --- a/app/src/main/java/dev/brahmkshatriya/echo/di/ContextProviderForRepo.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.brahmkshatriya.echo.di - -import android.content.Context -import dev.brahmkshatriya.echo.common.clients.ExtensionClient -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import tel.jeelpa.plugger.PluginRepo - -class ContextProviderForRepo( - private val context: Context, - private val pluginRepo: PluginRepo -) : PluginRepo { - override fun getAllPlugins(): Flow> = - pluginRepo.getAllPlugins().map { list -> - list.onEach { it.context = context } - } -} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/di/FlowModels.kt b/app/src/main/java/dev/brahmkshatriya/echo/di/FlowModels.kt new file mode 100644 index 00000000..ffa4ca2f --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/di/FlowModels.kt @@ -0,0 +1,18 @@ +package dev.brahmkshatriya.echo.di + +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.common.clients.HomeFeedClient +import dev.brahmkshatriya.echo.common.clients.SearchClient +import dev.brahmkshatriya.echo.common.clients.TrackClient +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + + +// Dagger cannot directly infer Foo, if Bar is an interface +// That means the Flow cannot be directly injected, +// So, we need to wrap it in a data class and inject that instead +data class MutableExtensionFlow(val flow: MutableStateFlow) +data class ExtensionFlow(val flow: Flow) +data class SearchFlow(val flow: Flow) +data class HomeFeedFlow(val flow: Flow) +data class TrackFlow(val flow: Flow) \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/di/PluginModule.kt b/app/src/main/java/dev/brahmkshatriya/echo/di/PluginModule.kt index 04c90a1e..6c0d4275 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/di/PluginModule.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/di/PluginModule.kt @@ -10,8 +10,9 @@ import dev.brahmkshatriya.echo.common.clients.HomeFeedClient import dev.brahmkshatriya.echo.common.clients.SearchClient import dev.brahmkshatriya.echo.common.clients.TrackClient import dev.brahmkshatriya.echo.data.extensions.LocalExtensionRepo -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map import tel.jeelpa.plugger.PluginRepo import tel.jeelpa.plugger.RepoComposer import tel.jeelpa.plugger.models.PluginConfiguration @@ -36,40 +37,38 @@ class PluginModule { val filePluginConfig = FilePluginConfig(application.filesDir.absolutePath, ".echo") val apkPluginConfig = PluginConfiguration("dev.brahmkshatriya.echo") - val composer = RepoComposer( + return RepoComposer( FileSystemPluginLoader(application, filePluginConfig, loader), - ApkPluginLoader(application, apkPluginConfig, loader), - LocalExtensionRepo() + ApkPluginLoader(application, apkPluginConfig, loader, ApkManifestParser()), + LocalExtensionRepo(application) ) - - return ContextProviderForRepo(application, composer) } + private val mutableExtensionFlow = MutableExtensionFlow(MutableStateFlow(null)) + private val extensionFlow = mutableExtensionFlow.flow.asStateFlow() + @Provides @Singleton - fun getExtensionClients(pluginLoader: PluginRepo): List { - val clients = runBlocking { pluginLoader.getAllPlugins().first() } - return clients - } + fun provideExtensionSharedFlow() = mutableExtensionFlow @Provides @Singleton - fun provideExtension(pluginLoader: PluginRepo): ExtensionClient = - getExtensionClients(pluginLoader).first() + fun providesExtensionClient() = + ExtensionFlow(extensionFlow) @Provides @Singleton - fun provideSearchClient(pluginLoader: PluginRepo): SearchClient = - provideExtension(pluginLoader) as SearchClient + fun providesSearchClient() = + SearchFlow(extensionFlow.map { it as? SearchClient }) @Provides @Singleton - fun provideHomeClient(pluginLoader: PluginRepo): HomeFeedClient = - provideExtension(pluginLoader) as HomeFeedClient + fun providesHomeClient() = + HomeFeedFlow(extensionFlow.map { it as? HomeFeedClient }) @Provides @Singleton - fun provideTrackClient(pluginLoader: PluginRepo): TrackClient = - provideExtension(pluginLoader) as TrackClient + fun providesTrackClient() = + TrackFlow(extensionFlow.map { it as? TrackClient }) } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/player/InitPlayer.kt b/app/src/main/java/dev/brahmkshatriya/echo/player/InitPlayer.kt index ff889238..aeffa01b 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/player/InitPlayer.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/player/InitPlayer.kt @@ -37,6 +37,8 @@ fun initPlayer( activity: MainActivity, player: MediaBrowser ) { + println("init player") + val playerBinding = activity.binding.bottomPlayer val container = activity.binding.bottomPlayerContainer as View @@ -89,9 +91,10 @@ fun initPlayer( container.post { bottomBehavior.state = PlayerBackButtonHelper.playerCollapsed.value + container.translationY = 0f } - activity.observe(activity.fromNotification) { - bottomBehavior.state = STATE_EXPANDED + activity.observe(playerViewModel.fromNotification) { + if (it) bottomBehavior.state = STATE_EXPANDED } playerBinding.playerClose.setOnClickListener { @@ -249,14 +252,18 @@ fun initPlayer( } observe(playerViewModel.seekToPrevious) { player.seekToPrevious() + player.playWhenReady = true } observe(playerViewModel.seekToNext) { player.seekToNext() + player.playWhenReady = true } observe(playerViewModel.audioIndexFlow) { - player.seekToDefaultPosition(it) - if (bottomBehavior.state == STATE_HIDDEN) - bottomBehavior.state = STATE_COLLAPSED + if (it >= 0) { + player.seekToDefaultPosition(it) + if (bottomBehavior.state == STATE_HIDDEN) + bottomBehavior.state = STATE_COLLAPSED + } } observe(playerViewModel.seekTo) { player.seekTo(it) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/player/PlaybackService.kt b/app/src/main/java/dev/brahmkshatriya/echo/player/PlaybackService.kt index ebb915c8..50ffa551 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/player/PlaybackService.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/player/PlaybackService.kt @@ -1,6 +1,5 @@ package dev.brahmkshatriya.echo.player -import android.app.Application import android.app.PendingIntent import android.content.Intent import androidx.annotation.OptIn @@ -14,17 +13,13 @@ import androidx.media3.session.MediaSession import dagger.hilt.android.AndroidEntryPoint import dev.brahmkshatriya.echo.MainActivity import dev.brahmkshatriya.echo.R -import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.di.ExtensionFlow import javax.inject.Inject @AndroidEntryPoint class PlaybackService : MediaLibraryService() { - - @Inject - lateinit var app: Application - @Inject - lateinit var extension: ExtensionClient + lateinit var extension: ExtensionFlow private var mediaLibrarySession: MediaLibrarySession? = null @@ -49,9 +44,10 @@ class PlaybackService : MediaLibraryService() { val pendingIntent = PendingIntent .getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) - mediaLibrarySession = MediaLibrarySession.Builder(this, player, PlayerSessionCallback(app, extension)) - .setSessionActivity(pendingIntent) - .build() + mediaLibrarySession = + MediaLibrarySession.Builder(this, player, PlayerSessionCallback(this, extension.flow)) + .setSessionActivity(pendingIntent) + .build() val notificationProvider = DefaultMediaNotificationProvider .Builder(this) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/player/PlayerListener.kt b/app/src/main/java/dev/brahmkshatriya/echo/player/PlayerListener.kt index 7f3d0afb..74f721ce 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/player/PlayerListener.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/player/PlayerListener.kt @@ -3,6 +3,7 @@ package dev.brahmkshatriya.echo.player import android.annotation.SuppressLint import android.os.Handler import android.os.Looper +import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.Timeline @@ -94,6 +95,7 @@ class PlayerListener( } fun update(mediaId: String){ + println("Update ${player.duration == C.TIME_UNSET}") viewModel.track.value = tracks[mediaId] viewModel.totalDuration.value = player.duration.toInt() viewModel.isPlaying.value = player.isPlaying diff --git a/app/src/main/java/dev/brahmkshatriya/echo/player/PlayerSessionCallback.kt b/app/src/main/java/dev/brahmkshatriya/echo/player/PlayerSessionCallback.kt index cdd97b41..4a35d642 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/player/PlayerSessionCallback.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/player/PlayerSessionCallback.kt @@ -1,6 +1,6 @@ package dev.brahmkshatriya.echo.player -import android.app.Application +import android.content.Context import android.os.Handler import android.os.Looper import android.widget.Toast @@ -9,25 +9,36 @@ import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession import androidx.paging.AsyncPagingDataDiffer import com.google.common.util.concurrent.ListenableFuture +import dev.brahmkshatriya.echo.common.clients.ExtensionClient import dev.brahmkshatriya.echo.common.clients.SearchClient import dev.brahmkshatriya.echo.common.clients.TrackClient import dev.brahmkshatriya.echo.common.models.EchoMediaItem import dev.brahmkshatriya.echo.common.models.MediaItemsContainer import dev.brahmkshatriya.echo.ui.adapters.MediaItemsContainerAdapter +import dev.brahmkshatriya.echo.ui.utils.observe import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.guava.future import kotlinx.coroutines.plus class PlayerSessionCallback( - private val context: Application, - private val extension: Any + private val context: Context, + extensionFlow: Flow ) : MediaLibraryService.MediaLibrarySession.Callback { private val scope = CoroutineScope(Dispatchers.IO) + Job() + init { + scope.observe(extensionFlow){ + extension = it + } + } + + private var extension : ExtensionClient? = null + private val differ = AsyncPagingDataDiffer( MediaItemsContainerAdapter, @@ -55,7 +66,8 @@ class PlayerSessionCallback( toast(reason) return super.onAddMediaItems(mediaSession, controller, mediaItems) } - + val extension = extension + ?: return default("Extension isn't loaded.") if (extension !is SearchClient) return default("Extension does not support Searching") if (extension !is TrackClient) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/player/PlayerViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/player/PlayerViewModel.kt index 6ed6c787..4a15de73 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/player/PlayerViewModel.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/player/PlayerViewModel.kt @@ -6,6 +6,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dev.brahmkshatriya.echo.common.clients.TrackClient import dev.brahmkshatriya.echo.common.models.StreamableAudio import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.di.TrackFlow +import dev.brahmkshatriya.echo.ui.utils.observe import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch @@ -13,10 +15,19 @@ import javax.inject.Inject @HiltViewModel class PlayerViewModel @Inject constructor( - private val trackClient: TrackClient, + trackFlow: TrackFlow, // private val radioClient: RadioClient ) : ViewModel() { + val fromNotification: MutableSharedFlow = MutableSharedFlow() + + private var trackClient: TrackClient? = null + init { + viewModelScope.observe(trackFlow.flow){ + trackClient = it + } + } + data class TrackWithStream( val track: Track, val audio: StreamableAudio @@ -32,15 +43,18 @@ class PlayerViewModel @Inject constructor( val audioQueueFlow = MutableSharedFlow() val clearQueueFlow = MutableSharedFlow() - private suspend fun loadStreamable(track: Track) = - TrackWithStream(track, trackClient.getStreamable(track)) - + private suspend fun loadStreamable(track: Track): TrackWithStream? { + val stream = trackClient?.getStreamable(track) ?: return null + return TrackWithStream(track, stream) + } private val queue = mutableListOf() private suspend fun loadAndAddToQueue(track: Track): Int { val stream = loadStreamable(track) - queue.add(stream) - audioQueueFlow.emit(stream) - return queue.count() - 1 + return stream?.let { + queue.add(it) + audioQueueFlow.emit(it) + queue.count() - 1 + } ?: -1 } fun play(track: Track) { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/adapters/NotSupportedAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapters/NotSupportedAdapter.kt new file mode 100644 index 00000000..e20a4edf --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/adapters/NotSupportedAdapter.kt @@ -0,0 +1,44 @@ +package dev.brahmkshatriya.echo.ui.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.navigation.findNavController +import androidx.recyclerview.widget.RecyclerView +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.databinding.ItemNotSupportedBinding + +class NotSupportedAdapter( + private val clientStringId: Int +) : RecyclerView.Adapter() { + + override fun getItemCount() = 1 + + class ViewHolder(val binding: ItemNotSupportedBinding) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( + ItemNotSupportedBinding + .inflate(LayoutInflater.from(parent.context), parent, false) + ) + + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val binding = holder.binding + binding.topAppBar.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.menu_settings -> { + true + } + + R.id.menu_extensions -> { + binding.root.findNavController().navigate(R.id.dialog_extension) + true + } + + else -> false + } + } + val clientName = binding.root.context.getString(clientStringId) + binding.notSupportedTextView.text = + binding.root.context.getString(R.string.is_not_supported, clientName) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/dialogs/ExtensionDialogFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/dialogs/ExtensionDialogFragment.kt deleted file mode 100644 index b3a876bd..00000000 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/dialogs/ExtensionDialogFragment.kt +++ /dev/null @@ -1,55 +0,0 @@ -package dev.brahmkshatriya.echo.ui.dialogs - -import android.annotation.SuppressLint -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.DialogFragment -import androidx.navigation.fragment.findNavController -import androidx.navigation.ui.NavigationUI.setupWithNavController -import dev.brahmkshatriya.echo.databinding.ButtonExtensionBinding -import dev.brahmkshatriya.echo.databinding.DialogExtensionBinding - - -class ExtensionDialogFragment : DialogFragment() { - - private lateinit var binding: DialogExtensionBinding - - override fun onCreateView(inflater: LayoutInflater, parent: ViewGroup?, state: Bundle?): View { - binding = DialogExtensionBinding.inflate(inflater, parent, false) - return binding.root - } - - @SuppressLint("ResourceType") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val navController = findNavController() - setupWithNavController(binding.topAppBar, navController) - - (0 .. 5).forEach { - val button = ButtonExtensionBinding - .inflate(layoutInflater, binding.toggleButton, true) - .root - button.text = "Bruh" - binding.toggleButton.addView(button) - button.id = it - } - binding.toggleButton.check(1) - binding.toggleButton.addOnButtonCheckedListener { _, checkedId, isChecked -> - if (isChecked) { - Toast.makeText(requireContext(), checkedId.toString(), Toast.LENGTH_SHORT).show() - } - } - - binding.addExtension.setOnClickListener { - val button = ButtonExtensionBinding - .inflate(layoutInflater, binding.toggleButton, true) - .root - button.text = "Bruh" - binding.toggleButton.addView(button) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionDialogFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionDialogFragment.kt new file mode 100644 index 00000000..32850359 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionDialogFragment.kt @@ -0,0 +1,75 @@ +package dev.brahmkshatriya.echo.ui.extension + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.ui.NavigationUI.setupWithNavController +import com.google.android.material.button.MaterialButtonToggleGroup.OnButtonCheckedListener +import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder +import dev.brahmkshatriya.echo.databinding.ButtonExtensionBinding +import dev.brahmkshatriya.echo.databinding.DialogExtensionBinding +import dev.brahmkshatriya.echo.ui.utils.loadInto +import dev.brahmkshatriya.echo.ui.utils.observe + + +class ExtensionDialogFragment : DialogFragment() { + + private lateinit var binding: DialogExtensionBinding + private val viewModel: ExtensionViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, parent: ViewGroup?, state: Bundle?): View { + binding = DialogExtensionBinding.inflate(inflater, parent, false) + return binding.root + } + + @SuppressLint("ResourceType") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val navController = findNavController() + setupWithNavController(binding.topAppBar, navController) + + binding.addExtension.isEnabled = false + + var oldListener: OnButtonCheckedListener? = null + val extensionFlow = viewModel.extensionListFlow ?: return + observe(extensionFlow) { list -> + binding.buttonToggleGroup.removeAllViews() + + val map = list.mapIndexed { id, extension -> + val button = ButtonExtensionBinding + .inflate(layoutInflater, binding.buttonToggleGroup, false) + .root + val metadata = extension.getMetadata() + button.text = metadata.name + binding.buttonToggleGroup.addView(button) + metadata.iconUrl?.toImageHolder()?.loadInto(button) + button.id = id + id to extension + }.toMap() + + val checked = map.filter { + it.value == viewModel.mutableExtensionFlow.flow.value + }.keys.firstOrNull() + + if (checked != null) + binding.buttonToggleGroup.check(checked) + + val listener = OnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) map[checkedId]?.let { + viewModel.setExtension(it) + } + } + binding.buttonToggleGroup.run { + oldListener?.let { removeOnButtonCheckedListener(it) } + addOnButtonCheckedListener(listener) + oldListener = listener + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionViewModel.kt new file mode 100644 index 00000000..0224795a --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/extension/ExtensionViewModel.kt @@ -0,0 +1,29 @@ +package dev.brahmkshatriya.echo.ui.extension + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.brahmkshatriya.echo.common.clients.ExtensionClient +import dev.brahmkshatriya.echo.di.MutableExtensionFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ExtensionViewModel @Inject constructor( + val mutableExtensionFlow: MutableExtensionFlow +) : ViewModel() { + var extensionListFlow: Flow>? = null + set(value) { + field = value + viewModelScope.launch { + mutableExtensionFlow.flow.value = value?.first()?.firstOrNull() + } + } + fun setExtension(extension: ExtensionClient) { + viewModelScope.launch { + mutableExtensionFlow.flow.value = extension + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/home/HomeFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/home/HomeFragment.kt index 56108f2d..ce344743 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/home/HomeFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/home/HomeFragment.kt @@ -13,11 +13,12 @@ import dagger.hilt.android.AndroidEntryPoint import dev.brahmkshatriya.echo.R import dev.brahmkshatriya.echo.common.models.Track import dev.brahmkshatriya.echo.databinding.FragmentRecyclerBinding +import dev.brahmkshatriya.echo.player.PlayerBackButtonHelper +import dev.brahmkshatriya.echo.player.PlayerViewModel import dev.brahmkshatriya.echo.ui.adapters.ClickListener import dev.brahmkshatriya.echo.ui.adapters.HeaderAdapter import dev.brahmkshatriya.echo.ui.adapters.MediaItemsContainerAdapter -import dev.brahmkshatriya.echo.player.PlayerBackButtonHelper -import dev.brahmkshatriya.echo.player.PlayerViewModel +import dev.brahmkshatriya.echo.ui.adapters.NotSupportedAdapter import dev.brahmkshatriya.echo.ui.utils.autoCleared import dev.brahmkshatriya.echo.ui.utils.dpToPx import dev.brahmkshatriya.echo.ui.utils.observe @@ -55,18 +56,26 @@ class HomeFragment : Fragment() { }) val concat = mediaItemsContainerAdapter.withLoadingFooter() - - binding.recyclerView.adapter = ConcatAdapter(headerAdapter, concat) - binding.recyclerView.layoutManager = LinearLayoutManager(context) - binding.swipeRefresh.setOnRefreshListener { mediaItemsContainerAdapter.refresh() } - mediaItemsContainerAdapter.addLoadStateListener { binding.swipeRefresh.isRefreshing = it.refresh is LoadState.Loading } + val concatAdapter = ConcatAdapter(headerAdapter, concat) + binding.recyclerView.layoutManager = LinearLayoutManager(context) + + observe(homeViewModel.homeFeedFlow.flow) { + binding.recyclerView.adapter = if (it != null) { + binding.swipeRefresh.isEnabled = true + concatAdapter + } else { + binding.swipeRefresh.isEnabled = false + NotSupportedAdapter(R.string.home) + } + } + observe(homeViewModel.feed) { if (it != null) mediaItemsContainerAdapter.submitData(it) } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/home/HomeViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/home/HomeViewModel.kt index e9940fb1..ca086677 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/home/HomeViewModel.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/home/HomeViewModel.kt @@ -7,6 +7,8 @@ import androidx.paging.cachedIn import dagger.hilt.android.lifecycle.HiltViewModel import dev.brahmkshatriya.echo.common.clients.HomeFeedClient import dev.brahmkshatriya.echo.common.models.MediaItemsContainer +import dev.brahmkshatriya.echo.di.MutableExtensionFlow +import dev.brahmkshatriya.echo.ui.utils.observe import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -16,23 +18,27 @@ import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( - private val homeClient: HomeFeedClient + val homeFeedFlow: MutableExtensionFlow ) : ViewModel() { init { viewModelScope.launch { - loadFeed() + observe(homeFeedFlow.flow) { + homeClient = it as? HomeFeedClient + loadFeed() + } } } - private val _feed: MutableStateFlow?> = MutableStateFlow(null) + private val _feed: MutableStateFlow?> = MutableStateFlow(null) val feed = _feed.asStateFlow() val genre: String? = null + private var homeClient: HomeFeedClient? = null fun loadFeed() { viewModelScope.launch(Dispatchers.IO) { - homeClient.getHomeFeed(genre).cachedIn(viewModelScope).collectLatest { + homeClient?.getHomeFeed(genre)?.cachedIn(viewModelScope)?.collectLatest { _feed.value = it } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/search/SearchFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/search/SearchFragment.kt index 0bbef80b..66040255 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/search/SearchFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/search/SearchFragment.kt @@ -9,13 +9,15 @@ import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint +import dev.brahmkshatriya.echo.R import dev.brahmkshatriya.echo.common.models.Track import dev.brahmkshatriya.echo.databinding.FragmentSearchBinding +import dev.brahmkshatriya.echo.player.PlayerBackButtonHelper +import dev.brahmkshatriya.echo.player.PlayerViewModel import dev.brahmkshatriya.echo.ui.adapters.ClickListener import dev.brahmkshatriya.echo.ui.adapters.MediaItemsContainerAdapter +import dev.brahmkshatriya.echo.ui.adapters.NotSupportedAdapter import dev.brahmkshatriya.echo.ui.adapters.SearchHeaderAdapter -import dev.brahmkshatriya.echo.player.PlayerBackButtonHelper -import dev.brahmkshatriya.echo.player.PlayerViewModel import dev.brahmkshatriya.echo.ui.utils.observe import dev.brahmkshatriya.echo.ui.utils.updatePaddingWithSystemInsets @@ -34,7 +36,6 @@ class SearchFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - PlayerBackButtonHelper.addCallback(this) { if (!it) binding.catSearchView.hide() } @@ -62,8 +63,17 @@ class SearchFragment : Fragment() { playerViewModel.addToQueue(item) } }) + + val concatAdapter = ConcatAdapter(header, adapter) binding.catRecyclerView.layoutManager = LinearLayoutManager(requireContext()) - binding.catRecyclerView.adapter = ConcatAdapter(header, adapter) + + observe(searchViewModel.searchFlow.flow) { + binding.catRecyclerView.adapter = if (it != null) { + concatAdapter + } else { + NotSupportedAdapter(R.string.search) + } + } observe(searchViewModel.result){ if (it != null) adapter.submitData(it) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/search/SearchViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/search/SearchViewModel.kt index dd6f3e31..87f04375 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/search/SearchViewModel.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/search/SearchViewModel.kt @@ -6,6 +6,7 @@ import androidx.paging.PagingData import dagger.hilt.android.lifecycle.HiltViewModel import dev.brahmkshatriya.echo.common.clients.SearchClient import dev.brahmkshatriya.echo.common.models.MediaItemsContainer +import dev.brahmkshatriya.echo.di.SearchFlow import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -15,17 +16,27 @@ import javax.inject.Inject @HiltViewModel class SearchViewModel @Inject constructor( - private val searchClient: SearchClient + val searchFlow: SearchFlow ) : ViewModel() { + init { + viewModelScope.launch { + searchFlow.flow.collectLatest { + searchClient = it + } + } + } + private val _result: MutableStateFlow?> = MutableStateFlow(null) val result = _result.asStateFlow() var query: String? = null + private var searchClient: SearchClient? = null + fun search(query: String) { this.query = query viewModelScope.launch(Dispatchers.IO) { - searchClient.search(query).collectLatest { + searchClient?.search(query)?.collectLatest { _result.value = it } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/utils/FlowUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/utils/FlowUtils.kt index 9a57aa1a..dd539515 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/utils/FlowUtils.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/utils/FlowUtils.kt @@ -4,6 +4,7 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch @@ -20,4 +21,8 @@ fun LifecycleOwner.observe(flow: Flow, block: suspend (T) -> Unit) { fun LifecycleOwner.emit(flow: MutableSharedFlow, block: () -> T) { lifecycleScope.launch { flow.emit(block()) } +} + +fun CoroutineScope.observe(flow: Flow, block: suspend (T) -> Unit) { + launch { flow.collect(block) } } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/utils/ImageLoading.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/utils/ImageLoading.kt index ccce6ed8..4c51e61d 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/utils/ImageLoading.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/utils/ImageLoading.kt @@ -1,21 +1,41 @@ package dev.brahmkshatriya.echo.ui.utils +import android.graphics.drawable.Drawable import android.widget.ImageView import com.bumptech.glide.Glide import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.request.target.CustomViewTarget +import com.bumptech.glide.request.transition.Transition +import com.google.android.material.button.MaterialButton import dev.brahmkshatriya.echo.common.models.ImageHolder -fun ImageHolder.loadInto(imageView: ImageView) { - if (this is ImageHolder.BitmapHolder) - Glide.with(imageView) - .load(this.bitmap) - .into(imageView) - if (this is ImageHolder.UrlHolder) - Glide.with(imageView) - .load(GlideUrl(this.url) { this.headers }) - .into(imageView) - if (this is ImageHolder.UriHolder) - Glide.with(imageView) - .load(this.uri) - .into(imageView) +fun ImageHolder.loadInto(imageView: ImageView, placeholder: Int? = null, error: Int? = null) { + val builder = when (this) { + is ImageHolder.BitmapHolder -> Glide.with(imageView).load(this.bitmap) + is ImageHolder.UrlHolder -> Glide.with(imageView).load(GlideUrl(this.url) { this.headers }) + is ImageHolder.UriHolder -> Glide.with(imageView).load(this.uri) + } + placeholder?.let { builder.placeholder(it) } + error?.let { builder.error(it) } + builder.into(imageView) +} + +fun ImageHolder.loadInto(button: MaterialButton, placeholder: Int? = null, error: Int? = null) { + val builder = when (this) { + is ImageHolder.BitmapHolder -> Glide.with(button).load(this.bitmap) + is ImageHolder.UrlHolder -> Glide.with(button).load(GlideUrl(this.url) { this.headers }) + is ImageHolder.UriHolder -> Glide.with(button).load(this.uri) + } + placeholder?.let { builder.placeholder(it) } + error?.let { builder.error(it) } + builder.into(MaterialButtonTarget(button)) +} + +class MaterialButtonTarget(private val button: MaterialButton) + : CustomViewTarget(button) { + override fun onLoadFailed(errorDrawable: Drawable?) { button.icon = errorDrawable } + override fun onResourceCleared(placeholder: Drawable?) { button.icon = placeholder } + override fun onResourceReady(resource: Drawable, transition: Transition?) { + button.icon = resource + } } \ No newline at end of file diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml index bfb093dd..35d61f1b 100644 --- a/app/src/main/res/layout-land/activity_main.xml +++ b/app/src/main/res/layout-land/activity_main.xml @@ -21,6 +21,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="?attr/colorSurfaceContainerLow" + android:translationY="72dp" app:behavior_peekHeight="72dp" app:behavior_hideable="true" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 59298d30..8cfdf448 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -32,6 +32,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="?attr/colorSurfaceContainerLow" + android:translationY="152dp" app:behavior_peekHeight="152dp" app:behavior_hideable="true" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> diff --git a/app/src/main/res/layout/button_extension.xml b/app/src/main/res/layout/button_extension.xml index 051dcb7f..20469f5f 100644 --- a/app/src/main/res/layout/button_extension.xml +++ b/app/src/main/res/layout/button_extension.xml @@ -1,5 +1,5 @@ -