Reorganize and group modules
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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"))
|
||||
|
||||
}
|
||||
Vendored
+21
@@ -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()) }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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"))
|
||||
|
||||
}
|
||||
Vendored
+21
@@ -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()) }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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"))
|
||||
}
|
||||
Vendored
+21
@@ -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()) }
|
||||
}
|
||||
+57
@@ -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?>
|
||||
}
|
||||
+20
@@ -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)
|
||||
}
|
||||
}
|
||||
+50
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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"))
|
||||
|
||||
}
|
||||
Vendored
+21
@@ -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()) }
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package de.mm20.launcher2.icons
|
||||
|
||||
import de.mm20.launcher2.icons.transformations.LauncherIconTransformation
|
||||
|
||||
internal interface TransformableDynamicLauncherIcon {
|
||||
fun setTransformations(transformations: List<LauncherIconTransformation>)
|
||||
}
|
||||
+42
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+19
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
@@ -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)
|
||||
}
|
||||
}
|
||||
+87
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
+11
@@ -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`)
|
||||
}
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+32
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+24
@@ -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
|
||||
}
|
||||
+53
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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"))
|
||||
|
||||
}
|
||||
Vendored
+21
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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"))
|
||||
}
|
||||
Vendored
+21
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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"))
|
||||
|
||||
}
|
||||
Vendored
+21
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user