From 6ca440f553d8b09b96543517b9337883a2b0ddcc Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Sat, 15 Jan 2022 14:31:13 +0100 Subject: [PATCH] Refactor badges and notifications, migrate badge settings --- .../de/mm20/launcher2/LauncherApplication.kt | 2 + .../fragment/PreferencesBadgesFragment.kt | 11 -- applications/build.gradle.kts | 1 - .../launcher2/applications/AppRepository.kt | 39 ++--- .../de/mm20/launcher2/applications/Module.kt | 2 +- .../launcher2/search/data/AppSerialization.kt | 8 - .../mm20/launcher2/search/data/AppShortcut.kt | 7 - badges/build.gradle.kts | 2 + .../mm20/launcher2/badges/BadgeRepository.kt | 88 +++++++++++ .../java/de/mm20/launcher2/badges/Module.kt | 1 + .../providers/AppShortcutBadgeProvider.kt | 38 +++++ .../badges/providers/BadgeProvider.kt | 13 ++ .../badges/providers/CloudBadgeProvider.kt | 19 +++ .../providers/NotificationBadgeProvider.kt | 34 +++++ .../providers/SuspendedAppsBadgeProvider.kt | 33 ++++ .../providers/WorkProfileBadgeProvider.kt | 28 ++++ music/build.gradle.kts | 1 + .../java/de/mm20/launcher2/music/Module.kt | 2 +- .../mm20/launcher2/music/MusicRepository.kt | 35 ++++- notifications/build.gradle.kts | 2 - .../de/mm20/launcher2/notifications/Module.kt | 7 + .../notifications/NotificationRepository.kt | 63 ++++++++ .../notifications/NotificationService.kt | 80 +--------- .../de/mm20/launcher2/preferences/Defaults.kt | 9 +- preferences/src/main/proto/settings.proto | 8 + .../search/ApplicationDetailRepresentation.kt | 144 +++++++++++------- .../legacy/search/BasicGridRepresentation.kt | 31 +++- .../search/ContactDetailRepresentation.kt | 32 +++- .../search/ContactListRepresentation.kt | 32 +++- .../legacy/search/FileDetailRepresentation.kt | 101 ++++++++---- .../legacy/search/FileListRepresentation.kt | 44 ++++-- .../search/ShortcutDetailRepresentation.kt | 55 +++++-- .../search/WebsiteDetailRepresentation.kt | 44 ++++-- .../search/WebsiteListRepresentation.kt | 29 +++- .../ui/legacy/searchable/SearchableView.kt | 24 ++- .../ui/legacy/view/LauncherIconView.kt | 25 +-- .../launcher2/ui/settings/SettingsActivity.kt | 4 + .../ui/settings/badges/BadgeSettingsScreen.kt | 55 +++++++ .../settings/badges/BadgeSettingsScreenVM.kt | 71 +++++++++ .../res/layout/view_application_detail.xml | 16 +- 40 files changed, 937 insertions(+), 303 deletions(-) create mode 100644 badges/src/main/java/de/mm20/launcher2/badges/BadgeRepository.kt create mode 100644 badges/src/main/java/de/mm20/launcher2/badges/providers/AppShortcutBadgeProvider.kt create mode 100644 badges/src/main/java/de/mm20/launcher2/badges/providers/BadgeProvider.kt create mode 100644 badges/src/main/java/de/mm20/launcher2/badges/providers/CloudBadgeProvider.kt create mode 100644 badges/src/main/java/de/mm20/launcher2/badges/providers/NotificationBadgeProvider.kt create mode 100644 badges/src/main/java/de/mm20/launcher2/badges/providers/SuspendedAppsBadgeProvider.kt create mode 100644 badges/src/main/java/de/mm20/launcher2/badges/providers/WorkProfileBadgeProvider.kt create mode 100644 notifications/src/main/java/de/mm20/launcher2/notifications/Module.kt create mode 100644 notifications/src/main/java/de/mm20/launcher2/notifications/NotificationRepository.kt create mode 100644 ui/src/main/java/de/mm20/launcher2/ui/settings/badges/BadgeSettingsScreen.kt create mode 100644 ui/src/main/java/de/mm20/launcher2/ui/settings/badges/BadgeSettingsScreenVM.kt diff --git a/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt b/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt index 3aa4d2d5..081abd5a 100644 --- a/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt +++ b/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt @@ -21,6 +21,7 @@ import de.mm20.launcher2.websites.websitesModule import de.mm20.launcher2.widgets.widgetsModule import de.mm20.launcher2.wikipedia.wikipediaModule import de.mm20.launcher2.database.databaseModule +import de.mm20.launcher2.notifications.notificationsModule import de.mm20.launcher2.permissions.permissionsModule import de.mm20.launcher2.preferences.preferencesModule import de.mm20.launcher2.weather.weatherModule @@ -68,6 +69,7 @@ class LauncherApplication : Application(), CoroutineScope { hiddenItemsModule, iconsModule, musicModule, + notificationsModule, permissionsModule, preferencesModule, searchModule, diff --git a/app/src/main/java/de/mm20/launcher2/fragment/PreferencesBadgesFragment.kt b/app/src/main/java/de/mm20/launcher2/fragment/PreferencesBadgesFragment.kt index 33bbb2d5..9b2ddb65 100644 --- a/app/src/main/java/de/mm20/launcher2/fragment/PreferencesBadgesFragment.kt +++ b/app/src/main/java/de/mm20/launcher2/fragment/PreferencesBadgesFragment.kt @@ -5,37 +5,26 @@ import androidx.appcompat.app.AppCompatActivity import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import de.mm20.launcher2.R -import de.mm20.launcher2.badges.BadgeProvider -import de.mm20.launcher2.notifications.NotificationService -import org.koin.android.ext.android.inject class PreferencesBadgesFragment : PreferenceFragmentCompat() { - private val badgesProvider: BadgeProvider by inject() - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.preferences_badges) findPreference("notification_badges")?.setOnPreferenceChangeListener { _, newValue -> if (newValue as Boolean) { - NotificationService.getInstance()?.generateBadges() } else { - badgesProvider.removeNotificationBadges() } true } findPreference("suspended_badges")?.setOnPreferenceChangeListener { _, newValue -> if (newValue as Boolean) { - badgesProvider.addSuspendBadges() } else { - badgesProvider.removeSuspendBadges() } true } findPreference("cloud_badges")?.setOnPreferenceChangeListener { _, newValue -> if (newValue as Boolean) { - badgesProvider.addCloudBadges() } else { - badgesProvider.removeCloudBadges() } true } diff --git a/applications/build.gradle.kts b/applications/build.gradle.kts index 1357e41c..49b7ca32 100644 --- a/applications/build.gradle.kts +++ b/applications/build.gradle.kts @@ -47,7 +47,6 @@ dependencies { implementation(project(":base")) implementation(project(":preferences")) implementation(project(":ktx")) - implementation(project(":badges")) implementation(project(":hiddenitems")) implementation(project(":compat")) diff --git a/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt b/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt index 0a835652..1bfdeb20 100644 --- a/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt +++ b/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt @@ -9,9 +9,8 @@ import android.content.pm.PackageInstaller import android.content.pm.ShortcutInfo import android.os.Process import android.os.UserHandle +import android.os.UserManager import android.util.Log -import de.mm20.launcher2.badges.Badge -import de.mm20.launcher2.badges.BadgeProvider import de.mm20.launcher2.hiddenitems.HiddenItemsRepository import de.mm20.launcher2.preferences.LauncherPreferences import de.mm20.launcher2.search.data.AppInstallation @@ -23,12 +22,12 @@ import kotlinx.coroutines.withContext interface AppRepository { fun search(query: String): Flow> + fun getSuspendedPackages(): Flow> } class AppRepositoryImpl( private val context: Context, hiddenItemsRepository: HiddenItemsRepository, - private val badgeProvider: BadgeProvider ) : AppRepository { private val launcherApps = @@ -37,6 +36,7 @@ class AppRepositoryImpl( private val installedApps = MutableStateFlow>(emptyList()) private val installations = MutableStateFlow>(mutableListOf()) private val hiddenItems = hiddenItemsRepository.hiddenItemsKeys + private val suspendedPackages = MutableStateFlow>(emptyList()) private val profiles: List = @@ -101,12 +101,8 @@ class AppRepositoryImpl( override fun onPackagesSuspended(packageNames: Array?, user: UserHandle?) { super.onPackagesSuspended(packageNames, user) - packageNames?.forEach { - badgeProvider.setBadge( - "app://$it", - Badge(iconRes = R.drawable.ic_badge_suspended) - ) - } + packageNames ?: return + suspendedPackages.value = suspendedPackages.value + packageNames } override fun onPackagesUnsuspended( @@ -114,9 +110,8 @@ class AppRepositoryImpl( user: UserHandle? ) { super.onPackagesUnsuspended(packageNames, user) - packageNames?.forEach { - badgeProvider.removeBadge("app://$it") - } + packageNames ?: return + suspendedPackages.value = suspendedPackages.value.filter { packageNames.contains(it) } } }) @@ -130,15 +125,6 @@ class AppRepositoryImpl( packageInstaller.registerSessionCallback(object : PackageInstaller.SessionCallback() { override fun onProgressChanged(sessionId: Int, progress: Float) { val session = packageInstaller.getSessionInfo(sessionId) ?: return - val pkg = session.appPackageName ?: return - if (!installingPackages.containsKey(sessionId)) { - val key = "app://$pkg" - val badge = badgeProvider.getBadge(key)?.also { it.progress = null } - ?: Badge() - badgeProvider.setBadge(key, badge) - return - } - badgeProvider.updateBadge("app://$pkg", Badge(progress = progress)) } override fun onActiveChanged(sessionId: Int, active: Boolean) { @@ -149,10 +135,6 @@ class AppRepositoryImpl( override fun onFinished(sessionId: Int, success: Boolean) { val pkg = installingPackages[sessionId] installingPackages.remove(sessionId) - val key = "app://$pkg" - val badge = badgeProvider.getBadge(key)?.apply { progress = null } - ?: Badge() - badgeProvider.setBadge(key, badge) val inst = installations.value inst.removeAll { it.session.sessionId == sessionId @@ -172,7 +154,7 @@ class AppRepositoryImpl( override fun onCreated(sessionId: Int) { val session = packageInstaller.getSessionInfo(sessionId) ?: return installingPackages[sessionId] = session.appPackageName ?: return - if (installedApps.value?.any { it.`package` == session.appPackageName } == true) return + if (installedApps.value.any { it.`package` == session.appPackageName }) return if (session.appLabel.isNullOrBlank() || !session.isActive) return val appInstallation = AppInstallation(session) val inst = installations.value ?: mutableListOf() @@ -182,6 +164,11 @@ class AppRepositoryImpl( }) } + + override fun getSuspendedPackages(): Flow> { + return suspendedPackages + } + private fun getApplications(packageName: String): List { if (packageName == context.packageName) return emptyList() diff --git a/applications/src/main/java/de/mm20/launcher2/applications/Module.kt b/applications/src/main/java/de/mm20/launcher2/applications/Module.kt index fe879c42..1bb7be8f 100644 --- a/applications/src/main/java/de/mm20/launcher2/applications/Module.kt +++ b/applications/src/main/java/de/mm20/launcher2/applications/Module.kt @@ -5,5 +5,5 @@ import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module val applicationsModule = module { - single { AppRepositoryImpl(androidContext(), get(), get()) } + single { AppRepositoryImpl(androidContext(), get()) } } \ No newline at end of file diff --git a/applications/src/main/java/de/mm20/launcher2/search/data/AppSerialization.kt b/applications/src/main/java/de/mm20/launcher2/search/data/AppSerialization.kt index 1b3b8b45..d0a9ad15 100644 --- a/applications/src/main/java/de/mm20/launcher2/search/data/AppSerialization.kt +++ b/applications/src/main/java/de/mm20/launcher2/search/data/AppSerialization.kt @@ -10,13 +10,11 @@ import android.os.Process import android.os.UserManager import androidx.annotation.RequiresApi import androidx.core.content.getSystemService -import de.mm20.launcher2.badges.BadgeProvider import de.mm20.launcher2.ktx.jsonObjectOf import de.mm20.launcher2.search.SearchableDeserializer import de.mm20.launcher2.search.SearchableSerializer import org.json.JSONObject import org.koin.core.component.KoinComponent -import org.koin.core.component.inject class LauncherAppSerializer : SearchableSerializer { override fun serialize(searchable: Searchable): String { @@ -71,7 +69,6 @@ class AppShortcutDeserializer( @RequiresApi(Build.VERSION_CODES.N_MR1) override fun deserialize(serialized: String): Searchable? { - val badgeProvider: BadgeProvider by inject() val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps if (!launcherApps.hasShortcutHostPermission()) return null else { @@ -104,11 +101,6 @@ class AppShortcutDeserializer( return null } else { val activity = shortcuts[0].activity - if (activity != null) { - badgeProvider.addAppShortcutBadge( - activity - ) - } return AppShortcut( context = context, launcherShortcut = shortcuts[0], diff --git a/applications/src/main/java/de/mm20/launcher2/search/data/AppShortcut.kt b/applications/src/main/java/de/mm20/launcher2/search/data/AppShortcut.kt index ea91daab..4a7f6334 100644 --- a/applications/src/main/java/de/mm20/launcher2/search/data/AppShortcut.kt +++ b/applications/src/main/java/de/mm20/launcher2/search/data/AppShortcut.kt @@ -12,19 +12,12 @@ import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import de.mm20.launcher2.applications.R -import de.mm20.launcher2.badges.Badge -import de.mm20.launcher2.badges.BadgeProvider -import de.mm20.launcher2.graphics.BadgeDrawable import de.mm20.launcher2.icons.LauncherIcon import de.mm20.launcher2.ktx.getSerialNumber import de.mm20.launcher2.ktx.isAtLeastApiLevel -import de.mm20.launcher2.ktx.jsonObjectOf import de.mm20.launcher2.preferences.LauncherPreferences import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.json.JSONObject import java.lang.IllegalStateException @RequiresApi(Build.VERSION_CODES.N_MR1) diff --git a/badges/build.gradle.kts b/badges/build.gradle.kts index 2bd6b354..1768bf29 100644 --- a/badges/build.gradle.kts +++ b/badges/build.gradle.kts @@ -44,6 +44,8 @@ dependencies { implementation(libs.koin.android) implementation(project(":ktx")) + implementation(project(":applications")) + implementation(project(":notifications")) implementation(project(":preferences")) implementation(project(":base")) diff --git a/badges/src/main/java/de/mm20/launcher2/badges/BadgeRepository.kt b/badges/src/main/java/de/mm20/launcher2/badges/BadgeRepository.kt new file mode 100644 index 00000000..9fc258cb --- /dev/null +++ b/badges/src/main/java/de/mm20/launcher2/badges/BadgeRepository.kt @@ -0,0 +1,88 @@ +package de.mm20.launcher2.badges + +import android.content.Context +import android.util.Log +import de.mm20.launcher2.badges.providers.* +import de.mm20.launcher2.badges.providers.BadgeProvider +import de.mm20.launcher2.preferences.LauncherDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +interface BadgeRepository { + fun getBadge(badgeKey: String): Flow +} + +internal class BadgeRepositoryImpl(private val context: Context) : BadgeRepository, KoinComponent { + + private val dataStore: LauncherDataStore by inject() + private val scope = CoroutineScope(Dispatchers.Main + Job()) + + private val badgeProviders = MutableStateFlow>(emptyList()) + + init { + scope.launch { + dataStore.data.map { it.badges }.distinctUntilChanged().collectLatest { + val providers = mutableListOf() + if (it.notifications) { + providers += NotificationBadgeProvider() + } + if (it.cloudFiles) { + providers += CloudBadgeProvider() + } + if (it.shortcuts) { + providers += AppShortcutBadgeProvider(context) + } + if (it.suspendedApps) { + providers += SuspendedAppsBadgeProvider() + } + providers += WorkProfileBadgeProvider(context) + badgeProviders.value = providers + } + } + } + + override fun getBadge(badgeKey: String): Flow = channelFlow { + badgeProviders.collectLatest { providers -> + if (providers.isEmpty()) { + send(null) + return@collectLatest + } + combine(providers.map { it.getBadge(badgeKey) }) { badges -> + if (badges.all { it == null }) { + return@combine null + } + val badge = Badge() + var progresses = 0 + badges.filterNotNull().forEach { + if (it.icon != null && badge.icon == null) badge.icon = it.icon + if (it.iconRes != null && badge.iconRes == null) badge.iconRes = it.iconRes + it.number?.let { a -> + badge.number?.let { b -> badge.number = a + b } ?: run { + badge.number = a + } + } + it.progress?.let { a -> + badge.progress?.let { + b -> badge.progress = a + b + } ?: run { + badge.progress = a + } + progresses++ + } + } + if (progresses > 0) { + badge.progress?.let { badge.progress = it / progresses } + } + return@combine badge + }.collectLatest { + send(it) + } + } + } + +} \ No newline at end of file diff --git a/badges/src/main/java/de/mm20/launcher2/badges/Module.kt b/badges/src/main/java/de/mm20/launcher2/badges/Module.kt index b7cc9710..e47c0e84 100644 --- a/badges/src/main/java/de/mm20/launcher2/badges/Module.kt +++ b/badges/src/main/java/de/mm20/launcher2/badges/Module.kt @@ -5,4 +5,5 @@ import org.koin.dsl.module val badgesModule = module { single { BadgeProvider(androidContext()) } + single { BadgeRepositoryImpl(androidContext()) } } \ No newline at end of file diff --git a/badges/src/main/java/de/mm20/launcher2/badges/providers/AppShortcutBadgeProvider.kt b/badges/src/main/java/de/mm20/launcher2/badges/providers/AppShortcutBadgeProvider.kt new file mode 100644 index 00000000..4f1da0e6 --- /dev/null +++ b/badges/src/main/java/de/mm20/launcher2/badges/providers/AppShortcutBadgeProvider.kt @@ -0,0 +1,38 @@ +package de.mm20.launcher2.badges.providers + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import de.mm20.launcher2.badges.Badge +import de.mm20.launcher2.graphics.BadgeDrawable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.withContext + +class AppShortcutBadgeProvider( + private val context: Context +) : BadgeProvider { + override fun getBadge(badgeKey: String): Flow = channelFlow { + if (badgeKey.startsWith("shortcut://")) { + val componentName = ComponentName.unflattenFromString(badgeKey.substring(11)) + if (componentName == null) { + send(null) + return@channelFlow + } + withContext(Dispatchers.IO) { + val icon = try { + context.packageManager.getActivityIcon( + componentName + ) + } catch (e: PackageManager.NameNotFoundException) { + return@withContext + } + val badge = Badge(icon = BadgeDrawable(context, icon)) + send(badge) + } + } else { + send(null) + } + } +} \ No newline at end of file diff --git a/badges/src/main/java/de/mm20/launcher2/badges/providers/BadgeProvider.kt b/badges/src/main/java/de/mm20/launcher2/badges/providers/BadgeProvider.kt new file mode 100644 index 00000000..7010b77f --- /dev/null +++ b/badges/src/main/java/de/mm20/launcher2/badges/providers/BadgeProvider.kt @@ -0,0 +1,13 @@ +package de.mm20.launcher2.badges.providers + +import de.mm20.launcher2.badges.Badge +import kotlinx.coroutines.flow.Flow + +interface BadgeProvider { + /** + * This must emit a value as soon as possible because the + * BadgeRepository is waiting for values from every provider. + * null must be emitted if no badge should be shown. + */ + fun getBadge(badgeKey: String): Flow +} \ No newline at end of file diff --git a/badges/src/main/java/de/mm20/launcher2/badges/providers/CloudBadgeProvider.kt b/badges/src/main/java/de/mm20/launcher2/badges/providers/CloudBadgeProvider.kt new file mode 100644 index 00000000..886e35ce --- /dev/null +++ b/badges/src/main/java/de/mm20/launcher2/badges/providers/CloudBadgeProvider.kt @@ -0,0 +1,19 @@ +package de.mm20.launcher2.badges.providers + +import android.util.Log +import de.mm20.launcher2.badges.Badge +import de.mm20.launcher2.badges.R +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class CloudBadgeProvider: BadgeProvider { + override fun getBadge(badgeKey: String): Flow = flow { + when(badgeKey) { + "gdrive://" -> emit(Badge(iconRes = R.drawable.ic_badge_gdrive)) + "onedrive://" -> emit(Badge(iconRes = R.drawable.ic_badge_onedrive)) + "owncloud://" -> emit(Badge(iconRes = R.drawable.ic_badge_owncloud)) + "nextcloud://" -> emit(Badge(iconRes = R.drawable.ic_badge_nextcloud)) + else -> emit(null) + } + } +} \ No newline at end of file diff --git a/badges/src/main/java/de/mm20/launcher2/badges/providers/NotificationBadgeProvider.kt b/badges/src/main/java/de/mm20/launcher2/badges/providers/NotificationBadgeProvider.kt new file mode 100644 index 00000000..70b87683 --- /dev/null +++ b/badges/src/main/java/de/mm20/launcher2/badges/providers/NotificationBadgeProvider.kt @@ -0,0 +1,34 @@ +package de.mm20.launcher2.badges.providers + +import de.mm20.launcher2.badges.Badge +import de.mm20.launcher2.notifications.NotificationRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class NotificationBadgeProvider : BadgeProvider, KoinComponent { + private val notificationRepository: NotificationRepository by inject() + + override fun getBadge(badgeKey: String): Flow = channelFlow { + if (badgeKey.startsWith("app://")) { + val packageName = badgeKey.substring(6) + notificationRepository.notifications.map { + it.filter { it.packageName == packageName } + }.collectLatest { + if (it.isEmpty()) { + send(null) + } else { + val badge = Badge(number = it.sumOf { + it.notification.number + }) + send(badge) + } + } + } else { + send(null) + } + } +} \ No newline at end of file diff --git a/badges/src/main/java/de/mm20/launcher2/badges/providers/SuspendedAppsBadgeProvider.kt b/badges/src/main/java/de/mm20/launcher2/badges/providers/SuspendedAppsBadgeProvider.kt new file mode 100644 index 00000000..2ef7bb02 --- /dev/null +++ b/badges/src/main/java/de/mm20/launcher2/badges/providers/SuspendedAppsBadgeProvider.kt @@ -0,0 +1,33 @@ +package de.mm20.launcher2.badges.providers + +import de.mm20.launcher2.applications.AppRepository +import de.mm20.launcher2.badges.Badge +import de.mm20.launcher2.badges.R +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class SuspendedAppsBadgeProvider : BadgeProvider, KoinComponent { + private val appRepository: AppRepository by inject() + + override fun getBadge(badgeKey: String): Flow = channelFlow { + if (badgeKey.startsWith("app://")) { + val packageName = badgeKey.substring(6) + appRepository.getSuspendedPackages().collectLatest { + if (it.contains(packageName)) { + send( + Badge( + iconRes = R.drawable.ic_badge_suspended + ) + ) + } else { + send(null) + } + } + } else { + send(null) + } + } +} \ No newline at end of file diff --git a/badges/src/main/java/de/mm20/launcher2/badges/providers/WorkProfileBadgeProvider.kt b/badges/src/main/java/de/mm20/launcher2/badges/providers/WorkProfileBadgeProvider.kt new file mode 100644 index 00000000..9d9642f2 --- /dev/null +++ b/badges/src/main/java/de/mm20/launcher2/badges/providers/WorkProfileBadgeProvider.kt @@ -0,0 +1,28 @@ +package de.mm20.launcher2.badges.providers + +import android.content.Context +import android.os.Process +import de.mm20.launcher2.badges.Badge +import de.mm20.launcher2.badges.R +import de.mm20.launcher2.ktx.getSerialNumber +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class WorkProfileBadgeProvider(private val context: Context) : BadgeProvider { + override fun getBadge(badgeKey: String): Flow = flow { + if (badgeKey.startsWith("profile://")) { + val serialNumber = badgeKey.substring(10).toLong() + if (serialNumber != Process.myUserHandle().getSerialNumber(context)) { + emit( + Badge( + iconRes = R.drawable.ic_badge_workprofile + ) + ) + } else { + emit(null) + } + } else { + emit(null) + } + } +} \ No newline at end of file diff --git a/music/build.gradle.kts b/music/build.gradle.kts index e15a99bc..7b35c9f4 100644 --- a/music/build.gradle.kts +++ b/music/build.gradle.kts @@ -44,5 +44,6 @@ dependencies { implementation(project(":ktx")) implementation(project(":preferences")) + implementation(project(":notifications")) } \ No newline at end of file diff --git a/music/src/main/java/de/mm20/launcher2/music/Module.kt b/music/src/main/java/de/mm20/launcher2/music/Module.kt index 61e25a4e..de8fc139 100644 --- a/music/src/main/java/de/mm20/launcher2/music/Module.kt +++ b/music/src/main/java/de/mm20/launcher2/music/Module.kt @@ -5,5 +5,5 @@ import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module val musicModule = module { - single { MusicRepositoryImpl(androidContext()) } + single { MusicRepositoryImpl(androidContext(), get()) } } \ No newline at end of file diff --git a/music/src/main/java/de/mm20/launcher2/music/MusicRepository.kt b/music/src/main/java/de/mm20/launcher2/music/MusicRepository.kt index 9b78561e..4a854b6a 100644 --- a/music/src/main/java/de/mm20/launcher2/music/MusicRepository.kt +++ b/music/src/main/java/de/mm20/launcher2/music/MusicRepository.kt @@ -1,13 +1,16 @@ package de.mm20.launcher2.music +import android.app.Notification import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.media.AudioManager +import android.media.session.MediaSession import android.support.v4.media.session.MediaSessionCompat import android.view.KeyEvent +import androidx.core.app.NotificationCompat import androidx.core.content.edit import androidx.core.graphics.scale import androidx.media2.common.MediaItem @@ -15,6 +18,7 @@ import androidx.media2.common.MediaMetadata import androidx.media2.common.SessionPlayer import androidx.media2.session.MediaController import androidx.media2.session.SessionCommandGroup +import de.mm20.launcher2.notifications.NotificationRepository import de.mm20.launcher2.preferences.LauncherDataStore import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -31,8 +35,6 @@ interface MusicRepository { val album: Flow val albumArt: Flow - fun setMediaSession(token: MediaSessionCompat.Token, packageName: String) - fun next() fun previous() fun pause() @@ -45,7 +47,10 @@ interface MusicRepository { fun resetPlayer() } -class MusicRepositoryImpl(val context: Context) : MusicRepository, KoinComponent { +class MusicRepositoryImpl( + private val context: Context, + private val notificationRepository: NotificationRepository +) : MusicRepository, KoinComponent { private val scope = CoroutineScope(Job() + Dispatchers.Main) private val dataStore: LauncherDataStore by inject() @@ -62,7 +67,29 @@ class MusicRepositoryImpl(val context: Context) : MusicRepository, KoinComponent private val semaphore = Semaphore(permits = 1) - override fun setMediaSession(token: MediaSessionCompat.Token, packageName: String) { + init { + scope.launch { + notificationRepository.notifications + .mapNotNull { + it + .sortedByDescending { it.postTime } + .find { + it.notification.category == Notification.CATEGORY_TRANSPORT || it.notification.category == Notification.CATEGORY_SERVICE + } + } + .collectLatest { + val token = + it.notification.extras[NotificationCompat.EXTRA_MEDIA_SESSION] as? MediaSession.Token + ?: return@collectLatest + setMediaSession( + MediaSessionCompat.Token.fromToken(token), + it.packageName + ) + } + } + } + + private fun setMediaSession(token: MediaSessionCompat.Token, packageName: String) { if (token.toString() == lastToken.toString()) return scope.launch { diff --git a/notifications/build.gradle.kts b/notifications/build.gradle.kts index 6298b041..37173d87 100644 --- a/notifications/build.gradle.kts +++ b/notifications/build.gradle.kts @@ -44,9 +44,7 @@ dependencies { implementation(libs.koin.android) - implementation(project(":music")) implementation(project(":preferences")) - implementation(project(":badges")) implementation(project(":permissions")) } \ No newline at end of file diff --git a/notifications/src/main/java/de/mm20/launcher2/notifications/Module.kt b/notifications/src/main/java/de/mm20/launcher2/notifications/Module.kt new file mode 100644 index 00000000..52d03f2b --- /dev/null +++ b/notifications/src/main/java/de/mm20/launcher2/notifications/Module.kt @@ -0,0 +1,7 @@ +package de.mm20.launcher2.notifications + +import org.koin.dsl.module + +val notificationsModule = module { + single { NotificationRepositoryImpl() } +} \ No newline at end of file diff --git a/notifications/src/main/java/de/mm20/launcher2/notifications/NotificationRepository.kt b/notifications/src/main/java/de/mm20/launcher2/notifications/NotificationRepository.kt new file mode 100644 index 00000000..b576646c --- /dev/null +++ b/notifications/src/main/java/de/mm20/launcher2/notifications/NotificationRepository.kt @@ -0,0 +1,63 @@ +package de.mm20.launcher2.notifications + +import android.service.notification.StatusBarNotification +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +interface NotificationRepository { + val notifications: Flow> + + /** + * Internal use only. Used by NotificationService. + */ + fun setNotifications(notifications: List) + + /** + * Internal use only. Used by NotificationService. + */ + fun postNotification(notification: StatusBarNotification) + + /** + * Internal use only. Used by NotificationService. + */ + fun removeNotification(notification: StatusBarNotification) + + /** + * Cancel a notification + */ + fun cancelNotification(notification: StatusBarNotification) +} + +internal class NotificationRepositoryImpl() : NotificationRepository { + private val scope = CoroutineScope(Job() + Dispatchers.Main) + override val notifications: MutableStateFlow> = MutableStateFlow( + emptyList() + ) + + override fun setNotifications(notifications: List) { + this.notifications.value = notifications + } + + override fun postNotification(notification: StatusBarNotification) { + notifications.value = notifications.value.filter { !isEqual(it, notification) } + notification + } + + override fun removeNotification(notification: StatusBarNotification) { + notifications.value = notifications.value.filter { !isEqual(it, notification) } + } + + private fun isEqual( + notification1: StatusBarNotification, + notification2: StatusBarNotification + ): Boolean { + return notification1.key == notification2.key + } + + override fun cancelNotification(notification: StatusBarNotification) { + NotificationService.getInstance()?.cancelNotification(notification.key) + } + +} \ No newline at end of file diff --git a/notifications/src/main/java/de/mm20/launcher2/notifications/NotificationService.kt b/notifications/src/main/java/de/mm20/launcher2/notifications/NotificationService.kt index 50c176d4..b25ad599 100644 --- a/notifications/src/main/java/de/mm20/launcher2/notifications/NotificationService.kt +++ b/notifications/src/main/java/de/mm20/launcher2/notifications/NotificationService.kt @@ -1,28 +1,18 @@ package de.mm20.launcher2.notifications -import android.app.Notification import android.app.Service import android.content.Intent -import android.graphics.drawable.Drawable -import android.media.session.MediaSession import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification -import android.support.v4.media.session.MediaSessionCompat import android.util.Log -import androidx.core.app.NotificationCompat -import de.mm20.launcher2.badges.Badge -import de.mm20.launcher2.badges.BadgeProvider -import de.mm20.launcher2.music.MusicRepository import de.mm20.launcher2.permissions.PermissionsManager -import de.mm20.launcher2.preferences.LauncherPreferences import org.koin.android.ext.android.inject import java.lang.ref.WeakReference class NotificationService : NotificationListenerService() { - private val musicRepository: MusicRepository by inject() + private val notificationRepository: NotificationRepository by inject() - private val badgeProvider: BadgeProvider by inject() private val permissionsManager: PermissionsManager by inject() override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -35,19 +25,10 @@ class NotificationService : NotificationListenerService() { permissionsManager.reportNotificationListenerState(true) instance = WeakReference(this) val notifications = getNotifications().sortedBy { it.postTime } - for (n in notifications) { - /*val intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) } - if (packageManager.queryIntentActivities(intent, 0).none { it.activityInfo.packageName == n.packageName }) continue*/ - val token = n.notification.extras[NotificationCompat.EXTRA_MEDIA_SESSION] as? MediaSession.Token - ?: continue - musicRepository.setMediaSession(MediaSessionCompat.Token.fromToken(token), n.packageName) - } - if (LauncherPreferences.instance.notificationBadges) { - generateBadges() - } + notificationRepository.setNotifications(notifications) } - fun getNotifications(): Array { + private fun getNotifications(): Array { return try { activeNotifications } catch (e: SecurityException) { @@ -55,73 +36,26 @@ class NotificationService : NotificationListenerService() { } } - fun generateBadges() { - badgeProvider.removeNotificationBadges() - getNotifications().forEach { - val pkg = it.packageName - val badge = badgeProvider.getBadge("app://$pkg") ?: Badge() - badge.number = activeNotifications.filter { - it.packageName == pkg - }.sumBy { - it.notification.number - } - badgeProvider.setBadge("app://$pkg", badge) - } - } - - fun getNotifications(packageName: String): List { - return getNotifications().filter { it.packageName == packageName } - } - - private fun getLargeIcon(notification: Notification): Drawable? { - return notification.getLargeIcon()?.loadDrawable(this) - } - override fun onNotificationPosted(sbn: StatusBarNotification) { - if (sbn.notification.category == Notification.CATEGORY_TRANSPORT || sbn.notification.category == Notification.CATEGORY_SERVICE) { - /*val intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) } - if (packageManager.queryIntentActivities(intent, 0).none { it.activityInfo.packageName == sbn.packageName }) return*/ - val token = sbn.notification.extras[NotificationCompat.EXTRA_MEDIA_SESSION] as? MediaSession.Token - ?: return - musicRepository.setMediaSession(MediaSessionCompat.Token.fromToken(token), sbn.packageName) - } - if (LauncherPreferences.instance.notificationBadges) { - val pkg = sbn.packageName - val badge = badgeProvider.getBadge("app://$pkg") ?: Badge() - badge.number = activeNotifications.filter { it.packageName == pkg }.sumBy { - it.notification.number - } - badgeProvider.setBadge("app://$pkg", badge) - } + notificationRepository.postNotification(sbn) } override fun onNotificationRemoved(sbn: StatusBarNotification) { super.onNotificationRemoved(sbn) - if (LauncherPreferences.instance.notificationBadges) { - val pkg = sbn.packageName - if (getNotifications().any { it.packageName == pkg && it.id != sbn.id }) { - val badge = badgeProvider.getBadge("app://$pkg") ?: Badge() - badge.number = activeNotifications.filter { it.packageName == pkg }.sumBy { - it.notification.number - } - badgeProvider.setBadge("app://$pkg", badge) - } else { - badgeProvider.removeBadge("app://$pkg") - } - } + notificationRepository.removeNotification(sbn) } override fun onListenerDisconnected() { super.onListenerDisconnected() - badgeProvider.removeNotificationBadges() permissionsManager.reportNotificationListenerState(false) + notificationRepository.setNotifications(emptyList()) Log.d("MM20", "Notification listener disconnected") } companion object { private var instance: WeakReference? = null - fun getInstance(): NotificationService? { + internal fun getInstance(): NotificationService? { return instance?.get() } } diff --git a/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt b/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt index 016385b9..e01710e8 100644 --- a/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt +++ b/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt @@ -63,7 +63,7 @@ fun createFactorySettings(context: Context): Settings { .newBuilder() .setEnabled(false) .setImages(false) - .setCustomUrl(null) + .setCustomUrl("") ) .setWebsiteSearch(Settings.WebsiteSearchSettings .newBuilder() @@ -73,5 +73,12 @@ fun createFactorySettings(context: Context): Settings { .newBuilder() .setEnabled(true) ) + .setBadges(Settings.BadgeSettings + .newBuilder() + .setNotifications(true) + .setCloudFiles(true) + .setShortcuts(true) + .setSuspendedApps(true) + ) .build() } \ No newline at end of file diff --git a/preferences/src/main/proto/settings.proto b/preferences/src/main/proto/settings.proto index dee00b34..cca5caaf 100644 --- a/preferences/src/main/proto/settings.proto +++ b/preferences/src/main/proto/settings.proto @@ -109,4 +109,12 @@ message Settings { } CalendarWidgetSettings calendar_widget = 17; + message BadgeSettings { + bool notifications = 1; + bool suspended_apps = 2; + bool cloud_files = 3; + bool shortcuts = 4; + } + BadgeSettings badges = 18; + } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ApplicationDetailRepresentation.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ApplicationDetailRepresentation.kt index dc8c87ea..9202e25d 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ApplicationDetailRepresentation.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ApplicationDetailRepresentation.kt @@ -21,6 +21,7 @@ import android.net.Uri import android.os.Build import android.os.Handler import android.os.Process +import android.service.notification.StatusBarNotification import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.core.app.NotificationCompat @@ -32,15 +33,13 @@ import androidx.lifecycle.* import androidx.transition.Scene import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipGroup -import de.mm20.launcher2.badges.BadgeProvider +import de.mm20.launcher2.badges.BadgeRepository import de.mm20.launcher2.crashreporter.CrashReporter import de.mm20.launcher2.favorites.FavoritesRepository import de.mm20.launcher2.icons.IconRepository -import de.mm20.launcher2.ktx.castToOrNull -import de.mm20.launcher2.ktx.dp -import de.mm20.launcher2.ktx.getBadgeIcon -import de.mm20.launcher2.ktx.lifecycleScope +import de.mm20.launcher2.ktx.* import de.mm20.launcher2.legacy.helper.ActivityStarter +import de.mm20.launcher2.notifications.NotificationRepository import de.mm20.launcher2.notifications.NotificationService import de.mm20.launcher2.search.data.AppInstallation import de.mm20.launcher2.search.data.Application @@ -50,8 +49,9 @@ import de.mm20.launcher2.transition.ChangingLayoutTransition import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.legacy.searchable.SearchableView import de.mm20.launcher2.ui.legacy.view.* +import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.map import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.util.concurrent.Executors @@ -60,7 +60,10 @@ import kotlin.math.roundToInt class ApplicationDetailRepresentation : Representation, KoinComponent { private val iconRepository: IconRepository by inject() - private val badgeProvider: BadgeProvider by inject() + private val badgeRepository: BadgeRepository by inject() + private val notificationRepository: NotificationRepository by inject() + + private var job: Job? = null override fun getScene( rootView: SearchableView, @@ -75,15 +78,35 @@ class ApplicationDetailRepresentation : Representation, KoinComponent { setOnClickListener(null) setOnLongClickListener(null) findViewById(R.id.appName).text = application.label - findViewById(R.id.icon).apply { - badge = badgeProvider.getLiveBadge(application.badgeKey) + val iconView = findViewById(R.id.icon).apply { shape = LauncherIconView.getDefaultShape(context) icon = iconRepository.getIconIfCached(application) - lifecycleScope.launch { - iconRepository.getIcon(application, (84 * rootView.dp).toInt()) - .collectLatest { - icon = it + } + + val notificationView = findViewById(R.id.notifications) + notificationView.layoutTransition = ChangingLayoutTransition() + + job = rootView.scope.launch { + rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + iconRepository.getIcon(application, (84 * rootView.dp).toInt()) + .collectLatest { + iconView.icon = it + } + } + launch { + badgeRepository.getBadge(application.badgeKey).collectLatest { + iconView.badge = it } + } + launch { + notificationRepository + .notifications + .map { it.filter { it.packageName == application.`package` } } + .collectLatest { + updateNotifications(notificationView, it) + } + } } } findViewById(R.id.appCard).also { @@ -142,9 +165,59 @@ class ApplicationDetailRepresentation : Representation, KoinComponent { } } + scene.setExitAction { + job?.cancel() + } + return scene } + private fun updateNotifications(chipGroup: ChipGroup, notifications: List) { + val context = chipGroup.context + chipGroup.removeAllViews() + notifications.forEach { + var title = it.notification.tickerText + if (title.isNullOrBlank()) { + title = it.notification.extras.getCharSequence(Notification.EXTRA_TITLE) + } + if (title.isNullOrBlank()) { + title = it.notification.extras.getCharSequence(Notification.EXTRA_TEXT) + } + if (title == null) title = "" + if (!NotificationCompat.isGroupSummary(it.notification)) { + val view = Chip(context) + view.text = title + view.chipIcon = + createShortcutDrawable(getNotificationChipIcon(context, it.notification)) + view.chipStrokeWidth = 1 * context.dp + view.chipStrokeColor = ContextCompat.getColorStateList(context, R.color.chip_stroke) + view.chipBackgroundColor = + ContextCompat.getColorStateList(context, R.color.chip_background) + view.setTextAppearanceResource(R.style.ChipTextAppearance) + view.closeIconTint = ColorStateList.valueOf( + ContextCompat.getColor( + context, + R.color.text_color_secondary + ) + ) + + view.isCloseIconVisible = it.isClearable + + view.setOnClickListener { _ -> + try { + it.notification.contentIntent?.send() + } catch (e: PendingIntent.CanceledException) { + } + } + view.setOnCloseIconClickListener { _ -> + notificationRepository.cancelNotification(it) + } + chipGroup.addView(view) + } + + } + } + private fun setupToolbar(rootView: SearchableView, toolbar: ToolbarView, app: Application) { val context = rootView.context toolbar.clear() @@ -222,51 +295,6 @@ class ApplicationDetailRepresentation : Representation, KoinComponent { private fun setupShortcuts(appShortcuts: ChipGroup, app: Application) { val context = appShortcuts.context - appShortcuts.removeAllViews() - val ns = NotificationService.getInstance() - val notifications = ns?.getNotifications(app.`package`) - notifications?.forEach { - var title = it.notification.tickerText - if (title.isNullOrBlank()) { - title = it.notification.extras.getCharSequence(Notification.EXTRA_TITLE) - } - if (title.isNullOrBlank()) { - title = it.notification.extras.getCharSequence(Notification.EXTRA_TEXT) - } - if (title == null) title = "" - if (!NotificationCompat.isGroupSummary(it.notification)) { - val view = Chip(context) - view.text = title - view.chipIcon = - createShortcutDrawable(getNotificationChipIcon(context, it.notification)) - view.chipStrokeWidth = 1 * context.dp - view.chipStrokeColor = ContextCompat.getColorStateList(context, R.color.chip_stroke) - view.chipBackgroundColor = - ContextCompat.getColorStateList(context, R.color.chip_background) - view.setTextAppearanceResource(R.style.ChipTextAppearance) - view.closeIconTint = ColorStateList.valueOf( - ContextCompat.getColor( - context, - R.color.text_color_secondary - ) - ) - - view.isCloseIconVisible = it.isClearable - - view.setOnClickListener { _ -> - try { - it.notification.contentIntent?.send() - } catch (e: PendingIntent.CanceledException) { - } - } - view.setOnCloseIconClickListener { _ -> - ns.cancelNotification(it.key) - appShortcuts.removeView(view) - } - appShortcuts.addView(view) - } - - } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/BasicGridRepresentation.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/BasicGridRepresentation.kt index 29ec7feb..6de83249 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/BasicGridRepresentation.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/BasicGridRepresentation.kt @@ -2,16 +2,20 @@ package de.mm20.launcher2.ui.legacy.search import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.transition.Scene -import de.mm20.launcher2.badges.BadgeProvider +import de.mm20.launcher2.badges.BadgeRepository import de.mm20.launcher2.icons.IconRepository import de.mm20.launcher2.ktx.dp +import de.mm20.launcher2.ktx.lifecycleOwner import de.mm20.launcher2.ktx.lifecycleScope import de.mm20.launcher2.legacy.helper.ActivityStarter import de.mm20.launcher2.search.data.Searchable import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.legacy.searchable.SearchableView import de.mm20.launcher2.ui.legacy.view.LauncherIconView +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -20,7 +24,9 @@ import org.koin.core.component.inject class BasicGridRepresentation : Representation, KoinComponent { private val iconRepository: IconRepository by inject() - private val badgeProvider: BadgeProvider by inject() + private val badgeRepository: BadgeRepository by inject() + + private var job: Job? = null override fun getScene( rootView: SearchableView, @@ -40,7 +46,6 @@ class BasicGridRepresentation : Representation, KoinComponent { .alpha(1f) .start()*/ findViewById(R.id.icon).apply { - badge = badgeProvider.getLiveBadge(searchable.badgeKey) shape = LauncherIconView.getDefaultShape(context) setOnClickListener { if (!ActivityStarter.start( @@ -53,9 +58,20 @@ class BasicGridRepresentation : Representation, KoinComponent { } } icon = iconRepository.getIconIfCached(searchable) - lifecycleScope.launch { - iconRepository.getIcon(searchable, (84 * rootView.dp).toInt()).collectLatest { - icon = it + + job = rootView.scope.launch { + rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + iconRepository.getIcon(searchable, (84 * rootView.dp).toInt()) + .collectLatest { + icon = it + } + } + launch { + badgeRepository.getBadge(searchable.badgeKey).collectLatest { + badge = it + } + } } } setOnLongClickListener { @@ -65,6 +81,9 @@ class BasicGridRepresentation : Representation, KoinComponent { } } } + scene.setExitAction { + job?.cancel() + } return scene diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ContactDetailRepresentation.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ContactDetailRepresentation.kt index 92b2383a..22853318 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ContactDetailRepresentation.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ContactDetailRepresentation.kt @@ -13,10 +13,13 @@ import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.PopupMenu +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.transition.Scene -import de.mm20.launcher2.badges.BadgeProvider +import de.mm20.launcher2.badges.BadgeRepository import de.mm20.launcher2.icons.IconRepository import de.mm20.launcher2.ktx.dp +import de.mm20.launcher2.ktx.lifecycleOwner import de.mm20.launcher2.ktx.lifecycleScope import de.mm20.launcher2.ktx.setStartCompoundDrawable import de.mm20.launcher2.legacy.helper.ActivityStarter @@ -25,6 +28,7 @@ import de.mm20.launcher2.search.data.Searchable import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.legacy.searchable.SearchableView import de.mm20.launcher2.ui.legacy.view.* +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -33,8 +37,10 @@ import java.net.URLEncoder class ContactDetailRepresentation : Representation, KoinComponent { - val iconRepository: IconRepository by inject() - val badgeProvider: BadgeProvider by inject() + private val iconRepository: IconRepository by inject() + private val badgeRepository: BadgeRepository by inject() + + private var job: Job? = null override fun getScene( rootView: SearchableView, @@ -48,12 +54,21 @@ class ContactDetailRepresentation : Representation, KoinComponent { scene.setEnterAction { with(rootView) { findViewById(R.id.icon).apply { - badge = badgeProvider.getLiveBadge(contact.badgeKey) shape = LauncherIconView.getDefaultShape(context) icon = iconRepository.getIconIfCached(contact) - lifecycleScope.launch { - iconRepository.getIcon(contact, (84 * rootView.dp).toInt()).collectLatest { - icon = it + job = rootView.scope.launch { + rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + iconRepository.getIcon(contact, (84 * rootView.dp).toInt()) + .collectLatest { + icon = it + } + } + launch { + badgeRepository.getBadge(contact.badgeKey).collectLatest { + badge = it + } + } } } } @@ -67,6 +82,9 @@ class ContactDetailRepresentation : Representation, KoinComponent { addShortcuts(rootView, contact) } } + scene.setExitAction { + job?.cancel() + } return scene } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ContactListRepresentation.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ContactListRepresentation.kt index 43ee5690..7aff3a30 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ContactListRepresentation.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ContactListRepresentation.kt @@ -2,11 +2,14 @@ package de.mm20.launcher2.ui.legacy.search import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.transition.Scene +import de.mm20.launcher2.badges.BadgeRepository import de.mm20.launcher2.ui.R -import de.mm20.launcher2.badges.BadgeProvider import de.mm20.launcher2.icons.IconRepository import de.mm20.launcher2.ktx.dp +import de.mm20.launcher2.ktx.lifecycleOwner import de.mm20.launcher2.ktx.lifecycleScope import de.mm20.launcher2.search.data.Contact import de.mm20.launcher2.search.data.Searchable @@ -15,6 +18,7 @@ import de.mm20.launcher2.ui.legacy.view.FavoriteSwipeAction import de.mm20.launcher2.ui.legacy.view.HideSwipeAction import de.mm20.launcher2.ui.legacy.view.LauncherIconView import de.mm20.launcher2.ui.legacy.view.SwipeCardView +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -22,8 +26,10 @@ import org.koin.core.component.inject class ContactListRepresentation : Representation, KoinComponent { - val iconRepository: IconRepository by inject() - val badgeProvider: BadgeProvider by inject() + private val iconRepository: IconRepository by inject() + private val badgeRepository: BadgeRepository by inject() + + private var job: Job? = null override fun getScene(rootView: SearchableView, searchable: Searchable, previousRepresentation: Int?): Scene { val contact = searchable as Contact @@ -32,12 +38,21 @@ class ContactListRepresentation : Representation, KoinComponent { scene.setEnterAction { with(rootView) { findViewById(R.id.icon).apply { - badge = badgeProvider.getLiveBadge(contact.badgeKey) shape = LauncherIconView.getDefaultShape(context) icon = iconRepository.getIconIfCached(contact) - lifecycleScope.launch { - iconRepository.getIcon(contact, (84 * rootView.dp).toInt()).collectLatest { - icon = it + job = rootView.scope.launch { + rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + iconRepository.getIcon(searchable, (84 * rootView.dp).toInt()) + .collectLatest { + icon = it + } + } + launch { + badgeRepository.getBadge(searchable.badgeKey).collectLatest { + badge = it + } + } } } } @@ -60,6 +75,9 @@ class ContactListRepresentation : Representation, KoinComponent { } } + scene.setExitAction { + job?.cancel() + } return scene } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/FileDetailRepresentation.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/FileDetailRepresentation.kt index a46afa22..756281b8 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/FileDetailRepresentation.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/FileDetailRepresentation.kt @@ -5,19 +5,22 @@ import android.content.Intent import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.core.content.FileProvider +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.transition.Scene import com.google.android.material.dialog.MaterialAlertDialogBuilder -import de.mm20.launcher2.ui.R -import de.mm20.launcher2.badges.BadgeProvider +import de.mm20.launcher2.badges.BadgeRepository import de.mm20.launcher2.files.FilesViewModel -import de.mm20.launcher2.ktx.dp import de.mm20.launcher2.icons.IconRepository -import de.mm20.launcher2.ktx.lifecycleScope +import de.mm20.launcher2.ktx.dp +import de.mm20.launcher2.ktx.lifecycleOwner import de.mm20.launcher2.search.data.File import de.mm20.launcher2.search.data.GDriveFile import de.mm20.launcher2.search.data.Searchable +import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.legacy.searchable.SearchableView import de.mm20.launcher2.ui.legacy.view.* +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel @@ -27,10 +30,16 @@ import java.text.DecimalFormat class FileDetailRepresentation : Representation, KoinComponent { - val iconRepository: IconRepository by inject() - val badgeProvider: BadgeProvider by inject() + private val iconRepository: IconRepository by inject() + private val badgeRepository: BadgeRepository by inject() - override fun getScene(rootView: SearchableView, searchable: Searchable, previousRepresentation: Int?): Scene { + private var job: Job? = null + + override fun getScene( + rootView: SearchableView, + searchable: Searchable, + previousRepresentation: Int? + ): Scene { val file = searchable as File val context = rootView.context as AppCompatActivity val scene = Scene.getSceneForLayout(rootView, R.layout.view_file_detail, rootView.context) @@ -39,12 +48,21 @@ class FileDetailRepresentation : Representation, KoinComponent { findViewById(R.id.fileLabel).text = file.label findViewById(R.id.fileInfo).text = getInfo(context, file) findViewById(R.id.icon).apply { - badge = badgeProvider.getLiveBadge(file.badgeKey) shape = LauncherIconView.getDefaultShape(context) icon = iconRepository.getIconIfCached(file) - lifecycleScope.launch { - iconRepository.getIcon(file, (84 * rootView.dp).toInt()).collectLatest { - icon = it + job = rootView.scope.launch { + rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + iconRepository.getIcon(searchable, (84 * rootView.dp).toInt()) + .collectLatest { + icon = it + } + } + launch { + badgeRepository.getBadge(searchable.badgeKey).collectLatest { + badge = it + } + } } } } @@ -55,6 +73,9 @@ class FileDetailRepresentation : Representation, KoinComponent { setupMenu(rootView, findViewById(R.id.fileToolbar), file) } } + scene.setExitAction { + job?.cancel() + } return scene } @@ -62,7 +83,8 @@ class FileDetailRepresentation : Representation, KoinComponent { val context = toolbar.context toolbar.clear() - val backAction = ToolbarAction(R.drawable.ic_arrow_back, context.getString(R.string.menu_back)) + val backAction = + ToolbarAction(R.drawable.ic_arrow_back, context.getString(R.string.menu_back)) backAction.clickAction = { rootView.back() } @@ -72,7 +94,8 @@ class FileDetailRepresentation : Representation, KoinComponent { toolbar.addAction(favAction, ToolbarView.PLACEMENT_END) if (file.isDeletable) { - val deleteAction = ToolbarAction(R.drawable.ic_delete, context.getString(R.string.menu_delete)) + val deleteAction = + ToolbarAction(R.drawable.ic_delete, context.getString(R.string.menu_delete)) deleteAction.clickAction = { delete(context, file) } @@ -83,7 +106,8 @@ class FileDetailRepresentation : Representation, KoinComponent { toolbar.addAction(hideAction, ToolbarView.PLACEMENT_END) if (file !is GDriveFile) { - val shareAction = ToolbarAction(R.drawable.ic_share, context.getString(R.string.menu_share)) + val shareAction = + ToolbarAction(R.drawable.ic_share, context.getString(R.string.menu_share)) shareAction.clickAction = { share(context, file) } @@ -93,16 +117,19 @@ class FileDetailRepresentation : Representation, KoinComponent { private fun delete(context: Context, file: File) { MaterialAlertDialogBuilder(context) - .setMessage(context.getString( - if (file.isDirectory) R.string.alert_delete_directory - else R.string.alert_delete_file, - file.path)) - .setPositiveButton(android.R.string.ok) {dialog, _ -> + .setMessage( + context.getString( + if (file.isDirectory) R.string.alert_delete_directory + else R.string.alert_delete_file, + file.path + ) + ) + .setPositiveButton(android.R.string.ok) { dialog, _ -> val fileViewModel: FilesViewModel by (context as AppCompatActivity).viewModel() dialog.dismiss() fileViewModel.deleteFile(file) } - .setNegativeButton(android.R.string.cancel) {dialog, _ -> + .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } .show() @@ -111,9 +138,11 @@ class FileDetailRepresentation : Representation, KoinComponent { private fun share(context: Context, fileDetail: File) { val shareIntent = Intent(Intent.ACTION_SEND) shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - val uri = FileProvider.getUriForFile(context, - context.applicationContext.packageName + ".fileprovider", - java.io.File(fileDetail.path)) + val uri = FileProvider.getUriForFile( + context, + context.applicationContext.packageName + ".fileprovider", + java.io.File(fileDetail.path) + ) shareIntent.putExtra(Intent.EXTRA_STREAM, uri) shareIntent.type = fileDetail.mimeType context.startActivity(Intent.createChooser(shareIntent, null)) @@ -122,17 +151,35 @@ class FileDetailRepresentation : Representation, KoinComponent { private fun getInfo(context: Context, file: File): String { val sb = StringBuilder() - sb.append(context.getString(R.string.file_meta_data_entry, context.getString(R.string.file_meta_type), file.mimeType)) + sb.append( + context.getString( + R.string.file_meta_data_entry, + context.getString(R.string.file_meta_type), + file.mimeType + ) + ) for ((k, v) in file.metaData) { sb.append("\n") - .append(context.getString(R.string.file_meta_data_entry, context.getString(k), v)) + .append(context.getString(R.string.file_meta_data_entry, context.getString(k), v)) } if (!file.isDirectory) { - sb.append("\n").append(context.getString(R.string.file_meta_data_entry, context.getString(R.string.file_meta_size), formatFileSize(file.size))) + sb.append("\n").append( + context.getString( + R.string.file_meta_data_entry, + context.getString(R.string.file_meta_size), + formatFileSize(file.size) + ) + ) } if (file.path.isNotEmpty()) { - sb.append("\n").append(context.getString(R.string.file_meta_data_entry, context.getString(R.string.file_meta_path), file.path)) + sb.append("\n").append( + context.getString( + R.string.file_meta_data_entry, + context.getString(R.string.file_meta_path), + file.path + ) + ) } return sb.toString() } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/FileListRepresentation.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/FileListRepresentation.kt index 057b7af0..933cec6e 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/FileListRepresentation.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/FileListRepresentation.kt @@ -1,22 +1,24 @@ package de.mm20.launcher2.ui.legacy.search -import android.content.Context import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.transition.Scene -import de.mm20.launcher2.ui.R -import de.mm20.launcher2.badges.BadgeProvider -import de.mm20.launcher2.ktx.dp +import de.mm20.launcher2.badges.BadgeRepository import de.mm20.launcher2.icons.IconRepository -import de.mm20.launcher2.ktx.lifecycleScope +import de.mm20.launcher2.ktx.dp +import de.mm20.launcher2.ktx.lifecycleOwner import de.mm20.launcher2.legacy.helper.ActivityStarter import de.mm20.launcher2.search.data.File import de.mm20.launcher2.search.data.Searchable +import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.legacy.searchable.SearchableView import de.mm20.launcher2.ui.legacy.view.FavoriteSwipeAction import de.mm20.launcher2.ui.legacy.view.HideSwipeAction import de.mm20.launcher2.ui.legacy.view.LauncherIconView import de.mm20.launcher2.ui.legacy.view.SwipeCardView +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -24,10 +26,16 @@ import org.koin.core.component.inject class FileListRepresentation : Representation, KoinComponent { - val iconRepository: IconRepository by inject() - val badgeProvider: BadgeProvider by inject() + private val iconRepository: IconRepository by inject() + private val badgeRepository: BadgeRepository by inject() - override fun getScene(rootView: SearchableView, searchable: Searchable, previousRepresentation: Int?): Scene { + private var job: Job? = null + + override fun getScene( + rootView: SearchableView, + searchable: Searchable, + previousRepresentation: Int? + ): Scene { val file = searchable as File val context = rootView.context as AppCompatActivity val scene = Scene.getSceneForLayout(rootView, R.layout.view_file_list, rootView.context) @@ -36,12 +44,21 @@ class FileListRepresentation : Representation, KoinComponent { findViewById(R.id.fileLabel).text = file.label findViewById(R.id.fileInfo).text = file.getFileType(context) findViewById(R.id.icon).apply { - badge = badgeProvider.getLiveBadge(file.badgeKey) shape = LauncherIconView.getDefaultShape(context) icon = iconRepository.getIconIfCached(file) - lifecycleScope.launch { - iconRepository.getIcon(file, (84 * rootView.dp).toInt()).collectLatest { - icon = it + job = rootView.scope.launch { + rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + iconRepository.getIcon(searchable, (84 * rootView.dp).toInt()) + .collectLatest { + icon = it + } + } + launch { + badgeRepository.getBadge(searchable.badgeKey).collectLatest { + badge = it + } + } } } } @@ -58,6 +75,9 @@ class FileListRepresentation : Representation, KoinComponent { } } } + scene.setExitAction { + job?.cancel() + } return scene } } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ShortcutDetailRepresentation.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ShortcutDetailRepresentation.kt index 4cae0b1f..07483317 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ShortcutDetailRepresentation.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ShortcutDetailRepresentation.kt @@ -4,11 +4,13 @@ import android.os.Build import android.widget.TextView import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.transition.Scene -import de.mm20.launcher2.badges.BadgeProvider -import de.mm20.launcher2.ktx.dp +import de.mm20.launcher2.badges.BadgeRepository import de.mm20.launcher2.icons.IconRepository -import de.mm20.launcher2.ktx.lifecycleScope +import de.mm20.launcher2.ktx.dp +import de.mm20.launcher2.ktx.lifecycleOwner import de.mm20.launcher2.search.data.AppShortcut import de.mm20.launcher2.search.data.Searchable import de.mm20.launcher2.ui.R @@ -17,18 +19,25 @@ import de.mm20.launcher2.ui.legacy.view.FavoriteToolbarAction import de.mm20.launcher2.ui.legacy.view.LauncherIconView import de.mm20.launcher2.ui.legacy.view.ToolbarAction import de.mm20.launcher2.ui.legacy.view.ToolbarView +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject @RequiresApi(Build.VERSION_CODES.N_MR1) -class AppShortcutDetailRepresentation: Representation, KoinComponent { +class AppShortcutDetailRepresentation : Representation, KoinComponent { - val iconRepository: IconRepository by inject() - val badgeProvider: BadgeProvider by inject() + private val iconRepository: IconRepository by inject() + private val badgeRepository: BadgeRepository by inject() - override fun getScene(rootView: SearchableView, searchable: Searchable, previousRepresentation: Int?): Scene { + private var job: Job? = null + + override fun getScene( + rootView: SearchableView, + searchable: Searchable, + previousRepresentation: Int? + ): Scene { val appShortcut = searchable as AppShortcut val context = rootView.context as AppCompatActivity val scene = Scene.getSceneForLayout(rootView, R.layout.view_application_detail, context) @@ -38,32 +47,50 @@ class AppShortcutDetailRepresentation: Representation, KoinComponent { setOnLongClickListener(null) findViewById(R.id.appName).text = appShortcut.label findViewById(R.id.icon).apply { - badge = badgeProvider.getLiveBadge(appShortcut.badgeKey) shape = LauncherIconView.getDefaultShape(context) icon = iconRepository.getIconIfCached(appShortcut) - lifecycleScope.launch { - iconRepository.getIcon(appShortcut, (84 * rootView.dp).toInt()).collectLatest { - icon = it + job = rootView.scope.launch { + rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + iconRepository.getIcon(searchable, (84 * rootView.dp).toInt()) + .collectLatest { + icon = it + } + } + launch { + badgeRepository.getBadge(searchable.badgeKey).collectLatest { + badge = it + } + } } } } val appName = appShortcut.appName - findViewById(R.id.appInfo).text = context.getString(R.string.shortcut_summary, appName) + findViewById(R.id.appInfo).text = + context.getString(R.string.shortcut_summary, appName) val toolbar = findViewById(R.id.appToolbar) setupToolbar(this, toolbar, appShortcut) } } + scene.setExitAction { + job?.cancel() + } return scene } - private fun setupToolbar(searchableView: SearchableView, toolbar: ToolbarView, shortcut: AppShortcut) { + private fun setupToolbar( + searchableView: SearchableView, + toolbar: ToolbarView, + shortcut: AppShortcut + ) { val context = searchableView.context val favAction = FavoriteToolbarAction(context, shortcut) toolbar.addAction(favAction, ToolbarView.PLACEMENT_END) - val backAction = ToolbarAction(R.drawable.ic_arrow_back, context.getString(R.string.menu_back)) + val backAction = + ToolbarAction(R.drawable.ic_arrow_back, context.getString(R.string.menu_back)) backAction.clickAction = { searchableView.back() } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/WebsiteDetailRepresentation.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/WebsiteDetailRepresentation.kt index faf9ddd2..eef0a7d5 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/WebsiteDetailRepresentation.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/WebsiteDetailRepresentation.kt @@ -7,12 +7,13 @@ import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.transition.Scene import com.bumptech.glide.Glide -import de.mm20.launcher2.badges.BadgeProvider -import de.mm20.launcher2.ktx.dp import de.mm20.launcher2.icons.IconRepository -import de.mm20.launcher2.ktx.lifecycleScope +import de.mm20.launcher2.ktx.dp +import de.mm20.launcher2.ktx.lifecycleOwner import de.mm20.launcher2.legacy.helper.ActivityStarter import de.mm20.launcher2.search.data.Searchable import de.mm20.launcher2.search.data.Website @@ -22,7 +23,7 @@ import de.mm20.launcher2.ui.legacy.view.FavoriteToolbarAction import de.mm20.launcher2.ui.legacy.view.LauncherIconView import de.mm20.launcher2.ui.legacy.view.ToolbarAction import de.mm20.launcher2.ui.legacy.view.ToolbarView -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -30,13 +31,18 @@ import org.koin.core.component.inject class WebsiteDetailRepresentation : Representation, KoinComponent { - val iconRepository: IconRepository by inject() - val badgeProvider: BadgeProvider by inject() + private val iconRepository: IconRepository by inject() + private var job: Job? = null - override fun getScene(rootView: SearchableView, searchable: Searchable, previousRepresentation: Int?): Scene { + override fun getScene( + rootView: SearchableView, + searchable: Searchable, + previousRepresentation: Int? + ): Scene { val website = searchable as Website val context = rootView.context as AppCompatActivity - val scene = Scene.getSceneForLayout(rootView, R.layout.view_website_detail, rootView.context) + val scene = + Scene.getSceneForLayout(rootView, R.layout.view_website_detail, rootView.context) scene.setEnterAction { with(rootView) { if (!hasBack()) { @@ -67,12 +73,14 @@ class WebsiteDetailRepresentation : Representation, KoinComponent { label.transitionName = null websiteFavIcon.transitionName = "icon" websiteFavIcon.apply { - badge = badgeProvider.getLiveBadge(website.badgeKey) shape = LauncherIconView.getDefaultShape(context) icon = iconRepository.getIconIfCached(website) - lifecycleScope.launch { - iconRepository.getIcon(website, (84 * rootView.dp).toInt()).collectLatest { - icon = it + job = rootView.scope.launch { + rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + iconRepository.getIcon(website, (84 * rootView.dp).toInt()) + .collectLatest { + icon = it + } } } } @@ -89,6 +97,10 @@ class WebsiteDetailRepresentation : Representation, KoinComponent { setupMenu(rootView, toolbar, website) } } + + scene.setExitAction { + job?.cancel() + } return scene } @@ -97,7 +109,8 @@ class WebsiteDetailRepresentation : Representation, KoinComponent { toolbar.clear() if (rootView.hasBack()) { - val backAction = ToolbarAction(R.drawable.ic_arrow_back, context.getString(R.string.menu_back)) + val backAction = + ToolbarAction(R.drawable.ic_arrow_back, context.getString(R.string.menu_back)) backAction.clickAction = { rootView.back() } @@ -115,7 +128,10 @@ class WebsiteDetailRepresentation : Representation, KoinComponent { private fun share(context: Context, website: Website) { val shareIntent = Intent(Intent.ACTION_SEND) - shareIntent.putExtra(Intent.EXTRA_TEXT, "${website.label}\n\n${website.description}\n\n${website.url}") + shareIntent.putExtra( + Intent.EXTRA_TEXT, + "${website.label}\n\n${website.description}\n\n${website.url}" + ) shareIntent.type = "text/plain" context.startActivity(Intent.createChooser(shareIntent, null)) } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/WebsiteListRepresentation.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/WebsiteListRepresentation.kt index 67cdb626..4a4d9da5 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/WebsiteListRepresentation.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/WebsiteListRepresentation.kt @@ -7,11 +7,14 @@ import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.transition.Scene import com.bumptech.glide.Glide -import de.mm20.launcher2.badges.BadgeProvider +import de.mm20.launcher2.badges.BadgeRepository import de.mm20.launcher2.icons.IconRepository import de.mm20.launcher2.ktx.dp +import de.mm20.launcher2.ktx.lifecycleOwner import de.mm20.launcher2.ktx.lifecycleScope import de.mm20.launcher2.legacy.helper.ActivityStarter import de.mm20.launcher2.search.data.Searchable @@ -22,6 +25,7 @@ import de.mm20.launcher2.ui.legacy.view.FavoriteToolbarAction import de.mm20.launcher2.ui.legacy.view.LauncherIconView import de.mm20.launcher2.ui.legacy.view.ToolbarAction import de.mm20.launcher2.ui.legacy.view.ToolbarView +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -30,9 +34,11 @@ import org.koin.core.component.inject class WebsiteListRepresentation : Representation, KoinComponent { - val iconRepository: IconRepository by inject() + private val iconRepository: IconRepository by inject() + private val badgeRepository: BadgeRepository by inject() + + private var job: Job? = null - val badgeProvider: BadgeProvider by inject() override fun getScene( rootView: SearchableView, @@ -72,12 +78,21 @@ class WebsiteListRepresentation : Representation, KoinComponent { label.transitionName = null websiteFavIcon.transitionName = "icon" websiteFavIcon.apply { - badge = badgeProvider.getLiveBadge(website.badgeKey) shape = LauncherIconView.getDefaultShape(context) icon = iconRepository.getIconIfCached(website) - lifecycleScope.launch { - iconRepository.getIcon(website, (84 * rootView.dp).toInt()).collectLatest { - icon = it + job = rootView.scope.launch { + rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + iconRepository.getIcon(searchable, (84 * rootView.dp).toInt()) + .collectLatest { + icon = it + } + } + launch { + badgeRepository.getBadge(searchable.badgeKey).collectLatest { + badge = it + } + } } } } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/searchable/SearchableView.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/searchable/SearchableView.kt index 14db1c83..f2c00ec8 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/searchable/SearchableView.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/searchable/SearchableView.kt @@ -13,6 +13,10 @@ import de.mm20.launcher2.ui.legacy.search.* import de.mm20.launcher2.ui.legacy.transition.LauncherCards import de.mm20.launcher2.ui.legacy.transition.LauncherIconViewTransition import de.mm20.launcher2.ui.legacy.view.AspectRationImageView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel @SuppressLint("ViewConstructor") open class SearchableView(context: Context, representation: Int) : FrameLayout(context) { @@ -26,6 +30,8 @@ open class SearchableView(context: Context, representation: Int) : FrameLayout(c private var defaultRepresentation = representation + val scope = CoroutineScope(Job() + Dispatchers.Main) + var representation = representation set(value) { val oldVal = field @@ -107,8 +113,11 @@ open class SearchableView(context: Context, representation: Int) : FrameLayout(c private fun applyScene(scene: Scene) { val transition = TransitionSet().apply { - addTransition(ChangeBounds().setInterpolator(DecelerateInterpolator()).excludeTarget( - AspectRationImageView::class.java, true)) + addTransition( + ChangeBounds().setInterpolator(DecelerateInterpolator()).excludeTarget( + AspectRationImageView::class.java, true + ) + ) addTransition(LauncherIconViewTransition()) addTransition(TextResize()) addTransition(LauncherCards()) @@ -132,12 +141,21 @@ open class SearchableView(context: Context, representation: Int) : FrameLayout(c return defaultRepresentation != REPRESENTATION_FULL } + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + scope.cancel() + } + companion object { const val REPRESENTATION_GRID = 0 const val REPRESENTATION_LIST = 1 const val REPRESENTATION_FULL = 2 - fun getView(context: Context, searchable: Searchable?, representation: Int): SearchableView { + fun getView( + context: Context, + searchable: Searchable?, + representation: Int + ): SearchableView { return SearchableView(context, representation) } } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/view/LauncherIconView.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/view/LauncherIconView.kt index 2594e63e..02490d04 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/view/LauncherIconView.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/view/LauncherIconView.kt @@ -7,6 +7,7 @@ import android.graphics.* import android.graphics.drawable.AdaptiveIconDrawable import android.os.Build import android.util.AttributeSet +import android.util.Log import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.view.View @@ -14,23 +15,34 @@ import android.view.ViewConfiguration import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LiveData import androidx.lifecycle.Observer +import androidx.lifecycle.repeatOnLifecycle import com.bartoszlipinski.viewpropertyobjectanimator.ViewPropertyObjectAnimator import de.mm20.launcher2.badges.Badge +import de.mm20.launcher2.badges.BadgeRepository import de.mm20.launcher2.ktx.dp import de.mm20.launcher2.ktx.toRectF import de.mm20.launcher2.icons.LauncherIcon +import de.mm20.launcher2.ktx.lifecycleOwner +import de.mm20.launcher2.ktx.lifecycleScope import de.mm20.launcher2.preferences.IconShape import de.mm20.launcher2.preferences.LauncherPreferences import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.legacy.helper.BitmapHolder +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import java.lang.Math.pow +import java.lang.Runnable import kotlin.math.abs import kotlin.math.hypot import kotlin.math.roundToInt -class LauncherIconView : View { +class LauncherIconView : View, KoinComponent { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleRes: Int) : super(context, attrs, defStyleRes) @@ -71,17 +83,12 @@ class LauncherIconView : View { invalidate() } - var badge: LiveData? = null + var badge: Badge? = null set(value) { field = value - value?.observe(context as AppCompatActivity, badgeObserver) invalidate() } - private val badgeObserver = Observer { - invalidate() - } - private val iconObserver: (LauncherIcon) -> Unit = { foregroundScale = it.foregroundScale backgroundScale = it.backgroundScale @@ -341,13 +348,11 @@ class LauncherIconView : View { badgeRect.right = drawRect.right.toFloat() badgeRect.bottom = drawRect.bottom.toFloat() - val badge = badge?.value ?: return + val badge = badge ?: return val badgeNumber = badge.number val badgeProgress = badge.progress val badgeIcon = badge.icon ?: badge.iconRes?.let { ContextCompat.getDrawable(context, it) } - if (badgeNumber == null && badgeProgress == null && badgeIcon == null) return - badgePaint.color = icon?.badgeColor ?: 0 canvas.drawOval(badgeRect, badgeShadowPaint) canvas.drawOval(badgeRect, badgePaint) diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt index 17004db8..f164a3cb 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt @@ -23,6 +23,7 @@ import de.mm20.launcher2.ui.base.BaseActivity import de.mm20.launcher2.ui.locals.LocalNavController import de.mm20.launcher2.ui.settings.about.AboutSettingsScreen import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen +import de.mm20.launcher2.ui.settings.badges.BadgeSettingsScreen import de.mm20.launcher2.ui.settings.calendarwidget.CalendarWidgetSettingsScreen import de.mm20.launcher2.ui.settings.clockwidget.ClockWidgetSettingsScreen import de.mm20.launcher2.ui.settings.debug.DebugSettingsScreen @@ -109,6 +110,9 @@ class SettingsActivity : BaseActivity() { composable("settings/widgets/clock") { ClockWidgetSettingsScreen() } + composable("settings/badges") { + BadgeSettingsScreen() + } composable("settings/about") { AboutSettingsScreen() } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/badges/BadgeSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/badges/BadgeSettingsScreen.kt new file mode 100644 index 00000000..3e04216f --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/badges/BadgeSettingsScreen.kt @@ -0,0 +1,55 @@ +package de.mm20.launcher2.ui.settings.badges + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.preferences.PreferenceScreen +import de.mm20.launcher2.ui.component.preferences.SwitchPreference + +@Composable +fun BadgeSettingsScreen() { + val viewModel: BadgeSettingsScreenVM = viewModel() + PreferenceScreen(title = stringResource(R.string.preference_screen_badges)) { + item { + val notifications by viewModel.notifications.observeAsState() + SwitchPreference( + title = stringResource(R.string.preference_notification_badges), + summary = stringResource(R.string.preference_notification_badges_summary), + value = notifications == true, + onValueChanged = { + viewModel.setNotifications(it) + } + ) + val cloudFiles by viewModel.cloudFiles.observeAsState() + SwitchPreference( + title = stringResource(R.string.preference_cloud_badges), + summary = stringResource(R.string.preference_cloud_badges_summary), + value = cloudFiles == true, + onValueChanged = { + viewModel.setCloudFiles(it) + } + ) + val suspendedApps by viewModel.suspendedApps.observeAsState() + SwitchPreference( + title = stringResource(R.string.preference_suspended_badges), + summary = stringResource(R.string.preference_suspended_badges_summary), + value = suspendedApps == true, + onValueChanged = { + viewModel.setSuspendedApps(it) + } + ) + val shortcuts by viewModel.shortcuts.observeAsState() + SwitchPreference( + title = stringResource(R.string.preference_shortcut_badges), + summary = stringResource(R.string.preference_shortcut_badges_summary), + value = shortcuts == true, + onValueChanged = { + viewModel.setShortcuts(it) + } + ) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/badges/BadgeSettingsScreenVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/badges/BadgeSettingsScreenVM.kt new file mode 100644 index 00000000..dbf54b8d --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/badges/BadgeSettingsScreenVM.kt @@ -0,0 +1,71 @@ +package de.mm20.launcher2.ui.settings.badges + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import de.mm20.launcher2.preferences.LauncherDataStore +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class BadgeSettingsScreenVM : ViewModel(), KoinComponent { + + private val dataStore: LauncherDataStore by inject() + + val notifications = dataStore.data.map { it.badges.notifications }.asLiveData() + fun setNotifications(notifications: Boolean) { + viewModelScope.launch { + dataStore.updateData { + it.toBuilder() + .setBadges( + it.badges.toBuilder() + .setNotifications(notifications) + ) + .build() + } + } + } + + val cloudFiles = dataStore.data.map { it.badges.cloudFiles }.asLiveData() + fun setCloudFiles(cloudFiles: Boolean) { + viewModelScope.launch { + dataStore.updateData { + it.toBuilder() + .setBadges( + it.badges.toBuilder() + .setCloudFiles(cloudFiles) + ) + .build() + } + } + } + + val shortcuts = dataStore.data.map { it.badges.shortcuts }.asLiveData() + fun setShortcuts(shortcuts: Boolean) { + viewModelScope.launch { + dataStore.updateData { + it.toBuilder() + .setBadges( + it.badges.toBuilder() + .setShortcuts(shortcuts) + ) + .build() + } + } + } + + val suspendedApps = dataStore.data.map { it.badges.suspendedApps }.asLiveData() + fun setSuspendedApps(suspendedApps: Boolean) { + viewModelScope.launch { + dataStore.updateData { + it.toBuilder() + .setBadges( + it.badges.toBuilder() + .setSuspendedApps(suspendedApps) + ) + .build() + } + } + } +} \ No newline at end of file diff --git a/ui/src/main/res/layout/view_application_detail.xml b/ui/src/main/res/layout/view_application_detail.xml index c0d4e451..78dc90a9 100644 --- a/ui/src/main/res/layout/view_application_detail.xml +++ b/ui/src/main/res/layout/view_application_detail.xml @@ -58,6 +58,18 @@ app:layout_constraintTop_toBottomOf="@+id/appName" tools:text="Version 1.0\ncom.app.name" /> + + - - + app:layout_constraintTop_toBottomOf="@+id/notifications"/>