diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b0178fed..e1c99a88 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -114,6 +114,7 @@ dependencies { // Test libs testImplementation(kotlin("test")) testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testImplementation("io.kotest:kotest-assertions-core:5.9.1") testImplementation("io.mockk:mockk-android:1.13.14") testImplementation("io.mockk:mockk-agent:1.13.14") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1") diff --git a/app/src/main/java/com/jkuester/unlauncher/datasource/AbstractDataRepository.kt b/app/src/main/java/com/jkuester/unlauncher/datasource/AbstractDataRepository.kt index a2bb9185..e0c1d5b3 100644 --- a/app/src/main/java/com/jkuester/unlauncher/datasource/AbstractDataRepository.kt +++ b/app/src/main/java/com/jkuester/unlauncher/datasource/AbstractDataRepository.kt @@ -13,6 +13,7 @@ import java.io.IOException import java.io.InputStream import java.io.OutputStream import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first @@ -40,15 +41,13 @@ abstract class AbstractDataRepository( fun get(): T = runBlocking { dataFlow.first() } - fun updateAsync(transform: suspend (t: T) -> T) = lifecycleScope.launch { + fun updateAsync(transform: suspend (t: T) -> T) = lifecycleScope.launch(Dispatchers.IO) { dataStore.updateData(transform) } } -abstract class AbstractDataSerializer( - getDefaultInstance: () -> T, - private val parseFrom: (InputStream) -> T -) : Serializer where T : GeneratedMessageLite { +abstract class AbstractDataSerializer(getDefaultInstance: () -> T, private val parseFrom: (InputStream) -> T) : + Serializer where T : GeneratedMessageLite { override val defaultValue = getDefaultInstance() override suspend fun readFrom(input: InputStream): T = try { diff --git a/app/src/main/java/com/jkuester/unlauncher/datasource/DataStoreModule.kt b/app/src/main/java/com/jkuester/unlauncher/datasource/DataStoreModule.kt index 42f0213b..cbc52220 100644 --- a/app/src/main/java/com/jkuester/unlauncher/datasource/DataStoreModule.kt +++ b/app/src/main/java/com/jkuester/unlauncher/datasource/DataStoreModule.kt @@ -7,8 +7,6 @@ import com.jkuester.unlauncher.datasource.sharedPrefsMigration as quickButtonSha import com.jkuester.unlauncher.datastore.proto.CorePreferences import com.jkuester.unlauncher.datastore.proto.QuickButtonPreferences import com.jkuester.unlauncher.datastore.proto.UnlauncherApps -import com.sduduzog.slimlauncher.datasource.apps.UnlauncherAppsMigrations -import com.sduduzog.slimlauncher.datasource.apps.UnlauncherAppsSerializer import com.sduduzog.slimlauncher.datasource.coreprefs.CorePreferencesMigrations import com.sduduzog.slimlauncher.datasource.coreprefs.CorePreferencesSerializer import dagger.Module @@ -32,7 +30,7 @@ private val Context.quickButtonPreferencesStore: DataStore by dataStore( fileName = "unlauncher_apps.proto", serializer = UnlauncherAppsSerializer, - produceMigrations = { context -> UnlauncherAppsMigrations().get(context) } + produceMigrations = { listOf(SortAppsMigration) } ) private val Context.corePreferencesStore: DataStore by dataStore( diff --git a/app/src/main/java/com/jkuester/unlauncher/datasource/UnlauncherAppsMigrations.kt b/app/src/main/java/com/jkuester/unlauncher/datasource/UnlauncherAppsMigrations.kt new file mode 100644 index 00000000..808f7a34 --- /dev/null +++ b/app/src/main/java/com/jkuester/unlauncher/datasource/UnlauncherAppsMigrations.kt @@ -0,0 +1,15 @@ +@file:Suppress("ktlint:standard:filename") + +package com.jkuester.unlauncher.datasource + +import androidx.datastore.core.DataMigration +import com.jkuester.unlauncher.datastore.proto.UnlauncherApps + +object SortAppsMigration : DataMigration { + private const val VERSION = 1 + + override suspend fun shouldMigrate(currentData: UnlauncherApps) = currentData.version < VERSION + override suspend fun migrate(currentData: UnlauncherApps) = sortApps(currentData) + .let(setVersion(VERSION)) + override suspend fun cleanUp() {} +} diff --git a/app/src/main/java/com/jkuester/unlauncher/datasource/UnlauncherAppsRepository.kt b/app/src/main/java/com/jkuester/unlauncher/datasource/UnlauncherAppsRepository.kt new file mode 100644 index 00000000..5bea9c28 --- /dev/null +++ b/app/src/main/java/com/jkuester/unlauncher/datasource/UnlauncherAppsRepository.kt @@ -0,0 +1,150 @@ +package com.jkuester.unlauncher.datasource + +import androidx.datastore.core.DataStore +import com.jkuester.unlauncher.datastore.proto.UnlauncherApp +import com.jkuester.unlauncher.datastore.proto.UnlauncherApps +import com.sduduzog.slimlauncher.data.model.App +import com.sduduzog.slimlauncher.models.HomeApp +import dagger.hilt.android.scopes.ActivityScoped +import java.util.Locale +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope + +private fun appMatches(unlauncherApp: UnlauncherApp, packageName: String, className: String) = + unlauncherApp.packageName == packageName && unlauncherApp.className == className +private fun appMatches(app: App): (UnlauncherApp) -> Boolean = { + appMatches(it, app.packageName, app.activityName) +} +private fun appMatches(unlauncherApp: UnlauncherApp): (App) -> Boolean = { + appMatches(unlauncherApp, it.packageName, it.activityName) +} +private fun appMatches(homeApp: HomeApp): (UnlauncherApp) -> Boolean = { + appMatches(it, homeApp.packageName, homeApp.activityName) +} +private fun unlauncherAppMatches(app: UnlauncherApp): (UnlauncherApp) -> Boolean = { + appMatches(it, app.packageName, app.className) +} +private fun findUnlauncherApp(unlauncherApps: List): (HomeApp) -> UnlauncherApp? = { homeApp -> + unlauncherApps.firstOrNull(appMatches(homeApp)) +} +private fun unlauncherAppNotFound(unlauncherApps: List): (UnlauncherApp) -> Boolean = { app -> + unlauncherApps.firstOrNull(unlauncherAppMatches(app)) == null +} +private fun unlauncherAppNotFound(unlauncherApps: UnlauncherApps): (App) -> Boolean = { app -> + unlauncherApps.appsList.firstOrNull(appMatches(app)) == null +} +private fun appNotFound(apps: List): (UnlauncherApp) -> Boolean = { unlauncherApp -> + apps.firstOrNull(appMatches(unlauncherApp)) == null +} + +private fun buildUnlauncherApp(app: App): UnlauncherApp = UnlauncherApp + .newBuilder() + .setPackageName(app.packageName) + .setClassName(app.activityName) + .setUserSerial(app.userSerial) + .setDisplayName(app.appName) + .setDisplayInDrawer(true) + .build() + +private fun buildUnlauncherApps(unlauncherApps: UnlauncherApps, apps: List): UnlauncherApps = + unlauncherApps + .toBuilder() + .clearApps() + .addAllApps(apps) + .build() + +private fun unlauncherAppOrder(app: UnlauncherApp) = app.displayName.uppercase(Locale.getDefault()) + +fun setApps(apps: List): (UnlauncherApps) -> UnlauncherApps = { originalApps -> + val appsToAdd = apps + .filter(unlauncherAppNotFound(originalApps)) + .map(::buildUnlauncherApp) + val appsToRemove = originalApps.appsList + .filter(appNotFound(apps)) + if (appsToAdd.isEmpty() && appsToRemove.isEmpty()) { + originalApps + } else { + originalApps.appsList + .filter { app -> !appsToRemove.contains(app) } + .plus(appsToAdd) + .sortedBy(::unlauncherAppOrder) + .let { buildUnlauncherApps(originalApps, it) } + } +} + +private fun setHomeApp(isHomeApp: Boolean): (UnlauncherApp) -> UnlauncherApp = { + it.toBuilder() + .setHomeApp(isHomeApp) + .setDisplayInDrawer(!isHomeApp) + .build() +} + +fun setHomeApps(apps: List): (UnlauncherApps) -> UnlauncherApps = { originalApps -> + val originalHomeApps = originalApps.appsList + .filter { it.homeApp } + .toSet() + val newHomeApps = apps + .mapNotNull(findUnlauncherApp(originalApps.appsList)) + .toSet() + val appsToRemove = originalHomeApps + .minus(newHomeApps) + .map(setHomeApp(false)) + val appsToAdd = newHomeApps + .minus(originalHomeApps) + .map(setHomeApp(true)) + val modifiedApps = appsToRemove.plus(appsToAdd) + if (modifiedApps.isEmpty()) { + originalApps + } else { + originalApps.appsList + .filter(unlauncherAppNotFound(modifiedApps)) + .plus(modifiedApps) + .sortedBy(::unlauncherAppOrder) + .let { buildUnlauncherApps(originalApps, it) } + } +} + +fun sortApps(unlauncherApps: UnlauncherApps): UnlauncherApps = unlauncherApps + .toBuilder() + .clearApps() + .addAllApps(unlauncherApps.appsList.sortedBy(::unlauncherAppOrder)) + .build() + +private fun updateApp( + appToUpdate: UnlauncherApp, + update: (UnlauncherApp) -> UnlauncherApp +): (UnlauncherApps) -> UnlauncherApps = { originalApps -> + when (val i = originalApps.appsList.indexOf(appToUpdate)) { + -1 -> originalApps + else -> originalApps + .toBuilder() + .setApps(i, update(appToUpdate)) + .build() + } +} + +fun setDisplayName(appToUpdate: UnlauncherApp, displayName: String): (UnlauncherApps) -> UnlauncherApps = + { originalApps -> + updateApp(appToUpdate) { it.toBuilder().setDisplayName(displayName).build() }(originalApps) + .let(::sortApps) + } + +fun setDisplayInDrawer(appToUpdate: UnlauncherApp, displayInDrawer: Boolean): (UnlauncherApps) -> UnlauncherApps = + updateApp(appToUpdate) { it.toBuilder().setDisplayInDrawer(displayInDrawer).build() } + +fun setVersion(version: Int): (UnlauncherApps) -> UnlauncherApps = { it.toBuilder().setVersion(version).build() } + +@ActivityScoped +class UnlauncherAppsRepository @Inject constructor( + unlauncherAppsStore: DataStore, + lifecycleScope: CoroutineScope +) : AbstractDataRepository( + unlauncherAppsStore, + lifecycleScope, + UnlauncherApps::getDefaultInstance +) + +object UnlauncherAppsSerializer : AbstractDataSerializer( + UnlauncherApps::getDefaultInstance, + UnlauncherApps::parseFrom +) diff --git a/app/src/main/java/com/sduduzog/slimlauncher/adapters/AppDrawerAdapter.kt b/app/src/main/java/com/sduduzog/slimlauncher/adapters/AppDrawerAdapter.kt index c18d6895..8639ba65 100644 --- a/app/src/main/java/com/sduduzog/slimlauncher/adapters/AppDrawerAdapter.kt +++ b/app/src/main/java/com/sduduzog/slimlauncher/adapters/AppDrawerAdapter.kt @@ -9,9 +9,9 @@ import android.view.ViewGroup import android.widget.TextView import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.RecyclerView +import com.jkuester.unlauncher.datasource.UnlauncherAppsRepository import com.jkuester.unlauncher.datastore.proto.UnlauncherApp import com.sduduzog.slimlauncher.R -import com.sduduzog.slimlauncher.datasource.apps.UnlauncherAppsRepository import com.sduduzog.slimlauncher.datasource.coreprefs.CorePreferencesRepository import com.sduduzog.slimlauncher.ui.main.HomeFragment import com.sduduzog.slimlauncher.utils.firstUppercase @@ -31,7 +31,7 @@ class AppDrawerAdapter( private var gravity = 3 init { - unlauncherAppsRepo.liveData().observe( + unlauncherAppsRepo.observe( lifecycleOwner ) { unlauncherApps -> apps = unlauncherApps.appsList diff --git a/app/src/main/java/com/sduduzog/slimlauncher/adapters/CustomizeAppDrawerAppsAdapter.kt b/app/src/main/java/com/sduduzog/slimlauncher/adapters/CustomizeAppDrawerAppsAdapter.kt index 3b90259f..666812e8 100644 --- a/app/src/main/java/com/sduduzog/slimlauncher/adapters/CustomizeAppDrawerAppsAdapter.kt +++ b/app/src/main/java/com/sduduzog/slimlauncher/adapters/CustomizeAppDrawerAppsAdapter.kt @@ -6,18 +6,17 @@ import android.view.ViewGroup import android.widget.CheckBox import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.RecyclerView +import com.jkuester.unlauncher.datasource.UnlauncherAppsRepository +import com.jkuester.unlauncher.datasource.setDisplayInDrawer import com.jkuester.unlauncher.datastore.proto.UnlauncherApps import com.sduduzog.slimlauncher.R -import com.sduduzog.slimlauncher.datasource.apps.UnlauncherAppsRepository -class CustomizeAppDrawerAppsAdapter( - lifecycleOwner: LifecycleOwner, - private val appsRepo: UnlauncherAppsRepository -) : RecyclerView.Adapter() { +class CustomizeAppDrawerAppsAdapter(lifecycleOwner: LifecycleOwner, private val appsRepo: UnlauncherAppsRepository) : + RecyclerView.Adapter() { private var apps: UnlauncherApps = UnlauncherApps.getDefaultInstance() init { - appsRepo.liveData().observe(lifecycleOwner, { unlauncherApps -> + appsRepo.observe(lifecycleOwner, { unlauncherApps -> apps = unlauncherApps }) } @@ -29,7 +28,7 @@ class CustomizeAppDrawerAppsAdapter( holder.appName.text = item.displayName holder.appName.isChecked = item.displayInDrawer holder.itemView.setOnClickListener { - appsRepo.updateDisplayInDrawer(item, holder.appName.isChecked) + appsRepo.updateAsync(setDisplayInDrawer(item, holder.appName.isChecked)) } } diff --git a/app/src/main/java/com/sduduzog/slimlauncher/datasource/apps/UnlauncherAppsMigrations.kt b/app/src/main/java/com/sduduzog/slimlauncher/datasource/apps/UnlauncherAppsMigrations.kt deleted file mode 100644 index 2624f390..00000000 --- a/app/src/main/java/com/sduduzog/slimlauncher/datasource/apps/UnlauncherAppsMigrations.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.sduduzog.slimlauncher.datasource.apps - -import android.content.Context -import androidx.datastore.core.DataMigration -import com.jkuester.unlauncher.datastore.proto.UnlauncherApps - -class UnlauncherAppsMigrations { - - fun get(context: Context): List> { - return listOf(object : UnlauncherAppsMigration(1) { - // Re-sort the apps with new alphabetizing scheme - override suspend fun migrate(currentData: UnlauncherApps): UnlauncherApps { - val builder = currentData.toBuilder() - sortAppsAlphabetically(builder) - return updateVersion(builder) - } - }) - } - - abstract class UnlauncherAppsMigration(private val version: Int) : - DataMigration { - override suspend fun shouldMigrate(currentData: UnlauncherApps): Boolean = currentData.version < version - - override suspend fun cleanUp() {} - - fun updateVersion(builder: UnlauncherApps.Builder): UnlauncherApps = builder.setVersion(version).build() - } -} diff --git a/app/src/main/java/com/sduduzog/slimlauncher/datasource/apps/UnlauncherAppsRepository.kt b/app/src/main/java/com/sduduzog/slimlauncher/datasource/apps/UnlauncherAppsRepository.kt deleted file mode 100644 index 518b871d..00000000 --- a/app/src/main/java/com/sduduzog/slimlauncher/datasource/apps/UnlauncherAppsRepository.kt +++ /dev/null @@ -1,176 +0,0 @@ -package com.sduduzog.slimlauncher.datasource.apps - -import android.app.Activity -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.datastore.core.DataStore -import androidx.lifecycle.LiveData -import androidx.lifecycle.asLiveData -import androidx.lifecycle.lifecycleScope -import com.jkuester.unlauncher.datastore.proto.UnlauncherApp -import com.jkuester.unlauncher.datastore.proto.UnlauncherApps -import com.sduduzog.slimlauncher.data.model.App -import com.sduduzog.slimlauncher.models.HomeApp -import dagger.hilt.android.scopes.ActivityScoped -import java.io.IOException -import java.util.Locale -import javax.inject.Inject -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.launch - -@ActivityScoped -class UnlauncherAppsRepository @Inject constructor( - private val unlauncherAppsStore: DataStore, - activity: Activity -) { - private val lifecycleScope = (activity as ComponentActivity).lifecycleScope - private val unlauncherAppsFlow: Flow = - unlauncherAppsStore.data - .catch { exception -> - if (exception is IOException) { - Log.e( - "UnlauncherAppsRepo", - "Error reading Unlauncher apps.", - exception - ) - emit(UnlauncherApps.getDefaultInstance()) - } else { - throw exception - } - } - - fun liveData(): LiveData = unlauncherAppsFlow.asLiveData() - - suspend fun setApps(apps: List) { - unlauncherAppsStore.updateData { unlauncherApps -> - val unlauncherAppsBuilder = unlauncherApps.toBuilder() - // Add any new apps - var appAdded = false - apps.filter { app -> - findApp( - unlauncherAppsBuilder.appsList, - app.packageName, - app.activityName - ) == null - }.forEach { app -> - unlauncherAppsBuilder.addApps( - UnlauncherApp.newBuilder().setPackageName(app.packageName) - .setClassName(app.activityName).setUserSerial(app.userSerial) - .setDisplayName(app.appName).setDisplayInDrawer(true) - ) - appAdded = true - } - // Remove any apps that no longer exist - unlauncherApps.appsList.filter { unlauncherApp -> - apps.find { app -> - unlauncherApp.packageName == app.packageName && - unlauncherApp.className == app.activityName - } == null - }.forEach { unlauncherApp -> - unlauncherAppsBuilder.removeApps( - unlauncherAppsBuilder.appsList.indexOf( - unlauncherApp - ) - ) - } - - if (appAdded) { - sortAppsAlphabetically(unlauncherAppsBuilder) - } - unlauncherAppsBuilder.build() - } - } - - suspend fun setHomeApps(apps: List) { - unlauncherAppsStore.updateData { unlauncherApps -> - val unlauncherAppsBuilder = unlauncherApps.toBuilder() - val unlauncherHomeApps = mutableListOf() - - // Set home apps - apps.forEach { homeApp -> - findApp( - unlauncherAppsBuilder.appsList, - homeApp.packageName, - homeApp.activityName - )?.let { unlauncherApp -> - if (!unlauncherApp.homeApp) { - val index = unlauncherAppsBuilder.appsList.indexOf(unlauncherApp) - if (index >= 0) { - unlauncherAppsBuilder.setApps( - index, - unlauncherApp.toBuilder().setHomeApp(true) - .setDisplayInDrawer(false) - .build() - ) - } - } - unlauncherHomeApps.add(unlauncherApp) - } - } - - // Clear out old home apps - unlauncherAppsBuilder.appsList - .filter { findApp(unlauncherHomeApps, it.packageName, it.className) == null } - .filter { it.homeApp } - .forEach { unlauncherApp -> - val index = unlauncherAppsBuilder.appsList.indexOf(unlauncherApp) - if (index >= 0) { - unlauncherAppsBuilder.setApps( - index, - unlauncherApp.toBuilder().setHomeApp(false).setDisplayInDrawer(true) - .build() - ) - } - } - - unlauncherAppsBuilder.build() - } - } - - fun updateDisplayName(appToUpdate: UnlauncherApp, displayName: String) { - lifecycleScope.launch { - unlauncherAppsStore.updateData { currentApps -> - val builder = currentApps.toBuilder() - val index = builder.appsList.indexOf(appToUpdate) - if (index >= 0) { - builder.setApps( - index, - appToUpdate.toBuilder().setDisplayName(displayName) - ) - } - - sortAppsAlphabetically(builder) - builder.build() - } - } - } - - fun updateDisplayInDrawer(appToUpdate: UnlauncherApp, displayInDrawer: Boolean) { - lifecycleScope.launch { - unlauncherAppsStore.updateData { currentApps -> - val builder = currentApps.toBuilder() - val index = builder.appsList.indexOf(appToUpdate) - if (index >= 0) { - builder.setApps( - index, - appToUpdate.toBuilder().setDisplayInDrawer(displayInDrawer) - ) - } - builder.build() - } - } - } - - private fun findApp(unlauncherApps: List, packageName: String, className: String): UnlauncherApp? = - unlauncherApps.firstOrNull { app -> - packageName == app.packageName && className == app.className - } -} - -fun sortAppsAlphabetically(unlauncherAppsBuilder: UnlauncherApps.Builder) { - val sortedApps = - unlauncherAppsBuilder.appsList.sortedBy { it.displayName.uppercase(Locale.getDefault()) } - unlauncherAppsBuilder.clearApps() - unlauncherAppsBuilder.addAllApps(sortedApps) -} diff --git a/app/src/main/java/com/sduduzog/slimlauncher/datasource/apps/UnlauncherAppsSerializer.kt b/app/src/main/java/com/sduduzog/slimlauncher/datasource/apps/UnlauncherAppsSerializer.kt deleted file mode 100644 index f71d7880..00000000 --- a/app/src/main/java/com/sduduzog/slimlauncher/datasource/apps/UnlauncherAppsSerializer.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.sduduzog.slimlauncher.datasource.apps - -import androidx.datastore.core.CorruptionException -import androidx.datastore.core.Serializer -import com.google.protobuf.InvalidProtocolBufferException -import com.jkuester.unlauncher.datastore.proto.UnlauncherApps -import java.io.InputStream -import java.io.OutputStream - -/** - * Serializer for the [UnlauncherApps] object defined in quick_button_preferences.proto. - */ -object UnlauncherAppsSerializer : Serializer { - override val defaultValue: UnlauncherApps = UnlauncherApps.getDefaultInstance() - - @Suppress("BlockingMethodInNonBlockingContext") - override suspend fun readFrom(input: InputStream): UnlauncherApps { - try { - return UnlauncherApps.parseFrom(input) - } catch (exception: InvalidProtocolBufferException) { - throw CorruptionException("Cannot read proto.", exception) - } - } - - @Suppress("BlockingMethodInNonBlockingContext") - override suspend fun writeTo(t: UnlauncherApps, output: OutputStream) = t.writeTo(output) -} diff --git a/app/src/main/java/com/sduduzog/slimlauncher/ui/dialogs/RenameAppDisplayNameDialog.kt b/app/src/main/java/com/sduduzog/slimlauncher/ui/dialogs/RenameAppDisplayNameDialog.kt index 1d0080b5..9e751dde 100644 --- a/app/src/main/java/com/sduduzog/slimlauncher/ui/dialogs/RenameAppDisplayNameDialog.kt +++ b/app/src/main/java/com/sduduzog/slimlauncher/ui/dialogs/RenameAppDisplayNameDialog.kt @@ -6,10 +6,11 @@ import android.widget.EditText import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment +import com.jkuester.unlauncher.datasource.UnlauncherAppsRepository +import com.jkuester.unlauncher.datasource.setDisplayName import com.jkuester.unlauncher.datastore.proto.UnlauncherApp import com.sduduzog.slimlauncher.R import com.sduduzog.slimlauncher.databinding.RenameDialogEditTextBinding -import com.sduduzog.slimlauncher.datasource.apps.UnlauncherAppsRepository class RenameAppDisplayNameDialog : DialogFragment() { private lateinit var app: UnlauncherApp @@ -39,7 +40,7 @@ class RenameAppDisplayNameDialog : DialogFragment() { private fun updateApp(newName: String) { if (newName.isNotEmpty()) { - unlauncherAppsRepo.updateDisplayName(app, newName) + unlauncherAppsRepo.updateAsync(setDisplayName(app, newName)) } else { Toast.makeText( context, diff --git a/app/src/main/java/com/sduduzog/slimlauncher/ui/main/HomeFragment.kt b/app/src/main/java/com/sduduzog/slimlauncher/ui/main/HomeFragment.kt index 3ef61a56..8ff64cb1 100644 --- a/app/src/main/java/com/sduduzog/slimlauncher/ui/main/HomeFragment.kt +++ b/app/src/main/java/com/sduduzog/slimlauncher/ui/main/HomeFragment.kt @@ -35,7 +35,11 @@ import androidx.lifecycle.lifecycleScope import androidx.navigation.Navigation import androidx.recyclerview.widget.LinearLayoutManager import com.jkuester.unlauncher.datasource.QuickButtonPreferencesRepository +import com.jkuester.unlauncher.datasource.UnlauncherAppsRepository import com.jkuester.unlauncher.datasource.getIconResourceId +import com.jkuester.unlauncher.datasource.setApps +import com.jkuester.unlauncher.datasource.setDisplayInDrawer +import com.jkuester.unlauncher.datasource.setHomeApps import com.jkuester.unlauncher.datastore.proto.ClockType import com.jkuester.unlauncher.datastore.proto.SearchBarPosition import com.jkuester.unlauncher.datastore.proto.UnlauncherApp @@ -45,7 +49,6 @@ import com.sduduzog.slimlauncher.adapters.HomeAdapter import com.sduduzog.slimlauncher.databinding.HomeFragmentBottomBinding import com.sduduzog.slimlauncher.databinding.HomeFragmentContentBinding import com.sduduzog.slimlauncher.databinding.HomeFragmentDefaultBinding -import com.sduduzog.slimlauncher.datasource.apps.UnlauncherAppsRepository import com.sduduzog.slimlauncher.datasource.coreprefs.CorePreferencesRepository import com.sduduzog.slimlauncher.models.HomeApp import com.sduduzog.slimlauncher.models.MainViewModel @@ -115,10 +118,7 @@ class HomeFragment : } ) - // Set the home apps in the Unlauncher data - lifecycleScope.launch { - unlauncherAppsRepo.setHomeApps(apps) - } + unlauncherAppsRepo.updateAsync(setHomeApps(apps)) } } @@ -192,8 +192,8 @@ class HomeFragment : private fun refreshApps() { val installedApps = getInstalledApps() + unlauncherAppsRepo.updateAsync(setApps(installedApps)) lifecycleScope.launch(Dispatchers.IO) { - unlauncherAppsRepo.setApps(installedApps) viewModel.filterHomeApps(installedApps) } } @@ -447,7 +447,7 @@ class HomeFragment : startActivity(intent) } R.id.hide -> { - unlauncherAppsRepo.updateDisplayInDrawer(app, false) + unlauncherAppsRepo.updateAsync(setDisplayInDrawer(app, false)) Toast.makeText( context, "Unhide under Unlauncher's Options > Customize Drawer > Visible Apps", diff --git a/app/src/main/java/com/sduduzog/slimlauncher/ui/options/CustomizeAppDrawerAppListFragment.kt b/app/src/main/java/com/sduduzog/slimlauncher/ui/options/CustomizeAppDrawerAppListFragment.kt index d579496d..dc755dd2 100644 --- a/app/src/main/java/com/sduduzog/slimlauncher/ui/options/CustomizeAppDrawerAppListFragment.kt +++ b/app/src/main/java/com/sduduzog/slimlauncher/ui/options/CustomizeAppDrawerAppListFragment.kt @@ -4,10 +4,10 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import com.jkuester.unlauncher.datasource.UnlauncherAppsRepository import com.sduduzog.slimlauncher.R import com.sduduzog.slimlauncher.adapters.CustomizeAppDrawerAppsAdapter import com.sduduzog.slimlauncher.databinding.CustomizeAppDrawerAppListFragmentBinding -import com.sduduzog.slimlauncher.datasource.apps.UnlauncherAppsRepository import com.sduduzog.slimlauncher.utils.BaseFragment import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -32,13 +32,12 @@ class CustomizeAppDrawerAppListFragment : BaseFragment() { ) customiseAppDrawerAppListFragment.customizeAppDrawerFragmentAppList.adapter = CustomizeAppDrawerAppsAdapter(viewLifecycleOwner, unlauncherAppsRepo) - unlauncherAppsRepo.liveData().observe(viewLifecycleOwner) { - it?.let { - customiseAppDrawerAppListFragment.customizeAppDrawerFragmentAppProgressBar - .visibility = View.GONE - } ?: run { - customiseAppDrawerAppListFragment.customizeAppDrawerFragmentAppProgressBar + unlauncherAppsRepo.observe(viewLifecycleOwner) { + when (it.appsList.isEmpty()) { + true -> customiseAppDrawerAppListFragment.customizeAppDrawerFragmentAppProgressBar .visibility = View.VISIBLE + false -> customiseAppDrawerAppListFragment.customizeAppDrawerFragmentAppProgressBar + .visibility = View.GONE } } customiseAppDrawerAppListFragment.customizeAppDrawerFragmentBack.setOnClickListener { diff --git a/app/src/test/java/com/jkuester/unlauncher/datasource/UnlauncherAppsMigrationsTest.kt b/app/src/test/java/com/jkuester/unlauncher/datasource/UnlauncherAppsMigrationsTest.kt new file mode 100644 index 00000000..007ebdbf --- /dev/null +++ b/app/src/test/java/com/jkuester/unlauncher/datasource/UnlauncherAppsMigrationsTest.kt @@ -0,0 +1,64 @@ +package com.jkuester.unlauncher.datasource + +import com.jkuester.unlauncher.datastore.proto.UnlauncherApps +import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@MockKExtension.CheckUnnecessaryStub +@MockKExtension.ConfirmVerification +@ExtendWith(MockKExtension::class) +class UnlauncherAppsMigrationsTest { + @ParameterizedTest + @CsvSource( + "-1, true", + "0, true", + "1, false", + "2, false" + ) + fun sortAppsMigration_shouldMigrate(version: String, expected: String) = runTest { + val apps = UnlauncherApps + .newBuilder() + .build() + .let(setVersion(version.toInt())) + + val shouldMigrate = SortAppsMigration.shouldMigrate(apps) + + shouldMigrate shouldBe expected.toBoolean() + } + + @Test + fun sortAppsMigration_migrate() = runTest { + val originalApps = mockk() + val sortedApps = mockk() + val migratedApps = mockk() + val mockSetVersion = mockk<(t: UnlauncherApps) -> UnlauncherApps>() + every { mockSetVersion(any()) } returns migratedApps + mockkStatic(::setVersion, ::sortApps) + every { setVersion(any()) } returns mockSetVersion + every { sortApps(any()) } returns sortedApps + + val result = SortAppsMigration.migrate(originalApps) + + result shouldBe migratedApps + verify(exactly = 1) { sortApps(originalApps) } + verify(exactly = 1) { mockSetVersion(sortedApps) } + verify(exactly = 1) { setVersion(1) } + } + + @Test + fun sortAppsMigration_cleanUp() = runTest { + shouldNotThrow { SortAppsMigration.cleanUp() } + } +} diff --git a/app/src/test/java/com/jkuester/unlauncher/datasource/UnlauncherAppsRepositoryTest.kt b/app/src/test/java/com/jkuester/unlauncher/datasource/UnlauncherAppsRepositoryTest.kt new file mode 100644 index 00000000..198e091f --- /dev/null +++ b/app/src/test/java/com/jkuester/unlauncher/datasource/UnlauncherAppsRepositoryTest.kt @@ -0,0 +1,262 @@ +package com.jkuester.unlauncher.datasource + +import androidx.datastore.core.DataStore +import com.jkuester.unlauncher.datastore.proto.UnlauncherApp +import com.jkuester.unlauncher.datastore.proto.UnlauncherApps +import com.sduduzog.slimlauncher.data.model.App +import com.sduduzog.slimlauncher.models.HomeApp +import io.kotest.matchers.Matcher +import io.kotest.matchers.MatcherResult +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.extension.ExtendWith + +private fun match(expected: App) = Matcher { actual -> + MatcherResult( + actual.packageName == expected.packageName && + actual.className == expected.activityName && + actual.displayName == expected.appName, + { "App $actual should match $expected" }, + { "App $actual should not match $expected" }, + ) +} + +private val app0 = App("appName0", "packageName0", "activityName0", 0) +private val app1 = App("appName1", "packageName1", "activityName1", 0) +private val app2 = App("appName2", "packageName2", "activityName2", 0) +private val unlauncherApp0 = UnlauncherApp + .newBuilder() + .setPackageName(app0.packageName) + .setClassName(app0.activityName) + .setDisplayName(app0.appName) + .build() +private val unlauncherApp1 = UnlauncherApp + .newBuilder() + .setPackageName(app1.packageName) + .setClassName(app1.activityName) + .setDisplayName(app1.appName) + .build() +private val unlauncherApp2 = UnlauncherApp + .newBuilder() + .setPackageName(app2.packageName) + .setClassName(app2.activityName) + .setDisplayName(app2.appName) + .build() + +@MockKExtension.CheckUnnecessaryStub +@MockKExtension.ConfirmVerification +@ExtendWith(MockKExtension::class) +class UnlauncherAppsRepositoryTest { + @Test + fun setApps_currentAppsEmpty() { + val originalApps = UnlauncherApps.newBuilder().build() + val newApps = listOf(app2, app0, app1) + + val updatedApps = setApps(newApps)(originalApps) + + updatedApps.appsList shouldHaveSize 3 + updatedApps.appsList[0] should match(app0) + updatedApps.appsList[1] should match(app1) + updatedApps.appsList[2] should match(app2) + } + + @Test + fun setApps_currentAppsMatch() { + val originalApps = UnlauncherApps + .newBuilder() + .addAllApps(listOf(unlauncherApp0, unlauncherApp1, unlauncherApp2)) + .build() + val newApps = listOf(app0, app1, app2) + + val updatedApps = setApps(newApps)(originalApps) + + updatedApps shouldBe originalApps + } + + @Test + fun setApps_someCurrentAppsDoNotMatch() { + val originalApps = UnlauncherApps + .newBuilder() + .addAllApps(listOf(unlauncherApp0, unlauncherApp1)) + .build() + val newApps = listOf(app1, app2) + + val updatedApps = setApps(newApps)(originalApps) + + updatedApps.appsList shouldHaveSize 2 + updatedApps.appsList[0] should match(app1) + updatedApps.appsList[1] should match(app2) + } + + @Test + fun setApps_somePartialMatches() { + val originalApps = UnlauncherApps + .newBuilder() + .addAllApps(listOf(unlauncherApp0, unlauncherApp1)) + .build() + val newApps = listOf( + app0.copy(packageName = "different"), + app1.copy(activityName = "different") + ) + + val updatedApps = setApps(newApps)(originalApps) + + updatedApps.appsList shouldHaveSize 2 + updatedApps.appsList[0] should match(newApps[0]) + updatedApps.appsList[1] should match(newApps[1]) + } + + @Test + fun setApps_newAppsEmpty() { + val originalApps = UnlauncherApps + .newBuilder() + .addAllApps(listOf(unlauncherApp0, unlauncherApp1)) + .build() + val newApps = emptyList() + + val updatedApps = setApps(newApps)(originalApps) + + updatedApps.appsList.shouldBeEmpty() + } + + @Test + fun setHomeApps_currentAppsEmpty() { + val originalApps = UnlauncherApps.newBuilder().build() + val homeApps = listOf(app2, app0, app1) + .mapIndexed { index, app -> HomeApp.from(app, index) } + + val updatedApps = setHomeApps(homeApps)(originalApps) + + updatedApps.appsList.shouldBeEmpty() + } + + @Test + fun setHomeApps_allAlreadyHome() { + val homeApps = listOf(app2, app0, app1) + .mapIndexed { index, app -> HomeApp.from(app, index) } + val originalApps = UnlauncherApps + .newBuilder() + .addAllApps( + listOf(unlauncherApp0, unlauncherApp1, unlauncherApp2) + .map { it.toBuilder().setHomeApp(true).build() } + ) + .build() + + val updatedApps = setHomeApps(homeApps)(originalApps) + + updatedApps shouldBe originalApps + } + + @Test + fun setHomeApps_someAlreadyHome() { + val homeApps = listOf(app0, app1) + .mapIndexed { index, app -> HomeApp.from(app, index) } + val originalApps = UnlauncherApps + .newBuilder() + .addAllApps( + listOf(unlauncherApp1, unlauncherApp2) + .map { it.toBuilder().setHomeApp(true).build() } + .plus(unlauncherApp0) + ) + .build() + + val updatedApps = setHomeApps(homeApps)(originalApps) + + updatedApps.appsList shouldHaveSize 3 + updatedApps.appsList[0].homeApp shouldBe true + updatedApps.appsList[1].homeApp shouldBe true + updatedApps.appsList[2].homeApp shouldBe false + } + + @Test + fun sortApps() { + val originalApps = UnlauncherApps + .newBuilder() + .addAllApps(listOf(unlauncherApp2, unlauncherApp0, unlauncherApp1)) + .build() + + val updatedApps = sortApps(originalApps) + + updatedApps.appsList shouldContainExactly listOf(unlauncherApp0, unlauncherApp1, unlauncherApp2) + } + + @Test + fun setDisplayName() { + val originalApps = UnlauncherApps + .newBuilder() + .addAllApps(listOf(unlauncherApp0, unlauncherApp1, unlauncherApp2)) + .build() + val newName = "Zello" + + val updatedApps = setDisplayName(unlauncherApp1, newName)(originalApps) + + updatedApps.appsList shouldHaveSize 3 + updatedApps.appsList[0] should match(app0) + updatedApps.appsList[1] should match(app2) + updatedApps.appsList[2].displayName shouldBe newName + updatedApps.appsList[2].packageName shouldBe unlauncherApp1.packageName + updatedApps.appsList[2].className shouldBe unlauncherApp1.className + } + + @Test + fun setDisplayName_appNotFound() { + val originalApps = UnlauncherApps + .newBuilder() + .addAllApps(listOf(unlauncherApp0, unlauncherApp2)) + .build() + val newName = "Zello" + + val updatedApps = setDisplayName(unlauncherApp1, newName)(originalApps) + + updatedApps shouldBe originalApps + } + + @Test + fun setDisplayInDrawer() { + val originalApps = UnlauncherApps + .newBuilder() + .addAllApps(listOf(unlauncherApp0, unlauncherApp1, unlauncherApp2)) + .build() + + val updatedApps = setDisplayInDrawer(unlauncherApp1, true)(originalApps) + + updatedApps.appsList shouldHaveSize 3 + updatedApps.appsList[0] should match(app0) + updatedApps.appsList[1].displayInDrawer shouldBe true + updatedApps.appsList[1].packageName shouldBe unlauncherApp1.packageName + updatedApps.appsList[1].className shouldBe unlauncherApp1.className + updatedApps.appsList[2] should match(app2) + } + + @Test + fun setVersion() { + val originalApps = UnlauncherApps + .newBuilder() + .setVersion(1) + .build() + val version = 2 + + val updatedApps = setVersion(version)(originalApps) + + updatedApps.version shouldBe version + } + + @Test + fun constructUnlauncherAppsRepository() = runTest { + val dataStore = mockk>() + every { dataStore.data } returns emptyFlow() + assertDoesNotThrow { UnlauncherAppsRepository(dataStore, backgroundScope) } + verify(exactly = 1) { dataStore.data } + } +}