diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-05-29 13:20:32 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-06-09 10:31:21 +0200 |
| commit | f220345dc31a298e4734b985d785cfdd24f03bea (patch) | |
| tree | 5a61991627ade27df670c5932941a5cc50d57b65 | |
| parent | a298585757ad1c8e6c65cb405db9ce9a31dd0b33 (diff) | |
| download | mullvadvpn-f220345dc31a298e4734b985d785cfdd24f03bea.tar.xz mullvadvpn-f220345dc31a298e4734b985d785cfdd24f03bea.zip | |
Replace ApplicationImageView with compose implementation
7 files changed, 71 insertions, 98 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreenTest.kt index dc011abd05..8b2f2394c0 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreenTest.kt @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.compose.screen +import android.graphics.Bitmap import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -22,10 +23,11 @@ import org.koin.dsl.module class SplitTunnelingScreenTest { @get:Rule val composeTestRule = createComposeRule() + private val mockBitmap: Bitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888) private val testModule = module { single { mockk<ApplicationsIconManager>().apply { - every { getAppIcon(any()) } returns mockk(relaxed = true) + every { getAppIcon(any()) } returns mockBitmap } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManager.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManager.kt index ebfbc1f379..e1ff07022c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManager.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManager.kt @@ -1,22 +1,23 @@ package net.mullvad.mullvadvpn.applist import android.content.pm.PackageManager -import android.graphics.drawable.Drawable +import android.graphics.Bitmap import android.os.Looper import androidx.annotation.WorkerThread import androidx.collection.LruCache +import androidx.core.graphics.drawable.toBitmap class ApplicationsIconManager(private val packageManager: PackageManager) { - private val iconsCache = LruCache<String, Drawable>(500) + private val iconsCache = LruCache<String, Bitmap>(500) @WorkerThread @Throws(PackageManager.NameNotFoundException::class) - fun getAppIcon(packageName: String): Drawable { + fun getAppIcon(packageName: String): Bitmap { check(!Looper.getMainLooper().isCurrentThread) { "Should not be called from MainThread" } iconsCache.get(packageName)?.let { return it } - return packageManager.getApplicationIcon(packageName).also { + return packageManager.getApplicationIcon(packageName).toBitmap().also { iconsCache.put(packageName, it) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt index bab0a2caa5..48694332b7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.compose.cell +import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -12,25 +13,29 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.viewinterop.AndroidView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.theme.AppTheme import net.mullvad.mullvadvpn.compose.theme.Dimens import net.mullvad.mullvadvpn.compose.theme.typeface.listItemText -import net.mullvad.mullvadvpn.ui.widget.ApplicationImageView +import org.koin.androidx.compose.get @Preview @Composable -fun PreviewTunnelingCell() { +private fun PreviewTunnelingCell() { AppTheme { Column(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { - SplitTunnelingCell("Mullvad VPN", "", false) - SplitTunnelingCell("Mullvad VPN", "", true) + SplitTunnelingCell(title = "Mullvad VPN", packageName = "", isSelected = false) + SplitTunnelingCell(title = "Mullvad VPN", packageName = "", isSelected = true) } } } @@ -41,8 +46,16 @@ fun SplitTunnelingCell( packageName: String?, isSelected: Boolean, modifier: Modifier = Modifier, + onResolveIcon: (String) -> Bitmap? = { null }, onCellClicked: () -> Unit = {} ) { + var icon by remember(packageName) { mutableStateOf<ImageBitmap?>(null) } + LaunchedEffect(packageName) { + launch(Dispatchers.IO) { + val bitmap = onResolveIcon(packageName ?: "") + icon = bitmap?.asImageBitmap() + } + } Row( modifier = modifier @@ -53,11 +66,10 @@ fun SplitTunnelingCell( .background(MaterialTheme.colorScheme.primaryContainer) .clickable(onClick = onCellClicked) ) { - AndroidView( - factory = { context -> ApplicationImageView(context) }, - update = { applicationImageView -> - applicationImageView.packageName = packageName ?: "" - }, + Image( + painter = icon?.let { iconImage -> BitmapPainter(iconImage) } + ?: painterResource(id = R.drawable.ic_icons_missing), + contentDescription = null, modifier = Modifier.padding(start = Dimens.cellStartPadding) .align(Alignment.CenterVertically) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt index 3c3044a268..e624170479 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.compose.screen +import android.graphics.Bitmap import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -35,6 +36,7 @@ import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState import net.mullvad.mullvadvpn.compose.theme.AppTheme import net.mullvad.mullvadvpn.compose.theme.Dimens +import org.koin.androidx.compose.get @Preview @Composable @@ -48,7 +50,7 @@ fun PreviewSplitTunnelingScreen() { AppData( packageName = "my.package.a", name = "TitleA", - iconRes = R.drawable.icon_alert + iconRes = R.drawable.icon_alert, ), AppData( packageName = "my.package.b", @@ -77,7 +79,8 @@ fun SplitTunnelingScreen( onShowSystemAppsClick: (show: Boolean) -> Unit = {}, onExcludeAppClick: (packageName: String) -> Unit = {}, onIncludeAppClick: (packageName: String) -> Unit = {}, - onBackClick: () -> Unit = {} + onBackClick: () -> Unit = {}, + onResolveIcon: (String) -> Bitmap? = { null }, ) { val state = rememberCollapsingToolbarScaffoldState() val progress = state.toolbarState.progress @@ -161,7 +164,8 @@ fun SplitTunnelingScreen( title = listItem.name, packageName = listItem.packageName, isSelected = true, - modifier = Modifier.animateItemPlacement().fillMaxWidth() + modifier = Modifier.animateItemPlacement().fillMaxWidth(), + onResolveIcon = onResolveIcon ) { onIncludeAppClick(listItem.packageName) } @@ -205,7 +209,8 @@ fun SplitTunnelingScreen( title = listItem.name, packageName = listItem.packageName, isSelected = false, - modifier = Modifier.animateItemPlacement().fillMaxWidth() + modifier = Modifier.animateItemPlacement().fillMaxWidth(), + onResolveIcon = onResolveIcon ) { onExcludeAppClick(listItem.packageName) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt index 59328dc273..854167a03b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt @@ -7,13 +7,16 @@ import android.view.ViewGroup import androidx.compose.runtime.collectAsState import androidx.compose.ui.platform.ComposeView import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.applist.ApplicationsIconManager import net.mullvad.mullvadvpn.compose.screen.SplitTunnelingScreen import net.mullvad.mullvadvpn.compose.theme.AppTheme import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel class SplitTunnelingFragment : BaseFragment() { private val viewModel: SplitTunnelingViewModel by viewModel() + private val applicationsIconManager: ApplicationsIconManager by inject() override fun onCreateView( inflater: LayoutInflater, @@ -29,7 +32,10 @@ class SplitTunnelingFragment : BaseFragment() { onShowSystemAppsClick = viewModel::onShowSystemAppsClick, onExcludeAppClick = viewModel::onExcludeAppClick, onIncludeAppClick = viewModel::onIncludeAppClick, - onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() } + onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }, + onResolveIcon = { packageName -> + applicationsIconManager.getAppIcon(packageName) + } ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ApplicationImageView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ApplicationImageView.kt deleted file mode 100644 index 43a8a58fbe..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ApplicationImageView.kt +++ /dev/null @@ -1,69 +0,0 @@ -package net.mullvad.mullvadvpn.ui.widget - -import android.content.Context -import android.content.pm.PackageManager -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import androidx.appcompat.widget.AppCompatImageView -import androidx.core.content.res.ResourcesCompat -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.applist.ApplicationsIconManager -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -class ApplicationImageView -@JvmOverloads -constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.applicationListItemViewStyle, -) : AppCompatImageView(context, attrs, defStyleAttr), KoinComponent { - private val viewScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val iconManager: ApplicationsIconManager by inject() - - private var updateImageJob: Job? = null - - var packageName: String = "" - set(value) { - field = value - updateImage() - } - - init { - updateImage(ResourcesCompat.getDrawable(resources, R.drawable.ic_icons_missing, null)!!) - } - - private fun updateImage() { - updateImageJob?.cancel() - updateImageJob = viewScope.launch { loadImage()?.let { drawable -> updateImage(drawable) } } - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - updateImage() - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - updateImageJob?.cancel() - viewScope.coroutineContext.cancelChildren() - } - - private suspend fun loadImage(): Drawable? = - withContext(Dispatchers.Default) { - try { - iconManager.getAppIcon(packageName) - } catch (e: PackageManager.NameNotFoundException) { - null - } - } - - private fun updateImage(drawable: Drawable) = setImageDrawable(drawable) -} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManagerTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManagerTest.kt index 8db700f452..3e26a37c2c 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManagerTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManagerTest.kt @@ -1,8 +1,10 @@ package net.mullvad.mullvadvpn.applist import android.content.pm.PackageManager +import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.os.Looper +import androidx.core.graphics.drawable.toBitmap import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -22,6 +24,7 @@ class ApplicationsIconManagerTest { @Before fun setUp() { mockkStatic(Looper::class) + mockkStatic(DRAWABLE_EXTENSION_CLASS) every { Looper.getMainLooper() } returns mockedMainLooper } @@ -33,13 +36,16 @@ class ApplicationsIconManagerTest { @Test fun test_first_time_load_icon_from_PM() { val testPackageName = "test" - val mockedDrawable = mockk<Drawable>() + val mockedBitmap = mockk<Bitmap>() + val mockedDrawable = mockk<Drawable>().apply { every { toBitmap() } returns mockedBitmap } every { mockedPackageManager.getApplicationIcon(testPackageName) } returns mockedDrawable every { mockedMainLooper.isCurrentThread } returns false + every { mockedDrawable.intrinsicWidth } returns 0 + every { mockedDrawable.intrinsicHeight } returns 0 val result = testSubject.getAppIcon(testPackageName) - assertEquals(mockedDrawable, result) + assertEquals(mockedBitmap, result) verify { mockedMainLooper.isCurrentThread mockedPackageManager.getApplicationIcon(testPackageName) @@ -49,15 +55,18 @@ class ApplicationsIconManagerTest { @Test fun test_second_time_load_icon_from_cache() { val testPackageName = "test" - val mockedDrawable = mockk<Drawable>() + val mockedBitmap = mockk<Bitmap>() + val mockedDrawable = mockk<Drawable>().apply { every { toBitmap() } returns mockedBitmap } every { mockedPackageManager.getApplicationIcon(testPackageName) } returns mockedDrawable every { mockedMainLooper.isCurrentThread } returns false + every { mockedDrawable.intrinsicWidth } returns 0 + every { mockedDrawable.intrinsicHeight } returns 0 val result = testSubject.getAppIcon(testPackageName) val result2 = testSubject.getAppIcon(testPackageName) - assertEquals(mockedDrawable, result) - assertEquals(mockedDrawable, result2) + assertEquals(mockedBitmap, result) + assertEquals(mockedBitmap, result2) verify(exactly = 2) { mockedMainLooper.isCurrentThread } verify(exactly = 1) { mockedPackageManager.getApplicationIcon(testPackageName) } } @@ -65,16 +74,19 @@ class ApplicationsIconManagerTest { @Test fun test_second_time_load_icon_from_PM_after_clear() { val testPackageName = "test" - val mockedDrawable = mockk<Drawable>() + val mockedBitmap = mockk<Bitmap>() + val mockedDrawable = mockk<Drawable>().apply { every { toBitmap() } returns mockedBitmap } every { mockedPackageManager.getApplicationIcon(testPackageName) } returns mockedDrawable every { mockedMainLooper.isCurrentThread } returns false + every { mockedDrawable.intrinsicWidth } returns 0 + every { mockedDrawable.intrinsicHeight } returns 0 val result = testSubject.getAppIcon(testPackageName) testSubject.dispose() val result2 = testSubject.getAppIcon(testPackageName) - assertEquals(mockedDrawable, result) - assertEquals(mockedDrawable, result2) + assertEquals(mockedBitmap, result) + assertEquals(mockedBitmap, result2) verify(exactly = 2) { mockedMainLooper.isCurrentThread mockedPackageManager.getApplicationIcon(testPackageName) @@ -91,4 +103,8 @@ class ApplicationsIconManagerTest { } verify { mockedMainLooper.isCurrentThread } } + + companion object { + private const val DRAWABLE_EXTENSION_CLASS = "androidx.core.graphics.drawable.DrawableKt" + } } |
