Reorganize and group modules

This commit is contained in:
MM20
2022-12-13 17:37:26 +01:00
parent bac24baad2
commit 3f8880a90a
995 changed files with 501 additions and 298 deletions
+1
View File
@@ -0,0 +1 @@
/build
+48
View File
@@ -0,0 +1,48 @@
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
compileSdk = sdk.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = sdk.versions.minSdk.get().toInt()
targetSdk = sdk.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.accounts"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.koin.android)
implementation(project(":libs:g-services"))
implementation(project(":libs:ms-services"))
implementation(project(":libs:owncloud"))
implementation(project(":libs:nextcloud"))
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,6 @@
package de.mm20.launcher2.accounts
data class Account(
val userName: String,
val type: AccountType,
)
@@ -0,0 +1,8 @@
package de.mm20.launcher2.accounts
enum class AccountType {
Google,
Microsoft,
Nextcloud,
Owncloud,
}
@@ -0,0 +1,130 @@
package de.mm20.launcher2.accounts
import android.app.Activity
import android.content.Context
import de.mm20.launcher2.gservices.GoogleApiHelper
import de.mm20.launcher2.msservices.MicrosoftGraphApiHelper
import de.mm20.launcher2.nextcloud.NextcloudApiHelper
import de.mm20.launcher2.owncloud.OwncloudClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
interface AccountsRepository {
fun signin(context: Activity, accountType: AccountType)
fun signout(accountType: AccountType)
/**
* Whether support for this account type is enabled in this build
*/
fun isSupported(accountType: AccountType): Boolean
suspend fun getCurrentlySignedInAccount(accountType: AccountType): Account?
}
internal class AccountsRepositoryImpl(
context: Context
) : AccountsRepository {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private val googleApiHelper = GoogleApiHelper.getInstance(context)
private val msGraphApiHelper = MicrosoftGraphApiHelper.getInstance(context)
private val nextcloudApiHelper = NextcloudApiHelper(context)
private val owncloudApiHelper = OwncloudClient(context)
override fun signin(context: Activity, accountType: AccountType) {
when (accountType) {
AccountType.Google -> {
scope.launch {
googleApiHelper.login(context)
}
}
AccountType.Microsoft -> {
scope.launch {
msGraphApiHelper.login(context)
}
}
AccountType.Nextcloud ->
scope.launch {
nextcloudApiHelper.login(context)
}
AccountType.Owncloud ->
scope.launch {
owncloudApiHelper.login(context, 0)
}
}
}
override fun signout(accountType: AccountType) {
when (accountType) {
AccountType.Google -> {
googleApiHelper.logout()
}
AccountType.Microsoft -> {
scope.launch {
msGraphApiHelper.logout()
}
}
AccountType.Nextcloud -> {
scope.launch {
nextcloudApiHelper.logout()
}
}
AccountType.Owncloud -> {
owncloudApiHelper.logout()
}
}
}
override fun isSupported(accountType: AccountType): Boolean {
return when (accountType) {
AccountType.Google -> googleApiHelper.isAvailable()
AccountType.Microsoft -> msGraphApiHelper.isAvailable()
AccountType.Nextcloud -> true
AccountType.Owncloud -> true
}
}
override suspend fun getCurrentlySignedInAccount(accountType: AccountType): Account? {
return when (accountType) {
AccountType.Google -> {
getGoogleAccount()
}
AccountType.Microsoft -> {
getMicrosoftAccount()
}
AccountType.Nextcloud -> {
getNextcloudAccount()
}
AccountType.Owncloud -> {
getOwncloudAccount()
}
}
}
private suspend fun getGoogleAccount(): Account? {
return googleApiHelper.getAccount()?.let {
Account(it.name, AccountType.Google)
}
}
private suspend fun getMicrosoftAccount(): Account? {
return msGraphApiHelper.getUser()?.let {
Account(it.name, AccountType.Microsoft)
}
}
private suspend fun getNextcloudAccount(): Account? {
return nextcloudApiHelper.getLoggedInUser()?.let {
Account(it.displayName, AccountType.Nextcloud)
}
}
private suspend fun getOwncloudAccount(): Account? {
return owncloudApiHelper.getLoggedInUser()?.let {
Account(it.displayName, AccountType.Owncloud)
}
}
}
@@ -0,0 +1,8 @@
package de.mm20.launcher2.accounts
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val accountsModule = module {
factory<AccountsRepository> { AccountsRepositoryImpl(androidContext()) }
}
+1
View File
@@ -0,0 +1 @@
/build
+50
View File
@@ -0,0 +1,50 @@
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
compileSdk = sdk.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = sdk.versions.minSdk.get().toInt()
targetSdk = sdk.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.backup"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.koin.android)
implementation(project(":data:favorites"))
implementation(project(":data:widgets"))
implementation(project(":data:search-actions"))
implementation(project(":core:preferences"))
implementation(project(":core:ktx"))
implementation(project(":data:customattrs"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,15 @@
package de.mm20.launcher2.backup
enum class BackupComponent(val value: String) {
Settings("settings"),
Favorites("favorites"),
Widgets("widgets"),
Customizations("customizations"),
SearchActions("searchactions");
companion object {
fun fromValue(value: String): BackupComponent? {
return values().firstOrNull { it.value == value }
}
}
}
@@ -0,0 +1,207 @@
package de.mm20.launcher2.backup
import android.content.Context
import android.net.Uri
import android.os.Build
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.export
import de.mm20.launcher2.preferences.import
import de.mm20.launcher2.searchactions.SearchActionRepository
import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.*
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class BackupManager(
private val context: Context,
private val dataStore: LauncherDataStore,
private val favoritesRepository: FavoritesRepository,
private val widgetRepository: WidgetRepository,
private val searchActionRepository: SearchActionRepository,
private val customAttrsRepository: CustomAttributesRepository,
) {
private val scope = CoroutineScope(Dispatchers.Default + Job())
/**
* Create a backup
* @return Uri to the created backup archive
*/
suspend fun backup(
uri: Uri,
include: Set<BackupComponent> = BackupComponent.values().toSet()
) {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val meta = BackupMetadata(
appVersionName = packageInfo.versionName,
timestamp = System.currentTimeMillis(),
deviceName = Build.MODEL,
components = include,
format = BackupFormat,
)
withContext(Dispatchers.IO) {
val outputStream = context.contentResolver.openOutputStream(uri) ?: return@withContext null
val backupDir = File(context.externalCacheDir, "backup")
if (backupDir.exists()) {
backupDir.deleteRecursively()
}
backupDir.mkdirs()
val metaFile = File(backupDir, "meta")
meta.writeToFile(metaFile)
if (include.contains(BackupComponent.Settings)) {
dataStore.export(backupDir)
}
if (include.contains(BackupComponent.Favorites)) {
favoritesRepository.export(backupDir)
}
if (include.contains(BackupComponent.Widgets)) {
widgetRepository.export(backupDir)
}
if (include.contains(BackupComponent.SearchActions)) {
searchActionRepository.export(backupDir)
}
if (include.contains(BackupComponent.Customizations)) {
customAttrsRepository.export(backupDir)
}
createArchive(backupDir, outputStream)
outputStream.close()
}
}
suspend fun restore(
uri: Uri,
include: Set<BackupComponent> = BackupComponent.values().toSet()
) {
val job = scope.launch {
withContext(Dispatchers.IO) {
val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext
val restoreDir = File(context.cacheDir, "restore")
if (restoreDir.exists()) {
restoreDir.deleteRecursively()
}
restoreDir.mkdirs()
extractArchive(inputStream, restoreDir)
inputStream.close()
if (include.contains(BackupComponent.Settings)) {
dataStore.import(context, restoreDir)
}
if (include.contains(BackupComponent.Favorites)) {
favoritesRepository.import(restoreDir)
}
if (include.contains(BackupComponent.Widgets)) {
widgetRepository.import(restoreDir)
}
if (include.contains(BackupComponent.SearchActions)) {
searchActionRepository.import(restoreDir)
}
if (include.contains(BackupComponent.Customizations)) {
customAttrsRepository.import(restoreDir)
}
}
}
job.join()
}
suspend fun readBackupMeta(uri: Uri): BackupMetadata? {
return withContext(Dispatchers.IO) {
val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext null
val zipStream = ZipInputStream(inputStream)
var entry = zipStream.nextEntry
while(entry != null) {
if (entry.name == "meta") {
val metadata = BackupMetadata.fromInputStream(zipStream)
zipStream.close()
return@withContext metadata
}
zipStream.closeEntry()
entry = zipStream.nextEntry
}
return@withContext null
}
}
private suspend fun createArchive(dir: File, outputStream: OutputStream) = withContext(Dispatchers.IO){
val zipStream = ZipOutputStream(outputStream)
val fileList = dir.listFiles()
for (file in fileList) {
zipStream.putNextEntry(ZipEntry(file.name))
file.inputStream().use {
it.copyTo(zipStream)
}
zipStream.closeEntry()
}
zipStream.close()
}
private suspend fun extractArchive(inputStream: InputStream, outDir: File) = withContext(Dispatchers.IO) {
val zipStream = ZipInputStream(inputStream)
var entry = zipStream.nextEntry
while(entry != null) {
val file = File(outDir, entry.name)
file.outputStream().use {
zipStream.copyTo(it)
}
zipStream.closeEntry()
entry = zipStream.nextEntry
}
}
fun checkCompatibility(meta: BackupMetadata): BackupCompatibility {
val format = meta.format.split(".")
val x = format.getOrNull(0)?.toIntOrNull() ?: return BackupCompatibility.Incompatible
val y = format.getOrNull(1)?.toIntOrNull() ?: return BackupCompatibility.Incompatible
if (x != BackupFormatMajor) return BackupCompatibility.Incompatible
if (y != BackupFormatMinor) return BackupCompatibility.PartiallyCompatible
return BackupCompatibility.Compatible
}
companion object {
private const val BackupFormatMajor = 1
private const val BackupFormatMinor = 4
internal const val BackupFormat = "$BackupFormatMajor.$BackupFormatMinor"
}
}
enum class BackupCompatibility {
/**
* Fully compatible, can be fully restored
*/
Compatible,
/**
* Incompatible, cannot be restored
*/
Incompatible,
/**
* Compatible but has been created on a different version and parts of the backup use a different format
* or were not supported / are not supported anymore so parts of the backup might not be restored.
*/
PartiallyCompatible
}
@@ -0,0 +1,64 @@
package de.mm20.launcher2.backup
import de.mm20.launcher2.ktx.jsonObjectOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.io.File
import java.io.InputStream
data class BackupMetadata(
val deviceName: String,
val timestamp: Long,
val appVersionName: String,
/**
* Backup schema version in format x.y.
*/
val format: String,
val components: Set<BackupComponent>,
) {
internal suspend fun writeToFile(file: File) {
val json = jsonObjectOf(
"device" to deviceName,
"timestamp" to timestamp,
"format" to format,
"versionName" to appVersionName,
"components" to JSONArray(components.map { it.value })
)
withContext(Dispatchers.IO) {
file.outputStream().bufferedWriter().use {
it.write(json.toString())
}
}
}
companion object {
internal suspend fun fromInputStream(inputStream: InputStream): BackupMetadata? {
return withContext(Dispatchers.IO) {
val text = inputStream.reader().readText()
try {
val json = JSONObject(text)
return@withContext BackupMetadata(
deviceName = json.optString("device"),
timestamp = json.optLong("timestamp"),
format = json.optString("format"),
appVersionName = json.optString("versionName"),
components = json.getJSONArray("components").let {
val set = mutableSetOf<BackupComponent>()
for (i in 0 until it.length()) {
val component = BackupComponent.fromValue(it.getString(i))
if (component != null) set.add(component)
}
set
}
)
} catch (e: JSONException) {
return@withContext null
}
}
}
}
}
@@ -0,0 +1,8 @@
package de.mm20.launcher2.backup
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val backupModule = module {
single { BackupManager(androidContext(), get(), get(), get(), get(), get()) }
}
+1
View File
@@ -0,0 +1 @@
/build
+53
View File
@@ -0,0 +1,53 @@
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
compileSdk = sdk.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = sdk.versions.minSdk.get().toInt()
targetSdk = sdk.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.badges"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.koin.android)
implementation(project(":core:ktx"))
implementation(project(":data:applications"))
implementation(project(":data:appshortcuts"))
implementation(project(":data:notifications"))
implementation(project(":core:preferences"))
implementation(project(":core:base"))
implementation(project(":data:files"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.kts.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
/
</manifest>
@@ -0,0 +1,10 @@
package de.mm20.launcher2.badges
import android.graphics.drawable.Drawable
data class Badge(
var number: Int? = null,
var progress: Float? = null,
var iconRes: Int? = null,
var icon: Drawable? = null
)
@@ -0,0 +1,86 @@
package de.mm20.launcher2.badges
import android.content.Context
import de.mm20.launcher2.badges.providers.*
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.Searchable
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
interface BadgeRepository {
fun getBadge(searchable: Searchable): Flow<Badge?>
}
internal class BadgeRepositoryImpl(private val context: Context) : BadgeRepository, KoinComponent {
private val dataStore: LauncherDataStore by inject()
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private val badgeProviders = MutableStateFlow<List<BadgeProvider>>(emptyList())
init {
scope.launch {
dataStore.data.map { it.badges }.distinctUntilChanged().collectLatest {
val providers = mutableListOf<BadgeProvider>()
providers += WorkProfileBadgeProvider()
if (it.notifications) {
providers += NotificationBadgeProvider()
}
if (it.cloudFiles) {
providers += CloudBadgeProvider()
}
if (it.shortcuts) {
providers += AppShortcutBadgeProvider(context)
}
if (it.suspendedApps) {
providers += SuspendedAppsBadgeProvider()
}
badgeProviders.value = providers
}
}
}
override fun getBadge(searchable: Searchable): Flow<Badge?> = channelFlow {
withContext(Dispatchers.Default) {
badgeProviders.collectLatest { providers ->
if (providers.isEmpty()) {
send(null)
return@collectLatest
}
combine(providers.map { it.getBadge(searchable) }) { 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)
}
}
}
}
}
@@ -0,0 +1,8 @@
package de.mm20.launcher2.badges
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val badgesModule = module {
single<BadgeRepository> { BadgeRepositoryImpl(androidContext()) }
}
@@ -0,0 +1,57 @@
package de.mm20.launcher2.badges.providers
import android.content.Context
import android.content.pm.PackageManager
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.graphics.BadgeDrawable
import de.mm20.launcher2.search.data.LauncherShortcut
import de.mm20.launcher2.search.data.LegacyShortcut
import de.mm20.launcher2.search.Searchable
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(searchable: Searchable): Flow<Badge?> = channelFlow {
if (searchable is LauncherShortcut) {
val componentName = searchable.launcherShortcut.activity
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 if (searchable is LegacyShortcut) {
val packageName = searchable.packageName
if (packageName == null) {
send(null)
return@channelFlow
}
withContext(Dispatchers.IO) {
val icon = try {
context.packageManager.getApplicationIcon(
packageName
)
} catch (e: PackageManager.NameNotFoundException) {
return@withContext
}
val badge = Badge(icon = BadgeDrawable(context, icon))
send(badge)
}
} else {
send(null)
}
}
}
@@ -0,0 +1,14 @@
package de.mm20.launcher2.badges.providers
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.search.Searchable
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(searchable: Searchable): Flow<Badge?>
}
@@ -0,0 +1,20 @@
package de.mm20.launcher2.badges.providers
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.Searchable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class CloudBadgeProvider: BadgeProvider {
override fun getBadge(searchable: Searchable): Flow<Badge?> = flow {
if (searchable is File) {
val iconResId = searchable.providerIconRes
if (iconResId != null) {
emit(Badge(iconRes = iconResId))
return@flow
}
}
emit(null)
}
}
@@ -0,0 +1,50 @@
package de.mm20.launcher2.badges.providers
import android.app.Notification
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.notifications.NotificationRepository
import de.mm20.launcher2.search.Searchable
import de.mm20.launcher2.search.data.LauncherApp
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(searchable: Searchable): Flow<Badge?> = channelFlow {
if (searchable is LauncherApp) {
val packageName = searchable.`package`
notificationRepository.notifications.map {
it.filter { it.packageName == packageName }
}.collectLatest {
if (it.isEmpty()) {
send(null)
} else {
val badge = Badge(
number = it.distinctBy { it.notification.shortcutId }.sumOf {
if(it.notification.shortcutId == null) 0
else it.notification.number
},
progress = it.mapNotNull {
if (!it.notification.extras.containsKey(Notification.EXTRA_PROGRESS)) return@mapNotNull null
val progress = it.notification.extras.getInt(Notification.EXTRA_PROGRESS)
val progressMax = it.notification.extras.getInt(Notification.EXTRA_PROGRESS_MAX).takeIf { it > 0 } ?: return@mapNotNull null
return@mapNotNull progress.toFloat() / progressMax.toFloat()
}
.takeIf { it.isNotEmpty() }
?.let {
it.sumOf { it.toDouble() }.toFloat() / it.size
}
)
send(badge)
}
}
} else {
send(null)
}
}
}
@@ -0,0 +1,35 @@
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 de.mm20.launcher2.search.Searchable
import de.mm20.launcher2.search.data.LauncherApp
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(searchable: Searchable): Flow<Badge?> = channelFlow {
if (searchable is LauncherApp) {
val packageName = searchable.`package`
appRepository.getSuspendedPackages().collectLatest {
if (it.contains(packageName)) {
send(
Badge(
iconRes = R.drawable.ic_badge_suspended
)
)
} else {
send(null)
}
}
} else {
send(null)
}
}
}
@@ -0,0 +1,23 @@
package de.mm20.launcher2.badges.providers
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.badges.R
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.search.data.LauncherShortcut
import de.mm20.launcher2.search.Searchable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class WorkProfileBadgeProvider : BadgeProvider {
override fun getBadge(searchable: Searchable): Flow<Badge?> = flow {
if (searchable is LauncherApp && !searchable.isMainProfile || searchable is LauncherShortcut && !searchable.isMainProfile) {
emit(
Badge(
iconRes = R.drawable.ic_badge_workprofile
)
)
} else {
emit(null)
}
}
}
+1
View File
@@ -0,0 +1 @@
/build
+57
View File
@@ -0,0 +1,57 @@
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
compileSdk = sdk.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = sdk.versions.minSdk.get().toInt()
targetSdk = sdk.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.icons"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.palette)
implementation(libs.materialcomponents.core)
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.koin.android)
implementation(project(":core:database"))
implementation(project(":core:preferences"))
implementation(project(":core:ktx"))
implementation(project(":core:base"))
implementation(project(":data:applications"))
implementation(project(":core:crashreporter"))
api(project(":data:customattrs"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.kts.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<intent>
<action android:name="org.adw.ActivityStarter.THEMES" />
</intent>
<intent>
<action android:name="com.novalauncher.THEME" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,76 @@
package de.mm20.launcher2.icons
import android.content.res.Resources
import android.graphics.drawable.AdaptiveIconDrawable
import androidx.core.content.res.ResourcesCompat
import de.mm20.launcher2.icons.transformations.LauncherIconTransformation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.Instant
import java.time.ZoneId
internal class DynamicCalendarIcon(
val resources: Resources,
val resourceIds: IntArray,
val isThemed: Boolean = false,
private var transformations: List<LauncherIconTransformation> = emptyList(),
) : DynamicLauncherIcon, TransformableDynamicLauncherIcon {
init {
if (resourceIds.size < 31) throw IllegalArgumentException("DynamicCalendarIcon resourceIds must at least have 31 items")
}
override suspend fun getIcon(time: Long): StaticLauncherIcon = withContext(Dispatchers.IO) {
val day = Instant.ofEpochMilli(time).atZone(ZoneId.systemDefault()).dayOfMonth
val resId = resourceIds[day - 1]
val drawable = try {
ResourcesCompat.getDrawable(resources, resId, null)
} catch (e: Resources.NotFoundException) {
null
} ?: return@withContext StaticLauncherIcon(
foregroundLayer = TextLayer(day.toString()),
backgroundLayer = ColorLayer()
)
var icon = if (isThemed) {
StaticLauncherIcon(
foregroundLayer = TintedIconLayer(
icon = drawable,
scale = 0.5f,
),
backgroundLayer = ColorLayer()
)
} else if (drawable is AdaptiveIconDrawable) {
return@withContext StaticLauncherIcon(
foregroundLayer = drawable.foreground?.let {
StaticIconLayer(
icon = it,
scale = 1.5f,
)
} ?: TransparentLayer,
backgroundLayer = drawable.background?.let {
StaticIconLayer(
icon = it,
scale = 1.5f,
)
} ?: TransparentLayer,
)
} else StaticLauncherIcon(
foregroundLayer = StaticIconLayer(
icon = drawable,
scale = 1f,
),
backgroundLayer = TransparentLayer
)
for (transformation in transformations) {
icon = transformation.transform(icon)
}
return@withContext icon
}
override fun setTransformations(transformations: List<LauncherIconTransformation>) {
this.transformations = transformations
}
}
@@ -0,0 +1,26 @@
package de.mm20.launcher2.icons
import de.mm20.launcher2.database.entities.IconPackEntity
data class IconPack(
val name: String,
val packageName: String,
val version: String,
var scale: Float = 1f
) {
constructor(entity: IconPackEntity) : this(
name = entity.name,
packageName = entity.packageName,
version = entity.packageName,
scale = entity.scale
)
fun toDatabaseEntity(): IconPackEntity {
return IconPackEntity(
name = name,
scale = scale,
version = version,
packageName = packageName
)
}
}
@@ -0,0 +1,30 @@
package de.mm20.launcher2.icons
import android.content.ComponentName
import de.mm20.launcher2.database.entities.IconEntity
data class IconPackIcon(
val type: String,
val componentName: ComponentName?,
val drawable: String?,
val iconPack: String,
val scale: Float? = null
) {
constructor(entity: IconEntity) : this(
type = entity.type,
componentName = entity.componentName,
drawable = entity.drawable,
iconPack = entity.iconPack,
scale = entity.scale
)
fun toDatabaseEntity(): IconEntity {
return IconEntity(
type = type,
componentName = componentName,
drawable = drawable,
iconPack = iconPack,
scale = scale
)
}
}
@@ -0,0 +1,659 @@
package de.mm20.launcher2.icons
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.content.res.Resources
import android.content.res.XmlResourceParser
import android.graphics.*
import android.graphics.drawable.*
import android.util.Log
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.toBitmap
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.ktx.obtainTypedArrayOrNull
import de.mm20.launcher2.ktx.randomElementOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import org.xmlpull.v1.XmlPullParserFactory
import java.io.InputStreamReader
import kotlin.math.roundToInt
private val SUPPORTED_GRAYSCALE_MAP_PROVIDERS = arrayOf(
"com.google.android.apps.nexuslauncher", // Pixel Launcher
"app.lawnchair.lawnicons", // Lawnicons
"app.lawnchair", // Lawnchair
"de.mm20.launcher2.themedicons",
"de.kvaesitso.icons",
)
class IconPackManager(
private val context: Context,
private val appDatabase: AppDatabase,
) {
suspend fun getInstalledIconPacks(): List<IconPack> {
return withContext(Dispatchers.IO) {
appDatabase.iconDao().getInstalledIconPacks().map {
IconPack(it)
}
}
}
suspend fun updateIconPacks() {
withContext(Dispatchers.IO) {
UpdateIconPacksWorker(context).doWork()
}
}
suspend fun getIcon(iconPack: String, componentName: ComponentName): LauncherIcon? {
val res = try {
context.packageManager.getResourcesForApplication(iconPack)
} catch (e: PackageManager.NameNotFoundException) {
Log.e("MM20", "Icon pack package $iconPack not found!")
return null
}
val iconDao = appDatabase.iconDao()
val icon = iconDao.getIcon(componentName.flattenToString(), iconPack)
?: return null
val drawableName = icon.drawable ?: return null
if (icon.type == "calendar") {
return getIconPackCalendarIcon(context, iconPack, drawableName)
}
val resId = res.getIdentifier(drawableName, "drawable", iconPack).takeIf { it != 0 }
?: return null
val drawable = try {
ResourcesCompat.getDrawable(res, resId, context.theme) ?: return null
} catch (e: Resources.NotFoundException) {
return null
}
return when (drawable) {
is AdaptiveIconDrawable -> {
return StaticLauncherIcon(
foregroundLayer = drawable.foreground?.let {
StaticIconLayer(
icon = it,
scale = 1.5f,
)
} ?: TransparentLayer,
backgroundLayer = drawable.background?.let {
StaticIconLayer(
icon = it,
scale = 1.5f,
)
} ?: TransparentLayer,
)
}
else -> {
StaticLauncherIcon(
foregroundLayer = StaticIconLayer(
icon = drawable,
scale = 1f
),
backgroundLayer = TransparentLayer
)
}
}
}
suspend fun generateIcon(
context: Context,
iconPack: String,
baseIcon: Drawable,
size: Int
): LauncherIcon? {
val back = getIconBack(iconPack)
val upon = getIconUpon(iconPack)
val mask = getIconMask(iconPack)
val scale = getPackScale(iconPack)
if (back == null && upon == null && mask == null) {
return null
}
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint()
paint.isAntiAlias = true
paint.isFilterBitmap = true
paint.isDither = true
var inBounds: Rect
var outBounds: Rect
val icon = baseIcon.toBitmap(width = size, height = size)
inBounds = Rect(0, 0, icon.width, icon.height)
outBounds = Rect(
(bitmap.width * (1 - scale) * 0.5).roundToInt(),
(bitmap.height * (1 - scale) * 0.5).roundToInt(),
(bitmap.width - bitmap.width * (1 - scale) * 0.5).roundToInt(),
(bitmap.height - bitmap.height * (1 - scale) * 0.5).roundToInt()
)
canvas.drawBitmap(icon, inBounds, outBounds, paint)
val pack = iconPack
val pm = context.packageManager
val res = try {
pm.getResourcesForApplication(pack)
} catch (e: Resources.NotFoundException) {
return null
} catch (e: PackageManager.NameNotFoundException) {
return null
}
if (mask != null) {
res.getIdentifier(mask, "drawable", pack).takeIf { it != 0 }?.let {
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
val maskDrawable = try {
ResourcesCompat.getDrawable(res, it, null) ?: return null
} catch (e: Resources.NotFoundException) {
return null
}
val maskBmp = maskDrawable.toBitmap(size, size)
inBounds = Rect(0, 0, maskBmp.width, maskBmp.height)
outBounds = Rect(0, 0, bitmap.width, bitmap.height)
canvas.drawBitmap(maskBmp, inBounds, outBounds, paint)
}
}
if (upon != null) {
res.getIdentifier(upon, "drawable", pack).takeIf { it != 0 }?.let {
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_OVER)
val maskDrawable = try {
ResourcesCompat.getDrawable(res, it, null) ?: return null
} catch (e: Resources.NotFoundException) {
return null
}
val maskBmp = maskDrawable.toBitmap(size, size)
inBounds = Rect(0, 0, maskBmp.width, maskBmp.height)
outBounds = Rect(0, 0, bitmap.width, bitmap.height)
canvas.drawBitmap(maskBmp, inBounds, outBounds, paint)
}
}
if (back != null) {
res.getIdentifier(back, "drawable", pack).takeIf { it != 0 }?.let {
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER)
val maskDrawable = try {
ResourcesCompat.getDrawable(res, it, null) ?: return null
} catch (e: Resources.NotFoundException) {
return null
}
val maskBmp = maskDrawable.toBitmap(size, size)
inBounds = Rect(0, 0, maskBmp.width, maskBmp.height)
outBounds = Rect(0, 0, bitmap.width, bitmap.height)
canvas.drawBitmap(maskBmp, inBounds, outBounds, paint)
}
}
return StaticLauncherIcon(
foregroundLayer = StaticIconLayer(
icon = BitmapDrawable(context.resources, bitmap),
scale = 1f,
),
backgroundLayer = TransparentLayer
)
}
suspend fun getAllIconPackIcons(componentName: ComponentName): List<IconPackIcon> {
val iconDao = appDatabase.iconDao()
return iconDao.getIconsFromAllPacks(componentName.flattenToString())
.map { IconPackIcon(it) }
}
private suspend fun getIconBack(iconPack: String): String? {
val iconDao = appDatabase.iconDao()
val iconbacks = iconDao.getIconBacks(iconPack)
return iconbacks.randomElementOrNull()
}
private suspend fun getIconUpon(iconPack: String): String? {
val iconDao = appDatabase.iconDao()
val iconupons = iconDao.getIconUpons(iconPack)
return iconupons.randomElementOrNull()
}
private suspend fun getIconMask(iconPack: String): String? {
val iconDao = appDatabase.iconDao()
val iconmasks = iconDao.getIconMasks(iconPack)
return iconmasks.randomElementOrNull()
}
private suspend fun getPackScale(iconPack: String): Float {
val iconDao = appDatabase.iconDao()
return iconDao.getScale(iconPack) ?: 1f
}
private fun getIconPackCalendarIcon(
context: Context,
iconPack: String,
baseIconName: String
): DynamicCalendarIcon? {
val resources = try {
context.packageManager.getResourcesForApplication(iconPack)
} catch (e: PackageManager.NameNotFoundException) {
return null
}
val drawableIds = (1..31).map {
val drawableName = baseIconName + it
val id = resources.getIdentifier(drawableName, "drawable", iconPack)
if (id == 0) return null
id
}.toIntArray()
return DynamicCalendarIcon(
resources = resources,
resourceIds = drawableIds
)
}
suspend fun getThemedIcon(packageName: String): LauncherIcon? {
val icon = getGreyscaleIcon(packageName) ?: return null
val resId = icon.drawable?.toIntOrNull() ?: return null
try {
val resources = context.packageManager.getResourcesForApplication(icon.iconPack)
return getThemedClockIcon(resources, resId) ?: getThemedCalendarIcon(
resources,
resId,
iconProviderPackage = icon.iconPack
) ?: getThemedStaticIcon(resources, resId)
} catch (e: PackageManager.NameNotFoundException) {
CrashReporter.logException(e)
}
return null
}
suspend fun getGreyscaleIcon(packageName: String): IconPackIcon? {
val iconDao = AppDatabase.getInstance(context).iconDao()
return iconDao.getGreyscaleIcon(ComponentName(packageName, packageName).flattenToString())
?.let { IconPackIcon(it) }
}
private fun getThemedStaticIcon(resources: Resources, resId: Int): LauncherIcon? {
try {
val fg = ResourcesCompat.getDrawable(resources, resId, null) ?: return null
return StaticLauncherIcon(
foregroundLayer = TintedIconLayer(
icon = fg,
scale = 0.5f,
),
backgroundLayer = ColorLayer()
)
} catch (e: Resources.NotFoundException) {
return null
}
}
private fun getThemedClockIcon(resources: Resources, resId: Int): LauncherIcon? {
try {
val array = resources.obtainTypedArrayOrNull(resId) ?: return null
var i = 0
var drawable: LayerDrawable? = null
var minuteIndex: Int? = null
var defaultMinute = 0
var hourIndex: Int? = null
var defaultHour = 0
while (i < array.length()) {
when (array.getString(i)) {
"com.android.launcher3.LEVEL_PER_TICK_ICON_ROUND" -> {
i++
drawable = array.getDrawable(i) as? LayerDrawable
}
"com.android.launcher3.HOUR_LAYER_INDEX" -> {
i++
hourIndex = array.getInt(i, -1).takeIf { it != -1 }
}
"com.android.launcher3.MINUTE_LAYER_INDEX" -> {
i++
minuteIndex = array.getInt(i, -1).takeIf { it != -1 }
}
"com.android.launcher3.DEFAULT_HOUR" -> {
i++
defaultHour = array.getInt(i, 0)
}
"com.android.launcher3.DEFAULT_MINUTE" -> {
i++
defaultMinute = array.getInt(i, 0)
}
}
i++
}
if (drawable != null && minuteIndex != null && hourIndex != null) {
return StaticLauncherIcon(
foregroundLayer = TintedClockLayer(
sublayers = (0 until drawable.numberOfLayers).map {
val drw = drawable.getDrawable(it)
if (drw is RotateDrawable) {
drw.level = when (it) {
hourIndex -> {
(12 - defaultHour) * 60
}
minuteIndex -> {
(60 - defaultMinute)
}
else -> 0
}
}
ClockSublayer(
drawable = drw,
role = when (it) {
hourIndex -> ClockSublayerRole.Hour
minuteIndex -> ClockSublayerRole.Minute
else -> ClockSublayerRole.Static
}
)
},
scale = 1.5f,
),
backgroundLayer = ColorLayer()
)
}
} catch (e: Resources.NotFoundException) {
}
return null
}
private fun getThemedCalendarIcon(
resources: Resources,
resId: Int,
iconProviderPackage: String
): LauncherIcon? {
try {
val array = resources.obtainTypedArrayOrNull(resId) ?: return null
if (array.length() != 31) return null
return DynamicCalendarIcon(
resources = resources,
resourceIds = IntArray(31) {
array.getResourceId(it, 0).takeIf { it != 0 } ?: return null
},
isThemed = true
)
} catch (e: Resources.NotFoundException) {
}
return null
}
suspend fun searchIconPackIcon(query: String): List<IconPackIcon> {
val iconDao = appDatabase.iconDao()
return iconDao.searchIconPackIcons("%$query%").map {
IconPackIcon(it)
}
}
suspend fun searchThemedIcons(query: String): List<IconPackIcon> {
val iconDao = appDatabase.iconDao()
return iconDao.searchGreyscaleIcons("%$query%").map {
IconPackIcon(it)
}
}
}
class UpdateIconPacksWorker(val context: Context) {
fun doWork() {
val packs = loadInstalledPacks(context).map { it.activityInfo.packageName }
val grayscaleProviders = loadInstalledGreyscaleProviders(context)
val iconDao = AppDatabase.getInstance(context).iconDao()
iconDao.uninstallIconPacksExcept(
packs.union(grayscaleProviders).toList()
)
for (pack in packs) {
try {
val packInfo = context.packageManager.getPackageInfo(pack, 0)
val iconPack = IconPack(
name = packInfo.applicationInfo.loadLabel(context.packageManager).toString(),
packageName = pack,
version = packInfo.versionName
)
//if (iconDao.isInstalled(iconPack)) continue
installIconPack(iconPack)
} catch (e: PackageManager.NameNotFoundException) {
continue
}
}
val supportedGrayscaleMapPackages = SUPPORTED_GRAYSCALE_MAP_PROVIDERS
supportedGrayscaleMapPackages.forEach { installGrayscaleIconMap(it) }
}
private fun loadInstalledGreyscaleProviders(context: Context): List<String> {
val pm = context.packageManager
return SUPPORTED_GRAYSCALE_MAP_PROVIDERS.filter {
try {
pm.getPackageInfo(it, 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
}
}
private fun loadInstalledPacks(context: Context): List<ResolveInfo> {
val packs = mutableListOf<ResolveInfo>()
val pm = context.packageManager
var intent = Intent("org.adw.ActivityStarter.THEMES")
val adwPacks = pm.queryIntentActivities(intent, 0)
packs.addAll(adwPacks)
intent = Intent("com.novalauncher.THEME")
val novaPacks = pm.queryIntentActivities(intent, 0)
novaPacks.forEach {
if (packs.none { p -> p.activityInfo.packageName == it.activityInfo.packageName }) packs.add(
it
)
}
packs.sortWith(ResolveInfo.DisplayNameComparator(pm))
return packs
}
private fun installIconPack(iconPack: IconPack) {
val pkgName = iconPack.packageName
val icons = mutableListOf<IconPackIcon>()
val database = AppDatabase.getInstance(context)
database.runInTransaction {
try {
val res = context.packageManager.getResourcesForApplication(pkgName)
val parser: XmlPullParser
var inStream: InputStreamReader? = null
val xmlId = res.getIdentifier("appfilter", "xml", pkgName)
if (xmlId != 0) parser = res.getXml(xmlId)
else {
val rawId = res.getIdentifier("appfilter", "raw", pkgName)
if (rawId == 0) {
Log.e(
"MM20",
"Icon pack $pkgName has no appfilter.xml, neither in xml nor in raw"
)
return@runInTransaction
}
parser = XmlPullParserFactory.newInstance().newPullParser()
inStream = res.openRawResource(rawId).reader()
parser.setInput(inStream)
}
val iconDao = database.iconDao()
iconDao.deleteIconPack(iconPack.toDatabaseEntity())
iconDao.deleteIcons(iconPack.packageName)
loop@ while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType != XmlPullParser.START_TAG) continue
when (parser.name) {
"item" -> {
val component = parser.getAttributeValue(null, "component")
?: continue@loop
val drawable = parser.getAttributeValue(null, "drawable")
?: continue@loop
if (component.length <= 14) continue@loop
val componentName = ComponentName.unflattenFromString(
component.substring(
14,
component.lastIndex
)
)
?: continue@loop
val icon = IconPackIcon(
componentName = componentName,
drawable = drawable,
iconPack = pkgName,
type = "app"
)
icons.add(icon)
}
"calendar" -> {
val component = parser.getAttributeValue(null, "component")
?: continue@loop
val drawable = parser.getAttributeValue(null, "prefix") ?: continue@loop
if (component.length < 14) continue@loop
val componentName = ComponentName.unflattenFromString(
component.substring(
14,
component.lastIndex
)
)
?: continue@loop
val icon = IconPackIcon(
componentName = componentName,
drawable = drawable,
iconPack = pkgName,
type = "calendar"
)
icons.add(icon)
}
"iconback" -> {
for (i in 0 until parser.attributeCount) {
if (parser.getAttributeName(i).startsWith("img")) {
val drawable = parser.getAttributeValue(i)
val icon = IconPackIcon(
componentName = null,
drawable = drawable,
iconPack = pkgName,
type = "iconback"
)
icons.add(icon)
}
}
}
"iconupon" -> {
for (i in 0 until parser.attributeCount) {
if (parser.getAttributeName(i).startsWith("img")) {
val drawable = parser.getAttributeValue(i)
val icon = IconPackIcon(
componentName = null,
drawable = drawable,
iconPack = pkgName,
type = "iconupon"
)
icons.add(icon)
}
}
}
"iconmask" -> {
for (i in 0 until parser.attributeCount) {
if (parser.getAttributeName(i).startsWith("img")) {
val drawable = parser.getAttributeValue(i)
val icon = IconPackIcon(
componentName = null,
drawable = drawable,
iconPack = pkgName,
type = "iconmask"
)
icons.add(icon)
}
}
}
"scale" -> {
val scale = parser.getAttributeValue(null, "factor")?.toFloatOrNull()
?: continue@loop
iconPack.scale = scale
}
}
if (icons.size >= 100) {
iconDao.insertAll(icons.map { it.toDatabaseEntity() })
icons.clear()
}
}
if (icons.isNotEmpty()) {
iconDao.insertAll(icons.map { it.toDatabaseEntity() })
}
iconDao.installIconPack(iconPack.toDatabaseEntity())
(parser as? XmlResourceParser)?.close()
inStream?.close()
Log.d("MM20", "Icon pack has been installed successfully")
} catch (e: PackageManager.NameNotFoundException) {
Log.e("MM20", "Could not install icon pack $pkgName: package not found.")
} catch (e: XmlPullParserException) {
CrashReporter.logException(e)
}
}
}
private fun installGrayscaleIconMap(packageName: String) {
val database = AppDatabase.getInstance(context)
database.runInTransaction {
val iconDao = database.iconDao()
try {
val resources = context.packageManager.getResourcesForApplication(packageName)
val resId = resources.getIdentifier("grayscale_icon_map", "xml", packageName)
iconDao.deleteIcons(packageName)
if (resId == 0) {
return@runInTransaction
}
val icons = mutableListOf<IconPackIcon>()
val parser = resources.getXml(resId)
loop@ while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType != XmlPullParser.START_TAG) continue
when (parser.name) {
"icon" -> {
val drawable =
parser.getAttributeResourceValue(null, "drawable", 0).toString()
val pkg = parser.getAttributeValue(null, "package")
val componentName = ComponentName(pkg, pkg)
val icon = IconPackIcon(
drawable = drawable,
componentName = componentName,
iconPack = packageName,
type = "greyscale_icon"
)
icons.add(icon)
}
}
if (icons.size >= 100) {
iconDao.insertAll(icons.map { it.toDatabaseEntity() })
icons.clear()
}
}
if (icons.isNotEmpty()) {
iconDao.insertAll(icons.map { it.toDatabaseEntity() })
}
} catch (e: PackageManager.NameNotFoundException) {
iconDao.deleteIcons(packageName)
return@runInTransaction
}
}
}
}
private const val PREFERENCE_NAME = "icon_pack"
private const val KEY_ICON_PACK = "icon_pack"
private const val KEY_VERSION = "version"
private const val KEY_ICONSCALE = "iconscale"
@@ -0,0 +1,388 @@
package de.mm20.launcher2.icons
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Color
import android.util.LruCache
import de.mm20.launcher2.data.customattrs.AdaptifiedLegacyIcon
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.data.customattrs.CustomIcon
import de.mm20.launcher2.data.customattrs.CustomIconPackIcon
import de.mm20.launcher2.data.customattrs.CustomThemedIcon
import de.mm20.launcher2.data.customattrs.DefaultPlaceholderIcon
import de.mm20.launcher2.data.customattrs.ForceThemedIcon
import de.mm20.launcher2.data.customattrs.UnmodifiedSystemDefaultIcon
import de.mm20.launcher2.icons.providers.CalendarIconProvider
import de.mm20.launcher2.icons.providers.CustomIconPackIconProvider
import de.mm20.launcher2.icons.providers.CustomThemedIconProvider
import de.mm20.launcher2.icons.providers.GoogleClockIconProvider
import de.mm20.launcher2.icons.providers.IconPackIconProvider
import de.mm20.launcher2.icons.providers.IconProvider
import de.mm20.launcher2.icons.providers.PlaceholderIconProvider
import de.mm20.launcher2.icons.providers.SystemIconProvider
import de.mm20.launcher2.icons.providers.ThemedIconProvider
import de.mm20.launcher2.icons.providers.ThemedPlaceholderIconProvider
import de.mm20.launcher2.icons.providers.getFirstIcon
import de.mm20.launcher2.icons.transformations.ForceThemedIconTransformation
import de.mm20.launcher2.icons.transformations.LauncherIconTransformation
import de.mm20.launcher2.icons.transformations.LegacyToAdaptiveTransformation
import de.mm20.launcher2.icons.transformations.transform
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.LauncherApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class IconRepository(
val context: Context,
private val iconPackManager: IconPackManager,
private val dataStore: LauncherDataStore,
private val customAttributesRepository: CustomAttributesRepository,
) {
private val appReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
requestIconPackListUpdate()
}
}
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private val cache = LruCache<String, LauncherIcon>(200)
private var iconProviders: MutableStateFlow<List<IconProvider>> = MutableStateFlow(listOf())
private var placeholderProvider: IconProvider? = null
private var transformations: MutableStateFlow<List<LauncherIconTransformation>> =
MutableStateFlow(
listOf()
)
init {
requestIconPackListUpdate()
context.registerReceiver(appReceiver, IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addAction(Intent.ACTION_MY_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_CHANGED)
addDataScheme("package")
})
scope.launch {
dataStore.data.map { it.icons }.distinctUntilChanged().collectLatest { settings ->
val placeholderProvider = if (settings.themedIcons) {
ThemedPlaceholderIconProvider(context)
} else {
PlaceholderIconProvider(context)
}
val providers = mutableListOf<IconProvider>()
if (settings.themedIcons) {
providers.add(ThemedIconProvider(iconPackManager))
}
if (settings.iconPack.isNotBlank()) {
providers.add(
IconPackIconProvider(
context,
settings.iconPack,
iconPackManager
)
)
}
providers.add(GoogleClockIconProvider(context))
providers.add(CalendarIconProvider(context))
providers.add(SystemIconProvider(context, settings.themedIcons))
providers.add(placeholderProvider)
cache.evictAll()
val transformations = mutableListOf<LauncherIconTransformation>()
if (settings.adaptify) transformations.add(LegacyToAdaptiveTransformation())
if (settings.themedIcons && settings.forceThemed) transformations.add(
ForceThemedIconTransformation()
)
this@IconRepository.placeholderProvider = placeholderProvider
iconProviders.value = providers
this@IconRepository.transformations.value = transformations
}
}
}
fun getIcon(searchable: SavableSearchable, size: Int): Flow<LauncherIcon> = channelFlow {
iconProviders.collectLatest { providers ->
transformations.collectLatest { transformations ->
customAttributesRepository.getCustomIcon(searchable).collectLatest { customIcon ->
val provs = getProviders(customIcon) + providers
val transforms = getTransformations(customIcon) ?: transformations
var icon = cache.get(searchable.key + customIcon.hashCode())
if (icon != null) {
send(icon)
return@collectLatest
}
val placeholder = placeholderProvider?.getIcon(searchable, size)
placeholder?.let { send(it) }
icon = provs.getFirstIcon(searchable, size)
if (icon != null) {
icon = icon.transform(transforms)
cache.put(searchable.key + customIcon.hashCode(), icon)
send(icon)
}
}
}
}
}
private fun getProviders(customIcon: CustomIcon?): List<IconProvider> {
if (customIcon is UnmodifiedSystemDefaultIcon) {
return listOf(
SystemIconProvider(context, false)
)
}
if (customIcon is CustomIconPackIcon) {
return listOf(
CustomIconPackIconProvider(
customIcon,
iconPackManager
)
)
}
if (customIcon is CustomThemedIcon) {
return listOf(
CustomThemedIconProvider(
customIcon,
iconPackManager
)
)
}
if (customIcon is DefaultPlaceholderIcon) {
return placeholderProvider?.let { listOf(it) } ?: emptyList()
}
return emptyList()
}
private fun getTransformations(customIcon: CustomIcon?): List<LauncherIconTransformation>? {
customIcon ?: return null
if (customIcon is AdaptifiedLegacyIcon) {
return listOf(
LegacyToAdaptiveTransformation(
foregroundScale = customIcon.fgScale,
backgroundColor = customIcon.bgColor
)
)
}
if (customIcon is ForceThemedIcon) {
return listOf(
ForceThemedIconTransformation()
)
}
if (customIcon is UnmodifiedSystemDefaultIcon) {
return emptyList()
}
return null
}
fun requestIconPackListUpdate() {
scope.launch {
iconPackManager.updateIconPacks()
}
}
suspend fun getInstalledIconPacks(): List<IconPack> {
return iconPackManager.getInstalledIconPacks()
}
suspend fun getCustomIconSuggestions(
searchable: SavableSearchable,
size: Int
): List<CustomIconWithPreview> {
val suggestions = mutableListOf<CustomIconWithPreview>()
val rawIcon = iconProviders.first().getFirstIcon(searchable, size) ?: return emptyList()
val defaultTransformations = transformations.first()
val transformationOptions = mutableListOf<CustomIcon>(UnmodifiedSystemDefaultIcon)
if (rawIcon is StaticLauncherIcon && rawIcon.backgroundLayer is TransparentLayer) {
// Legacy icons that simply fill the entire canvas
transformationOptions.add(
AdaptifiedLegacyIcon(
fgScale = 1f,
bgColor = 1
)
)
// 48x48 with 5px padding used to be the default icon size for icons generated by
// the Android Studio asset generator. Upscale these icons to remove that padding.
transformationOptions.add(
AdaptifiedLegacyIcon(
fgScale = 48f / 38f,
bgColor = 1
)
)
// Android 7.1 round icons (48x48 circle with 1px padding)
transformationOptions.add(
AdaptifiedLegacyIcon(
fgScale = 48f / 44f,
bgColor = 1
)
)
transformationOptions.add(
AdaptifiedLegacyIcon(
fgScale = 0.7f,
bgColor = 0
)
)
transformationOptions.add(
AdaptifiedLegacyIcon(
fgScale = 0.7f,
bgColor = Color.WHITE,
)
)
}
val providerOptions = mutableListOf<CustomIcon>()
if (searchable is LauncherApp) {
val iconPackIcons = iconPackManager.getAllIconPackIcons(
searchable.launcherActivityInfo.componentName
)
providerOptions.addAll(
iconPackIcons.mapNotNull {
CustomIconPackIcon(
iconPackPackage = it.iconPack,
iconComponentName = it.componentName?.flattenToString()
?: return@mapNotNull null
)
}
)
val themedIcon = iconPackManager.getGreyscaleIcon(searchable.`package`)
if (themedIcon != null && themedIcon.componentName?.packageName != null) {
providerOptions.add(
CustomThemedIcon(
iconPackageName = themedIcon.componentName.packageName,
)
)
} else {
transformationOptions.add(
ForceThemedIcon
)
}
} else {
transformationOptions.add(
ForceThemedIcon
)
}
providerOptions.add(DefaultPlaceholderIcon)
suggestions.addAll(
transformationOptions.map {
val transformations = getTransformations(it) ?: defaultTransformations
val providers = getProviders(it)
val icon = providers.getFirstIcon(searchable, size) ?: rawIcon
CustomIconWithPreview(
preview = icon.transform(transformations),
customIcon = it,
)
}
)
suggestions.addAll(
providerOptions.mapNotNull {
val providers = getProviders(it)
val icon = providers.getFirstIcon(searchable, size) ?: return@mapNotNull null
CustomIconWithPreview(
preview = icon.transform(defaultTransformations),
customIcon = it,
)
}
)
return suggestions
}
suspend fun getUncustomizedDefaultIcon(
searchable: SavableSearchable,
size: Int
): CustomIconWithPreview? {
val icon = iconProviders.first().getFirstIcon(searchable, size)
?.transform(transformations.first()) ?: return null
return CustomIconWithPreview(
customIcon = null,
preview = icon
)
}
suspend fun searchCustomIcons(query: String): List<CustomIconWithPreview> {
val transformations = this.transformations.first()
val iconPackIcons = iconPackManager.searchIconPackIcon(query).mapNotNull {
val componentName = it.componentName ?: return@mapNotNull null
CustomIconWithPreview(
customIcon = CustomIconPackIcon(
iconPackPackage = it.iconPack,
iconComponentName = componentName.flattenToString(),
),
preview = iconPackManager.getIcon(it.iconPack, componentName)
?.transform(transformations) ?: return@mapNotNull null
)
}
val themedIcons = iconPackManager.searchThemedIcons(query).mapNotNull {
val componentName = it.componentName ?: return@mapNotNull null
CustomIconWithPreview(
customIcon = CustomThemedIcon(
iconPackageName = componentName.packageName,
),
preview = iconPackManager.getThemedIcon(componentName.packageName)
?.transform(transformations) ?: return@mapNotNull null
)
}
return iconPackIcons + themedIcons
}
fun setCustomIcon(searchable: SavableSearchable, icon: CustomIcon?) {
customAttributesRepository.setCustomIcon(searchable, icon)
}
}
data class CustomIconWithPreview(
val preview: LauncherIcon,
val customIcon: CustomIcon?,
)
@@ -0,0 +1,9 @@
package de.mm20.launcher2.icons
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val iconsModule = module {
single { IconPackManager(androidContext(), get()) }
single { IconRepository(androidContext(), get(), get(), get()) }
}
@@ -0,0 +1,7 @@
package de.mm20.launcher2.icons
import de.mm20.launcher2.icons.transformations.LauncherIconTransformation
internal interface TransformableDynamicLauncherIcon {
fun setTransformations(transformations: List<LauncherIconTransformation>)
}
@@ -0,0 +1,42 @@
package de.mm20.launcher2.icons.providers
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import de.mm20.launcher2.icons.DynamicCalendarIcon
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.ktx.obtainTypedArrayOrNull
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.LauncherApp
class CalendarIconProvider(val context: Context): IconProvider {
override suspend fun getIcon(searchable: SavableSearchable, size: Int): LauncherIcon? {
if(searchable !is LauncherApp) return null
val component = ComponentName(searchable.`package`, searchable.activity)
val pm = context.packageManager
val ai = try {
pm.getActivityInfo(component, PackageManager.GET_META_DATA)
} catch (e: PackageManager.NameNotFoundException) {
return null
}
val resources = pm.getResourcesForActivity(component)
var arrayId = ai.metaData?.getInt("com.teslacoilsw.launcher.calendarIconArray") ?: 0
if (arrayId == 0) arrayId = ai.metaData?.getInt("com.google.android.calendar.dynamic_icons")
?: return null
if (arrayId == 0) return null
val typedArray = resources.obtainTypedArrayOrNull(arrayId) ?: return null
if (typedArray.length() != 31) {
typedArray.recycle()
return null
}
val drawableIds = IntArray(31)
for (i in 0 until 31) {
drawableIds[i] = typedArray.getResourceId(i, 0)
}
typedArray.recycle()
return DynamicCalendarIcon(
resources = resources,
resourceIds = drawableIds
)
}
}
@@ -0,0 +1,19 @@
package de.mm20.launcher2.icons.providers
import android.content.ComponentName
import de.mm20.launcher2.data.customattrs.CustomIconPackIcon
import de.mm20.launcher2.icons.IconPackManager
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.SavableSearchable
class CustomIconPackIconProvider(
private val customIcon: CustomIconPackIcon,
private val iconPackManager: IconPackManager,
) : IconProvider {
override suspend fun getIcon(searchable: SavableSearchable, size: Int): LauncherIcon? {
return iconPackManager.getIcon(
customIcon.iconPackPackage,
ComponentName.unflattenFromString(customIcon.iconComponentName) ?: return null
)
}
}
@@ -0,0 +1,15 @@
package de.mm20.launcher2.icons.providers
import de.mm20.launcher2.data.customattrs.CustomThemedIcon
import de.mm20.launcher2.icons.IconPackManager
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.SavableSearchable
class CustomThemedIconProvider(
private val customIcon: CustomThemedIcon,
private val iconPackManager: IconPackManager,
): IconProvider {
override suspend fun getIcon(searchable: SavableSearchable, size: Int): LauncherIcon? {
return iconPackManager.getThemedIcon(customIcon.iconPackageName)
}
}
@@ -0,0 +1,87 @@
package de.mm20.launcher2.icons.providers
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Resources
import android.graphics.drawable.AdaptiveIconDrawable
import android.graphics.drawable.LayerDrawable
import android.graphics.drawable.RotateDrawable
import androidx.core.content.res.ResourcesCompat
import de.mm20.launcher2.icons.*
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.LauncherApp
class GoogleClockIconProvider(val context: Context) : IconProvider {
override suspend fun getIcon(searchable: SavableSearchable, size: Int): LauncherIcon? {
if (searchable !is LauncherApp) return null
if (searchable.`package` != "com.google.android.deskclock") return null
val pm = context.packageManager
val appInfo = try {
pm.getApplicationInfo(
"com.google.android.deskclock",
PackageManager.GET_META_DATA
)
} catch (e: PackageManager.NameNotFoundException) {
return null
}
val drawable =
appInfo.metaData.getInt("com.android.launcher3.LEVEL_PER_TICK_ICON_ROUND")
val resources = pm.getResourcesForApplication(appInfo)
val baseIcon = try {
ResourcesCompat.getDrawable(resources, drawable, null) as? AdaptiveIconDrawable
?: return null
} catch (e: Resources.NotFoundException) {
return null
}
val foreground = baseIcon.foreground as? LayerDrawable ?: return null
val hourLayer =
appInfo.metaData.getInt("com.android.launcher3.HOUR_LAYER_INDEX")
val minuteLayer =
appInfo.metaData.getInt("com.android.launcher3.MINUTE_LAYER_INDEX")
val secondLayer =
appInfo.metaData.getInt("com.android.launcher3.SECOND_LAYER_INDEX")
val defaultHour =
appInfo.metaData.getInt("com.android.launcher3.DEFAULT_HOUR")
val defaultMinute =
appInfo.metaData.getInt("com.android.launcher3.DEFAULT_MINUTE")
val defaultSecond =
appInfo.metaData.getInt("com.android.launcher3.DEFAULT_SECOND")
return StaticLauncherIcon(
foregroundLayer = ClockLayer(
sublayers = (0 until foreground.numberOfLayers).map {
val drw = foreground.getDrawable(it)
if (drw is RotateDrawable) {
drw.level = when (it) {
hourLayer -> {
(12 - defaultHour) * 60
}
minuteLayer -> {
(60 - defaultMinute)
}
secondLayer -> {
(60 - defaultSecond) * 10
}
else -> 0
}
}
ClockSublayer(
drawable = drw,
role = when (it) {
hourLayer -> ClockSublayerRole.Hour
minuteLayer -> ClockSublayerRole.Minute
secondLayer -> ClockSublayerRole.Second
else -> ClockSublayerRole.Static
}
)
},
scale = 1.5f,
),
backgroundLayer = StaticIconLayer(
icon = baseIcon.background,
scale = 1.5f,
)
)
}
}
@@ -0,0 +1,30 @@
package de.mm20.launcher2.icons.providers
import android.content.ComponentName
import android.content.Context
import de.mm20.launcher2.icons.*
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.LauncherApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class IconPackIconProvider(
private val context: Context,
private val iconPack: String,
private val iconPackManager: IconPackManager,
): IconProvider {
override suspend fun getIcon(searchable: SavableSearchable, size: Int): LauncherIcon? {
if (searchable !is LauncherApp) return null
val component = ComponentName(searchable.`package`, searchable.activity)
return iconPackManager.getIcon(iconPack, component)
?: iconPackManager.generateIcon(
context,
iconPack,
baseIcon = withContext(Dispatchers.IO) {
searchable.launcherActivityInfo.getIcon(context.resources.displayMetrics.densityDpi)
},
size = size,
)
}
}
@@ -0,0 +1,21 @@
package de.mm20.launcher2.icons.providers
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.SavableSearchable
interface IconProvider {
suspend fun getIcon(searchable: SavableSearchable, size: Int): LauncherIcon?
}
internal suspend fun Iterable<IconProvider>.getFirstIcon(
searchable: SavableSearchable,
size: Int
): LauncherIcon? {
for (provider in this) {
val icon = provider.getIcon(searchable, size)
if (icon != null) {
return icon
}
}
return null
}
@@ -0,0 +1,11 @@
package de.mm20.launcher2.icons.providers
import android.content.Context
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.SavableSearchable
class PlaceholderIconProvider(val context: Context) : IconProvider {
override suspend fun getIcon(searchable: SavableSearchable, size: Int): LauncherIcon {
return searchable.getPlaceholderIcon(context)
}
}
@@ -0,0 +1,14 @@
package de.mm20.launcher2.icons.providers
import android.content.Context
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.SavableSearchable
class SystemIconProvider(
private val context: Context,
private val themedIcons: Boolean,
) : IconProvider {
override suspend fun getIcon(searchable: SavableSearchable, size: Int): LauncherIcon? {
return searchable.loadIcon(context, size, themedIcons)
}
}
@@ -0,0 +1,15 @@
package de.mm20.launcher2.icons.providers
import de.mm20.launcher2.icons.*
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.LauncherApp
internal class ThemedIconProvider(
private val iconPackManager: IconPackManager,
) : IconProvider {
override suspend fun getIcon(searchable: SavableSearchable, size: Int): LauncherIcon? {
if (searchable !is LauncherApp) return null
return iconPackManager.getThemedIcon(searchable.`package`)
}
}
@@ -0,0 +1,40 @@
package de.mm20.launcher2.icons.providers
import android.content.Context
import de.mm20.launcher2.icons.*
import de.mm20.launcher2.search.SavableSearchable
internal class ThemedPlaceholderIconProvider(
private val context: Context,
) : IconProvider {
override suspend fun getIcon(searchable: SavableSearchable, size: Int): LauncherIcon {
val icon = searchable.getPlaceholderIcon(context)
return StaticLauncherIcon(
foregroundLayer = asThemed(icon.foregroundLayer),
backgroundLayer = asThemed(icon.backgroundLayer),
)
}
private fun asThemed(layer: LauncherIconLayer): LauncherIconLayer {
return when (layer) {
is ClockLayer -> TintedClockLayer(
scale = layer.scale,
color = 0,
sublayers = layer.sublayers,
)
is ColorLayer -> layer.copy(color = 0)
is StaticIconLayer -> TintedIconLayer(
icon = layer.icon,
color = 0,
scale = layer.scale,
)
is TextLayer -> layer.copy(color = 0)
is TintedIconLayer -> layer.copy(color = 0)
is TintedClockLayer -> return layer.copy(color = 0)
is TransparentLayer -> return layer
}
}
}
@@ -0,0 +1,32 @@
package de.mm20.launcher2.icons.transformations
import de.mm20.launcher2.icons.*
internal class ForceThemedIconTransformation : LauncherIconTransformation {
override suspend fun transform(icon: StaticLauncherIcon): StaticLauncherIcon {
return StaticLauncherIcon(
foregroundLayer = asThemed(icon.foregroundLayer),
backgroundLayer = ColorLayer(0),
)
}
private fun asThemed(layer: LauncherIconLayer): LauncherIconLayer {
return when(layer) {
is ClockLayer -> TintedClockLayer(
scale = layer.scale,
sublayers = layer.sublayers,
)
is ColorLayer -> layer.copy(color = 0)
is StaticIconLayer -> TintedIconLayer(
color = 0,
icon = layer.icon,
scale = layer.scale / 1.5f,
)
is TextLayer -> layer.copy(
color = 0
)
else -> layer
}
}
}
@@ -0,0 +1,24 @@
package de.mm20.launcher2.icons.transformations
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TransformableDynamicLauncherIcon
internal interface LauncherIconTransformation {
suspend fun transform(icon: StaticLauncherIcon): StaticLauncherIcon
}
internal suspend fun LauncherIcon.transform(transformations: Iterable<LauncherIconTransformation>): LauncherIcon {
if (this is StaticLauncherIcon) {
var transformedIcon = this
for (transformation in transformations) {
transformedIcon = transformation.transform(transformedIcon as StaticLauncherIcon)
}
return transformedIcon
}
if (this is TransformableDynamicLauncherIcon) {
this.setTransformations(transformations.toList())
return this
}
return this
}
@@ -0,0 +1,53 @@
package de.mm20.launcher2.icons.transformations
import android.graphics.drawable.BitmapDrawable
import androidx.core.graphics.drawable.toBitmap
import androidx.palette.graphics.Palette
import de.mm20.launcher2.icons.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal class LegacyToAdaptiveTransformation(
private val foregroundScale: Float = 0.7f,
private val backgroundColor: Int = 1,
): LauncherIconTransformation {
override suspend fun transform(icon: StaticLauncherIcon): StaticLauncherIcon {
if (icon.backgroundLayer !is TransparentLayer) return icon
val bgColor = if (backgroundColor == 1) extractColor(icon.foregroundLayer) else backgroundColor
return StaticLauncherIcon(
foregroundLayer = scale(icon.foregroundLayer, foregroundScale),
backgroundLayer = ColorLayer(bgColor)
)
}
private fun scale(layer: LauncherIconLayer, scale: Float): LauncherIconLayer {
return when(layer) {
is ClockLayer -> layer.copy(scale = scale)
is StaticIconLayer -> layer.copy(scale = scale)
is TintedClockLayer -> layer.copy(scale = scale)
is TintedIconLayer -> layer.copy(scale = scale)
else -> layer
}
}
private suspend fun extractColor(layer: LauncherIconLayer): Int {
if (layer is StaticIconLayer) {
val drawable = layer.icon
val bitmap = if (drawable is BitmapDrawable) {
drawable.bitmap
} else {
drawable.toBitmap(48, 48)
}
val palette = withContext(Dispatchers.Default) {
Palette.from(bitmap).generate()
}
return palette.getDominantColor(0)
} else if (layer is ColorLayer) {
return layer.color
}
return 0
}
}
+1
View File
@@ -0,0 +1 @@
/build
+49
View File
@@ -0,0 +1,49 @@
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
compileSdk = sdk.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = sdk.versions.minSdk.get().toInt()
targetSdk = sdk.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.music"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.koin.android)
implementation(libs.coil.core)
implementation(project(":core:ktx"))
implementation(project(":core:preferences"))
implementation(project(":data:notifications"))
implementation(project(":core:crashreporter"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
/
</manifest>
@@ -0,0 +1,8 @@
package de.mm20.launcher2.music
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val musicModule = module {
single<MusicRepository> { MusicRepositoryImpl(androidContext(), get()) }
}
@@ -0,0 +1,483 @@
package de.mm20.launcher2.music
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.media.AudioManager
import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.MediaSession
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.service.notification.StatusBarNotification
import android.util.Log
import android.view.KeyEvent
import androidx.core.app.NotificationCompat
import androidx.core.content.edit
import androidx.core.graphics.drawable.toBitmap
import coil.imageLoader
import coil.request.ImageRequest
import coil.size.Scale
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.notifications.NotificationRepository
import de.mm20.launcher2.preferences.LauncherDataStore
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.IOException
interface MusicRepository {
val playbackState: Flow<PlaybackState>
val title: Flow<String?>
val artist: Flow<String?>
val album: Flow<String?>
val albumArt: Flow<Bitmap?>
val lastPlayerPackage: String?
fun next()
fun previous()
fun pause()
fun play()
fun togglePause()
fun openPlayer(): PendingIntent?
fun openPlayerChooser(context: Context)
fun resetPlayer()
}
internal class MusicRepositoryImpl(
private val context: Context,
notificationRepository: NotificationRepository
) : MusicRepository, KoinComponent {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private val dataStore: LauncherDataStore by inject()
private val preferences: SharedPreferences by lazy {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
override var lastPlayerPackage: String? = null
get() {
if (field == null) {
field = preferences.getString(PREFS_KEY_LAST_PLAYER, null)
}
return field
}
set(value) {
preferences.edit {
putString(PREFS_KEY_LAST_PLAYER, value)
}
field = value
}
private val currentMediaController: SharedFlow<MediaController?> =
combine(
notificationRepository.notifications,
dataStore.data.map { it.musicWidget.filterSources }
) { notifications, filter ->
withContext(Dispatchers.Default) {
val musicApps = if (filter) getMusicApps() else null
val sbn: StatusBarNotification? = notifications.filter {
it.notification.extras.getParcelable(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token != null &&
(musicApps?.contains(it.packageName) != false)
}.maxByOrNull { it.postTime }
return@withContext (sbn?.notification?.extras?.get(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token)
}
}
.distinctUntilChanged()
.map { token ->
if (token == null) return@map null
else {
return@map MediaController(context, token).also {
lastPlayerPackage = it.packageName
}
}
}
.shareIn(scope, SharingStarted.WhileSubscribed(), 1)
private val currentMetadata: SharedFlow<MediaMetadata?> = channelFlow {
currentMediaController.collectLatest { controller ->
if (controller == null) {
send(null)
return@collectLatest
}
send(controller.metadata)
val callback = object : MediaController.Callback() {
override fun onMetadataChanged(metadata: MediaMetadata?) {
super.onMetadataChanged(metadata)
trySend(metadata)
}
}
try {
controller.registerCallback(callback, Handler(Looper.getMainLooper()))
awaitCancellation()
} finally {
controller.unregisterCallback(callback)
}
}
}.shareIn(scope, SharingStarted.WhileSubscribed(), 1)
override val playbackState: SharedFlow<PlaybackState> = channelFlow {
currentMediaController.collectLatest { controller ->
if (controller == null) return@collectLatest send(PlaybackState.Stopped)
send(
when (controller.playbackState?.state) {
android.media.session.PlaybackState.STATE_PLAYING -> PlaybackState.Playing
android.media.session.PlaybackState.STATE_PAUSED -> PlaybackState.Paused
else -> PlaybackState.Stopped
}
)
val callback = object : MediaController.Callback() {
override fun onPlaybackStateChanged(state: android.media.session.PlaybackState?) {
super.onPlaybackStateChanged(state)
trySend(
when (state?.state) {
android.media.session.PlaybackState.STATE_PLAYING -> PlaybackState.Playing
android.media.session.PlaybackState.STATE_PAUSED -> PlaybackState.Paused
else -> PlaybackState.Stopped
}
)
}
}
try {
controller.registerCallback(callback, Handler(Looper.getMainLooper()))
awaitCancellation()
} finally {
controller.unregisterCallback(callback)
}
}
}.shareIn(scope, SharingStarted.WhileSubscribed(), 1)
private var lastTitle: String? = null
get() {
if (field == null) {
field = preferences.getString(PREFS_KEY_TITLE, null)
}
return field
}
set(value) {
preferences.edit {
putString(PREFS_KEY_TITLE, value)
}
field = value
}
override val title: Flow<String?> = channelFlow {
currentMetadata.collectLatest { metadata ->
if (metadata == null) {
send(lastTitle)
return@collectLatest
}
val title = metadata.getString(MediaMetadata.METADATA_KEY_TITLE)
?: metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
?: currentMediaController.firstOrNull()?.packageName?.let { pkg ->
getAppLabel(pkg)?.let {
context.getString(
R.string.music_widget_default_title,
it
)
}
}
lastTitle = title
send(title)
}
}.shareIn(scope, SharingStarted.WhileSubscribed(), 1)
private var lastArtist: String? = null
get() {
if (field == null) {
field = preferences.getString(PREFS_KEY_ARTIST, null)
}
return field
}
set(value) {
preferences.edit {
putString(PREFS_KEY_ARTIST, value)
}
field = value
}
override val artist: Flow<String?> = channelFlow {
currentMetadata.collectLatest { metadata ->
if (metadata == null) {
send(lastArtist)
return@collectLatest
}
val artist = metadata.getString(MediaMetadata.METADATA_KEY_ARTIST)
?: metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE)
?: currentMediaController.firstOrNull()?.packageName?.let { pkg ->
getAppLabel(pkg)
}
lastArtist = artist
send(artist)
}
}.shareIn(scope, SharingStarted.WhileSubscribed(), 1)
private var lastAlbum: String? = null
get() {
if (field == null) {
field = preferences.getString(PREFS_KEY_ALBUM, null)
}
return field
}
set(value) {
preferences.edit {
putString(PREFS_KEY_ALBUM, value)
}
field = value
}
override val album = channelFlow {
currentMetadata.collectLatest { metadata ->
if (metadata == null) {
send(lastAlbum)
return@collectLatest
}
val album = metadata.getString(MediaMetadata.METADATA_KEY_ALBUM)
lastAlbum = album
send(album)
}
}.shareIn(scope, SharingStarted.WhileSubscribed(), 1)
override val albumArt: Flow<Bitmap?> = channelFlow {
val size = context.resources.getDimensionPixelSize(R.dimen.album_art_size)
currentMetadata.collectLatest { metadata ->
if (metadata == null) {
val isNull = preferences.getString(PREFS_KEY_ALBUM_ART, "null") == "null"
if (isNull) {
send(null)
} else {
val bmp: Bitmap? = withContext(Dispatchers.IO) {
val file = java.io.File(context.filesDir, "album_art")
val request = ImageRequest.Builder(context)
.data(file)
.size(size)
.build()
context.imageLoader.execute(request).drawable?.toBitmap()
}
send(bmp)
}
return@collectLatest
}
val bitmap =
metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)?.let { resize(it, size) }
?: metadata.getBitmap(MediaMetadata.METADATA_KEY_ART)?.let { resize(it, size) }
?: metadata.getString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI)
?.let { loadBitmapFromUri(Uri.parse(it), size) }
?: metadata.getString(MediaMetadata.METADATA_KEY_ART_URI)
?.let { loadBitmapFromUri(Uri.parse(it), size) }
withContext(Dispatchers.IO) {
if (bitmap == null) {
preferences.edit {
putString(PREFS_KEY_ALBUM_ART, "null")
}
} else {
val file = java.io.File(context.filesDir, "album_art")
bitmap.compress(Bitmap.CompressFormat.PNG, 100, file.outputStream())
preferences.edit {
putString(PREFS_KEY_ALBUM_ART, "notnull")
}
}
}
send(bitmap)
}
}.shareIn(scope, SharingStarted.WhileSubscribed(), 1)
private suspend fun loadBitmapFromUri(uri: Uri, size: Int): Bitmap? {
try {
val request = ImageRequest.Builder(context)
.data(uri)
.size(size)
.scale(Scale.FILL)
.build()
context.imageLoader.execute(request).drawable?.toBitmap()
} catch (e: IOException) {
CrashReporter.logException(e)
} catch (e: SecurityException) {
CrashReporter.logException(e)
}
return null
}
private suspend fun resize(bitmap: Bitmap, size: Int): Bitmap? {
return withContext(Dispatchers.IO) {
val request = ImageRequest.Builder(context).data(bitmap)
.size(size)
.scale(Scale.FILL)
.build()
context.imageLoader.execute(request).drawable?.toBitmap()
}
}
private fun getAppLabel(packageName: String): String? {
return try {
context
.packageManager
.getPackageInfo(packageName, 0).applicationInfo
.loadLabel(context.packageManager).toString()
} catch (e: PackageManager.NameNotFoundException) {
null
}
}
override fun previous() {
scope.launch {
val controller = currentMediaController.firstOrNull()
if (controller != null) {
controller.transportControls.skipToPrevious()
} else {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS)
audioManager.dispatchMediaKeyEvent(downEvent)
val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PREVIOUS)
audioManager.dispatchMediaKeyEvent(upEvent)
}
}
}
override fun next() {
scope.launch {
val controller = currentMediaController.firstOrNull()
if (controller != null) {
controller.transportControls.skipToNext()
} else {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT)
audioManager.dispatchMediaKeyEvent(downEvent)
val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_NEXT)
audioManager.dispatchMediaKeyEvent(upEvent)
}
}
}
override fun play() {
scope.launch {
val controller = currentMediaController.firstOrNull()
if (controller != null) {
controller.transportControls.play()
} else {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY)
audioManager.dispatchMediaKeyEvent(downEvent)
val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY)
audioManager.dispatchMediaKeyEvent(upEvent)
}
}
}
override fun pause() {
scope.launch {
val controller = currentMediaController.firstOrNull()
if (controller != null) {
controller.transportControls.pause()
} else {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PAUSE)
audioManager.dispatchMediaKeyEvent(downEvent)
val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PAUSE)
audioManager.dispatchMediaKeyEvent(upEvent)
}
}
}
override fun togglePause() {
scope.launch {
val controller = currentMediaController.firstOrNull()
if (controller != null && controller.playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING) {
pause()
} else {
play()
}
}
}
override fun openPlayer(): PendingIntent? {
val controller = currentMediaController.replayCache.firstOrNull()
controller?.sessionActivity?.let {
return it
}
val packageName = controller?.packageName ?: lastPlayerPackage
val intent = packageName?.let {
context.packageManager.getLaunchIntentForPackage(it)?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
} ?: return null
if (context.packageManager.resolveActivity(intent, 0) == null) {
return null
}
return PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
}
override fun openPlayerChooser(context: Context) {
context.startActivity(
Intent.createChooser(
Intent("android.intent.action.MUSIC_PLAYER")
.apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
},
null
)
)
}
private fun getMusicApps(): Set<String> {
val apps = mutableSetOf<String>()
var intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) }
apps.addAll(context.packageManager.queryIntentActivities(intent, 0)
.map { it.activityInfo.packageName })
intent = Intent("android.intent.action.MUSIC_PLAYER")
apps.addAll(context.packageManager.queryIntentActivities(intent, 0)
.map { it.activityInfo.packageName })
return apps
}
override fun resetPlayer() {
scope.launch {
preferences.edit {
clear()
}
}
}
companion object {
private const val PREFS = "music"
private const val PREFS_KEY_TITLE = "title"
private const val PREFS_KEY_ARTIST = "artist"
private const val PREFS_KEY_ALBUM = "album"
private const val PREFS_KEY_ALBUM_ART = "album_art"
private const val PREFS_KEY_LAST_PLAYER = "last_player"
}
}
enum class PlaybackState {
Paused,
Playing,
Stopped
}
@@ -0,0 +1,3 @@
<resources>
<dimen name="album_art_size">144dp</dimen>
</resources>
+1
View File
@@ -0,0 +1 @@
/build
+67
View File
@@ -0,0 +1,67 @@
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
compileSdk = sdk.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = sdk.versions.minSdk.get().toInt()
targetSdk = sdk.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.search"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.koin.android)
implementation(libs.jsoup)
implementation(libs.okhttp)
implementation(libs.coil.core)
implementation(project(":data:applications"))
implementation(project(":data:appshortcuts"))
implementation(project(":data:calculator"))
implementation(project(":data:calendar"))
implementation(project(":data:contacts"))
implementation(project(":data:files"))
implementation(project(":data:unitconverter"))
implementation(project(":data:websites"))
implementation(project(":data:wikipedia"))
implementation(project(":data:customattrs"))
implementation(project(":data:search-actions"))
implementation(project(":core:base"))
implementation(project(":core:database"))
implementation(project(":core:preferences"))
implementation(project(":core:crashreporter"))
implementation(project(":core:ktx"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.kts.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1 @@
<manifest />
@@ -0,0 +1,22 @@
package de.mm20.launcher2.search
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val searchModule = module {
single<SearchService> {
SearchServiceImpl(
get(),
get(),
get(),
get(),
get(),
get(),
get(),
get(),
get(),
get(),
get(),
)
}
}
@@ -0,0 +1,256 @@
package de.mm20.launcher2.search
import de.mm20.launcher2.applications.AppRepository
import de.mm20.launcher2.appshortcuts.AppShortcutRepository
import de.mm20.launcher2.calculator.CalculatorRepository
import de.mm20.launcher2.calendar.CalendarRepository
import de.mm20.launcher2.contacts.ContactRepository
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.data.customattrs.utils.withCustomLabels
import de.mm20.launcher2.files.FileRepository
import de.mm20.launcher2.preferences.Settings.AppShortcutSearchSettings
import de.mm20.launcher2.preferences.Settings.CalculatorSearchSettings
import de.mm20.launcher2.preferences.Settings.CalendarSearchSettings
import de.mm20.launcher2.preferences.Settings.ContactsSearchSettings
import de.mm20.launcher2.preferences.Settings.FilesSearchSettings
import de.mm20.launcher2.preferences.Settings.UnitConverterSearchSettings
import de.mm20.launcher2.preferences.Settings.WebsiteSearchSettings
import de.mm20.launcher2.preferences.Settings.WikipediaSearchSettings
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.Calculator
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.search.data.Contact
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.GDriveFile
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.search.data.LocalFile
import de.mm20.launcher2.search.data.NextcloudFile
import de.mm20.launcher2.search.data.OneDriveFile
import de.mm20.launcher2.search.data.OwncloudFile
import de.mm20.launcher2.search.data.UnitConverter
import de.mm20.launcher2.search.data.Website
import de.mm20.launcher2.search.data.Wikipedia
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.SearchActionService
import de.mm20.launcher2.unitconverter.UnitConverterRepository
import de.mm20.launcher2.websites.WebsiteRepository
import de.mm20.launcher2.wikipedia.WikipediaRepository
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
interface SearchService {
fun search(
query: String,
shortcuts: AppShortcutSearchSettings,
contacts: ContactsSearchSettings,
calendars: CalendarSearchSettings,
files: FilesSearchSettings,
calculator: CalculatorSearchSettings,
unitConverter: UnitConverterSearchSettings,
websites: WebsiteSearchSettings,
wikipedia: WikipediaSearchSettings,
): Flow<ImmutableList<Searchable>>
}
internal class SearchServiceImpl(
private val appRepository: AppRepository,
private val appShortcutRepository: AppShortcutRepository,
private val calendarRepository: CalendarRepository,
private val contactRepository: ContactRepository,
private val fileRepository: FileRepository,
private val wikipediaRepository: WikipediaRepository,
private val unitConverterRepository: UnitConverterRepository,
private val calculatorRepository: CalculatorRepository,
private val websiteRepository: WebsiteRepository,
private val searchActionService: SearchActionService,
private val customAttributesRepository: CustomAttributesRepository,
) : SearchService {
override fun search(
query: String,
shortcuts: AppShortcutSearchSettings,
contacts: ContactsSearchSettings,
calendars: CalendarSearchSettings,
files: FilesSearchSettings,
calculator: CalculatorSearchSettings,
unitConverter: UnitConverterSearchSettings,
websites: WebsiteSearchSettings,
wikipedia: WikipediaSearchSettings,
): Flow<ImmutableList<Searchable>> = channelFlow {
var searchActionsReady = false
supervisorScope {
val results = MutableStateFlow(SearchResults())
launch {
appRepository.search(query)
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
it.copy(apps = r)
}
}
}
if (shortcuts.enabled) {
launch {
appShortcutRepository.search(query)
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
it.copy(shortcuts = r)
}
}
}
}
if (contacts.enabled) {
launch {
contactRepository.search(query)
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
it.copy(contacts = r)
}
}
}
}
if (calendars.enabled) {
launch {
calendarRepository.search(query)
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
it.copy(calendars = r)
}
}
}
}
if (calculator.enabled) {
launch {
calculatorRepository.search(query).collectLatest { r ->
results.update {
it.copy(calculators = r?.let { persistentListOf(it) }
?: persistentListOf())
}
}
}
}
if (unitConverter.enabled) {
launch {
unitConverterRepository.search(query, unitConverter.currencies).collectLatest { r ->
results.update {
it.copy(unitConverters = r?.let { persistentListOf(it) }
?: persistentListOf())
}
}
}
}
if (websites.enabled) {
launch {
websiteRepository.search(query)
.map { it?.let { listOf(it) } ?: listOf() }
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
it.copy(websites = r)
}
}
}
}
if (wikipedia.enabled) {
launch {
wikipediaRepository.search(query, loadImages = wikipedia.images)
.map { it?.let { listOf(it) } ?: listOf() }
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
it.copy(wikipedia = r)
}
}
}
}
if (files.localFiles || files.owncloud || files.onedrive || files.gdrive || files.nextcloud) {
launch {
fileRepository.search(
query,
local = files.localFiles,
nextcloud = files.nextcloud,
owncloud = files.owncloud,
onedrive = files.onedrive,
gdrive = files.gdrive,
)
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
it.copy(files = r)
}
}
}
}
launch {
customAttributesRepository.search(query)
.withCustomLabels(customAttributesRepository)
.collectLatest { r ->
results.update {
it.copy(
other = r
.filter {
it is LauncherApp ||
shortcuts.enabled && it is AppShortcut ||
files.localFiles && it is LocalFile ||
files.nextcloud && it is NextcloudFile ||
files.owncloud && it is OwncloudFile ||
files.onedrive && it is OneDriveFile ||
files.gdrive && it is GDriveFile ||
wikipedia.enabled && it is Wikipedia ||
websites.enabled && it is Website ||
calendars.enabled && it is CalendarEvent ||
contacts.enabled && it is Contact
}.toImmutableList()
)
}
}
}
launch {
searchActionService.search(query)
.collectLatest { r ->
results.update {
searchActionsReady = true
it.copy(searchActions = r)
}
}
}
launch {
results
.map { it.toList().sortedBy { it as? SavableSearchable }.toImmutableList() }
.collectLatest {
if (searchActionsReady) send(it)
}
}
}
}
}
internal data class SearchResults(
val apps: List<LauncherApp> = emptyList(),
val shortcuts: List<AppShortcut> = emptyList(),
val contacts: List<Contact> = emptyList(),
val calendars: List<CalendarEvent> = emptyList(),
val files: List<File> = emptyList(),
val calculators: List<Calculator> = emptyList(),
val unitConverters: List<UnitConverter> = emptyList(),
val websites: List<Website> = emptyList(),
val wikipedia: List<Wikipedia> = emptyList(),
val searchActions: List<SearchAction> = emptyList(),
val other: List<SavableSearchable> = emptyList(),
) {
fun toList(): List<Searchable> {
return searchActions + (apps + shortcuts + contacts + calendars + files + websites + wikipedia + other).distinctBy { it.key } + calculators + unitConverters
}
}
@@ -0,0 +1,81 @@
package de.mm20.launcher2.search.data
import android.content.Intent
import android.net.Uri
import de.mm20.launcher2.database.entities.WebsearchEntity
import java.net.URLEncoder
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
class Websearch(
var urlTemplate: String,
var label: String,
var color: Int,
var icon: String?,
var id: Long? = null,
var encoding: QueryEncoding = QueryEncoding.UrlEncode,
val query: String? = null,
) {
constructor(entity: WebsearchEntity, query: String? = null) : this(
urlTemplate = entity.urlTemplate,
label = entity.label,
icon = entity.icon,
color = entity.color,
id = entity.id,
query = query,
encoding = QueryEncoding.fromInt(entity.encoding)
)
fun toDatabaseEntity(): WebsearchEntity {
return WebsearchEntity(
urlTemplate = urlTemplate,
color = color,
icon = icon,
label = label,
id = id,
encoding = encoding.toInt()
)
}
fun getLaunchIntent(): Intent? {
if (query == null) return null
val intent = Intent(Intent.ACTION_VIEW)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
val url = urlTemplate.replace("\${1}", encodeQuery(query, encoding))
intent.data = Uri.parse(url)
return intent
}
private fun encodeQuery(query: String, encoding: QueryEncoding): String {
return when(encoding) {
QueryEncoding.UrlEncode -> Uri.encode(query)
QueryEncoding.FormData -> URLEncoder.encode(query, "UTF-8")
QueryEncoding.None -> query
}
}
enum class QueryEncoding {
UrlEncode,
FormData,
None;
fun toInt(): Int {
return when (this) {
UrlEncode -> 0
FormData -> 1
None -> 2
}
}
companion object {
fun fromInt(value: Int?): QueryEncoding {
return when (value) {
1 -> FormData
2 -> None
else -> UrlEncode
}
}
}
}
}
+1
View File
@@ -0,0 +1 @@
/build
+51
View File
@@ -0,0 +1,51 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
compileSdk = sdk.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = sdk.versions.minSdk.get().toInt()
targetSdk = sdk.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.services.tags"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.koin.android)
implementation(project(":core:preferences"))
implementation(project(":core:base"))
implementation(project(":core:ktx"))
implementation(project(":core:crashreporter"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,8 @@
package de.mm20.launcher2.services.tags
import de.mm20.launcher2.services.tags.impl.TagsServiceImpl
import org.koin.dsl.module
val servicesTagsModule = module {
single<TagsService> { TagsServiceImpl() }
}
@@ -0,0 +1,8 @@
package de.mm20.launcher2.services.tags
interface TagsService {
fun getTags(startsWith: String? = null): List<String>
fun renameTag(oldName: String, newName: String)
fun deleteTag(tag: String)
fun cloneTag(tag: String, newTag: String)
}
@@ -0,0 +1,22 @@
package de.mm20.launcher2.services.tags.impl
import de.mm20.launcher2.services.tags.TagsService
internal class TagsServiceImpl(
): TagsService {
override fun getTags(startsWith: String?): List<String> {
TODO("Not yet implemented")
}
override fun renameTag(oldName: String, newName: String) {
TODO("Not yet implemented")
}
override fun deleteTag(tag: String) {
TODO("Not yet implemented")
}
override fun cloneTag(tag: String, newTag: String) {
TODO("Not yet implemented")
}
}