Refactor badges and notifications, migrate badge settings

This commit is contained in:
MM20 2022-01-15 14:31:13 +01:00
parent dc64fdebdd
commit 6ca440f553
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
40 changed files with 937 additions and 303 deletions

View File

@ -21,6 +21,7 @@ import de.mm20.launcher2.websites.websitesModule
import de.mm20.launcher2.widgets.widgetsModule import de.mm20.launcher2.widgets.widgetsModule
import de.mm20.launcher2.wikipedia.wikipediaModule import de.mm20.launcher2.wikipedia.wikipediaModule
import de.mm20.launcher2.database.databaseModule import de.mm20.launcher2.database.databaseModule
import de.mm20.launcher2.notifications.notificationsModule
import de.mm20.launcher2.permissions.permissionsModule import de.mm20.launcher2.permissions.permissionsModule
import de.mm20.launcher2.preferences.preferencesModule import de.mm20.launcher2.preferences.preferencesModule
import de.mm20.launcher2.weather.weatherModule import de.mm20.launcher2.weather.weatherModule
@ -68,6 +69,7 @@ class LauncherApplication : Application(), CoroutineScope {
hiddenItemsModule, hiddenItemsModule,
iconsModule, iconsModule,
musicModule, musicModule,
notificationsModule,
permissionsModule, permissionsModule,
preferencesModule, preferencesModule,
searchModule, searchModule,

View File

@ -5,37 +5,26 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import de.mm20.launcher2.R 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() { class PreferencesBadgesFragment : PreferenceFragmentCompat() {
private val badgesProvider: BadgeProvider by inject()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.preferences_badges) addPreferencesFromResource(R.xml.preferences_badges)
findPreference<Preference>("notification_badges")?.setOnPreferenceChangeListener { _, newValue -> findPreference<Preference>("notification_badges")?.setOnPreferenceChangeListener { _, newValue ->
if (newValue as Boolean) { if (newValue as Boolean) {
NotificationService.getInstance()?.generateBadges()
} else { } else {
badgesProvider.removeNotificationBadges()
} }
true true
} }
findPreference<Preference>("suspended_badges")?.setOnPreferenceChangeListener { _, newValue -> findPreference<Preference>("suspended_badges")?.setOnPreferenceChangeListener { _, newValue ->
if (newValue as Boolean) { if (newValue as Boolean) {
badgesProvider.addSuspendBadges()
} else { } else {
badgesProvider.removeSuspendBadges()
} }
true true
} }
findPreference<Preference>("cloud_badges")?.setOnPreferenceChangeListener { _, newValue -> findPreference<Preference>("cloud_badges")?.setOnPreferenceChangeListener { _, newValue ->
if (newValue as Boolean) { if (newValue as Boolean) {
badgesProvider.addCloudBadges()
} else { } else {
badgesProvider.removeCloudBadges()
} }
true true
} }

View File

@ -47,7 +47,6 @@ dependencies {
implementation(project(":base")) implementation(project(":base"))
implementation(project(":preferences")) implementation(project(":preferences"))
implementation(project(":ktx")) implementation(project(":ktx"))
implementation(project(":badges"))
implementation(project(":hiddenitems")) implementation(project(":hiddenitems"))
implementation(project(":compat")) implementation(project(":compat"))

View File

@ -9,9 +9,8 @@ import android.content.pm.PackageInstaller
import android.content.pm.ShortcutInfo import android.content.pm.ShortcutInfo
import android.os.Process import android.os.Process
import android.os.UserHandle import android.os.UserHandle
import android.os.UserManager
import android.util.Log 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.hiddenitems.HiddenItemsRepository
import de.mm20.launcher2.preferences.LauncherPreferences import de.mm20.launcher2.preferences.LauncherPreferences
import de.mm20.launcher2.search.data.AppInstallation import de.mm20.launcher2.search.data.AppInstallation
@ -23,12 +22,12 @@ import kotlinx.coroutines.withContext
interface AppRepository { interface AppRepository {
fun search(query: String): Flow<List<Application>> fun search(query: String): Flow<List<Application>>
fun getSuspendedPackages(): Flow<List<String>>
} }
class AppRepositoryImpl( class AppRepositoryImpl(
private val context: Context, private val context: Context,
hiddenItemsRepository: HiddenItemsRepository, hiddenItemsRepository: HiddenItemsRepository,
private val badgeProvider: BadgeProvider
) : AppRepository { ) : AppRepository {
private val launcherApps = private val launcherApps =
@ -37,6 +36,7 @@ class AppRepositoryImpl(
private val installedApps = MutableStateFlow<List<Application>>(emptyList()) private val installedApps = MutableStateFlow<List<Application>>(emptyList())
private val installations = MutableStateFlow<MutableList<AppInstallation>>(mutableListOf()) private val installations = MutableStateFlow<MutableList<AppInstallation>>(mutableListOf())
private val hiddenItems = hiddenItemsRepository.hiddenItemsKeys private val hiddenItems = hiddenItemsRepository.hiddenItemsKeys
private val suspendedPackages = MutableStateFlow<List<String>>(emptyList())
private val profiles: List<UserHandle> = private val profiles: List<UserHandle> =
@ -101,12 +101,8 @@ class AppRepositoryImpl(
override fun onPackagesSuspended(packageNames: Array<out String>?, user: UserHandle?) { override fun onPackagesSuspended(packageNames: Array<out String>?, user: UserHandle?) {
super.onPackagesSuspended(packageNames, user) super.onPackagesSuspended(packageNames, user)
packageNames?.forEach { packageNames ?: return
badgeProvider.setBadge( suspendedPackages.value = suspendedPackages.value + packageNames
"app://$it",
Badge(iconRes = R.drawable.ic_badge_suspended)
)
}
} }
override fun onPackagesUnsuspended( override fun onPackagesUnsuspended(
@ -114,9 +110,8 @@ class AppRepositoryImpl(
user: UserHandle? user: UserHandle?
) { ) {
super.onPackagesUnsuspended(packageNames, user) super.onPackagesUnsuspended(packageNames, user)
packageNames?.forEach { packageNames ?: return
badgeProvider.removeBadge("app://$it") suspendedPackages.value = suspendedPackages.value.filter { packageNames.contains(it) }
}
} }
}) })
@ -130,15 +125,6 @@ class AppRepositoryImpl(
packageInstaller.registerSessionCallback(object : PackageInstaller.SessionCallback() { packageInstaller.registerSessionCallback(object : PackageInstaller.SessionCallback() {
override fun onProgressChanged(sessionId: Int, progress: Float) { override fun onProgressChanged(sessionId: Int, progress: Float) {
val session = packageInstaller.getSessionInfo(sessionId) ?: return 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) { override fun onActiveChanged(sessionId: Int, active: Boolean) {
@ -149,10 +135,6 @@ class AppRepositoryImpl(
override fun onFinished(sessionId: Int, success: Boolean) { override fun onFinished(sessionId: Int, success: Boolean) {
val pkg = installingPackages[sessionId] val pkg = installingPackages[sessionId]
installingPackages.remove(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 val inst = installations.value
inst.removeAll { inst.removeAll {
it.session.sessionId == sessionId it.session.sessionId == sessionId
@ -172,7 +154,7 @@ class AppRepositoryImpl(
override fun onCreated(sessionId: Int) { override fun onCreated(sessionId: Int) {
val session = packageInstaller.getSessionInfo(sessionId) ?: return val session = packageInstaller.getSessionInfo(sessionId) ?: return
installingPackages[sessionId] = session.appPackageName ?: 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 if (session.appLabel.isNullOrBlank() || !session.isActive) return
val appInstallation = AppInstallation(session) val appInstallation = AppInstallation(session)
val inst = installations.value ?: mutableListOf() val inst = installations.value ?: mutableListOf()
@ -182,6 +164,11 @@ class AppRepositoryImpl(
}) })
} }
override fun getSuspendedPackages(): Flow<List<String>> {
return suspendedPackages
}
private fun getApplications(packageName: String): List<Application> { private fun getApplications(packageName: String): List<Application> {
if (packageName == context.packageName) return emptyList() if (packageName == context.packageName) return emptyList()

View File

@ -5,5 +5,5 @@ import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
val applicationsModule = module { val applicationsModule = module {
single<AppRepository> { AppRepositoryImpl(androidContext(), get(), get()) } single<AppRepository> { AppRepositoryImpl(androidContext(), get()) }
} }

View File

@ -10,13 +10,11 @@ import android.os.Process
import android.os.UserManager import android.os.UserManager
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import de.mm20.launcher2.badges.BadgeProvider
import de.mm20.launcher2.ktx.jsonObjectOf import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.search.SearchableDeserializer import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer import de.mm20.launcher2.search.SearchableSerializer
import org.json.JSONObject import org.json.JSONObject
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class LauncherAppSerializer : SearchableSerializer { class LauncherAppSerializer : SearchableSerializer {
override fun serialize(searchable: Searchable): String { override fun serialize(searchable: Searchable): String {
@ -71,7 +69,6 @@ class AppShortcutDeserializer(
@RequiresApi(Build.VERSION_CODES.N_MR1) @RequiresApi(Build.VERSION_CODES.N_MR1)
override fun deserialize(serialized: String): Searchable? { override fun deserialize(serialized: String): Searchable? {
val badgeProvider: BadgeProvider by inject()
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
if (!launcherApps.hasShortcutHostPermission()) return null if (!launcherApps.hasShortcutHostPermission()) return null
else { else {
@ -104,11 +101,6 @@ class AppShortcutDeserializer(
return null return null
} else { } else {
val activity = shortcuts[0].activity val activity = shortcuts[0].activity
if (activity != null) {
badgeProvider.addAppShortcutBadge(
activity
)
}
return AppShortcut( return AppShortcut(
context = context, context = context,
launcherShortcut = shortcuts[0], launcherShortcut = shortcuts[0],

View File

@ -12,19 +12,12 @@ import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import de.mm20.launcher2.applications.R 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.icons.LauncherIcon
import de.mm20.launcher2.ktx.getSerialNumber import de.mm20.launcher2.ktx.getSerialNumber
import de.mm20.launcher2.ktx.isAtLeastApiLevel import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.preferences.LauncherPreferences import de.mm20.launcher2.preferences.LauncherPreferences
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.lang.IllegalStateException import java.lang.IllegalStateException
@RequiresApi(Build.VERSION_CODES.N_MR1) @RequiresApi(Build.VERSION_CODES.N_MR1)

View File

@ -44,6 +44,8 @@ dependencies {
implementation(libs.koin.android) implementation(libs.koin.android)
implementation(project(":ktx")) implementation(project(":ktx"))
implementation(project(":applications"))
implementation(project(":notifications"))
implementation(project(":preferences")) implementation(project(":preferences"))
implementation(project(":base")) implementation(project(":base"))

View File

@ -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<Badge?>
}
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<List<BadgeProvider>>(emptyList())
init {
scope.launch {
dataStore.data.map { it.badges }.distinctUntilChanged().collectLatest {
val providers = mutableListOf<BadgeProvider>()
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<Badge?> = 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)
}
}
}
}

View File

@ -5,4 +5,5 @@ import org.koin.dsl.module
val badgesModule = module { val badgesModule = module {
single { BadgeProvider(androidContext()) } single { BadgeProvider(androidContext()) }
single<BadgeRepository> { BadgeRepositoryImpl(androidContext()) }
} }

View File

@ -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<Badge?> = 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)
}
}
}

View File

@ -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<Badge?>
}

View File

@ -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<Badge?> = 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)
}
}
}

View File

@ -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<Badge?> = 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)
}
}
}

View File

@ -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<Badge?> = 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)
}
}
}

View File

@ -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<Badge?> = 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)
}
}
}

View File

@ -44,5 +44,6 @@ dependencies {
implementation(project(":ktx")) implementation(project(":ktx"))
implementation(project(":preferences")) implementation(project(":preferences"))
implementation(project(":notifications"))
} }

View File

@ -5,5 +5,5 @@ import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
val musicModule = module { val musicModule = module {
single<MusicRepository> { MusicRepositoryImpl(androidContext()) } single<MusicRepository> { MusicRepositoryImpl(androidContext(), get()) }
} }

View File

@ -1,13 +1,16 @@
package de.mm20.launcher2.music package de.mm20.launcher2.music
import android.app.Notification
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.media.AudioManager import android.media.AudioManager
import android.media.session.MediaSession
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent import android.view.KeyEvent
import androidx.core.app.NotificationCompat
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.graphics.scale import androidx.core.graphics.scale
import androidx.media2.common.MediaItem import androidx.media2.common.MediaItem
@ -15,6 +18,7 @@ import androidx.media2.common.MediaMetadata
import androidx.media2.common.SessionPlayer import androidx.media2.common.SessionPlayer
import androidx.media2.session.MediaController import androidx.media2.session.MediaController
import androidx.media2.session.SessionCommandGroup import androidx.media2.session.SessionCommandGroup
import de.mm20.launcher2.notifications.NotificationRepository
import de.mm20.launcher2.preferences.LauncherDataStore import de.mm20.launcher2.preferences.LauncherDataStore
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@ -31,8 +35,6 @@ interface MusicRepository {
val album: Flow<String?> val album: Flow<String?>
val albumArt: Flow<Bitmap?> val albumArt: Flow<Bitmap?>
fun setMediaSession(token: MediaSessionCompat.Token, packageName: String)
fun next() fun next()
fun previous() fun previous()
fun pause() fun pause()
@ -45,7 +47,10 @@ interface MusicRepository {
fun resetPlayer() 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 scope = CoroutineScope(Job() + Dispatchers.Main)
private val dataStore: LauncherDataStore by inject() private val dataStore: LauncherDataStore by inject()
@ -62,7 +67,29 @@ class MusicRepositoryImpl(val context: Context) : MusicRepository, KoinComponent
private val semaphore = Semaphore(permits = 1) 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 if (token.toString() == lastToken.toString()) return
scope.launch { scope.launch {

View File

@ -44,9 +44,7 @@ dependencies {
implementation(libs.koin.android) implementation(libs.koin.android)
implementation(project(":music"))
implementation(project(":preferences")) implementation(project(":preferences"))
implementation(project(":badges"))
implementation(project(":permissions")) implementation(project(":permissions"))
} }

View File

@ -0,0 +1,7 @@
package de.mm20.launcher2.notifications
import org.koin.dsl.module
val notificationsModule = module {
single<NotificationRepository> { NotificationRepositoryImpl() }
}

View File

@ -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<List<StatusBarNotification>>
/**
* Internal use only. Used by NotificationService.
*/
fun setNotifications(notifications: List<StatusBarNotification>)
/**
* 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<List<StatusBarNotification>> = MutableStateFlow(
emptyList()
)
override fun setNotifications(notifications: List<StatusBarNotification>) {
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)
}
}

View File

@ -1,28 +1,18 @@
package de.mm20.launcher2.notifications package de.mm20.launcher2.notifications
import android.app.Notification
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Drawable
import android.media.session.MediaSession
import android.service.notification.NotificationListenerService import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification import android.service.notification.StatusBarNotification
import android.support.v4.media.session.MediaSessionCompat
import android.util.Log 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.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherPreferences
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
class NotificationService : NotificationListenerService() { 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() private val permissionsManager: PermissionsManager by inject()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -35,19 +25,10 @@ class NotificationService : NotificationListenerService() {
permissionsManager.reportNotificationListenerState(true) permissionsManager.reportNotificationListenerState(true)
instance = WeakReference(this) instance = WeakReference(this)
val notifications = getNotifications().sortedBy { it.postTime } val notifications = getNotifications().sortedBy { it.postTime }
for (n in notifications) { notificationRepository.setNotifications(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()
}
} }
fun getNotifications(): Array<StatusBarNotification> { private fun getNotifications(): Array<StatusBarNotification> {
return try { return try {
activeNotifications activeNotifications
} catch (e: SecurityException) { } 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<StatusBarNotification> {
return getNotifications().filter { it.packageName == packageName }
}
private fun getLargeIcon(notification: Notification): Drawable? {
return notification.getLargeIcon()?.loadDrawable(this)
}
override fun onNotificationPosted(sbn: StatusBarNotification) { override fun onNotificationPosted(sbn: StatusBarNotification) {
if (sbn.notification.category == Notification.CATEGORY_TRANSPORT || sbn.notification.category == Notification.CATEGORY_SERVICE) { notificationRepository.postNotification(sbn)
/*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)
}
} }
override fun onNotificationRemoved(sbn: StatusBarNotification) { override fun onNotificationRemoved(sbn: StatusBarNotification) {
super.onNotificationRemoved(sbn) super.onNotificationRemoved(sbn)
if (LauncherPreferences.instance.notificationBadges) { notificationRepository.removeNotification(sbn)
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")
}
}
} }
override fun onListenerDisconnected() { override fun onListenerDisconnected() {
super.onListenerDisconnected() super.onListenerDisconnected()
badgeProvider.removeNotificationBadges()
permissionsManager.reportNotificationListenerState(false) permissionsManager.reportNotificationListenerState(false)
notificationRepository.setNotifications(emptyList())
Log.d("MM20", "Notification listener disconnected") Log.d("MM20", "Notification listener disconnected")
} }
companion object { companion object {
private var instance: WeakReference<NotificationService>? = null private var instance: WeakReference<NotificationService>? = null
fun getInstance(): NotificationService? { internal fun getInstance(): NotificationService? {
return instance?.get() return instance?.get()
} }
} }

View File

@ -63,7 +63,7 @@ fun createFactorySettings(context: Context): Settings {
.newBuilder() .newBuilder()
.setEnabled(false) .setEnabled(false)
.setImages(false) .setImages(false)
.setCustomUrl(null) .setCustomUrl("")
) )
.setWebsiteSearch(Settings.WebsiteSearchSettings .setWebsiteSearch(Settings.WebsiteSearchSettings
.newBuilder() .newBuilder()
@ -73,5 +73,12 @@ fun createFactorySettings(context: Context): Settings {
.newBuilder() .newBuilder()
.setEnabled(true) .setEnabled(true)
) )
.setBadges(Settings.BadgeSettings
.newBuilder()
.setNotifications(true)
.setCloudFiles(true)
.setShortcuts(true)
.setSuspendedApps(true)
)
.build() .build()
} }

View File

@ -109,4 +109,12 @@ message Settings {
} }
CalendarWidgetSettings calendar_widget = 17; CalendarWidgetSettings calendar_widget = 17;
message BadgeSettings {
bool notifications = 1;
bool suspended_apps = 2;
bool cloud_files = 3;
bool shortcuts = 4;
}
BadgeSettings badges = 18;
} }

View File

@ -21,6 +21,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Process import android.os.Process
import android.service.notification.StatusBarNotification
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
@ -32,15 +33,13 @@ import androidx.lifecycle.*
import androidx.transition.Scene import androidx.transition.Scene
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup 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.crashreporter.CrashReporter
import de.mm20.launcher2.favorites.FavoritesRepository import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.icons.IconRepository import de.mm20.launcher2.icons.IconRepository
import de.mm20.launcher2.ktx.castToOrNull import de.mm20.launcher2.ktx.*
import de.mm20.launcher2.ktx.dp
import de.mm20.launcher2.ktx.getBadgeIcon
import de.mm20.launcher2.ktx.lifecycleScope
import de.mm20.launcher2.legacy.helper.ActivityStarter import de.mm20.launcher2.legacy.helper.ActivityStarter
import de.mm20.launcher2.notifications.NotificationRepository
import de.mm20.launcher2.notifications.NotificationService import de.mm20.launcher2.notifications.NotificationService
import de.mm20.launcher2.search.data.AppInstallation import de.mm20.launcher2.search.data.AppInstallation
import de.mm20.launcher2.search.data.Application 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.R
import de.mm20.launcher2.ui.legacy.searchable.SearchableView import de.mm20.launcher2.ui.legacy.searchable.SearchableView
import de.mm20.launcher2.ui.legacy.view.* import de.mm20.launcher2.ui.legacy.view.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest 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.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import java.util.concurrent.Executors import java.util.concurrent.Executors
@ -60,7 +60,10 @@ import kotlin.math.roundToInt
class ApplicationDetailRepresentation : Representation, KoinComponent { class ApplicationDetailRepresentation : Representation, KoinComponent {
private val iconRepository: IconRepository by inject() 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( override fun getScene(
rootView: SearchableView, rootView: SearchableView,
@ -75,15 +78,35 @@ class ApplicationDetailRepresentation : Representation, KoinComponent {
setOnClickListener(null) setOnClickListener(null)
setOnLongClickListener(null) setOnLongClickListener(null)
findViewById<TextView>(R.id.appName).text = application.label findViewById<TextView>(R.id.appName).text = application.label
findViewById<LauncherIconView>(R.id.icon).apply { val iconView = findViewById<LauncherIconView>(R.id.icon).apply {
badge = badgeProvider.getLiveBadge(application.badgeKey)
shape = LauncherIconView.getDefaultShape(context) shape = LauncherIconView.getDefaultShape(context)
icon = iconRepository.getIconIfCached(application) icon = iconRepository.getIconIfCached(application)
lifecycleScope.launch { }
iconRepository.getIcon(application, (84 * rootView.dp).toInt())
.collectLatest { val notificationView = findViewById<ChipGroup>(R.id.notifications)
icon = it 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<SwipeCardView>(R.id.appCard).also { findViewById<SwipeCardView>(R.id.appCard).also {
@ -142,9 +165,59 @@ class ApplicationDetailRepresentation : Representation, KoinComponent {
} }
} }
scene.setExitAction {
job?.cancel()
}
return scene return scene
} }
private fun updateNotifications(chipGroup: ChipGroup, notifications: List<StatusBarNotification>) {
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) { private fun setupToolbar(rootView: SearchableView, toolbar: ToolbarView, app: Application) {
val context = rootView.context val context = rootView.context
toolbar.clear() toolbar.clear()
@ -222,51 +295,6 @@ class ApplicationDetailRepresentation : Representation, KoinComponent {
private fun setupShortcuts(appShortcuts: ChipGroup, app: Application) { private fun setupShortcuts(appShortcuts: ChipGroup, app: Application) {
val context = appShortcuts.context 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
val launcherApps = val launcherApps =
context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps

View File

@ -2,16 +2,20 @@ package de.mm20.launcher2.ui.legacy.search
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.transition.Scene 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.icons.IconRepository
import de.mm20.launcher2.ktx.dp import de.mm20.launcher2.ktx.dp
import de.mm20.launcher2.ktx.lifecycleOwner
import de.mm20.launcher2.ktx.lifecycleScope import de.mm20.launcher2.ktx.lifecycleScope
import de.mm20.launcher2.legacy.helper.ActivityStarter import de.mm20.launcher2.legacy.helper.ActivityStarter
import de.mm20.launcher2.search.data.Searchable import de.mm20.launcher2.search.data.Searchable
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.legacy.searchable.SearchableView import de.mm20.launcher2.ui.legacy.searchable.SearchableView
import de.mm20.launcher2.ui.legacy.view.LauncherIconView import de.mm20.launcher2.ui.legacy.view.LauncherIconView
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -20,7 +24,9 @@ import org.koin.core.component.inject
class BasicGridRepresentation : Representation, KoinComponent { class BasicGridRepresentation : Representation, KoinComponent {
private val iconRepository: IconRepository by inject() 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( override fun getScene(
rootView: SearchableView, rootView: SearchableView,
@ -40,7 +46,6 @@ class BasicGridRepresentation : Representation, KoinComponent {
.alpha(1f) .alpha(1f)
.start()*/ .start()*/
findViewById<LauncherIconView>(R.id.icon).apply { findViewById<LauncherIconView>(R.id.icon).apply {
badge = badgeProvider.getLiveBadge(searchable.badgeKey)
shape = LauncherIconView.getDefaultShape(context) shape = LauncherIconView.getDefaultShape(context)
setOnClickListener { setOnClickListener {
if (!ActivityStarter.start( if (!ActivityStarter.start(
@ -53,9 +58,20 @@ class BasicGridRepresentation : Representation, KoinComponent {
} }
} }
icon = iconRepository.getIconIfCached(searchable) icon = iconRepository.getIconIfCached(searchable)
lifecycleScope.launch {
iconRepository.getIcon(searchable, (84 * rootView.dp).toInt()).collectLatest { job = rootView.scope.launch {
icon = it 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 { setOnLongClickListener {
@ -65,6 +81,9 @@ class BasicGridRepresentation : Representation, KoinComponent {
} }
} }
} }
scene.setExitAction {
job?.cancel()
}
return scene return scene

View File

@ -13,10 +13,13 @@ import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.transition.Scene 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.icons.IconRepository
import de.mm20.launcher2.ktx.dp import de.mm20.launcher2.ktx.dp
import de.mm20.launcher2.ktx.lifecycleOwner
import de.mm20.launcher2.ktx.lifecycleScope import de.mm20.launcher2.ktx.lifecycleScope
import de.mm20.launcher2.ktx.setStartCompoundDrawable import de.mm20.launcher2.ktx.setStartCompoundDrawable
import de.mm20.launcher2.legacy.helper.ActivityStarter 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.R
import de.mm20.launcher2.ui.legacy.searchable.SearchableView import de.mm20.launcher2.ui.legacy.searchable.SearchableView
import de.mm20.launcher2.ui.legacy.view.* import de.mm20.launcher2.ui.legacy.view.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -33,8 +37,10 @@ import java.net.URLEncoder
class ContactDetailRepresentation : Representation, KoinComponent { class ContactDetailRepresentation : Representation, KoinComponent {
val iconRepository: IconRepository by inject() private val iconRepository: IconRepository by inject()
val badgeProvider: BadgeProvider by inject() private val badgeRepository: BadgeRepository by inject()
private var job: Job? = null
override fun getScene( override fun getScene(
rootView: SearchableView, rootView: SearchableView,
@ -48,12 +54,21 @@ class ContactDetailRepresentation : Representation, KoinComponent {
scene.setEnterAction { scene.setEnterAction {
with(rootView) { with(rootView) {
findViewById<LauncherIconView>(R.id.icon).apply { findViewById<LauncherIconView>(R.id.icon).apply {
badge = badgeProvider.getLiveBadge(contact.badgeKey)
shape = LauncherIconView.getDefaultShape(context) shape = LauncherIconView.getDefaultShape(context)
icon = iconRepository.getIconIfCached(contact) icon = iconRepository.getIconIfCached(contact)
lifecycleScope.launch { job = rootView.scope.launch {
iconRepository.getIcon(contact, (84 * rootView.dp).toInt()).collectLatest { rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
icon = it 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) addShortcuts(rootView, contact)
} }
} }
scene.setExitAction {
job?.cancel()
}
return scene return scene
} }

View File

@ -2,11 +2,14 @@ package de.mm20.launcher2.ui.legacy.search
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.transition.Scene import androidx.transition.Scene
import de.mm20.launcher2.badges.BadgeRepository
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.badges.BadgeProvider
import de.mm20.launcher2.icons.IconRepository import de.mm20.launcher2.icons.IconRepository
import de.mm20.launcher2.ktx.dp import de.mm20.launcher2.ktx.dp
import de.mm20.launcher2.ktx.lifecycleOwner
import de.mm20.launcher2.ktx.lifecycleScope import de.mm20.launcher2.ktx.lifecycleScope
import de.mm20.launcher2.search.data.Contact import de.mm20.launcher2.search.data.Contact
import de.mm20.launcher2.search.data.Searchable 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.HideSwipeAction
import de.mm20.launcher2.ui.legacy.view.LauncherIconView import de.mm20.launcher2.ui.legacy.view.LauncherIconView
import de.mm20.launcher2.ui.legacy.view.SwipeCardView import de.mm20.launcher2.ui.legacy.view.SwipeCardView
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -22,8 +26,10 @@ import org.koin.core.component.inject
class ContactListRepresentation : Representation, KoinComponent { class ContactListRepresentation : Representation, KoinComponent {
val iconRepository: IconRepository by inject() private val iconRepository: IconRepository by inject()
val badgeProvider: BadgeProvider by inject() private val badgeRepository: BadgeRepository 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 contact = searchable as Contact val contact = searchable as Contact
@ -32,12 +38,21 @@ class ContactListRepresentation : Representation, KoinComponent {
scene.setEnterAction { scene.setEnterAction {
with(rootView) { with(rootView) {
findViewById<LauncherIconView>(R.id.icon).apply { findViewById<LauncherIconView>(R.id.icon).apply {
badge = badgeProvider.getLiveBadge(contact.badgeKey)
shape = LauncherIconView.getDefaultShape(context) shape = LauncherIconView.getDefaultShape(context)
icon = iconRepository.getIconIfCached(contact) icon = iconRepository.getIconIfCached(contact)
lifecycleScope.launch { job = rootView.scope.launch {
iconRepository.getIcon(contact, (84 * rootView.dp).toInt()).collectLatest { rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
icon = it 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 return scene
} }

View File

@ -5,19 +5,22 @@ import android.content.Intent
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.transition.Scene import androidx.transition.Scene
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import de.mm20.launcher2.ui.R import de.mm20.launcher2.badges.BadgeRepository
import de.mm20.launcher2.badges.BadgeProvider
import de.mm20.launcher2.files.FilesViewModel import de.mm20.launcher2.files.FilesViewModel
import de.mm20.launcher2.ktx.dp
import de.mm20.launcher2.icons.IconRepository 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.File
import de.mm20.launcher2.search.data.GDriveFile import de.mm20.launcher2.search.data.GDriveFile
import de.mm20.launcher2.search.data.Searchable 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.searchable.SearchableView
import de.mm20.launcher2.ui.legacy.view.* import de.mm20.launcher2.ui.legacy.view.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
@ -27,10 +30,16 @@ import java.text.DecimalFormat
class FileDetailRepresentation : Representation, KoinComponent { class FileDetailRepresentation : Representation, KoinComponent {
val iconRepository: IconRepository by inject() private val iconRepository: IconRepository by inject()
val badgeProvider: BadgeProvider 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 file = searchable as File
val context = rootView.context as AppCompatActivity val context = rootView.context as AppCompatActivity
val scene = Scene.getSceneForLayout(rootView, R.layout.view_file_detail, rootView.context) val scene = Scene.getSceneForLayout(rootView, R.layout.view_file_detail, rootView.context)
@ -39,12 +48,21 @@ class FileDetailRepresentation : Representation, KoinComponent {
findViewById<TextView>(R.id.fileLabel).text = file.label findViewById<TextView>(R.id.fileLabel).text = file.label
findViewById<TextView>(R.id.fileInfo).text = getInfo(context, file) findViewById<TextView>(R.id.fileInfo).text = getInfo(context, file)
findViewById<LauncherIconView>(R.id.icon).apply { findViewById<LauncherIconView>(R.id.icon).apply {
badge = badgeProvider.getLiveBadge(file.badgeKey)
shape = LauncherIconView.getDefaultShape(context) shape = LauncherIconView.getDefaultShape(context)
icon = iconRepository.getIconIfCached(file) icon = iconRepository.getIconIfCached(file)
lifecycleScope.launch { job = rootView.scope.launch {
iconRepository.getIcon(file, (84 * rootView.dp).toInt()).collectLatest { rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
icon = it 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) setupMenu(rootView, findViewById(R.id.fileToolbar), file)
} }
} }
scene.setExitAction {
job?.cancel()
}
return scene return scene
} }
@ -62,7 +83,8 @@ class FileDetailRepresentation : Representation, KoinComponent {
val context = toolbar.context val context = toolbar.context
toolbar.clear() 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 = { backAction.clickAction = {
rootView.back() rootView.back()
} }
@ -72,7 +94,8 @@ class FileDetailRepresentation : Representation, KoinComponent {
toolbar.addAction(favAction, ToolbarView.PLACEMENT_END) toolbar.addAction(favAction, ToolbarView.PLACEMENT_END)
if (file.isDeletable) { 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 = { deleteAction.clickAction = {
delete(context, file) delete(context, file)
} }
@ -83,7 +106,8 @@ class FileDetailRepresentation : Representation, KoinComponent {
toolbar.addAction(hideAction, ToolbarView.PLACEMENT_END) toolbar.addAction(hideAction, ToolbarView.PLACEMENT_END)
if (file !is GDriveFile) { 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 = { shareAction.clickAction = {
share(context, file) share(context, file)
} }
@ -93,16 +117,19 @@ class FileDetailRepresentation : Representation, KoinComponent {
private fun delete(context: Context, file: File) { private fun delete(context: Context, file: File) {
MaterialAlertDialogBuilder(context) MaterialAlertDialogBuilder(context)
.setMessage(context.getString( .setMessage(
if (file.isDirectory) R.string.alert_delete_directory context.getString(
else R.string.alert_delete_file, if (file.isDirectory) R.string.alert_delete_directory
file.path)) else R.string.alert_delete_file,
.setPositiveButton(android.R.string.ok) {dialog, _ -> file.path
)
)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
val fileViewModel: FilesViewModel by (context as AppCompatActivity).viewModel() val fileViewModel: FilesViewModel by (context as AppCompatActivity).viewModel()
dialog.dismiss() dialog.dismiss()
fileViewModel.deleteFile(file) fileViewModel.deleteFile(file)
} }
.setNegativeButton(android.R.string.cancel) {dialog, _ -> .setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss() dialog.dismiss()
} }
.show() .show()
@ -111,9 +138,11 @@ class FileDetailRepresentation : Representation, KoinComponent {
private fun share(context: Context, fileDetail: File) { private fun share(context: Context, fileDetail: File) {
val shareIntent = Intent(Intent.ACTION_SEND) val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uri = FileProvider.getUriForFile(context, val uri = FileProvider.getUriForFile(
context.applicationContext.packageName + ".fileprovider", context,
java.io.File(fileDetail.path)) context.applicationContext.packageName + ".fileprovider",
java.io.File(fileDetail.path)
)
shareIntent.putExtra(Intent.EXTRA_STREAM, uri) shareIntent.putExtra(Intent.EXTRA_STREAM, uri)
shareIntent.type = fileDetail.mimeType shareIntent.type = fileDetail.mimeType
context.startActivity(Intent.createChooser(shareIntent, null)) context.startActivity(Intent.createChooser(shareIntent, null))
@ -122,17 +151,35 @@ class FileDetailRepresentation : Representation, KoinComponent {
private fun getInfo(context: Context, file: File): String { private fun getInfo(context: Context, file: File): String {
val sb = StringBuilder() 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) { for ((k, v) in file.metaData) {
sb.append("\n") 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) { 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()) { 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() return sb.toString()
} }

View File

@ -1,22 +1,24 @@
package de.mm20.launcher2.ui.legacy.search package de.mm20.launcher2.ui.legacy.search
import android.content.Context
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.transition.Scene import androidx.transition.Scene
import de.mm20.launcher2.ui.R import de.mm20.launcher2.badges.BadgeRepository
import de.mm20.launcher2.badges.BadgeProvider
import de.mm20.launcher2.ktx.dp
import de.mm20.launcher2.icons.IconRepository 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.legacy.helper.ActivityStarter
import de.mm20.launcher2.search.data.File import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.Searchable 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.searchable.SearchableView
import de.mm20.launcher2.ui.legacy.view.FavoriteSwipeAction import de.mm20.launcher2.ui.legacy.view.FavoriteSwipeAction
import de.mm20.launcher2.ui.legacy.view.HideSwipeAction import de.mm20.launcher2.ui.legacy.view.HideSwipeAction
import de.mm20.launcher2.ui.legacy.view.LauncherIconView import de.mm20.launcher2.ui.legacy.view.LauncherIconView
import de.mm20.launcher2.ui.legacy.view.SwipeCardView import de.mm20.launcher2.ui.legacy.view.SwipeCardView
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -24,10 +26,16 @@ import org.koin.core.component.inject
class FileListRepresentation : Representation, KoinComponent { class FileListRepresentation : Representation, KoinComponent {
val iconRepository: IconRepository by inject() private val iconRepository: IconRepository by inject()
val badgeProvider: BadgeProvider 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 file = searchable as File
val context = rootView.context as AppCompatActivity val context = rootView.context as AppCompatActivity
val scene = Scene.getSceneForLayout(rootView, R.layout.view_file_list, rootView.context) val scene = Scene.getSceneForLayout(rootView, R.layout.view_file_list, rootView.context)
@ -36,12 +44,21 @@ class FileListRepresentation : Representation, KoinComponent {
findViewById<TextView>(R.id.fileLabel).text = file.label findViewById<TextView>(R.id.fileLabel).text = file.label
findViewById<TextView>(R.id.fileInfo).text = file.getFileType(context) findViewById<TextView>(R.id.fileInfo).text = file.getFileType(context)
findViewById<LauncherIconView>(R.id.icon).apply { findViewById<LauncherIconView>(R.id.icon).apply {
badge = badgeProvider.getLiveBadge(file.badgeKey)
shape = LauncherIconView.getDefaultShape(context) shape = LauncherIconView.getDefaultShape(context)
icon = iconRepository.getIconIfCached(file) icon = iconRepository.getIconIfCached(file)
lifecycleScope.launch { job = rootView.scope.launch {
iconRepository.getIcon(file, (84 * rootView.dp).toInt()).collectLatest { rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
icon = it 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 return scene
} }
} }

View File

@ -4,11 +4,13 @@ import android.os.Build
import android.widget.TextView import android.widget.TextView
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.transition.Scene import androidx.transition.Scene
import de.mm20.launcher2.badges.BadgeProvider import de.mm20.launcher2.badges.BadgeRepository
import de.mm20.launcher2.ktx.dp
import de.mm20.launcher2.icons.IconRepository 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.AppShortcut
import de.mm20.launcher2.search.data.Searchable import de.mm20.launcher2.search.data.Searchable
import de.mm20.launcher2.ui.R 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.LauncherIconView
import de.mm20.launcher2.ui.legacy.view.ToolbarAction import de.mm20.launcher2.ui.legacy.view.ToolbarAction
import de.mm20.launcher2.ui.legacy.view.ToolbarView import de.mm20.launcher2.ui.legacy.view.ToolbarView
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@RequiresApi(Build.VERSION_CODES.N_MR1) @RequiresApi(Build.VERSION_CODES.N_MR1)
class AppShortcutDetailRepresentation: Representation, KoinComponent { class AppShortcutDetailRepresentation : Representation, KoinComponent {
val iconRepository: IconRepository by inject() private val iconRepository: IconRepository by inject()
val badgeProvider: BadgeProvider 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 appShortcut = searchable as AppShortcut
val context = rootView.context as AppCompatActivity val context = rootView.context as AppCompatActivity
val scene = Scene.getSceneForLayout(rootView, R.layout.view_application_detail, context) val scene = Scene.getSceneForLayout(rootView, R.layout.view_application_detail, context)
@ -38,32 +47,50 @@ class AppShortcutDetailRepresentation: Representation, KoinComponent {
setOnLongClickListener(null) setOnLongClickListener(null)
findViewById<TextView>(R.id.appName).text = appShortcut.label findViewById<TextView>(R.id.appName).text = appShortcut.label
findViewById<LauncherIconView>(R.id.icon).apply { findViewById<LauncherIconView>(R.id.icon).apply {
badge = badgeProvider.getLiveBadge(appShortcut.badgeKey)
shape = LauncherIconView.getDefaultShape(context) shape = LauncherIconView.getDefaultShape(context)
icon = iconRepository.getIconIfCached(appShortcut) icon = iconRepository.getIconIfCached(appShortcut)
lifecycleScope.launch { job = rootView.scope.launch {
iconRepository.getIcon(appShortcut, (84 * rootView.dp).toInt()).collectLatest { rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
icon = it launch {
iconRepository.getIcon(searchable, (84 * rootView.dp).toInt())
.collectLatest {
icon = it
}
}
launch {
badgeRepository.getBadge(searchable.badgeKey).collectLatest {
badge = it
}
}
} }
} }
} }
val appName = appShortcut.appName val appName = appShortcut.appName
findViewById<TextView>(R.id.appInfo).text = context.getString(R.string.shortcut_summary, appName) findViewById<TextView>(R.id.appInfo).text =
context.getString(R.string.shortcut_summary, appName)
val toolbar = findViewById<ToolbarView>(R.id.appToolbar) val toolbar = findViewById<ToolbarView>(R.id.appToolbar)
setupToolbar(this, toolbar, appShortcut) setupToolbar(this, toolbar, appShortcut)
} }
} }
scene.setExitAction {
job?.cancel()
}
return scene 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 context = searchableView.context
val favAction = FavoriteToolbarAction(context, shortcut) val favAction = FavoriteToolbarAction(context, shortcut)
toolbar.addAction(favAction, ToolbarView.PLACEMENT_END) 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 = { backAction.clickAction = {
searchableView.back() searchableView.back()
} }

View File

@ -7,12 +7,13 @@ import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.transition.Scene import androidx.transition.Scene
import com.bumptech.glide.Glide 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.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.legacy.helper.ActivityStarter
import de.mm20.launcher2.search.data.Searchable import de.mm20.launcher2.search.data.Searchable
import de.mm20.launcher2.search.data.Website 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.LauncherIconView
import de.mm20.launcher2.ui.legacy.view.ToolbarAction import de.mm20.launcher2.ui.legacy.view.ToolbarAction
import de.mm20.launcher2.ui.legacy.view.ToolbarView 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.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -30,13 +31,18 @@ import org.koin.core.component.inject
class WebsiteDetailRepresentation : Representation, KoinComponent { class WebsiteDetailRepresentation : Representation, KoinComponent {
val iconRepository: IconRepository by inject() private val iconRepository: IconRepository by inject()
val badgeProvider: BadgeProvider 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 website = searchable as Website
val context = rootView.context as AppCompatActivity 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 { scene.setEnterAction {
with(rootView) { with(rootView) {
if (!hasBack()) { if (!hasBack()) {
@ -67,12 +73,14 @@ class WebsiteDetailRepresentation : Representation, KoinComponent {
label.transitionName = null label.transitionName = null
websiteFavIcon.transitionName = "icon" websiteFavIcon.transitionName = "icon"
websiteFavIcon.apply { websiteFavIcon.apply {
badge = badgeProvider.getLiveBadge(website.badgeKey)
shape = LauncherIconView.getDefaultShape(context) shape = LauncherIconView.getDefaultShape(context)
icon = iconRepository.getIconIfCached(website) icon = iconRepository.getIconIfCached(website)
lifecycleScope.launch { job = rootView.scope.launch {
iconRepository.getIcon(website, (84 * rootView.dp).toInt()).collectLatest { rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
icon = it iconRepository.getIcon(website, (84 * rootView.dp).toInt())
.collectLatest {
icon = it
}
} }
} }
} }
@ -89,6 +97,10 @@ class WebsiteDetailRepresentation : Representation, KoinComponent {
setupMenu(rootView, toolbar, website) setupMenu(rootView, toolbar, website)
} }
} }
scene.setExitAction {
job?.cancel()
}
return scene return scene
} }
@ -97,7 +109,8 @@ class WebsiteDetailRepresentation : Representation, KoinComponent {
toolbar.clear() toolbar.clear()
if (rootView.hasBack()) { 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 = { backAction.clickAction = {
rootView.back() rootView.back()
} }
@ -115,7 +128,10 @@ class WebsiteDetailRepresentation : Representation, KoinComponent {
private fun share(context: Context, website: Website) { private fun share(context: Context, website: Website) {
val shareIntent = Intent(Intent.ACTION_SEND) 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" shareIntent.type = "text/plain"
context.startActivity(Intent.createChooser(shareIntent, null)) context.startActivity(Intent.createChooser(shareIntent, null))
} }

View File

@ -7,11 +7,14 @@ import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.transition.Scene import androidx.transition.Scene
import com.bumptech.glide.Glide 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.icons.IconRepository
import de.mm20.launcher2.ktx.dp import de.mm20.launcher2.ktx.dp
import de.mm20.launcher2.ktx.lifecycleOwner
import de.mm20.launcher2.ktx.lifecycleScope import de.mm20.launcher2.ktx.lifecycleScope
import de.mm20.launcher2.legacy.helper.ActivityStarter import de.mm20.launcher2.legacy.helper.ActivityStarter
import de.mm20.launcher2.search.data.Searchable 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.LauncherIconView
import de.mm20.launcher2.ui.legacy.view.ToolbarAction import de.mm20.launcher2.ui.legacy.view.ToolbarAction
import de.mm20.launcher2.ui.legacy.view.ToolbarView import de.mm20.launcher2.ui.legacy.view.ToolbarView
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -30,9 +34,11 @@ import org.koin.core.component.inject
class WebsiteListRepresentation : Representation, KoinComponent { 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( override fun getScene(
rootView: SearchableView, rootView: SearchableView,
@ -72,12 +78,21 @@ class WebsiteListRepresentation : Representation, KoinComponent {
label.transitionName = null label.transitionName = null
websiteFavIcon.transitionName = "icon" websiteFavIcon.transitionName = "icon"
websiteFavIcon.apply { websiteFavIcon.apply {
badge = badgeProvider.getLiveBadge(website.badgeKey)
shape = LauncherIconView.getDefaultShape(context) shape = LauncherIconView.getDefaultShape(context)
icon = iconRepository.getIconIfCached(website) icon = iconRepository.getIconIfCached(website)
lifecycleScope.launch { job = rootView.scope.launch {
iconRepository.getIcon(website, (84 * rootView.dp).toInt()).collectLatest { rootView.lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
icon = it launch {
iconRepository.getIcon(searchable, (84 * rootView.dp).toInt())
.collectLatest {
icon = it
}
}
launch {
badgeRepository.getBadge(searchable.badgeKey).collectLatest {
badge = it
}
}
} }
} }
} }

View File

@ -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.LauncherCards
import de.mm20.launcher2.ui.legacy.transition.LauncherIconViewTransition import de.mm20.launcher2.ui.legacy.transition.LauncherIconViewTransition
import de.mm20.launcher2.ui.legacy.view.AspectRationImageView 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") @SuppressLint("ViewConstructor")
open class SearchableView(context: Context, representation: Int) : FrameLayout(context) { 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 private var defaultRepresentation = representation
val scope = CoroutineScope(Job() + Dispatchers.Main)
var representation = representation var representation = representation
set(value) { set(value) {
val oldVal = field val oldVal = field
@ -107,8 +113,11 @@ open class SearchableView(context: Context, representation: Int) : FrameLayout(c
private fun applyScene(scene: Scene) { private fun applyScene(scene: Scene) {
val transition = TransitionSet().apply { val transition = TransitionSet().apply {
addTransition(ChangeBounds().setInterpolator(DecelerateInterpolator()).excludeTarget( addTransition(
AspectRationImageView::class.java, true)) ChangeBounds().setInterpolator(DecelerateInterpolator()).excludeTarget(
AspectRationImageView::class.java, true
)
)
addTransition(LauncherIconViewTransition()) addTransition(LauncherIconViewTransition())
addTransition(TextResize()) addTransition(TextResize())
addTransition(LauncherCards()) addTransition(LauncherCards())
@ -132,12 +141,21 @@ open class SearchableView(context: Context, representation: Int) : FrameLayout(c
return defaultRepresentation != REPRESENTATION_FULL return defaultRepresentation != REPRESENTATION_FULL
} }
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
scope.cancel()
}
companion object { companion object {
const val REPRESENTATION_GRID = 0 const val REPRESENTATION_GRID = 0
const val REPRESENTATION_LIST = 1 const val REPRESENTATION_LIST = 1
const val REPRESENTATION_FULL = 2 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) return SearchableView(context, representation)
} }
} }

View File

@ -7,6 +7,7 @@ import android.graphics.*
import android.graphics.drawable.AdaptiveIconDrawable import android.graphics.drawable.AdaptiveIconDrawable
import android.os.Build import android.os.Build
import android.util.AttributeSet import android.util.AttributeSet
import android.util.Log
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
@ -14,23 +15,34 @@ import android.view.ViewConfiguration
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.repeatOnLifecycle
import com.bartoszlipinski.viewpropertyobjectanimator.ViewPropertyObjectAnimator import com.bartoszlipinski.viewpropertyobjectanimator.ViewPropertyObjectAnimator
import de.mm20.launcher2.badges.Badge import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.badges.BadgeRepository
import de.mm20.launcher2.ktx.dp import de.mm20.launcher2.ktx.dp
import de.mm20.launcher2.ktx.toRectF import de.mm20.launcher2.ktx.toRectF
import de.mm20.launcher2.icons.LauncherIcon 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.IconShape
import de.mm20.launcher2.preferences.LauncherPreferences import de.mm20.launcher2.preferences.LauncherPreferences
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.legacy.helper.BitmapHolder 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.Math.pow
import java.lang.Runnable
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.hypot import kotlin.math.hypot
import kotlin.math.roundToInt import kotlin.math.roundToInt
class LauncherIconView : View { class LauncherIconView : View, KoinComponent {
constructor(context: Context) : super(context) constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleRes: Int) : super(context, attrs, defStyleRes) constructor(context: Context, attrs: AttributeSet?, defStyleRes: Int) : super(context, attrs, defStyleRes)
@ -71,17 +83,12 @@ class LauncherIconView : View {
invalidate() invalidate()
} }
var badge: LiveData<Badge>? = null var badge: Badge? = null
set(value) { set(value) {
field = value field = value
value?.observe(context as AppCompatActivity, badgeObserver)
invalidate() invalidate()
} }
private val badgeObserver = Observer<Badge> {
invalidate()
}
private val iconObserver: (LauncherIcon) -> Unit = { private val iconObserver: (LauncherIcon) -> Unit = {
foregroundScale = it.foregroundScale foregroundScale = it.foregroundScale
backgroundScale = it.backgroundScale backgroundScale = it.backgroundScale
@ -341,13 +348,11 @@ class LauncherIconView : View {
badgeRect.right = drawRect.right.toFloat() badgeRect.right = drawRect.right.toFloat()
badgeRect.bottom = drawRect.bottom.toFloat() badgeRect.bottom = drawRect.bottom.toFloat()
val badge = badge?.value ?: return val badge = badge ?: return
val badgeNumber = badge.number val badgeNumber = badge.number
val badgeProgress = badge.progress val badgeProgress = badge.progress
val badgeIcon = badge.icon ?: badge.iconRes?.let { ContextCompat.getDrawable(context, it) } val badgeIcon = badge.icon ?: badge.iconRes?.let { ContextCompat.getDrawable(context, it) }
if (badgeNumber == null && badgeProgress == null && badgeIcon == null) return
badgePaint.color = icon?.badgeColor ?: 0 badgePaint.color = icon?.badgeColor ?: 0
canvas.drawOval(badgeRect, badgeShadowPaint) canvas.drawOval(badgeRect, badgeShadowPaint)
canvas.drawOval(badgeRect, badgePaint) canvas.drawOval(badgeRect, badgePaint)

View File

@ -23,6 +23,7 @@ import de.mm20.launcher2.ui.base.BaseActivity
import de.mm20.launcher2.ui.locals.LocalNavController import de.mm20.launcher2.ui.locals.LocalNavController
import de.mm20.launcher2.ui.settings.about.AboutSettingsScreen import de.mm20.launcher2.ui.settings.about.AboutSettingsScreen
import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen 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.calendarwidget.CalendarWidgetSettingsScreen
import de.mm20.launcher2.ui.settings.clockwidget.ClockWidgetSettingsScreen import de.mm20.launcher2.ui.settings.clockwidget.ClockWidgetSettingsScreen
import de.mm20.launcher2.ui.settings.debug.DebugSettingsScreen import de.mm20.launcher2.ui.settings.debug.DebugSettingsScreen
@ -109,6 +110,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/widgets/clock") { composable("settings/widgets/clock") {
ClockWidgetSettingsScreen() ClockWidgetSettingsScreen()
} }
composable("settings/badges") {
BadgeSettingsScreen()
}
composable("settings/about") { composable("settings/about") {
AboutSettingsScreen() AboutSettingsScreen()
} }

View File

@ -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)
}
)
}
}
}

View File

@ -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()
}
}
}
}

View File

@ -58,6 +58,18 @@
app:layout_constraintTop_toBottomOf="@+id/appName" app:layout_constraintTop_toBottomOf="@+id/appName"
tools:text="Version 1.0\ncom.app.name" /> tools:text="Version 1.0\ncom.app.name" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/notifications"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
app:chipSpacingVertical="4dp"
app:layout_constraintEnd_toStartOf="@+id/icon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/appInfo"/>
<com.google.android.material.chip.ChipGroup <com.google.android.material.chip.ChipGroup
android:id="@+id/appShortcuts" android:id="@+id/appShortcuts"
android:layout_width="0dp" android:layout_width="0dp"
@ -67,9 +79,7 @@
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
app:layout_constraintEnd_toStartOf="@+id/icon" app:layout_constraintEnd_toStartOf="@+id/icon"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/appInfo"> app:layout_constraintTop_toBottomOf="@+id/notifications"/>
</com.google.android.material.chip.ChipGroup>
<androidx.constraintlayout.widget.Barrier <androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier2" android:id="@+id/barrier2"