Reorganize and group modules

This commit is contained in:
MM20
2022-12-13 17:37:26 +01:00
parent bac24baad2
commit 3f8880a90a
995 changed files with 501 additions and 298 deletions
+1
View File
@@ -0,0 +1 @@
/build
+52
View File
@@ -0,0 +1,52 @@
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 = JavaVersion.VERSION_1_8.toString()
}
namespace = "de.mm20.launcher2.applications"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.koin.android)
implementation(libs.commons.text)
implementation(project(":core:base"))
implementation(project(":core:ktx"))
implementation(project(":core:compat"))
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.kts.kts.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,186 @@
package de.mm20.launcher2.applications
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.LauncherActivityInfo
import android.content.pm.LauncherApps
import android.content.pm.ShortcutInfo
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.os.UserHandle
import android.util.Log
import de.mm20.launcher2.ktx.normalize
import de.mm20.launcher2.search.data.LauncherApp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import org.apache.commons.text.similarity.FuzzyScore
import java.util.*
interface AppRepository {
fun getAllInstalledApps(): Flow<List<LauncherApp>>
fun getSuspendedPackages(): Flow<List<String>>
fun search(query: String): Flow<ImmutableList<LauncherApp>>
}
internal class AppRepositoryImpl(
private val context: Context,
) : AppRepository {
private val launcherApps =
context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
private val installedApps = MutableStateFlow<List<LauncherApp>>(emptyList())
private val suspendedPackages = MutableStateFlow<List<String>>(emptyList())
private val profiles: List<UserHandle> =
launcherApps.profiles.takeIf { it.isNotEmpty() } ?: listOf(Process.myUserHandle())
init {
launcherApps.registerCallback(object : LauncherApps.Callback() {
override fun onPackagesUnavailable(
packageNames: Array<out String>,
user: UserHandle,
replacing: Boolean
) {
installedApps.value =
installedApps.value.filter { !packageNames.contains(it.`package`) }
}
override fun onPackageChanged(packageName: String, user: UserHandle) {
val apps = installedApps.value.toMutableList()
apps.removeAll { packageName == it.`package` }
apps.addAll(getApplications(packageName))
installedApps.value = apps
}
override fun onPackagesAvailable(
packageNames: Array<out String>,
user: UserHandle,
replacing: Boolean
) {
val apps = installedApps.value.toMutableList()
for (packageName in packageNames) {
apps.addAll(getApplications(packageName))
}
installedApps.value = apps
}
override fun onPackageAdded(packageName: String, user: UserHandle) {
Log.d("MM20", "App installed: $packageName")
val apps = installedApps.value.toMutableList()
apps.addAll(getApplications(packageName))
installedApps.value = apps
}
override fun onPackageRemoved(packageName: String, user: UserHandle) {
installedApps.value =
installedApps.value.filter { packageName != (it.`package`) || it.getUser() != user }
}
override fun onShortcutsChanged(
packageName: String,
shortcuts: MutableList<ShortcutInfo>,
user: UserHandle
) {
super.onShortcutsChanged(packageName, shortcuts, user)
onPackageChanged(packageName, user)
}
override fun onPackagesSuspended(packageNames: Array<out String>?, user: UserHandle?) {
super.onPackagesSuspended(packageNames, user)
packageNames ?: return
suspendedPackages.value = suspendedPackages.value + packageNames
}
override fun onPackagesUnsuspended(
packageNames: Array<out String>?,
user: UserHandle?
) {
super.onPackagesUnsuspended(packageNames, user)
packageNames ?: return
suspendedPackages.value =
suspendedPackages.value.filter { packageNames.contains(it) }
}
}, Handler(Looper.getMainLooper()))
val apps = profiles.map { p ->
launcherApps.getActivityList(null, p).mapNotNull { getApplication(it, p) }
}.flatten()
installedApps.value = apps
}
override fun getSuspendedPackages(): Flow<List<String>> {
return suspendedPackages
}
private fun getApplications(packageName: String): List<LauncherApp> {
if (packageName == context.packageName) return emptyList()
return profiles.map { p ->
launcherApps.getActivityList(packageName, p).mapNotNull { getApplication(it, p) }
}.flatten()
}
private fun getApplication(
launcherActivityInfo: LauncherActivityInfo,
profile: UserHandle
): LauncherApp? {
if (launcherActivityInfo.applicationInfo.packageName == context.packageName && !context.packageName.endsWith(
".debug"
)
) return null
return LauncherApp(context, launcherActivityInfo)
}
override fun search(query: String): Flow<ImmutableList<LauncherApp>> = channelFlow {
installedApps.collectLatest { apps ->
withContext(Dispatchers.Default) {
val appResults = mutableListOf<LauncherApp>()
if (query.isEmpty()) {
appResults.addAll(apps)
} else {
appResults.addAll(apps.filter {
matches(it.label, query)
})
val componentName = ComponentName.unflattenFromString(query)
getActivityByComponentName(componentName)?.let { appResults.add(it) }
}
appResults.sort()
send(appResults.toImmutableList())
}
}
}
override fun getAllInstalledApps(): Flow<List<LauncherApp>> {
return installedApps
}
private fun matches(label: String, query: String): Boolean {
val normalizedLabel = label.normalize()
val fuzzyScore = FuzzyScore(Locale.getDefault())
return fuzzyScore.fuzzyScore(label, query) >= query.length * 1.5 ||
fuzzyScore.fuzzyScore(normalizedLabel, query.normalize()) >= query.length * 1.5
}
private fun getActivityByComponentName(componentName: ComponentName?): LauncherApp? {
componentName ?: return null
val intent = Intent().setComponent(componentName)
val lai = launcherApps.resolveActivity(intent, Process.myUserHandle())
return lai?.let {
LauncherApp(context, lai)
}
}
}
@@ -0,0 +1,8 @@
package de.mm20.launcher2.applications
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val applicationsModule = module {
single<AppRepository> { AppRepositoryImpl(androidContext()) }
}
@@ -0,0 +1,43 @@
package de.mm20.launcher2.search.data
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.LauncherApps
import android.os.UserManager
import androidx.core.content.getSystemService
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer
import org.json.JSONObject
class LauncherAppSerializer : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as LauncherApp
val json = JSONObject()
json.put("package", searchable.`package`)
json.put("activity", searchable.activity)
json.put("user", searchable.userSerialNumber)
return json.toString()
}
override val typePrefix: String
get() = "app"
}
class LauncherAppDeserializer(val context: Context) : SearchableDeserializer {
override fun deserialize(serialized: String): SavableSearchable? {
val json = JSONObject(serialized)
val launcherApps = context.getSystemService<LauncherApps>()!!
val userManager = context.getSystemService<UserManager>()!!
val userSerial = json.optLong("user")
val user = userManager.getUserForSerialNumber(userSerial) ?: return null
val pkg = json.getString("package")
val intent = Intent().also {
it.component = ComponentName(pkg, json.getString("activity"))
}
val launcherActivityInfo = launcherApps.resolveActivity(intent, user) ?: return null
return LauncherApp(context, launcherActivityInfo)
}
}
@@ -0,0 +1,194 @@
package de.mm20.launcher2.search.data
import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Context
import android.content.pm.LauncherActivityInfo
import android.content.pm.LauncherApps
import android.content.pm.PackageManager
import android.graphics.drawable.AdaptiveIconDrawable
import android.os.Bundle
import android.os.Process
import android.os.UserHandle
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import de.mm20.launcher2.applications.R
import de.mm20.launcher2.compat.PackageManagerCompat
import de.mm20.launcher2.icons.*
import de.mm20.launcher2.ktx.getSerialNumber
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.search.SavableSearchable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
data class LauncherApp(
val launcherActivityInfo: LauncherActivityInfo,
override val label: String,
val `package`: String,
val activity: String,
val flags: Int,
val version: String?,
internal val userSerialNumber: Long,
override val labelOverride: String? = null,
) : SavableSearchable {
constructor(context: Context, launcherActivityInfo: LauncherActivityInfo): this(
launcherActivityInfo,
label = launcherActivityInfo.label.toString(),
`package` = launcherActivityInfo.applicationInfo.packageName,
activity = launcherActivityInfo.name,
flags = launcherActivityInfo.applicationInfo.flags,
version = getPackageVersionName(context, launcherActivityInfo.applicationInfo.packageName),
userSerialNumber = launcherActivityInfo.user.getSerialNumber(context)
)
val isMainProfile = launcherActivityInfo.user == Process.myUserHandle()
override val domain: String = Domain
override val preferDetailsOverLaunch: Boolean = false
override fun overrideLabel(label: String): LauncherApp {
return this.copy(labelOverride = label)
}
override val key: String
get() = if (isMainProfile) "${domain}://$`package`:$activity" else "${domain}://$`package`:$activity:${userSerialNumber}"
fun getUser(): UserHandle? {
return launcherActivityInfo.user
}
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
return StaticLauncherIcon(
foregroundLayer = TintedIconLayer(
icon = ContextCompat.getDrawable(context, R.drawable.ic_file_android)!!,
scale = 0.5f,
color = 0xff3dda84.toInt(),
),
backgroundLayer = ColorLayer(0xff3dda84.toInt())
)
}
override suspend fun loadIcon(
context: Context,
size: Int,
themed: Boolean,
): LauncherIcon? {
try {
val icon =
withContext(Dispatchers.IO) {
launcherActivityInfo.getIcon(context.resources.displayMetrics.densityDpi)
} ?: return null
if (icon is AdaptiveIconDrawable) {
if (themed && isAtLeastApiLevel(33) && icon.monochrome != null) {
return StaticLauncherIcon(
foregroundLayer = TintedIconLayer(
scale = 1f,
icon = icon.monochrome!!,
),
backgroundLayer = ColorLayer()
)
}
return StaticLauncherIcon(
foregroundLayer = icon.foreground?.let {
StaticIconLayer(
icon = it,
scale = 1.5f,
)
} ?: TransparentLayer,
backgroundLayer = icon.background?.let {
StaticIconLayer(
icon = it,
scale = 1.5f,
)
} ?: TransparentLayer,
)
} else {
return StaticLauncherIcon(
foregroundLayer = StaticIconLayer(
icon = icon,
scale = 1f,
),
backgroundLayer = TransparentLayer
)
}
} catch (e: PackageManager.NameNotFoundException) {
return null
}
}
override fun launch(context: Context, options: Bundle?): Boolean {
val launcherApps = context.getSystemService<LauncherApps>()!!
try {
launcherApps.startMainActivity(
ComponentName(`package`, activity),
launcherActivityInfo.user,
null,
options
)
} catch (e: SecurityException) {
return false
} catch (e: ActivityNotFoundException) {
return false
}
return true
}
fun getStoreDetails(context: Context): StoreLink? {
val pm = context.packageManager
return try {
val installSourceInfo = PackageManagerCompat.getInstallSource(pm, `package`)
getStoreLinkForInstaller(installSourceInfo.initiatingPackageName, `package`)
} catch (e: PackageManager.NameNotFoundException) {
null
}
}
companion object {
private fun getStoreLinkForInstaller(
installerPackage: String?,
packageName: String?
): StoreLink? {
if (packageName == null) return null
return when (installerPackage) {
"de.amazon.mShop.android", "com.amazon.venezia" -> {
StoreLink(
"Amazon App Shop",
"http://www.amazon.com/gp/mas/dl/android?p=${packageName}"
)
}
"com.android.vending" -> {
StoreLink(
"Google Play Store",
"https://play.google.com/store/apps/details?id=${packageName}"
)
}
"org.fdroid.fdroid", "com.aurora.adroid" -> {
StoreLink(
"F-Droid",
"https://f-droid.org/packages/${packageName}"
)
}
else -> null
}
}
fun getPackageVersionName(context: Context, packageName: String): String? {
return try {
context.packageManager.getPackageInfo(packageName, 0).versionName
} catch (e: PackageManager.NameNotFoundException) {
null
}
}
const val Domain = "app"
}
}
data class StoreLink(
val label: String,
val url: String
)
+1
View File
@@ -0,0 +1 @@
/build
+51
View File
@@ -0,0 +1,51 @@
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 = JavaVersion.VERSION_1_8.toString()
}
namespace = "de.mm20.launcher2.appshortcuts"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.koin.android)
implementation(libs.commons.text)
implementation(project(":data:applications"))
implementation(project(":core:permissions"))
implementation(project(":core:base"))
implementation(project(":core:ktx"))
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,229 @@
package de.mm20.launcher2.appshortcuts
import android.content.Context
import android.content.pm.LauncherActivityInfo
import android.content.pm.LauncherApps
import android.content.pm.ShortcutInfo
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.os.UserHandle
import android.util.Log
import androidx.core.content.getSystemService
import de.mm20.launcher2.ktx.normalize
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.search.data.LauncherShortcut
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.withContext
import org.apache.commons.text.similarity.FuzzyScore
import java.util.Locale
interface AppShortcutRepository {
fun search(query: String): Flow<ImmutableList<AppShortcut>>
suspend fun getShortcutsForActivity(
launcherActivityInfo: LauncherActivityInfo,
count: Int = 5
): List<LauncherShortcut>
suspend fun getShortcutsConfigActivities(): List<LauncherApp>
fun removePinnedShortcut(shortcut: LauncherShortcut)
}
internal class AppShortcutRepositoryImpl(
private val context: Context,
private val permissionsManager: PermissionsManager,
) : AppShortcutRepository {
private val scope = CoroutineScope(Dispatchers.Default + Job())
override suspend fun getShortcutsForActivity(
launcherActivityInfo: LauncherActivityInfo,
count: Int,
) = withContext(Dispatchers.IO) {
val launcherApps = context.getSystemService<LauncherApps>()!!
if (!launcherApps.hasShortcutHostPermission()) return@withContext emptyList()
val query = LauncherApps.ShortcutQuery()
.setPackage(launcherActivityInfo.applicationInfo.packageName)
.setQueryFlags(LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC or LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST)
val shortcuts = try {
launcherApps.getShortcuts(query, launcherActivityInfo.user)
} catch (e: IllegalStateException) {
emptyList()
}
val appShortcuts = mutableListOf<LauncherShortcut>()
appShortcuts.addAll(shortcuts
?.let {
if (it.size > count) it.subList(0, count)
else it
}
?.map {
LauncherShortcut(
context,
it,
)
} ?: emptyList())
appShortcuts
}
override fun search(query: String) = channelFlow<ImmutableList<AppShortcut>> {
if (query.length < 3) {
send(persistentListOf())
return@channelFlow
}
withContext(Dispatchers.IO) {
if (!permissionsManager.checkPermissionOnce(PermissionGroup.AppShortcuts)) {
send(persistentListOf())
return@withContext
}
shortcutChangeEmitter.collectLatest {
val launcherApps =
context.getSystemService<LauncherApps>() ?: return@collectLatest send(
persistentListOf()
)
val shortcutQuery = LauncherApps.ShortcutQuery()
shortcutQuery.setQueryFlags(
LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED or
LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC or
LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST or
LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED or
LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED_BY_ANY_LAUNCHER
)
val shortcuts = launcherApps.getShortcuts(shortcutQuery, Process.myUserHandle())
?.filter {
if (it.longLabel != null) {
return@filter matches(it.longLabel.toString(), query)
}
if (it.shortLabel != null) {
return@filter matches(it.shortLabel.toString(), query)
}
return@filter false
} ?: emptyList()
val pm = context.packageManager
send(
shortcuts.mapNotNull {
LauncherShortcut(
context,
it
)
}.toImmutableList()
)
}
}
}
private val shortcutChangeEmitter = callbackFlow {
send(Unit)
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
val callback = object : LauncherApps.Callback() {
override fun onPackageRemoved(packageName: String?, user: UserHandle?) {
}
override fun onPackageAdded(packageName: String?, user: UserHandle?) {
}
override fun onPackageChanged(packageName: String?, user: UserHandle?) {
}
override fun onPackagesAvailable(
packageNames: Array<out String>?,
user: UserHandle?,
replacing: Boolean
) {
}
override fun onPackagesUnavailable(
packageNames: Array<out String>?,
user: UserHandle?,
replacing: Boolean
) {
}
override fun onShortcutsChanged(
packageName: String,
shortcuts: MutableList<ShortcutInfo>,
user: UserHandle
) {
super.onShortcutsChanged(packageName, shortcuts, user)
trySend(Unit)
}
}
launcherApps.registerCallback(callback, Handler(Looper.getMainLooper()))
awaitClose {
launcherApps.unregisterCallback(callback)
}
}.shareIn(scope, SharingStarted.WhileSubscribed(500), 1)
override fun removePinnedShortcut(shortcut: LauncherShortcut) {
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
if (!launcherApps.hasShortcutHostPermission()) return
val pinnedShortcutsQuery = LauncherApps.ShortcutQuery().apply {
setQueryFlags(LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED)
}
val userHandle = shortcut.launcherShortcut.userHandle
val allPinned = launcherApps.getShortcuts(pinnedShortcutsQuery, userHandle)
if (allPinned == null) {
Log.e("MM20", "Could not remove shortcut ${shortcut.key}: shortcut query returned null")
return
}
launcherApps.pinShortcuts(
shortcut.launcherShortcut.`package`,
allPinned.filter { it.id != shortcut.launcherShortcut.id }.map { it.id },
userHandle
)
}
override suspend fun getShortcutsConfigActivities(): List<LauncherApp> {
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
if (!launcherApps.hasShortcutHostPermission()) return emptyList()
val results = mutableListOf<LauncherApp>()
val profiles = launcherApps.profiles
for (profile in profiles) {
val activities = launcherApps.getShortcutConfigActivityList(null, profile)
results.addAll(
activities.map {
LauncherApp(
context, it
)
}
)
}
return results.sorted()
}
private fun matches(label: String, query: String): Boolean {
val labelLatin = label.normalize()
val fuzzyScore = FuzzyScore(Locale.getDefault())
return fuzzyScore.fuzzyScore(label, query) >= query.length * 1.5 ||
fuzzyScore.fuzzyScore(labelLatin, query.normalize()) >= query.length * 1.5
}
}
@@ -0,0 +1,138 @@
package de.mm20.launcher2.appshortcuts
import android.content.Context
import android.content.Intent
import android.content.Intent.ShortcutIconResource
import android.content.pm.LauncherApps
import android.content.pm.PackageManager
import android.os.UserManager
import androidx.core.content.getSystemService
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.data.LauncherShortcut
import de.mm20.launcher2.search.data.LegacyShortcut
import org.json.JSONObject
import org.koin.core.component.KoinComponent
class LauncherShortcutSerializer : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as LauncherShortcut
return jsonObjectOf(
"packagename" to searchable.launcherShortcut.`package`,
"id" to searchable.launcherShortcut.id,
"user" to searchable.userSerialNumber,
).toString()
}
override val typePrefix: String
get() = "shortcut"
}
class LauncherShortcutDeserializer(
val context: Context
) : SearchableDeserializer, KoinComponent {
override fun deserialize(serialized: String): SavableSearchable? {
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
if (!launcherApps.hasShortcutHostPermission()) return null
else {
val json = JSONObject(serialized)
val packageName = json.getString("packagename")
val id = json.getString("id")
val userSerial = json.optLong("user")
val query = LauncherApps.ShortcutQuery()
query.setPackage(packageName)
query.setQueryFlags(LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED or
LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC or
LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST or
LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED or
LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED_BY_ANY_LAUNCHER
)
query.setShortcutIds(mutableListOf(id))
val userManager = context.getSystemService<UserManager>()!!
val user = userManager.getUserForSerialNumber(userSerial) ?: return null
val shortcuts = try {
launcherApps.getShortcuts(query, user)
} catch (e: IllegalStateException) {
return null
}
val pm = context.packageManager
val appName = try {
pm.getApplicationInfo(packageName, 0).loadLabel(pm).toString()
} catch (e: PackageManager.NameNotFoundException) {
return null
}
if (shortcuts == null || shortcuts.isEmpty()) {
return null
} else {
val activity = shortcuts[0].activity
return LauncherShortcut(
context = context,
launcherShortcut = shortcuts[0],
)
}
}
}
}
class LegacyShortcutSerializer: SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as LegacyShortcut
return jsonObjectOf(
"label" to searchable.label,
"intent" to searchable.intent.toUri(0),
"iconResource" to searchable.iconResource?.let {
jsonObjectOf(
"package" to it.packageName,
"resource" to it.resourceName,
)
}
).toString()
}
override val typePrefix: String
get() = "legacyshortcut"
}
class LegacyShortcutDeserializer(
val context: Context
): SearchableDeserializer {
override fun deserialize(serialized: String): SavableSearchable {
val json = JSONObject(serialized)
val label = json.getString("label")
val intent = Intent.parseUri(json.getString("intent"), 0)
val iconResourceObj = json.optJSONObject("iconResource")
val iconResource = iconResourceObj?.let {
ShortcutIconResource().apply {
packageName = iconResourceObj.getString("package")
resourceName = iconResourceObj.getString("resource")
}
}
val packageName = intent.`package` ?: intent.component?.packageName
val appName = try {
packageName?.let {
context
.packageManager
.getApplicationInfo(it, 0)
.loadLabel(context.packageManager)
.toString()
}
} catch (e: PackageManager.NameNotFoundException) {
null
}
return LegacyShortcut(
intent = intent,
label = label,
iconResource = iconResource,
appName = appName,
)
}
}
@@ -0,0 +1,8 @@
package de.mm20.launcher2.appshortcuts
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val appShortcutsModule = module {
single<AppShortcutRepository> { AppShortcutRepositoryImpl(androidContext(), get()) }
}
@@ -0,0 +1,36 @@
package de.mm20.launcher2.search.data
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import de.mm20.launcher2.appshortcuts.R
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TintedIconLayer
import de.mm20.launcher2.search.SavableSearchable
interface AppShortcut: SavableSearchable {
val appName: String?
override val preferDetailsOverLaunch: Boolean
get() = false
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
return StaticLauncherIcon(
foregroundLayer = TintedIconLayer(
color = 0xFF3DDA84.toInt(),
icon = ContextCompat.getDrawable(context, R.drawable.ic_file_android)!!,
scale = 0.5f,
),
backgroundLayer = ColorLayer(0xFF3DDA84.toInt()),
)
}
companion object {
fun fromPinRequestIntent(context: Context, data: Intent): AppShortcut? {
return LauncherShortcut.fromPinRequestIntent(context, data)
?: LegacyShortcut.fromPinRequestIntent(context, data)
}
}
}
@@ -0,0 +1,151 @@
package de.mm20.launcher2.search.data
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.LauncherApps
import android.content.pm.PackageManager
import android.content.pm.ShortcutInfo
import android.graphics.drawable.AdaptiveIconDrawable
import android.os.Bundle
import android.os.Process
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import de.mm20.launcher2.appshortcuts.R
import de.mm20.launcher2.icons.*
import de.mm20.launcher2.ktx.getSerialNumber
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Represents a modern (Android O+) launcher shortcut
*/
data class LauncherShortcut(
val launcherShortcut: ShortcutInfo,
override val appName: String?,
internal val userSerialNumber: Long,
override val labelOverride: String? = null,
) : AppShortcut {
override val domain: String = Domain
constructor(
context: Context,
launcherShortcut: ShortcutInfo,
): this(
launcherShortcut = launcherShortcut,
appName = try {
context.packageManager.getApplicationInfo(launcherShortcut.`package`, 0)
.loadLabel(context.packageManager).toString()
} catch (e: PackageManager.NameNotFoundException) {
null
},
userSerialNumber = launcherShortcut.userHandle.getSerialNumber(context)
)
override val label: String
get() = launcherShortcut.shortLabel?.toString() ?: ""
override fun overrideLabel(label: String): LauncherShortcut {
return this.copy(labelOverride = label)
}
override val preferDetailsOverLaunch: Boolean = false
val isMainProfile = launcherShortcut.userHandle == Process.myUserHandle()
override val key: String
get() = if (isMainProfile) {
"$domain://${launcherShortcut.`package`}/${launcherShortcut.id}"
} else {
"$domain://${launcherShortcut.`package`}/${launcherShortcut.id}:${userSerialNumber}"
}
override fun launch(context: Context, options: Bundle?): Boolean {
val launcherApps = context.getSystemService<LauncherApps>()!!
try {
launcherApps.startShortcut(launcherShortcut, null, options)
} catch (e: IllegalStateException) {
return false
} catch (e: ActivityNotFoundException) {
return false
}
return true
}
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
return StaticLauncherIcon(
foregroundLayer = TintedIconLayer(
color = 0xFF3DDA84.toInt(),
icon = ContextCompat.getDrawable(context, R.drawable.ic_file_android)!!,
scale = 0.5f,
),
backgroundLayer = ColorLayer(0xFF3DDA84.toInt()),
)
}
override suspend fun loadIcon(
context: Context,
size: Int,
themed: Boolean,
): LauncherIcon? {
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
val icon = withContext(Dispatchers.IO) {
launcherApps.getShortcutIconDrawable(
launcherShortcut,
context.resources.displayMetrics.densityDpi
)
} ?: return null
if (icon is AdaptiveIconDrawable) {
if (themed && isAtLeastApiLevel(33) && icon.monochrome != null) {
return StaticLauncherIcon(
foregroundLayer = TintedIconLayer(
scale = 1f,
icon = icon.monochrome!!,
),
backgroundLayer = ColorLayer()
)
}
return StaticLauncherIcon(
foregroundLayer = icon.foreground?.let {
StaticIconLayer(
icon = it,
scale = 1.5f,
)
} ?: TransparentLayer,
backgroundLayer = icon.background?.let {
StaticIconLayer(
icon = it,
scale = 1.5f,
)
} ?: TransparentLayer,
)
}
return StaticLauncherIcon(
foregroundLayer = StaticIconLayer(
icon = icon,
scale = 1f
),
backgroundLayer = TransparentLayer
)
}
companion object {
fun fromPinRequestIntent(context: Context, data: Intent): LauncherShortcut? {
val launcherApps =
context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
val pinRequest = launcherApps.getPinItemRequest(data)
val shortcutInfo = pinRequest?.shortcutInfo ?: return null
if (!pinRequest.accept()) return null
return LauncherShortcut(
context,
shortcutInfo,
)
}
const val Domain = "shortcut"
}
}
@@ -0,0 +1,105 @@
package de.mm20.launcher2.search.data
import android.content.Context
import android.content.Intent
import android.content.Intent.ShortcutIconResource
import android.graphics.drawable.AdaptiveIconDrawable
import android.os.Bundle
import de.mm20.launcher2.icons.*
import de.mm20.launcher2.ktx.getDrawableOrNull
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.ktx.tryStartActivity
data class LegacyShortcut(
val intent: Intent,
override val label: String,
override val appName: String?,
val iconResource: ShortcutIconResource?,
override val labelOverride: String? = null,
) : AppShortcut {
override val domain = Domain
override val key: String = "$domain://${intent.toUri(0)}"
override fun overrideLabel(label: String): LegacyShortcut {
return this.copy(labelOverride = label)
}
override fun launch(context: Context, options: Bundle?): Boolean {
return context.tryStartActivity(intent, options)
}
val packageName: String?
get() = intent.`package` ?: intent.component?.packageName
override suspend fun loadIcon(context: Context, size: Int, themed: Boolean): LauncherIcon? {
if (iconResource == null) return null
val resources = context.packageManager.getResourcesForApplication(iconResource.packageName)
val drawableId =
resources.getIdentifier(iconResource.resourceName, "drawable", iconResource.packageName)
if (drawableId == 0) return null
val icon = resources.getDrawableOrNull(drawableId) ?: return null
if (icon is AdaptiveIconDrawable) {
if (themed && isAtLeastApiLevel(33) && icon.monochrome != null) {
return StaticLauncherIcon(
foregroundLayer = TintedIconLayer(
scale = 1f,
icon = icon.monochrome!!,
),
backgroundLayer = ColorLayer()
)
}
return StaticLauncherIcon(
foregroundLayer = icon.foreground?.let {
StaticIconLayer(
icon = it,
scale = 1.5f,
)
} ?: TransparentLayer,
backgroundLayer = icon.background?.let {
StaticIconLayer(
icon = it,
scale = 1.5f,
)
} ?: TransparentLayer,
)
}
return StaticLauncherIcon(
foregroundLayer = StaticIconLayer(
icon = icon,
scale = 1f
),
backgroundLayer = TransparentLayer
)
}
companion object {
const val Domain = "legacyshortcut"
fun fromPinRequestIntent(context: Context, data: Intent): LegacyShortcut? {
val intent: Intent? = data.extras?.getParcelable(Intent.EXTRA_SHORTCUT_INTENT)
val name: String? = data.extras?.getString(Intent.EXTRA_SHORTCUT_NAME)
val iconResource: ShortcutIconResource? =
data.extras?.getParcelable(Intent.EXTRA_SHORTCUT_ICON_RESOURCE)
if (intent == null || name == null) {
return null
}
val packageName = intent.`package` ?: intent.component?.packageName
return LegacyShortcut(
intent = intent,
appName = packageName?.let {
context.packageManager.getApplicationInfo(
it, 0
).loadLabel(context.packageManager).toString()
},
label = name,
iconResource = iconResource
)
}
}
}
+1
View File
@@ -0,0 +1 @@
/build
+50
View File
@@ -0,0 +1,50 @@
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
compileSdk = sdk.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = sdk.versions.minSdk.get().toInt()
targetSdk = sdk.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.calculator"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.mathparser)
implementation(libs.koin.android)
implementation(project(":core:base"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.kts.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
/
</manifest>
@@ -0,0 +1,65 @@
package de.mm20.launcher2.calculator
import de.mm20.launcher2.search.data.Calculator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.mariuszgromada.math.mxparser.Expression
interface CalculatorRepository {
fun search(query: String): Flow<Calculator?>
}
class CalculatorRepositoryImpl : CalculatorRepository, KoinComponent {
override fun search(query: String): Flow<Calculator?> = channelFlow {
if (query.isBlank()) {
send(null)
return@channelFlow
}
send(queryCalculator(query))
}
private suspend fun queryCalculator(query: String): Calculator? {
return when {
query.matches(Regex("0x[0-9a-fA-F]+")) -> {
val solution = query.substring(2).toIntOrNull(16) ?: run {
return null
}
Calculator(term = query, solution = solution.toDouble())
}
query.matches(Regex("0b[01]+")) -> {
val solution = query.substring(2).toIntOrNull(2) ?: run {
return null
}
Calculator(term = query, solution = solution.toDouble())
}
query.matches(Regex("0[0-7]+")) -> {
val solution = query.substring(1).toIntOrNull(8) ?: run {
return null
}
Calculator(term = query, solution = solution.toDouble())
}
else -> {
withContext(Dispatchers.IO) {
val exp = Expression(query)
if (exp.checkSyntax()) {
Calculator(term = query, solution = exp.calculate())
} else {
val exp2 = Expression(query.replace(',', '.').replace(';', ','))
if (exp2.checkSyntax()) {
Calculator(term = query, solution = exp2.calculate())
} else null
}
}
}
}
}
}
@@ -0,0 +1,7 @@
package de.mm20.launcher2.calculator
import org.koin.dsl.module
val calculatorModule = module {
single<CalculatorRepository> { CalculatorRepositoryImpl() }
}
@@ -0,0 +1,90 @@
package de.mm20.launcher2.search.data
import de.mm20.launcher2.search.Searchable
import java.text.DecimalFormat
import java.util.*
import kotlin.math.abs
import kotlin.math.roundToInt
data class Calculator(
val term: String,
val solution: Double
): Searchable {
val formattedString: String
val formattedBinaryString: String
val formattedHexString: String
val formattedOctString: String
init {
if (solution.isNaN()) {
formattedString = "NaN"
formattedOctString = "NaN"
formattedBinaryString = "NaN"
formattedHexString = "NaN"
} else {
val nf =
if ((abs(solution) > 1e12 || abs(solution) < 1e-5) && solution != 0.0) DecimalFormat(
"#.######E0"
)
else DecimalFormat("#,###.######")
formattedString = nf.format(solution)
var s = StringBuffer(solution.roundToInt().toString(2))
while (s.length % 4 != 0) {
s = s.insert(0, '0')
}
for (i in s.length - 4 downTo 4 step 4) {
s.insert(i, ' ')
}
formattedBinaryString = s.toString()
s = StringBuffer(solution.roundToInt().toString(8))
while (s.length % 3 != 0) {
s = s.insert(0, '0')
}
for (i in s.length - 3 downTo 3 step 3) {
s.insert(i, ' ')
}
formattedOctString = s.toString()
s = StringBuffer(solution.roundToInt().toString(16).uppercase(Locale.getDefault()))
while (s.length % 2 != 0) {
s = s.insert(0, '0')
}
for (i in s.length - 2 downTo 2 step 2) {
s.insert(i, ' ')
}
formattedHexString = s.toString()
}
}
fun getBeatifiedTerm(): String {
if(term.matches(Regex("0x[0-9a-fA-F]+"))) {
return term.substring(2).uppercase(Locale.ROOT) + "₁₆"
}
if(term.matches(Regex("0b[01]+"))) {
return term.substring(2) + ""
}
if(term.matches(Regex("0[0-7]+"))) {
return term.substring(1) + ""
}
return term.replace(Regex("\\s+"), "")
.replace("pi", " \u03C0 ", ignoreCase = true)
.replace("*", " \u00D7 ")
.replace("-", " \u2212 ")
.replace("/", " \u2215 ")
.replace("+", " + ")
.replace(Regex("&{1,2}"), " \u2227 ")
.replace(Regex("\\|{1,2}"), " \u2228 ")
.replace("!=", " \u2260 ")
.replace("<>", " \u2260 ")
.replace(">=", " \u2265 ")
.replace("<=", " \u2264 ")
.replace("=", " = ")
.replace("<", " < ")
.replace(">", " > ")
}
}
+1
View File
@@ -0,0 +1 @@
/build
+50
View File
@@ -0,0 +1,50 @@
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
compileSdk = sdk.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = sdk.versions.minSdk.get().toInt()
targetSdk = sdk.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.calendar"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.koin.android)
implementation(project(":core:ktx"))
implementation(project(":core:base"))
implementation(project(":core:permissions"))
implementation(project(":libs:material-color-utilities"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.kts.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_CALENDAR" />
</manifest>
@@ -0,0 +1,213 @@
package de.mm20.launcher2.calendar
import android.content.ContentUris
import android.content.Context
import android.provider.CalendarContract
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.search.data.UserCalendar
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import java.util.Calendar
interface CalendarRepository {
fun search(query: String): Flow<ImmutableList<CalendarEvent>>
fun getUpcomingEvents(
excludeCalendars: List<Long>,
excludeAllDayEvents: Boolean
): Flow<List<CalendarEvent>>
suspend fun getCalendars(): List<UserCalendar>
}
internal class CalendarRepositoryImpl(
private val context: Context,
private val permissionsManager: PermissionsManager,
) : CalendarRepository {
override fun search(query: String): Flow<ImmutableList<CalendarEvent>> {
if (query.isBlank() || query.length < 3) {
return flow {
emit(persistentListOf())
}
}
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
return hasPermission.map {
if (it) {
val now = System.currentTimeMillis()
queryCalendarEvents(
query,
intervalStart = now,
intervalEnd = now + 14 * 24 * 60 * 60 * 1000L,
).toImmutableList()
} else {
persistentListOf()
}
}
}
private suspend fun queryCalendarEvents(
query: String,
intervalStart: Long,
intervalEnd: Long,
limit: Int = 10,
excludeAllDayEvents: Boolean = false,
excludeCalendars: List<Long> = emptyList(),
): List<CalendarEvent> {
val results = withContext(Dispatchers.IO) {
val results = mutableListOf<CalendarEvent>()
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
ContentUris.appendId(builder, intervalStart)
ContentUris.appendId(builder, intervalEnd)
val uri = builder.build()
val projection = arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.ALL_DAY,
CalendarContract.Instances.DISPLAY_COLOR,
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.CALENDAR_ID,
CalendarContract.Instances.DESCRIPTION
)
val selection = mutableListOf<String>()
if (query.isNotEmpty()) selection.add("${CalendarContract.Instances.TITLE} LIKE ?")
if (excludeCalendars.isNotEmpty()) selection.add("${CalendarContract.Instances.CALENDAR_ID} NOT IN (${excludeCalendars.joinToString()})")
if (excludeAllDayEvents) selection.add("${CalendarContract.Instances.ALL_DAY} = 0")
val selArgs = if (query.isBlank()) null else arrayOf("%$query%")
val sort =
"${CalendarContract.Instances.BEGIN} ASC" + if (limit > -1) " LIMIT $limit" else ""
val cursor = context.contentResolver.query(
uri,
projection,
selection.joinToString(separator = " AND "),
selArgs,
sort
) ?: return@withContext mutableListOf()
val proj = arrayOf(
CalendarContract.Attendees.EVENT_ID,
CalendarContract.Attendees.ATTENDEE_NAME,
CalendarContract.Attendees.ATTENDEE_EMAIL
)
val s = "${CalendarContract.Attendees.ATTENDEE_NAME} COLLATE NOCASE ASC"
while (cursor.moveToNext()) {
val sel = "${CalendarContract.Attendees.EVENT_ID} = ${cursor.getLong(0)}"
val cur = context.contentResolver.query(
CalendarContract.Attendees.CONTENT_URI,
proj, sel, null, s
) ?: return@withContext mutableListOf()
val attendees = mutableListOf<String>()
while (cur.moveToNext()) {
attendees.add(
cur.getStringOrNull(1).takeUnless { it.isNullOrBlank() }
?: cur.getStringOrNull(2)
?: continue
)
}
cur.close()
val allday = cursor.getInt(4) > 0
val begin = cursor.getLong(2)
val tzOffset = if (allday) {
Calendar.getInstance().timeZone.getOffset(begin)
} else {
0
}
val event = CalendarEvent(
label = cursor.getStringOrNull(1) ?: "",
id = cursor.getLong(0),
color = cursor.getInt(5),
startTime = begin - tzOffset,
endTime = cursor.getLong(3) - tzOffset - if (allday) 1 else 0,
allDay = allday,
location = cursor.getStringOrNull(6) ?: "",
attendees = attendees,
description = cursor.getStringOrNull(8)
?: "",
calendar = cursor.getLong(7)
)
results.add(event)
}
cursor.close()
return@withContext results
}
return results
}
override fun getUpcomingEvents(
excludeCalendars: List<Long>,
excludeAllDayEvents: Boolean,
): Flow<List<CalendarEvent>> = channelFlow {
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
hasPermission.collectLatest {
if (it) {
val now = System.currentTimeMillis()
val end = now + 14 * 24 * 60 * 60 * 1000L
val events = withContext(Dispatchers.IO) {
queryCalendarEvents(
query = "",
intervalStart = now,
intervalEnd = end,
limit = 700,
excludeAllDayEvents = excludeAllDayEvents,
excludeCalendars = excludeCalendars
)
}
send(events)
} else {
send(emptyList())
}
}
}
override suspend fun getCalendars(): List<UserCalendar> {
if (!permissionsManager.checkPermissionOnce(PermissionGroup.Calendar)) return emptyList()
return withContext(Dispatchers.IO) {
val calendars = mutableListOf<UserCalendar>()
val uri = CalendarContract.Calendars.CONTENT_URI
val proj = arrayOf(
CalendarContract.Calendars._ID,
CalendarContract.Calendars.NAME,
CalendarContract.Calendars.ACCOUNT_NAME,
CalendarContract.Calendars.CALENDAR_COLOR,
CalendarContract.Calendars.VISIBLE,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME,
)
val cursor = context.contentResolver.query(uri, proj, null, null, null)
?: return@withContext emptyList()
while (cursor.moveToNext()) {
try {
calendars.add(
UserCalendar(
id = cursor.getLong(0),
name = cursor.getStringOrNull(5) ?: cursor.getStringOrNull(1) ?: "",
owner = cursor.getStringOrNull(2) ?: "",
color = cursor.getInt(3)
)
)
} catch (e: NullPointerException) {
continue
}
}
cursor.close()
calendars.sortBy { it.owner }
return@withContext calendars
}
}
}
@@ -0,0 +1,106 @@
package de.mm20.launcher2.calendar
import android.Manifest
import android.content.ContentUris
import android.content.Context
import android.content.pm.PackageManager
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.data.CalendarEvent
import org.json.JSONObject
import java.util.*
class CalendarEventSerializer: SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as CalendarEvent
val json = JSONObject()
json.put("id", searchable.id)
return json.toString()
}
override val typePrefix: String
get() = "calendar"
}
class CalendarEventDeserializer(val context: Context): SearchableDeserializer {
override fun deserialize(serialized: String): SavableSearchable? {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) return null
val json = JSONObject(serialized)
val id = json.getLong("id")
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
ContentUris.appendId(builder, System.currentTimeMillis())
ContentUris.appendId(builder, System.currentTimeMillis() + 63072000000L)
val uri = builder.build()
val projection = arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.ALL_DAY,
CalendarContract.Instances.DISPLAY_COLOR,
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.CALENDAR_ID,
CalendarContract.Instances.DESCRIPTION
)
val selection = CalendarContract.Instances.EVENT_ID + " = ?"
val selArgs = arrayOf(id.toString())
val cursor = context.contentResolver.query(uri, projection, selection, selArgs, null)
?: return null
if (cursor.moveToNext()) {
val title = cursor.getStringOrNull(1) ?: ""
val begin = cursor.getLong(2)
val end = cursor.getLong(3)
val allday = cursor.getInt(4) != 0
val color = cursor.getInt(5)
val location = cursor.getStringOrNull(6)
val calendar = cursor.getLong(7)
val description = cursor.getStringOrNull(8)
?: ""
cursor.close()
val proj = arrayOf(
CalendarContract.Attendees.EVENT_ID,
CalendarContract.Attendees.ATTENDEE_NAME,
CalendarContract.Attendees.ATTENDEE_EMAIL
)
val sel = "${CalendarContract.Attendees.EVENT_ID} = $id"
val s = "${CalendarContract.Attendees.ATTENDEE_NAME} COLLATE NOCASE ASC"
val cur = context.contentResolver.query(
CalendarContract.Attendees.CONTENT_URI,
proj, sel, null, s
) ?: return null
val attendees = mutableListOf<String>()
while (cur.moveToNext()) {
attendees.add(
cur.getStringOrNull(1).takeUnless { it.isNullOrBlank() }
?: cur.getStringOrNull(2)
?: continue
)
}
cur.close()
val tzOffset = if (allday) {
Calendar.getInstance().timeZone.getOffset(begin)
} else {
0
}
return CalendarEvent(
label = title,
id = id,
color = color,
startTime = begin - tzOffset,
endTime = end - tzOffset - if (allday) 1 else 0,
allDay = allday,
location = location ?: "",
attendees = attendees,
description = description,
calendar = calendar
)
}
cursor.close()
return null
}
}
@@ -0,0 +1,8 @@
package de.mm20.launcher2.calendar
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val calendarModule = module {
single<CalendarRepository> { CalendarRepositoryImpl(androidContext(), get()) }
}
@@ -0,0 +1,70 @@
package de.mm20.launcher2.search.data
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.provider.CalendarContract
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TextLayer
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.SavableSearchable
import java.text.SimpleDateFormat
data class CalendarEvent(
override val label: String,
val id: Long,
val color: Int,
val startTime: Long,
val endTime: Long,
val allDay: Boolean,
val location: String,
val attendees: List<String>,
val description: String,
val calendar: Long,
override val labelOverride: String? = null,
) : SavableSearchable {
override val domain: String = Domain
override val key: String
get() = "$domain://$id"
override val preferDetailsOverLaunch: Boolean = true
override fun overrideLabel(label: String): CalendarEvent {
return this.copy(labelOverride = label)
}
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
val df = SimpleDateFormat("dd")
return StaticLauncherIcon(
foregroundLayer = TextLayer(
text = df.format(startTime),
color = color
),
backgroundLayer = ColorLayer(color)
)
}
private fun getLaunchIntent(): Intent {
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id)
return Intent(Intent.ACTION_VIEW).setData(uri).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
override fun launch(context: Context, options: Bundle?): Boolean {
return context.tryStartActivity(getLaunchIntent(), options)
}
companion object {
const val Domain = "calendar"
}
}
data class UserCalendar(
val id: Long,
val name: String,
val owner: String,
val color: Int
)
+1
View File
@@ -0,0 +1 @@
/build
+49
View File
@@ -0,0 +1,49 @@
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
compileSdk = sdk.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = sdk.versions.minSdk.get().toInt()
targetSdk = sdk.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.contacts"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.koin.android)
implementation(project(":core:ktx"))
implementation(project(":core:base"))
implementation(project(":core:permissions"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.kts.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
/
</manifest>
@@ -0,0 +1,67 @@
package de.mm20.launcher2.contacts
import android.content.Context
import android.provider.ContactsContract
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.search.data.Contact
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
interface ContactRepository {
fun search(query: String): Flow<ImmutableList<Contact>>
}
internal class ContactRepositoryImpl(
private val context: Context,
private val permissionsManager: PermissionsManager
) : ContactRepository {
override fun search(query: String): Flow<ImmutableList<Contact>> {
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Contacts)
if (query.length < 3) {
return flow {
emit(persistentListOf())
}
}
return hasPermission.map {
if (it) {
queryContacts(query)
} else {
persistentListOf()
}
}
}
private suspend fun queryContacts(query: String): ImmutableList<Contact> {
val results = withContext(Dispatchers.IO) {
val proj = arrayOf(
ContactsContract.RawContacts.CONTACT_ID,
ContactsContract.RawContacts._ID
)
val sel = "${ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY} LIKE ? OR ${ContactsContract.RawContacts.DISPLAY_NAME_ALTERNATIVE} LIKE ? OR ${ContactsContract.RawContacts.PHONETIC_NAME} LIKE ?"
val selArgs = arrayOf("%$query%", "%$query%", "%$query%")
val cursor = context.contentResolver.query(
ContactsContract.RawContacts.CONTENT_URI, proj, sel, selArgs, null
) ?: return@withContext mutableListOf()
//Maps raw contact ids to contact ids
val contactMap = mutableMapOf<Long, MutableSet<Long>>()
while (cursor.moveToNext()) {
contactMap.getOrPut(cursor.getLong(0)) { mutableSetOf() }.add(cursor.getLong(1))
}
cursor.close()
val results = mutableListOf<Contact>()
for ((id, rawIds) in contactMap) {
Contact.contactById(context, id, rawIds)?.let { results.add(it) }
}
results
}
return results.toImmutableList()
}
}
@@ -0,0 +1,51 @@
package de.mm20.launcher2.contacts
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.data.Contact
import org.json.JSONObject
class ContactSerializer : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as Contact
return jsonObjectOf(
"id" to searchable.id
).toString()
}
override val typePrefix: String
get() = "contact"
}
class ContactDeserializer(val context: Context) : SearchableDeserializer {
override fun deserialize(serialized: String): SavableSearchable? {
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_CONTACTS
) != PackageManager.PERMISSION_GRANTED
) return null
val id = JSONObject(serialized).getLong("id")
val rawContactsCursor = context.contentResolver.query(
ContactsContract.RawContacts.CONTENT_URI,
arrayOf(ContactsContract.RawContacts._ID),
"${ContactsContract.RawContacts.CONTACT_ID} = ?",
arrayOf(id.toString()),
null
) ?: return null
val rawContacts = mutableSetOf<Long>()
while (rawContactsCursor.moveToNext()) {
rawContacts.add(rawContactsCursor.getLong(0))
}
rawContactsCursor.close()
if (rawContacts.isEmpty()) return null
return Contact.contactById(context, id, rawContacts)
}
}
@@ -0,0 +1,8 @@
package de.mm20.launcher2.contacts
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val contactsModule = module {
single<ContactRepository> { ContactRepositoryImpl(androidContext(), get()) }
}
@@ -0,0 +1,229 @@
package de.mm20.launcher2.search.data
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.ContactsContract
import androidx.core.database.getStringOrNull
import androidx.core.graphics.drawable.toDrawable
import de.mm20.launcher2.icons.*
import de.mm20.launcher2.ktx.asBitmap
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.Searchable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URLEncoder
data class Contact(
val id: Long,
val firstName: String,
val lastName: String,
val displayName: String,
val lookupKey: String,
val phones: Set<ContactInfo>,
val emails: Set<ContactInfo>,
val telegram: Set<ContactInfo>,
val whatsapp: Set<ContactInfo>,
val postals: Set<ContactInfo>,
override val labelOverride: String? = null
) : Searchable, SavableSearchable {
override val domain: String = Domain
override val key: String
get() = "${Domain}://$id"
override val label: String
get() = "$firstName $lastName"
override fun overrideLabel(label: String): Contact {
return this.copy(labelOverride = label)
}
override val preferDetailsOverLaunch: Boolean = true
val summary: String
get() {
return phones.union(emails).joinToString(separator = ", ") { it.label }
}
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
val iconText =
if (firstName.isNotEmpty()) firstName[0].toString() else "" + if (lastName.isNotEmpty()) lastName[0].toString() else ""
return StaticLauncherIcon(
foregroundLayer = TextLayer(text = iconText, color = 0xFF2364AA.toInt()),
backgroundLayer = ColorLayer(0xFF2364AA.toInt())
)
}
override suspend fun loadIcon(
context: Context,
size: Int,
themed: Boolean,
): LauncherIcon? {
val contentResolver = context.contentResolver
val bmp = withContext(Dispatchers.IO) {
val uri =
ContactsContract.Contacts.getLookupUri(id, lookupKey) ?: return@withContext null
ContactsContract.Contacts.openContactPhotoInputStream(contentResolver, uri, false)
?.asBitmap()
} ?: return null
return StaticLauncherIcon(
foregroundLayer = StaticIconLayer(
icon = bmp.toDrawable(context.resources),
),
backgroundLayer = ColorLayer(0xFF2364AA.toInt())
)
}
override fun launch(context: Context, options: Bundle?): Boolean {
val intent = getLaunchIntent()
return context.tryStartActivity(intent, options)
}
private fun getLaunchIntent(): Intent {
val uri =
ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id)
return Intent(Intent.ACTION_VIEW).setData(uri).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
companion object {
internal fun contactById(context: Context, id: Long, rawIds: Set<Long>): Contact? {
val s = "(" + rawIds.joinToString(separator = " OR ",
transform = { "${ContactsContract.Data.RAW_CONTACT_ID} = $it" }) + ")" +
" AND (${ContactsContract.Data.MIMETYPE} = \"${ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE}\"" +
" OR ${ContactsContract.Data.MIMETYPE} = \"${ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE}\"" +
" OR ${ContactsContract.Data.MIMETYPE} = \"${ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE}\"" +
" OR ${ContactsContract.Data.MIMETYPE} = \"${ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE}\"" +
" OR ${ContactsContract.Data.MIMETYPE} = \"vnd.android.cursor.item/vnd.org.telegram.messenger.android.profile\"" +
" OR ${ContactsContract.Data.MIMETYPE} = \"vnd.android.cursor.item/vnd.com.whatsapp.profile\"" +
")"
val dataCursor = context.contentResolver.query(
ContactsContract.Data.CONTENT_URI,
null, s, null, null
) ?: return null
val phones = mutableSetOf<ContactInfo>()
val emails = mutableSetOf<ContactInfo>()
val telegram = mutableSetOf<ContactInfo>()
val whatsapp = mutableSetOf<ContactInfo>()
val postals = mutableSetOf<ContactInfo>()
var firstName = ""
var lastName = ""
var displayName = ""
val mimeTypeColumn = dataCursor.getColumnIndex(ContactsContract.Data.MIMETYPE)
val emailAddressColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS)
val numberColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
val addressColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS)
val displayNameColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME)
val givenNameColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME)
val familyNameColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME)
val data1Column = dataCursor.getColumnIndex(ContactsContract.Data.DATA1)
val data3Column = dataCursor.getColumnIndex(ContactsContract.Data.DATA3)
val idColumn = dataCursor.getColumnIndex(ContactsContract.Data._ID)
loop@ while (dataCursor.moveToNext()) {
when (dataCursor.getStringOrNull(mimeTypeColumn)) {
ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(emailAddressColumn)?.let {
emails.add(ContactInfo(it, "mailto:$it"))
}
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(numberColumn)?.let {
val phone = it.replace(Regex("[^+0-9]"), "")
phones.add(
ContactInfo(
phone,
"tel:$phone"
)
)
}
ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(addressColumn)?.let {
postals.add(
ContactInfo(
it.replace("\n", ", "),
"geo:0,0?q=${URLEncoder.encode(it, "utf8")}"
)
)
}
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> {
firstName = dataCursor.getStringOrNull(givenNameColumn) ?: ""
lastName = dataCursor.getStringOrNull(familyNameColumn) ?: ""
displayName = dataCursor.getStringOrNull(displayNameColumn) ?: ""
}
"vnd.android.cursor.item/vnd.org.telegram.messenger.android.profile" -> {
val data1 = dataCursor.getStringOrNull(data1Column)
?: continue@loop
val data3 = dataCursor.getStringOrNull(data3Column)
?: continue@loop
telegram.add(
ContactInfo(
data3.substringAfterLast(" "),
"tg:openmessage?user_id=$data1"
)
)
}
"vnd.android.cursor.item/vnd.com.whatsapp.profile" -> {
val data1 = dataCursor.getStringOrNull(data1Column)
?: continue@loop
val dataId = dataCursor.getLong(idColumn)
whatsapp.add(
ContactInfo(
"+${data1.substringBefore('@')}",
Uri.withAppendedPath(
ContactsContract.Data.CONTENT_URI,
dataId.toString()
).toString()
)
)
}
}
}
dataCursor.close()
val lookupKeyCursor = context.contentResolver.query(
ContactsContract.Contacts.CONTENT_URI,
arrayOf(ContactsContract.Contacts.LOOKUP_KEY),
"${ContactsContract.Contacts._ID} = ?",
arrayOf(id.toString()),
null
) ?: return null
var lookUpKey = ""
if (lookupKeyCursor.moveToNext()) {
lookUpKey = lookupKeyCursor.getString(0)
}
lookupKeyCursor.close()
return Contact(
id = id,
emails = emails,
phones = phones,
firstName = firstName,
lastName = lastName,
displayName = displayName,
postals = postals,
telegram = telegram,
whatsapp = whatsapp,
lookupKey = lookUpKey
)
}
const val Domain = "contact"
}
}
data class ContactInfo(
val label: String,
val data: String
)
+1
View File
@@ -0,0 +1 @@
/build
+50
View File
@@ -0,0 +1,50 @@
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
compileSdk = sdk.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = sdk.versions.minSdk.get().toInt()
targetSdk = sdk.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.currencies"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.work)
implementation(libs.okhttp)
implementation(project(":core:ktx"))
implementation(project(":core:i18n"))
implementation(project(":core:database"))
implementation(project(":core:crashreporter"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.kts.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,3 @@
<manifest>
</manifest>
@@ -0,0 +1,19 @@
package de.mm20.launcher2.currencies
import de.mm20.launcher2.database.entities.CurrencyEntity
data class Currency(
val symbol: String,
val value: Double,
val lastUpdate: Long
) {
constructor(entity: CurrencyEntity) : this(
symbol = entity.symbol,
value = entity.value,
lastUpdate = entity.lastUpdate
)
fun toDatabaseEntity(): CurrencyEntity {
return CurrencyEntity(symbol, value, lastUpdate)
}
}
@@ -0,0 +1,75 @@
package de.mm20.launcher2.currencies
import android.content.Context
import android.util.Log
import androidx.work.*
import de.mm20.launcher2.database.AppDatabase
import kotlinx.coroutines.*
import java.util.concurrent.TimeUnit
class CurrencyRepository(
private val context: Context,
) {
fun enableCurrencyUpdateWorker() {
val currencyWorker =
PeriodicWorkRequest.Builder(ExchangeRateWorker::class.java, 60, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"ExchangeRates",
ExistingPeriodicWorkPolicy.KEEP, currencyWorker
)
}
fun disableCurrencyUpdateWorker() {
WorkManager.getInstance(context).cancelUniqueWork("ExchangeRates")
}
suspend fun convertCurrency(
fromCurrency: String,
value: Double,
toCurrency: String? = null
): List<Pair<String, Double>> {
return withContext(Dispatchers.IO) {
val dao = AppDatabase.getInstance(context)
.currencyDao()
val from = Currency(dao.getCurrency(fromCurrency) ?: return@withContext emptyList())
return@withContext if (toCurrency == null) {
dao.getAllCurrencies().mapNotNull {
val to = Currency(it)
if (from.lastUpdate != to.lastUpdate) {
Log.w("MM20", "Exchange rate update dates do not match: $fromCurrency, $it")
return@mapNotNull null
}
if (from.symbol == to.symbol) return@mapNotNull null
to.symbol to value * to.value / from.value
}
} else {
val to = Currency(dao.getCurrency(toCurrency) ?: return@withContext emptyList())
if (from.lastUpdate != to.lastUpdate) {
Log.w(
"MM20",
"Exchange rate update dates do not match: $fromCurrency, $toCurrency"
)
return@withContext emptyList()
}
listOf(toCurrency to value * to.value / from.value)
}
}
}
suspend fun isValidCurrency(symbol: String): Boolean {
return withContext(Dispatchers.IO) {
AppDatabase.getInstance(context).currencyDao().exists(symbol)
}
}
suspend fun getLastUpdate(symbol: String): Long {
return withContext(Dispatchers.IO) {
AppDatabase.getInstance(context).currencyDao().getLastUpdate(symbol)
}
}
}
@@ -0,0 +1,56 @@
package de.mm20.launcher2.currencies
import android.content.Context
import android.util.Log
import androidx.work.Worker
import androidx.work.WorkerParameters
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.database.AppDatabase
import okhttp3.OkHttpClient
import okhttp3.Request
import org.w3c.dom.Element
import java.text.SimpleDateFormat
import javax.xml.parsers.DocumentBuilderFactory
class ExchangeRateWorker(val context: Context, params: WorkerParameters) : Worker(context, params) {
override fun doWork(): Result {
Log.d("MM20", "Updating currency exchange rates")
val httpClient = OkHttpClient()
val request = Request.Builder()
.url("https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml")
.get()
.build()
try {
val response = httpClient.newCall(request).execute()
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(response.body?.byteStream()
?: return Result.retry())
val cubes = document.getElementsByTagName("Cube")
val values = mutableListOf<Pair<String, Double>>()
var timestamp = System.currentTimeMillis()
values += "EUR" to 1.0
for (i in 0 until cubes.length) {
val cube = cubes.item(i) as? Element ?: continue
if (cube.hasAttribute("currency")) {
val symbol = cube.getAttribute("currency")
val value = cube.getAttribute("rate").toDoubleOrNull() ?: continue
values += symbol to value
} else if (cube.hasAttribute("time")) {
val date = cube.getAttribute("time")
timestamp = SimpleDateFormat("yyyy-MM-dd").parse(date).time
}
}
val currencies = values.map {
Currency(
symbol = it.first,
value = it.second,
lastUpdate = timestamp
).toDatabaseEntity()
}
AppDatabase.getInstance(context).currencyDao().insertAll(currencies)
return Result.success()
} catch (e: Exception) {
CrashReporter.logException(e)
return Result.retry()
}
}
}
+1
View File
@@ -0,0 +1 @@
/build
+48
View File
@@ -0,0 +1,48 @@
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
compileSdk = sdk.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = sdk.versions.minSdk.get().toInt()
targetSdk = sdk.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.data.customattrs"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.koin.android)
implementation(project(":core:database"))
implementation(project(":core:base"))
implementation(project(":core:ktx"))
implementation(project(":core:crashreporter"))
implementation(project(":data:favorites"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,188 @@
package de.mm20.launcher2.data.customattrs
import android.util.Log
import de.mm20.launcher2.database.entities.CustomAttributeEntity
import de.mm20.launcher2.ktx.jsonObjectOf
import org.json.JSONObject
sealed interface CustomAttribute {
fun toDatabaseEntity(key: String): CustomAttributeEntity
companion object {
internal fun fromDatabaseEntity(entity: CustomAttributeEntity?): CustomAttribute? {
if (entity == null) return null
return when (entity.type) {
CustomAttributeType.Label.value -> CustomLabel(
label = entity.value,
key = entity.key
)
CustomAttributeType.Tag.value -> CustomTag(
tagName = entity.value
)
CustomAttributeType.Icon.value -> CustomIcon.fromDatabaseEntity(entity)
else -> {
Log.e("MM20", "Invalid custom attribute type: ${entity.type}")
null
}
}
}
}
}
class CustomLabel(
val key: String,
val label: String,
) : CustomAttribute {
override fun toDatabaseEntity(key: String): CustomAttributeEntity {
return CustomAttributeEntity(
key = key,
type = CustomAttributeType.Label.value,
value = label,
)
}
}
class CustomTag(
val tagName: String
): CustomAttribute {
override fun toDatabaseEntity(key: String): CustomAttributeEntity {
return CustomAttributeEntity(
key = key,
type = CustomAttributeType.Tag.value,
value = tagName,
)
}
}
sealed class CustomIcon : CustomAttribute {
override fun toDatabaseEntity(key: String): CustomAttributeEntity {
return CustomAttributeEntity(
key = key,
type = CustomAttributeType.Icon.value,
value = this.toDatabaseValue()
)
}
internal abstract fun toDatabaseValue(): String
companion object {
internal fun fromDatabaseEntity(entity: CustomAttributeEntity): CustomIcon? {
val payload = JSONObject(entity.value)
val type = payload.getString("type")
return when (type) {
"custom_icon_pack_icon" -> {
CustomIconPackIcon(
iconComponentName = payload.getString("icon"),
iconPackPackage = payload.getString("icon_pack")
)
}
"custom_themed_icon" -> {
CustomThemedIcon(
iconPackageName = payload.getString("icon"),
)
}
"default_icon" -> {
UnmodifiedSystemDefaultIcon
}
"adaptified_legacy_icon" -> {
AdaptifiedLegacyIcon(
fgScale = payload.getDouble("fg_scale").toFloat(),
bgColor = payload.getInt("bg_color")
)
}
"force_themed_icon" -> ForceThemedIcon
"default_placeholder_icon" -> DefaultPlaceholderIcon
else -> null
}
}
}
}
data class CustomIconPackIcon(
val iconPackPackage: String,
val iconComponentName: String,
) : CustomIcon() {
override fun toDatabaseValue(): String {
return jsonObjectOf(
"type" to "custom_icon_pack_icon",
"icon" to iconComponentName,
"icon_pack" to iconPackPackage,
).toString()
}
}
data class AdaptifiedLegacyIcon(
val fgScale: Float,
/**
* The background color in ARGB format or [UnspecifiedColor] or [ThemeColor]
*/
val bgColor: Int = UnspecifiedColor,
): CustomIcon() {
override fun toDatabaseValue(): String {
return jsonObjectOf(
"type" to "adaptified_legacy_icon",
"fg_scale" to fgScale,
"bg_color" to bgColor,
).toString()
}
companion object {
/**
* Extract color from foreground icon
*/
const val UnspecifiedColor = 1
/**
* Use color from theme
*/
const val ThemeColor = 0
}
}
data class CustomThemedIcon(
val iconPackageName: String,
) : CustomIcon() {
override fun toDatabaseValue(): String {
return jsonObjectOf(
"type" to "custom_themed_icon",
"icon" to iconPackageName,
).toString()
}
}
object ForceThemedIcon : CustomIcon() {
override fun toDatabaseValue(): String {
return jsonObjectOf(
"type" to "force_themed_icon"
).toString()
}
}
/**
* Use default icon, ignore any icon pack, themed icon or force adaptive settings.
*/
object UnmodifiedSystemDefaultIcon: CustomIcon() {
override fun toDatabaseValue(): String {
return jsonObjectOf(
"type" to "default_icon"
).toString()
}
}
/**
* Use the default placeholder icon
*/
object DefaultPlaceholderIcon: CustomIcon() {
override fun toDatabaseValue(): String {
return jsonObjectOf(
"type" to "default_placeholder_icon"
).toString()
}
}
@@ -0,0 +1,13 @@
package de.mm20.launcher2.data.customattrs
enum class CustomAttributeType(val value: String) {
Icon("icon"),
Label("label"),
Tag("tag");
companion object {
internal fun fromValue(value: String): CustomAttributeType {
return values().first { it.value == value }
}
}
}
@@ -0,0 +1,225 @@
package de.mm20.launcher2.data.customattrs
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.database.entities.CustomAttributeEntity
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.search.SavableSearchable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import org.json.JSONArray
import org.json.JSONException
import java.io.File
interface CustomAttributesRepository {
fun search(query: String): Flow<ImmutableList<SavableSearchable>>
fun getCustomIcon(searchable: SavableSearchable): Flow<CustomIcon?>
fun setCustomIcon(searchable: SavableSearchable, icon: CustomIcon?)
fun getCustomLabels(items: List<SavableSearchable>): Flow<List<CustomLabel>>
fun setCustomLabel(searchable: SavableSearchable, label: String)
fun clearCustomLabel(searchable: SavableSearchable)
fun setTags(searchable: SavableSearchable, tags: List<String>)
fun getTags(searchable: SavableSearchable): Flow<List<String>>
suspend fun export(toDir: File)
suspend fun import(fromDir: File)
suspend fun getAllTags(startsWith: String? = null): List<String>
fun getItemsForTag(tag: String): Flow<List<SavableSearchable>>
fun addTag(item: SavableSearchable, tag: String)
fun renameTag(oldName: String, newName: String)
suspend fun cleanupDatabase(): Int
}
internal class CustomAttributesRepositoryImpl(
private val appDatabase: AppDatabase,
private val favoritesRepository: FavoritesRepository
) : CustomAttributesRepository {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
override fun getCustomIcon(searchable: SavableSearchable): Flow<CustomIcon?> {
val dao = appDatabase.customAttrsDao()
return dao.getCustomAttribute(searchable.key, CustomAttributeType.Icon.value)
.map {
CustomAttribute.fromDatabaseEntity(it) as? CustomIcon
}
}
override fun setCustomIcon(searchable: SavableSearchable, icon: CustomIcon?) {
val dao = appDatabase.customAttrsDao()
scope.launch {
dao.clearCustomAttribute(searchable.key, CustomAttributeType.Icon.value)
if (icon != null) {
dao.setCustomAttribute(icon.toDatabaseEntity(searchable.key))
}
}
}
override fun getCustomLabels(items: List<SavableSearchable>): Flow<List<CustomLabel>> {
val dao = appDatabase.customAttrsDao()
return dao.getCustomAttributes(items.map { it.key }, CustomAttributeType.Label.value)
.map { list ->
list.mapNotNull { CustomAttribute.fromDatabaseEntity(it) as? CustomLabel }
}
}
override fun setCustomLabel(searchable: SavableSearchable, label: String) {
val dao = appDatabase.customAttrsDao()
scope.launch {
favoritesRepository.save(searchable)
appDatabase.runInTransaction {
dao.clearCustomAttribute(searchable.key, CustomAttributeType.Label.value)
dao.setCustomAttribute(
CustomLabel(
key = searchable.key,
label = label,
).toDatabaseEntity(searchable.key)
)
}
}
}
override fun clearCustomLabel(searchable: SavableSearchable) {
val dao = appDatabase.customAttrsDao()
scope.launch {
dao.clearCustomAttribute(searchable.key, CustomAttributeType.Label.value)
}
}
override fun setTags(searchable: SavableSearchable, tags: List<String>) {
val dao = appDatabase.customAttrsDao()
scope.launch {
favoritesRepository.save(searchable)
dao.setTags(searchable.key, tags.map {
CustomTag(it).toDatabaseEntity(searchable.key)
})
}
}
override fun getTags(searchable: SavableSearchable): Flow<List<String>> {
val dao = appDatabase.customAttrsDao()
return dao.getCustomAttributes(listOf(searchable.key), CustomAttributeType.Tag.value).map {
it.map { it.value }
}
}
override suspend fun getAllTags(startsWith: String?): List<String> {
val dao = appDatabase.customAttrsDao()
return if (startsWith != null) {
dao.getAllTagsLike("$startsWith%")
} else {
dao.getAllTags()
}
}
override fun getItemsForTag(tag: String): Flow<List<SavableSearchable>> {
val dao = appDatabase.customAttrsDao()
return dao.getItemsWithTag(tag).map {
favoritesRepository.getFromKeys(it)
}
}
override fun addTag(item: SavableSearchable, tag: String) {
val dao = appDatabase.customAttrsDao()
scope.launch {
dao.addTag(item.key, tag)
}
}
override fun renameTag(oldName: String, newName: String) {
val dao = appDatabase.customAttrsDao()
scope.launch {
dao.renameTag(oldName, newName)
}
}
override fun search(query: String): Flow<ImmutableList<SavableSearchable>> {
if (query.isBlank()) {
return flow {
emit(persistentListOf())
}
}
val dao = appDatabase.customAttrsDao()
return dao.search("%$query%").map {
favoritesRepository.getFromKeys(it).toImmutableList()
}
}
override suspend fun export(toDir: File) = withContext(Dispatchers.IO) {
val dao = appDatabase.backupDao()
var page = 0
do {
val customAttrs = dao.exportCustomAttributes(limit = 100, offset = page * 100)
val jsonArray = JSONArray()
for (customAttr in customAttrs) {
jsonArray.put(
jsonObjectOf(
"key" to customAttr.key,
"value" to customAttr.value,
"type" to customAttr.type,
)
)
}
val file = File(toDir, "customizations.${page.toString().padStart(4, '0')}")
file.bufferedWriter().use {
it.write(jsonArray.toString())
}
page++
} while (customAttrs.size == 100)
}
override suspend fun import(fromDir: File) = withContext(Dispatchers.IO) {
val dao = appDatabase.backupDao()
dao.wipeCustomAttributes()
val files =
fromDir.listFiles { _, name -> name.startsWith("customizations.") }
?: return@withContext
for (file in files) {
val customAttrs = mutableListOf<CustomAttributeEntity>()
try {
val jsonArray = JSONArray(file.inputStream().reader().readText())
for (i in 0 until jsonArray.length()) {
val json = jsonArray.getJSONObject(i)
val entity = CustomAttributeEntity(
id = null,
type = json.getString("type"),
value = json.optString("value"),
key = json.optString("key"),
)
customAttrs.add(entity)
}
dao.importCustomAttributes(customAttrs)
} catch (e: JSONException) {
CrashReporter.logException(e)
}
}
}
override suspend fun cleanupDatabase(): Int {
val dao = appDatabase.backupDao()
var removed = 0
val job = scope.launch {
removed = dao.cleanUp()
}
job.join()
return removed
}
}
@@ -0,0 +1,7 @@
package de.mm20.launcher2.data.customattrs
import org.koin.dsl.module
val customAttrsModule = module {
single<CustomAttributesRepository> { CustomAttributesRepositoryImpl(get(), get()) }
}
@@ -0,0 +1,25 @@
package de.mm20.launcher2.data.customattrs.utils
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.search.SavableSearchable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
fun <T: SavableSearchable>Flow<List<T>>.withCustomLabels(
customAttributesRepository: CustomAttributesRepository,
): Flow<List<T>> = channelFlow {
this@withCustomLabels.collectLatest { items ->
val customLabels = customAttributesRepository.getCustomLabels(items)
customLabels.collectLatest { labels ->
send(items.map { item ->
val customLabel = labels.find { it.key == item.key }
if (customLabel != null) {
item.overrideLabel(customLabel.label) as T
} else {
item
}
})
}
}
}
+1
View File
@@ -0,0 +1 @@
/build
+59
View File
@@ -0,0 +1,59 @@
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.favorites"
}
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:base"))
implementation(project(":data:calendar"))
implementation(project(":core:database"))
implementation(project(":core:preferences"))
implementation(project(":data:applications"))
implementation(project(":data:appshortcuts"))
implementation(project(":data:contacts"))
implementation(project(":core:ktx"))
implementation(project(":data:files"))
implementation(project(":data:websites"))
implementation(project(":data:wikipedia"))
implementation(project(":services:badges"))
implementation(project(":core:crashreporter"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.kts.kts.kts.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 @@
<manifest />
@@ -0,0 +1,400 @@
package de.mm20.launcher2.favorites
import android.content.Context
import android.util.Log
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.database.entities.SavedSearchableEntity
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableDeserializer
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.json.JSONArray
import org.json.JSONException
import org.koin.core.component.KoinComponent
import java.io.File
interface FavoritesRepository {
/**
* Get favorites
* @param includeTypes Include only items of these types. Cannot be used together with excludeTypes.
* @param excludeTypes Exclude only items of these types. Cannot be used together with includeTypes.
* @param manuallySorted Include items that have been sorted manually
* @param automaticallySorted Include items that are pinned but not sorted
* @param frequentlyUsed Include items that are not pinned but most frequently used
* @param limit Maximum number of items returned.
*/
fun getFavorites(
includeTypes: List<String>? = null,
excludeTypes: List<String>? = null,
manuallySorted: Boolean = false,
automaticallySorted: Boolean = false,
frequentlyUsed: Boolean = false,
limit: Int = 100
): Flow<List<SavableSearchable>>
fun getHiddenCalendarEventKeys(): Flow<List<String>>
fun isPinned(searchable: SavableSearchable): Flow<Boolean>
fun pinItem(searchable: SavableSearchable)
fun unpinItem(searchable: SavableSearchable)
fun isHidden(searchable: SavableSearchable): Flow<Boolean>
fun hideItem(searchable: SavableSearchable)
fun unhideItem(searchable: SavableSearchable)
fun incrementLaunchCounter(searchable: SavableSearchable)
fun updateFavorites(
manuallySorted: List<SavableSearchable>,
automaticallySorted: List<SavableSearchable>,
)
fun getHiddenItems(): Flow<List<SavableSearchable>>
fun getHiddenItemKeys(): Flow<List<String>>
/**
* Remove this item from the Searchable database
*/
fun remove(searchable: SavableSearchable)
/**
* Remove this item from favorites and reset launch counter
*/
fun removeFromFavorites(searchable: SavableSearchable)
/**
* Ensure that this searchable exists in the Favorites table.
* If it doesn't exist, insert it with 0 launch count, not pinned and not hidden
*/
fun save(searchable: SavableSearchable)
/**
* Get items with the given keys from the favorites database.
* Items that don't exist in the database will not be returned.
*/
suspend fun getFromKeys(keys: List<String>): List<SavableSearchable>
suspend fun export(toDir: File)
suspend fun import(fromDir: File)
/**
* Remove database entries that are invalid. This includes
* - entries that cannot be deserialized anymore
* - entries that are inconsistent (the key column is not equal to the key of the searchable)
*/
suspend fun cleanupDatabase(): Int
}
internal class FavoritesRepositoryImpl(
private val context: Context,
private val database: AppDatabase,
) : FavoritesRepository, KoinComponent {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
override fun getFavorites(
includeTypes: List<String>?,
excludeTypes: List<String>?,
manuallySorted: Boolean,
automaticallySorted: Boolean,
frequentlyUsed: Boolean,
limit: Int
): Flow<List<SavableSearchable>> {
val dao = database.searchDao()
val entities = when {
includeTypes == null && excludeTypes == null -> dao.getFavorites(
manuallySorted = manuallySorted,
automaticallySorted = automaticallySorted,
frequentlyUsed = frequentlyUsed,
limit = limit
)
includeTypes != null && excludeTypes == null -> {
dao.getFavoritesWithTypes(
includeTypes = includeTypes,
manuallySorted = manuallySorted,
automaticallySorted = automaticallySorted,
frequentlyUsed = frequentlyUsed,
limit = limit
)
}
excludeTypes != null && includeTypes == null -> {
dao.getFavoritesWithoutTypes(
excludeTypes = excludeTypes,
manuallySorted = manuallySorted,
automaticallySorted = automaticallySorted,
frequentlyUsed = frequentlyUsed,
limit = limit
)
}
else -> throw IllegalArgumentException("You can either use includeTypes or excludeTypes, not both")
}
return entities.map {
it.mapNotNull { fromDatabaseEntity(it).searchable }
}
}
override fun getHiddenCalendarEventKeys(): Flow<List<String>> {
return database.searchDao().getHiddenCalendarEventKeys()
}
override fun isPinned(searchable: SavableSearchable): Flow<Boolean> {
return AppDatabase.getInstance(context).searchDao().isPinned(searchable.key)
}
override fun pinItem(searchable: SavableSearchable) {
scope.launch {
withContext(Dispatchers.IO) {
val dao = AppDatabase.getInstance(context).searchDao()
val databaseItem = dao.getFavorite(searchable.key)
val savedSearchable = SavedSearchable(
key = searchable.key,
searchable = searchable,
launchCount = databaseItem?.launchCount ?: 0,
pinPosition = 1,
hidden = false
)
savedSearchable.toDatabaseEntity()?.let { dao.insertReplaceExisting(it) }
}
}
}
override fun unpinItem(searchable: SavableSearchable) {
scope.launch {
withContext(Dispatchers.IO) {
AppDatabase.getInstance(context).searchDao().unpinFavorite(searchable.key)
}
}
}
override fun isHidden(searchable: SavableSearchable): Flow<Boolean> {
return AppDatabase.getInstance(context).searchDao().isHidden(searchable.key)
}
override fun hideItem(searchable: SavableSearchable) {
scope.launch {
withContext(Dispatchers.IO) {
val dao = AppDatabase.getInstance(context).searchDao()
val databaseItem = dao.getFavorite(searchable.key)
val savedSearchable = SavedSearchable(
key = searchable.key,
searchable = searchable,
launchCount = databaseItem?.launchCount ?: 0,
pinPosition = 0,
hidden = true
)
savedSearchable.toDatabaseEntity()?.let { dao.insertReplaceExisting(it) }
}
}
}
override fun unhideItem(searchable: SavableSearchable) {
scope.launch {
withContext(Dispatchers.IO) {
AppDatabase.getInstance(context).searchDao().unhideItem(searchable.key)
}
}
}
override fun incrementLaunchCounter(searchable: SavableSearchable) {
scope.launch {
withContext(Dispatchers.IO) {
val item = SavedSearchable(searchable.key, searchable, 0, 0, false)
item.toDatabaseEntity()?.let {
AppDatabase.getInstance(context).searchDao()
.incrementLaunchCount(it)
}
}
}
}
override fun getHiddenItems(): Flow<List<SavableSearchable>> {
return database.searchDao().getHiddenItems().map {
it.mapNotNull { fromDatabaseEntity(it).searchable }
}
}
override fun getHiddenItemKeys(): Flow<List<String>> {
return database.searchDao().getHiddenItemKeys()
}
override fun remove(searchable: SavableSearchable) {
scope.launch {
withContext(Dispatchers.IO) {
database.searchDao().deleteByKey(searchable.key)
}
}
}
override fun removeFromFavorites(searchable: SavableSearchable) {
scope.launch {
database.searchDao().resetPinStatusAndLaunchCounter(searchable.key)
}
}
override fun save(searchable: SavableSearchable) {
scope.launch {
withContext(Dispatchers.IO) {
val entity = SavedSearchable(
key = searchable.key,
searchable = searchable,
launchCount = 0,
pinPosition = 0,
hidden = false,
).toDatabaseEntity() ?: return@withContext
database.searchDao().insertSkipExisting(entity)
}
}
}
override fun updateFavorites(
manuallySorted: List<SavableSearchable>,
automaticallySorted: List<SavableSearchable>
) {
val dao = database.searchDao()
scope.launch {
withContext(Dispatchers.IO) {
val keys = manuallySorted.map { it.key } + automaticallySorted.map { it.key }
val entities = dao.getFromKeys(keys)
val updatedManuallySorted = manuallySorted.mapIndexedNotNull { index, searchable ->
val entity = entities.find { searchable.key == it.key } ?: SavedSearchable(
key = searchable.key,
searchable = searchable,
launchCount = 0,
pinPosition = 0,
hidden = false,
).toDatabaseEntity() ?: return@mapIndexedNotNull null
entity.pinPosition = manuallySorted.size - index + 1
entity
}
val updatedAutomaticallySorted =
automaticallySorted.mapIndexedNotNull { index, searchable ->
val entity = entities.find { searchable.key == it.key } ?: SavedSearchable(
key = searchable.key,
searchable = searchable,
launchCount = 0,
pinPosition = 0,
hidden = false,
).toDatabaseEntity() ?: return@mapIndexedNotNull null
entity.pinPosition = 1
entity
}
database.runInTransaction {
dao.unpinAll()
dao.insertAllReplaceExisting(updatedManuallySorted)
dao.insertAllReplaceExisting(updatedAutomaticallySorted)
}
}
}
}
private fun fromDatabaseEntity(entity: SavedSearchableEntity): SavedSearchable {
val deserializer: SearchableDeserializer =
getDeserializer(context, entity.type)
val searchable = deserializer.deserialize(entity.serializedSearchable)
if (searchable == null) removeInvalidItem(entity.key)
return SavedSearchable(
key = entity.key,
searchable = searchable,
launchCount = entity.launchCount,
pinPosition = entity.pinPosition,
hidden = entity.hidden
)
}
private fun removeInvalidItem(key: String) {
scope.launch {
database.searchDao().deleteByKey(key)
}
}
override suspend fun getFromKeys(keys: List<String>): List<SavableSearchable> {
val dao = database.searchDao()
return dao.getFromKeys(keys)
.mapNotNull { fromDatabaseEntity(it).searchable }
}
override suspend fun export(toDir: File) = withContext(Dispatchers.IO) {
val dao = database.backupDao()
var page = 0
do {
val favorites = dao.exportFavorites(limit = 100, offset = page * 100)
val jsonArray = JSONArray()
for (fav in favorites) {
jsonArray.put(
jsonObjectOf(
"key" to fav.key,
"type" to fav.type,
"hidden" to fav.hidden,
"launchCount" to fav.launchCount,
"pinPosition" to fav.pinPosition,
"searchable" to fav.serializedSearchable
)
)
}
val file = File(toDir, "favorites.${page.toString().padStart(4, '0')}")
file.bufferedWriter().use {
it.write(jsonArray.toString())
}
page++
} while (favorites.size == 100)
}
override suspend fun import(fromDir: File) = withContext(Dispatchers.IO) {
val dao = database.backupDao()
dao.wipeFavorites()
val files =
fromDir.listFiles { _, name -> name.startsWith("favorites.") } ?: return@withContext
for (file in files) {
val favorites = mutableListOf<SavedSearchableEntity>()
try {
val jsonArray = JSONArray(file.inputStream().reader().readText())
for (i in 0 until jsonArray.length()) {
val json = jsonArray.getJSONObject(i)
val entity = SavedSearchableEntity(
key = json.getString("key"),
type = json.optString("type").takeIf { it.isNotEmpty() } ?: continue,
serializedSearchable = json.getString("searchable"),
launchCount = json.getInt("launchCount"),
hidden = json.getBoolean("hidden"),
pinPosition = json.getInt("pinPosition")
)
favorites.add(entity)
}
dao.importFavorites(favorites)
} catch (e: JSONException) {
CrashReporter.logException(e)
}
}
}
override suspend fun cleanupDatabase(): Int {
var removed = 0
val job = scope.launch {
val dao = database.backupDao()
var page = 0
do {
val favorites = dao.exportFavorites(limit = 100, offset = page * 100)
for (fav in favorites) {
val item = fromDatabaseEntity(fav)
if (item.searchable == null || item.searchable.key != item.key) {
removeInvalidItem(item.key)
removed++
Log.i(
"MM20",
"SearchableDatabase cleanup: removed invalid item ${item.key}"
)
}
}
page++
} while (favorites.size == 100)
}
job.join()
return removed
}
}
@@ -0,0 +1,8 @@
package de.mm20.launcher2.favorites
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val favoritesModule = module {
single<FavoritesRepository> { FavoritesRepositoryImpl(androidContext(), get()) }
}
@@ -0,0 +1,30 @@
package de.mm20.launcher2.favorites
import de.mm20.launcher2.database.entities.SavedSearchableEntity
import de.mm20.launcher2.search.SavableSearchable
data class SavedSearchable(
val key: String,
/**
* null if searchable could not be deserialized (i.e. the app has been uninstalled)
*/
val searchable: SavableSearchable?,
var launchCount: Int,
var pinPosition: Int,
var hidden: Boolean
) {
fun toDatabaseEntity(): SavedSearchableEntity? {
val serializer = getSerializer(searchable)
val data = searchable?.let { serializer.serialize(it) } ?: return null
return SavedSearchableEntity(
key = key,
type = searchable.domain,
serializedSearchable = data,
hidden = hidden,
pinPosition = pinPosition,
launchCount = launchCount
)
}
}
@@ -0,0 +1,109 @@
package de.mm20.launcher2.favorites
import android.content.Context
import de.mm20.launcher2.appshortcuts.LauncherShortcutDeserializer
import de.mm20.launcher2.appshortcuts.LauncherShortcutSerializer
import de.mm20.launcher2.appshortcuts.LegacyShortcutDeserializer
import de.mm20.launcher2.appshortcuts.LegacyShortcutSerializer
import de.mm20.launcher2.calendar.CalendarEventDeserializer
import de.mm20.launcher2.calendar.CalendarEventSerializer
import de.mm20.launcher2.contacts.ContactDeserializer
import de.mm20.launcher2.contacts.ContactSerializer
import de.mm20.launcher2.files.*
import de.mm20.launcher2.search.NullDeserializer
import de.mm20.launcher2.search.NullSerializer
import de.mm20.launcher2.search.Searchable
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.data.*
import de.mm20.launcher2.websites.WebsiteDeserializer
import de.mm20.launcher2.websites.WebsiteSerializer
import de.mm20.launcher2.wikipedia.WikipediaDeserializer
import de.mm20.launcher2.wikipedia.WikipediaSerializer
internal fun getSerializer(searchable: Searchable?): SearchableSerializer {
if (searchable is LauncherApp) {
return LauncherAppSerializer()
}
if (searchable is LauncherShortcut) {
return LauncherShortcutSerializer()
}
if (searchable is LegacyShortcut) {
return LegacyShortcutSerializer()
}
if (searchable is CalendarEvent) {
return CalendarEventSerializer()
}
if (searchable is Contact) {
return ContactSerializer()
}
if (searchable is Wikipedia) {
return WikipediaSerializer()
}
if (searchable is GDriveFile) {
return GDriveFileSerializer()
}
if (searchable is OneDriveFile) {
return OneDriveFileSerializer()
}
if (searchable is OwncloudFile) {
return OwncloudFileSerializer()
}
if (searchable is NextcloudFile) {
return NextcloudFileSerializer()
}
if (searchable is LocalFile) {
return LocalFileSerializer()
}
if (searchable is Website) {
return WebsiteSerializer()
}
if (searchable is Tag) {
return TagSerializer()
}
return NullSerializer()
}
internal fun getDeserializer(context: Context, type: String): SearchableDeserializer {
if (type == LauncherApp.Domain) {
return LauncherAppDeserializer(context)
}
if (type == LauncherShortcut.Domain) {
return LauncherShortcutDeserializer(context)
}
if (type == LegacyShortcut.Domain) {
return LegacyShortcutDeserializer(context)
}
if (type == CalendarEvent.Domain) {
return CalendarEventDeserializer(context)
}
if (type == Contact.Domain) {
return ContactDeserializer(context)
}
if (type == Wikipedia.Domain) {
return WikipediaDeserializer(context)
}
if (type == GDriveFile.Domain) {
return GDriveFileDeserializer()
}
if (type == OneDriveFile.Domain) {
return OneDriveFileDeserializer()
}
if (type == NextcloudFile.Domain) {
return NextcloudFileDeserializer()
}
if (type == OwncloudFile.Domain) {
return OwncloudFileDeserializer()
}
if (type == LocalFile.Domain) {
return LocalFileDeserializer(context)
}
if (type == Website.Domain) {
return WebsiteDeserializer()
}
if (type == Tag.Domain) {
return TagDeserializer()
}
return NullDeserializer()
}
@@ -0,0 +1,28 @@
package de.mm20.launcher2.favorites
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.data.Tag
import org.json.JSONObject
class TagSerializer: SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as Tag
val json = JSONObject()
json.put("tag", searchable.tag)
return json.toString()
}
override val typePrefix: String
get() = "tag"
}
class TagDeserializer: SearchableDeserializer {
override fun deserialize(serialized: String): SavableSearchable {
val json = JSONObject(serialized)
return Tag(json.getString("tag"))
}
}
@@ -0,0 +1,39 @@
package de.mm20.launcher2.search.data
import android.content.Context
import android.os.Bundle
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TextLayer
import de.mm20.launcher2.search.SavableSearchable
data class Tag(
val tag: String,
override val labelOverride: String? = null
): SavableSearchable {
override val domain: String = Domain
override val key: String = "$domain://$tag"
override val label: String = tag
override val preferDetailsOverLaunch: Boolean = true
override fun launch(context: Context, options: Bundle?): Boolean {
return false
}
override fun overrideLabel(label: String): SavableSearchable {
return this.copy(labelOverride = label)
}
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
return StaticLauncherIcon(
foregroundLayer = TextLayer("#"),
backgroundLayer = ColorLayer()
)
}
companion object {
const val Domain = "tag"
}
}
+1
View File
@@ -0,0 +1 @@
/build
+55
View File
@@ -0,0 +1,55 @@
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.files"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.exifinterface)
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.koin.android)
implementation(project(":core:base"))
implementation(project(":core:ktx"))
implementation(project(":libs:ms-services"))
implementation(project(":libs:g-services"))
implementation(project(":libs:nextcloud"))
implementation(project(":libs:owncloud"))
implementation(project(":core:i18n"))
implementation(project(":core:permissions"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.kts.kts.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
+4
View File
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
/
</manifest>
@@ -0,0 +1,295 @@
package de.mm20.launcher2.files
import android.content.Context
import android.provider.MediaStore
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.data.*
import org.json.JSONObject
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class LocalFileSerializer : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as LocalFile
return jsonObjectOf(
"id" to searchable.id
).toString()
}
override val typePrefix: String
get() = "file"
}
class LocalFileDeserializer(
val context: Context
) : SearchableDeserializer, KoinComponent {
override fun deserialize(serialized: String): SavableSearchable? {
val permissionsManager: PermissionsManager = get()
if (!permissionsManager.checkPermissionOnce(
PermissionGroup.ExternalStorage
)
) return null
val json = JSONObject(serialized)
val uri = MediaStore.Files.getContentUri("external")
val proj = arrayOf(
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.SIZE,
MediaStore.Files.FileColumns.DATA,
MediaStore.Files.FileColumns.MIME_TYPE
)
val sel = "${MediaStore.Files.FileColumns._ID} = ?"
val selArgs = arrayOf(json.getLong("id").toString())
val cursor = context.contentResolver.query(uri, proj, sel, selArgs, null) ?: return null
if (cursor.moveToNext()) {
val path = cursor.getString(2)
if (!java.io.File(path).exists()) return null
val directory = java.io.File(path).isDirectory
val id = cursor.getLong(0)
val mimeType = cursor.getStringOrNull(3)
?: if (directory) "resource/folder" else LocalFile.getMimetypeByFileExtension(
path.substringAfterLast(
'.'
)
)
val size = cursor.getLong(1)
cursor.close()
return LocalFile(
path = path,
mimeType = mimeType,
size = size,
isDirectory = directory,
id = id,
metaData = LocalFile.getMetaData(context, mimeType, path)
)
}
cursor.close()
return null
}
}
class GDriveFileSerializer : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as GDriveFile
return jsonObjectOf(
"id" to searchable.fileId,
"label" to searchable.label,
"path" to searchable.path,
"mimeType" to searchable.mimeType,
"size" to searchable.size,
"directory" to searchable.isDirectory,
"color" to searchable.directoryColor,
"uri" to searchable.viewUri
).apply {
for ((k, v) in searchable.metaData) {
put(
when (k) {
R.string.file_meta_owner -> "owner"
R.string.file_meta_dimensions -> "dimensions"
else -> "other"
}, v
)
}
}.toString()
}
override val typePrefix: String
get() = "gdrive"
}
class GDriveFileDeserializer : SearchableDeserializer {
override fun deserialize(serialized: String): SavableSearchable {
val json = JSONObject(serialized)
val id = json.getString("id")
val label = json.getString("label")
val path = json.getString("path")
val mimeType = json.getString("mimeType")
val size = json.getLong("size")
val directory = json.getBoolean("directory")
val color = json.optString("color")
val uri = json.getString("uri")
val owner = json.optString("owner")
val dimensions = json.optString("dimensions")
val metaData = mutableListOf<Pair<Int, String>>()
owner.takeIf { it.isNotEmpty() }?.let { metaData.add(R.string.file_meta_owner to it) }
dimensions.takeIf { it.isNotEmpty() }
?.let { metaData.add(R.string.file_meta_dimensions to it) }
return GDriveFile(
fileId = id,
label = label,
path = path,
mimeType = mimeType,
size = size,
directoryColor = color,
isDirectory = directory,
viewUri = uri,
metaData = metaData
)
}
}
class OneDriveFileSerializer : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as OneDriveFile
return jsonObjectOf(
"id" to searchable.fileId,
"label" to searchable.label,
"mimeType" to searchable.mimeType,
"size" to searchable.size,
"directory" to searchable.isDirectory,
"webUrl" to searchable.webUrl
).apply {
for ((k, v) in searchable.metaData) {
put(
when (k) {
R.string.file_meta_owner -> "owner"
R.string.file_meta_dimensions -> "dimensions"
else -> "other"
}, v
)
}
}.toString()
}
override val typePrefix: String
get() = "onedrive"
}
class OneDriveFileDeserializer : SearchableDeserializer {
override fun deserialize(serialized: String): SavableSearchable {
val json = JSONObject(serialized)
val fileId = json.getString("id")
val label = json.getString("label")
val mimeType = json.getString("mimeType")
val size = json.getLong("size")
val isDirectory = json.getBoolean("directory")
val webUrl = json.getString("webUrl")
val owner = json.optString("owner")
val dimensions = json.optString("dimensions")
val metaData = mutableListOf<Pair<Int, String>>()
owner.takeIf { it.isNotEmpty() }?.let { metaData.add(R.string.file_meta_owner to it) }
dimensions.takeIf { it.isNotEmpty() }
?.let { metaData.add(R.string.file_meta_dimensions to it) }
return OneDriveFile(
fileId = fileId,
label = label,
path = "",
mimeType = mimeType,
size = size,
isDirectory = isDirectory,
metaData = metaData,
webUrl = webUrl
)
}
}
class NextcloudFileSerializer : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as NextcloudFile
return jsonObjectOf(
"id" to searchable.fileId,
"label" to searchable.label,
"path" to searchable.path,
"mimeType" to searchable.mimeType,
"size" to searchable.size,
"isDirectory" to searchable.isDirectory,
"server" to searchable.server
).apply {
for ((k, v) in searchable.metaData) {
put(
when (k) {
R.string.file_meta_owner -> "owner"
else -> "other"
}, v
)
}
}.toString()
}
override val typePrefix: String
get() = "nextcloud"
}
class NextcloudFileDeserializer : SearchableDeserializer {
override fun deserialize(serialized: String): SavableSearchable {
val json = JSONObject(serialized)
val id = json.getLong("id")
val label = json.getString("label")
val path = json.getString("path")
val mimeType = json.getString("mimeType")
val size = json.getLong("size")
val isDirectory = json.getBoolean("isDirectory")
val server = json.getString("server")
val owner = json.optString("owner").takeIf { it.isNotEmpty() }
return NextcloudFile(
fileId = id,
label = label,
path = path,
mimeType = mimeType,
size = size,
isDirectory = isDirectory,
server = server,
metaData = owner?.let { listOf(R.string.file_meta_owner to it) } ?: emptyList()
)
}
}
class OwncloudFileSerializer : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as OwncloudFile
return jsonObjectOf(
"id" to searchable.fileId,
"label" to searchable.label,
"path" to searchable.path,
"mimeType" to searchable.mimeType,
"size" to searchable.size,
"isDirectory" to searchable.isDirectory,
"server" to searchable.server
).apply {
for ((k, v) in searchable.metaData) {
put(
when (k) {
R.string.file_meta_owner -> "owner"
else -> "other"
}, v
)
}
}.toString()
}
override val typePrefix: String
get() = "owncloud"
}
class OwncloudFileDeserializer : SearchableDeserializer {
override fun deserialize(serialized: String): SavableSearchable {
val json = JSONObject(serialized)
val id = json.getLong("id")
val label = json.getString("label")
val path = json.getString("path")
val mimeType = json.getString("mimeType")
val size = json.getLong("size")
val isDirectory = json.getBoolean("isDirectory")
val server = json.getString("server")
val owner = json.optString("owner").takeIf { it.isNotEmpty() }
return OwncloudFile(
fileId = id,
label = label,
path = path,
mimeType = mimeType,
size = size,
isDirectory = isDirectory,
server = server,
metaData = owner?.let { listOf(R.string.file_meta_owner to it) } ?: emptyList()
)
}
}
@@ -0,0 +1,90 @@
package de.mm20.launcher2.files
import android.content.Context
import de.mm20.launcher2.files.providers.FileProvider
import de.mm20.launcher2.files.providers.GDriveFileProvider
import de.mm20.launcher2.files.providers.LocalFileProvider
import de.mm20.launcher2.files.providers.NextcloudFileProvider
import de.mm20.launcher2.files.providers.OneDriveFileProvider
import de.mm20.launcher2.files.providers.OwncloudFileProvider
import de.mm20.launcher2.nextcloud.NextcloudApiHelper
import de.mm20.launcher2.owncloud.OwncloudClient
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.search.data.File
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
interface FileRepository {
fun search(
query: String,
local: Boolean = true,
gdrive: Boolean = true,
onedrive: Boolean = true,
nextcloud: Boolean = true,
owncloud: Boolean = true,
): Flow<ImmutableList<File>>
fun deleteFile(file: File)
}
internal class FileRepositoryImpl(
private val context: Context,
private val permissionsManager: PermissionsManager,
) : FileRepository {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private val nextcloudClient by lazy {
NextcloudApiHelper(context)
}
private val owncloudClient by lazy {
OwncloudClient(context)
}
override fun search(
query: String,
local: Boolean,
gdrive: Boolean,
onedrive: Boolean,
nextcloud: Boolean,
owncloud: Boolean
) = channelFlow {
if (query.isBlank()) {
send(persistentListOf())
return@channelFlow
}
val providers = mutableListOf<FileProvider>()
if (local) providers.add(LocalFileProvider(context, permissionsManager))
if (gdrive) providers.add(GDriveFileProvider(context))
if (onedrive) providers.add(OneDriveFileProvider(context))
if (nextcloud) providers.add(NextcloudFileProvider(nextcloudClient))
if (owncloud) providers.add(OwncloudFileProvider(owncloudClient))
if (providers.isEmpty()) {
send(persistentListOf())
return@channelFlow
}
val results = mutableListOf<File>()
for (provider in providers) {
results.addAll(provider.search(query))
send(results.toImmutableList())
}
}
override fun deleteFile(file: File) {
scope.launch {
if (file.isDeletable) {
file.delete(context)
}
}
}
}
@@ -0,0 +1,8 @@
package de.mm20.launcher2.files
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val filesModule = module {
single<FileRepository> { FileRepositoryImpl(androidContext(), get()) }
}
@@ -0,0 +1,7 @@
package de.mm20.launcher2.files.providers
import de.mm20.launcher2.search.data.File
interface FileProvider {
suspend fun search(query: String): List<File>
}
@@ -0,0 +1,40 @@
package de.mm20.launcher2.files.providers
import android.content.Context
import de.mm20.launcher2.files.R
import de.mm20.launcher2.gservices.DriveFileMeta
import de.mm20.launcher2.gservices.GoogleApiHelper
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.GDriveFile
internal class GDriveFileProvider(
private val context: Context
) : FileProvider {
override suspend fun search(query: String): List<File> {
if (query.length < 4) return emptyList()
val driveFiles = GoogleApiHelper.getInstance(context).queryGDriveFiles(query)
return driveFiles.map {
GDriveFile(
fileId = it.fileId,
label = it.label,
size = it.size,
mimeType = it.mimeType,
isDirectory = it.isDirectory,
path = "",
directoryColor = it.directoryColor,
viewUri = it.viewUri,
metaData = getMetadata(it.metadata)
)
}
}
private fun getMetadata(file: DriveFileMeta): List<Pair<Int, String>> {
val metaData = mutableListOf<Pair<Int, String>>()
val owners = file.owners
metaData.add(R.string.file_meta_owner to owners.joinToString(separator = ", "))
val width = file.width ?: file.width
val height = file.height ?: file.height
if (width != null && height != null) metaData.add(R.string.file_meta_dimensions to "${width}x$height")
return metaData
}
}
@@ -0,0 +1,66 @@
package de.mm20.launcher2.files.providers
import android.content.Context
import android.provider.DocumentsContract
import android.provider.MediaStore
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.LocalFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal class LocalFileProvider(
private val context: Context,
private val permissionsManager: PermissionsManager
): FileProvider {
override suspend fun search(query: String): List<File> = withContext(Dispatchers.IO) {
if (!permissionsManager.checkPermissionOnce(PermissionGroup.ExternalStorage)) {
return@withContext emptyList()
}
val results = mutableListOf<LocalFile>()
val uri = MediaStore.Files.getContentUri("external").buildUpon()
.appendQueryParameter("limit", "10").build()
val projection = arrayOf(
MediaStore.Files.FileColumns.DISPLAY_NAME,
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.SIZE,
MediaStore.Files.FileColumns.DATA,
MediaStore.Files.FileColumns.MIME_TYPE
)
val selection =
if (query.length > 3) "${MediaStore.Files.FileColumns.TITLE} LIKE ?" else "${MediaStore.Files.FileColumns.TITLE} = ?"
val selArgs = if (query.length > 3) arrayOf("%$query%") else arrayOf(query)
val sort = "${MediaStore.Files.FileColumns.DISPLAY_NAME} COLLATE NOCASE ASC"
val cursor = context.contentResolver.query(uri, projection, selection, selArgs, sort)
?: return@withContext results
while (cursor.moveToNext()) {
if (results.size >= 10) {
break
}
val path = cursor.getString(3)
if (!java.io.File(path).exists()) continue
val directory = java.io.File(path).isDirectory
val mimeType = (cursor.getStringOrNull(4)
?: if (directory) "resource/folder" else LocalFile.getMimetypeByFileExtension(
path.substringAfterLast(
'.'
)
))
val file = LocalFile(
path = path,
mimeType = mimeType,
size = cursor.getLong(2),
isDirectory = directory,
id = cursor.getLong(1),
metaData = LocalFile.getMetaData(context, mimeType, path)
)
results.add(file)
}
cursor.close()
return@withContext results
}
}
@@ -0,0 +1,33 @@
package de.mm20.launcher2.files.providers
import de.mm20.launcher2.files.R
import de.mm20.launcher2.nextcloud.NextcloudApiHelper
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.NextcloudFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.min
internal class NextcloudFileProvider(
private val nextcloudClient: NextcloudApiHelper
) : FileProvider {
override suspend fun search(query: String): List<File> {
if (query.length < 4) return emptyList()
val server = nextcloudClient.getServer() ?: return emptyList()
return withContext(Dispatchers.IO) {
nextcloudClient.files.search(query).let { it.subList(0, min(10, it.size)) }.map {
NextcloudFile(
fileId = it.id,
label = it.name,
path = server + it.url,
mimeType = it.mimeType,
size = it.size,
isDirectory = it.isDirectory,
server = server,
metaData = it.owner?.let { listOf(R.string.file_meta_owner to it) }
?: emptyList()
)
}
}
}
}
@@ -0,0 +1,48 @@
package de.mm20.launcher2.files.providers
import android.content.Context
import de.mm20.launcher2.files.R
import de.mm20.launcher2.msservices.DriveItem
import de.mm20.launcher2.msservices.MicrosoftGraphApiHelper
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.OneDriveFile
internal class OneDriveFileProvider(
private val context: Context
) : FileProvider {
override suspend fun search(query: String): List<File> {
if (query.length < 4) return emptyList()
val driveItems = MicrosoftGraphApiHelper.getInstance(context).queryOneDriveFiles(query)
?: return emptyList()
val files = mutableListOf<OneDriveFile>()
for (driveItem in driveItems) {
files += OneDriveFile(
fileId = driveItem.id,
label = driveItem.label,
path = "",
mimeType = driveItem.mimeType,
size = driveItem.size,
isDirectory = driveItem.isDirectory,
metaData = getMetaData(driveItem),
webUrl = driveItem.webUrl
)
}
return files
}
private fun getMetaData(driveItem: DriveItem): List<Pair<Int, String>> {
val metaData = mutableListOf<Pair<Int, String>>()
driveItem.meta.owner?.let {
metaData.add(R.string.file_meta_owner to it)
} ?: driveItem.meta.createdBy?.let {
metaData.add(R.string.file_meta_owner to it)
}
val width = driveItem.meta.width
val height = driveItem.meta.height
if (width != null && height != null) {
metaData.add(R.string.file_meta_dimensions to "${width}x${height}")
}
return metaData
}
}
@@ -0,0 +1,27 @@
package de.mm20.launcher2.files.providers
import de.mm20.launcher2.files.R
import de.mm20.launcher2.owncloud.OwncloudClient
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.OwncloudFile
internal class OwncloudFileProvider(
private val owncloudClient: OwncloudClient
) : FileProvider {
override suspend fun search(query: String): List<File> {
if (query.length < 4) return emptyList()
val server = owncloudClient.getServer() ?: return emptyList()
return owncloudClient.files.query(query).map {
OwncloudFile(
fileId = it.id,
label = it.name,
path = server + it.url,
mimeType = it.mimeType,
size = it.size,
isDirectory = it.isDirectory,
server = server,
metaData = it.owner?.let { listOf(R.string.file_meta_owner to it) } ?: emptyList()
)
}
}
}
@@ -0,0 +1,25 @@
package de.mm20.launcher2.media
import android.graphics.Bitmap
import android.media.ThumbnailUtils
import android.os.Build
import android.os.CancellationSignal
import android.provider.MediaStore
import android.util.Size
import java.io.File
import java.io.IOException
object ThumbnailUtilsCompat {
fun createVideoThumbnail(file: File, size: Size, signal: CancellationSignal? = null): Bitmap? {
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ThumbnailUtils.createVideoThumbnail(file, size, signal)
} else {
ThumbnailUtils.createVideoThumbnail(file.absolutePath,
MediaStore.Video.Thumbnails.MICRO_KIND)
}
} catch (e: IOException) {
null
}
}
}
@@ -0,0 +1,134 @@
package de.mm20.launcher2.search.data
import android.content.Context
import androidx.core.content.ContextCompat
import de.mm20.launcher2.files.R
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TintedIconLayer
import de.mm20.launcher2.search.SavableSearchable
import java.util.*
interface File : SavableSearchable {
val path: String
val mimeType: String
val size: Long
val isDirectory: Boolean
val metaData: List<Pair<Int, String>>
val isStoredInCloud: Boolean
override val preferDetailsOverLaunch: Boolean
get() = false
open val providerIconRes: Int?
get() = null
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
val (resId, bgColor) = when {
isDirectory -> R.drawable.ic_file_folder to R.color.lightblue
mimeType.startsWith("image/") -> R.drawable.ic_file_picture to R.color.teal
mimeType.startsWith("audio/") -> R.drawable.ic_file_music to R.color.orange
mimeType.startsWith("video/") -> R.drawable.ic_file_video to R.color.purple
else -> when (mimeType) {
"application/zip", "application/x-gtar", "application/x-tar",
"application/java-archive", "application/x-7z-compressed",
"application/x-compressed-tar", "application/x-gzip", "application/x-bzip2" -> R.drawable.ic_file_archive to R.color.brown
"application/pdf" -> R.drawable.ic_file_pdf to R.color.red
"application/vnd.oasis.opendocument.text",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword", "text/plain", "application/vnd.google-apps.document" -> R.drawable.ic_file_document to R.color.blue
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel", "application/vnd.google-apps.spreadsheet" -> R.drawable.ic_file_spreadsheet to R.color.green
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.ms-powerpoint", "application/vnd.google-apps.presentation" -> R.drawable.ic_file_presentation to R.color.amber
"text/x-asm", "text/x-c", "text/x-java-source", "text/x-script.phyton", "text/x-pascal",
"text/x-script.perl", "text/javascript", "application/json" -> R.drawable.ic_file_code to R.color.pink
"text/xml", "text/html" -> R.drawable.ic_file_markup to R.color.deeporange
"application/vnd.android.package-archive" -> R.drawable.ic_file_android to R.color.lightgreen
"application/vnd.google-apps.form" -> R.drawable.ic_file_form to R.color.deeppurple
"application/vnd.google-apps.drawing" -> R.drawable.ic_file_picture to R.color.teal
else -> R.drawable.ic_file_generic to R.color.bluegrey
}
}
return StaticLauncherIcon(
foregroundLayer = TintedIconLayer(
icon = ContextCompat.getDrawable(context, resId)!!,
scale = 0.5f,
color = ContextCompat.getColor(context, bgColor)
),
backgroundLayer = ColorLayer(ContextCompat.getColor(context, bgColor))
)
}
fun getFileType(context: Context): String {
if (isDirectory) return context.getString(R.string.file_type_directory)
if (mimeType == "application/vendor.de.mm20.launcher2.backup") {
return context.getString(
R.string.file_type_launcherbackup,
context.getString(R.string.app_name)
)
}
val resource = when (mimeType) {
"application/zip",
"application/x-zip-compressed",
"application/x-gtar",
"application/x-tar",
"application/java-archive",
"application/x-7z-compressed" -> R.string.file_type_archive
"application/x-gzip",
"application/x-bzip2" -> R.string.file_type_compressed
"application/vnd.android.package-archive" -> R.string.file_type_android
"text/x-asm",
"text/x-c",
"text/x-java-source",
"text/x-script.phyton",
"text/x-pascal",
"text/x-script.perl",
"text/javascript",
"application/json" -> R.string.file_type_source_code
"application/vnd.oasis.opendocument.text",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
"application/x-iwork-pages-sffpages",
"application/vnd.apple.pages",
"application/vnd.google-apps.document" -> R.string.file_type_document
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
"application/x-iwork-numbers-sffnumbers",
"application/vnd.apple.numbers",
"application/vnd.google-apps.spreadsheet" -> R.string.file_type_spreadsheet
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.ms-powerpoint",
"application/x-iwork-keynote-sffkey",
"application/vnd.apple.keynote",
"application/vnd.google-apps.presentation" -> R.string.file_type_presentation
"text/plain" -> R.string.file_type_text
"application/vnd.google-apps.drawing" -> R.string.file_type_drawing
"application/vnd.google-apps.form" -> R.string.file_type_form
"application/epub+zip" -> R.string.file_type_ebook
else -> when {
mimeType.startsWith("image/") -> R.string.file_type_image
mimeType.startsWith("video/") -> R.string.file_type_video
mimeType.startsWith("audio/") -> R.string.file_type_music
else -> R.string.file_type_none
}
}
if (resource == R.string.file_type_none && label.matches(Regex(".+\\..+"))) {
val extension = label.substringAfterLast(".").uppercase(Locale.getDefault())
if (extension == "kvaesitso") return context.getString(
R.string.file_type_launcherbackup,
context.getString(R.string.app_name)
)
return context.getString(R.string.file_type_generic, extension)
}
return context.getString(resource)
}
val isDeletable: Boolean
get() = false
suspend fun delete(context: Context) {}
}
@@ -0,0 +1,49 @@
package de.mm20.launcher2.search.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import de.mm20.launcher2.files.R
import de.mm20.launcher2.ktx.tryStartActivity
data class GDriveFile(
val fileId: String,
override val label: String,
override val path: String,
override val mimeType: String,
override val size: Long,
override val isDirectory: Boolean,
override val metaData: List<Pair<Int, String>>,
val directoryColor: String?,
val viewUri: String,
override val labelOverride: String? = null,
) : File {
override fun overrideLabel(label: String): GDriveFile {
return this.copy(labelOverride = label)
}
override val domain: String = Domain
override val key: String = "$domain://$fileId"
override val isStoredInCloud = true
override val providerIconRes = R.drawable.ic_badge_gdrive
private fun getLaunchIntent(): Intent {
return Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(viewUri)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
}
override fun launch(context: Context, options: Bundle?): Boolean {
return context.tryStartActivity(getLaunchIntent(), options)
}
companion object {
const val Domain = "gdrive"
}
}
@@ -0,0 +1,333 @@
package de.mm20.launcher2.search.data
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.drawable.AdaptiveIconDrawable
import android.graphics.drawable.BitmapDrawable
import android.location.Geocoder
import android.media.MediaMetadataRetriever
import android.media.ThumbnailUtils
import android.net.Uri
import android.os.Bundle
import android.provider.MediaStore
import android.text.format.DateUtils
import android.util.Size
import androidx.core.content.FileProvider
import androidx.exifinterface.media.ExifInterface
import de.mm20.launcher2.files.R
import de.mm20.launcher2.icons.*
import de.mm20.launcher2.ktx.formatToString
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.media.ThumbnailUtilsCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.io.File as JavaIOFile
data class LocalFile(
val id: Long,
override val path: String,
override val mimeType: String,
override val size: Long,
override val isDirectory: Boolean,
override val metaData: List<Pair<Int, String>>,
override val labelOverride: String? = null
) : File {
override val label = path.substringAfterLast('/')
override fun overrideLabel(label: String): LocalFile {
return this.copy(labelOverride = label)
}
override val domain: String = Domain
override val key = "$domain://$path"
override val isStoredInCloud = false
override suspend fun loadIcon(
context: Context,
size: Int,
themed: Boolean,
): LauncherIcon? {
if (!JavaIOFile(path).exists()) return null
when {
mimeType.startsWith("image/") -> {
val thumbnail = withContext(Dispatchers.IO) {
ThumbnailUtils.extractThumbnail(
BitmapFactory.decodeFile(path),
size, size
)
} ?: return null
return StaticLauncherIcon(
foregroundLayer = StaticIconLayer(
icon = BitmapDrawable(context.resources, thumbnail),
scale = 1f,
),
backgroundLayer = ColorLayer()
)
}
mimeType.startsWith("video/") -> {
val thumbnail = withContext(Dispatchers.IO) {
ThumbnailUtilsCompat.createVideoThumbnail(
JavaIOFile(path),
Size(size, size)
)
} ?: return null
return StaticLauncherIcon(
foregroundLayer = StaticIconLayer(
icon = BitmapDrawable(context.resources, thumbnail),
scale = 1f,
),
backgroundLayer = ColorLayer()
)
}
mimeType.startsWith("audio/") -> {
val thumbnail = withContext(Dispatchers.IO) {
val mediaMetadataRetriever = MediaMetadataRetriever()
try {
mediaMetadataRetriever.setDataSource(path)
val thumbData = mediaMetadataRetriever.embeddedPicture
if (thumbData != null) {
val thumbnail = ThumbnailUtils.extractThumbnail(
BitmapFactory.decodeByteArray(thumbData, 0, thumbData.size),
size,
size
)
mediaMetadataRetriever.release()
return@withContext thumbnail
}
} catch (e: RuntimeException) {
}
mediaMetadataRetriever.release()
return@withContext null
}
thumbnail ?: return null
return StaticLauncherIcon(
foregroundLayer = StaticIconLayer(
icon = BitmapDrawable(context.resources, thumbnail),
scale = 1f,
),
backgroundLayer = ColorLayer()
)
}
mimeType == "application/vnd.android.package-archive" -> {
val pkgInfo = context.packageManager.getPackageArchiveInfo(path, 0)
val icon = withContext(Dispatchers.IO) {
pkgInfo?.applicationInfo?.loadIcon(context.packageManager)
} ?: return null
when (icon) {
is AdaptiveIconDrawable -> {
return StaticLauncherIcon(
foregroundLayer = icon.foreground?.let {
StaticIconLayer(
icon = it,
scale = 1.5f,
)
} ?: TransparentLayer,
backgroundLayer = icon.background?.let {
StaticIconLayer(
icon = it,
scale = 1.5f,
)
} ?: TransparentLayer,
)
}
else -> {
return StaticLauncherIcon(
foregroundLayer = StaticIconLayer(
icon = icon,
scale = 0.7f,
),
backgroundLayer = ColorLayer()
)
}
}
}
}
return null
}
private fun getLaunchIntent(context: Context): Intent {
val uri = if (isDirectory) {
Uri.parse(path)
} else {
FileProvider.getUriForFile(
context,
context.applicationContext.packageName + ".fileprovider", JavaIOFile(path)
)
}
return Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, mimeType)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
override fun launch(context: Context, options: Bundle?): Boolean {
return context.tryStartActivity(getLaunchIntent(context), options)
}
override val isDeletable: Boolean
get() {
val file = java.io.File(path)
return file.canWrite() && file.parentFile?.canWrite() == true
}
override suspend fun delete(context: Context) {
super.delete(context)
val file = java.io.File(path)
withContext(Dispatchers.IO) {
file.deleteRecursively()
context.contentResolver.delete(
MediaStore.Files.getContentUri("external"),
"${MediaStore.Files.FileColumns._ID} = ?",
arrayOf(id.toString())
)
}
}
companion object {
const val Domain = "file"
internal fun getMimetypeByFileExtension(extension: String): String {
return when (extension) {
"apk" -> "application/vnd.android.package-archive"
"zip" -> "application/zip"
"jar" -> "application/java-archive"
"txt" -> "text/plain"
"js" -> "text/javascript"
"html", "htm" -> "text/html"
"css" -> "text/css"
"gif" -> "image/gif"
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
"bmp" -> "image/bmp"
"webp" -> "image/webp"
"ico" -> "image/x-icon"
"midi" -> "audio/midi"
"mp3" -> "audio/mpeg3"
"webm" -> "audio/webm"
"ogg" -> "audio/ogg"
"wav" -> "audio/wav"
"mp4" -> "video/mp4"
"kvaesitso" -> "application/vendor.de.mm20.launcher2.backup"
else -> "application/octet-stream"
}
}
internal fun getMetaData(
context: Context,
mimeType: String,
path: String
): List<Pair<Int, String>> {
val metaData = mutableListOf<Pair<Int, String>>()
when {
mimeType.startsWith("audio/") -> {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(path)
arrayOf(
R.string.file_meta_title to MediaMetadataRetriever.METADATA_KEY_TITLE,
R.string.file_meta_artist to MediaMetadataRetriever.METADATA_KEY_ARTIST,
R.string.file_meta_album to MediaMetadataRetriever.METADATA_KEY_ALBUM,
R.string.file_meta_year to MediaMetadataRetriever.METADATA_KEY_YEAR
).forEach {
retriever.extractMetadata(it.second)
?.let { m -> metaData.add(it.first to m) }
}
val duration =
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLong() ?: 0
val d = DateUtils.formatElapsedTime((duration) / 1000)
metaData.add(3, R.string.file_meta_duration to d)
retriever.release()
} catch (e: RuntimeException) {
retriever.release()
}
}
mimeType.startsWith("video/") -> {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(path)
val width =
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
?.toLong() ?: 0
val height =
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
?.toLong() ?: 0
metaData.add(R.string.file_meta_dimensions to "${width}x$height")
val duration =
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLong() ?: 0
val d = DateUtils.formatElapsedTime(duration / 1000)
metaData.add(R.string.file_meta_duration to d)
val loc =
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
if (Geocoder.isPresent() && loc != null) {
val lon =
loc.substring(0, loc.lastIndexOfAny(charArrayOf('+', '-')))
.toDouble()
val lat = loc.substring(
loc.lastIndexOfAny(charArrayOf('+', '-')),
loc.indexOf('/')
).toDouble()
val list = Geocoder(context).getFromLocation(lon, lat, 1)
if (list != null && list.size > 0) {
metaData.add(R.string.file_meta_location to list[0].formatToString())
}
}
retriever.release()
} catch (e: RuntimeException) {
retriever.release()
}
}
mimeType.startsWith("image/") -> {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeFile(path, options)
val width = options.outWidth
val height = options.outHeight
metaData.add(R.string.file_meta_dimensions to "${width}x$height")
try {
val exif = ExifInterface(path)
val loc = exif.latLong
if (loc != null && Geocoder.isPresent()) {
val list = Geocoder(context).getFromLocation(loc[0], loc[1], 1)
if (list != null && list.size > 0) {
metaData.add(R.string.file_meta_location to list[0].formatToString())
}
}
} catch (_: IOException) {
}
}
mimeType == "application/vnd.android.package-archive" -> {
val pkgInfo = context.packageManager.getPackageArchiveInfo(path, 0)
?: return metaData
metaData.add(
R.string.file_meta_app_name to pkgInfo.applicationInfo.loadLabel(
context.packageManager
).toString()
)
metaData.add(R.string.file_meta_app_pkgname to pkgInfo.packageName)
metaData.add(R.string.file_meta_app_version to pkgInfo.versionName)
metaData.add(R.string.file_meta_app_min_sdk to pkgInfo.applicationInfo.minSdkVersion.toString())
}
}
return metaData
}
}
}
@@ -0,0 +1,64 @@
package de.mm20.launcher2.search.data
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import de.mm20.launcher2.files.R
import de.mm20.launcher2.ktx.tryStartActivity
data class NextcloudFile(
val fileId: Long,
override val label: String,
override val path: String,
override val mimeType: String,
override val size: Long,
override val isDirectory: Boolean,
val server: String,
override val metaData: List<Pair<Int, String>>,
override val labelOverride: String? = null,
) : File {
override fun overrideLabel(label: String): NextcloudFile {
return this.copy(labelOverride = label)
}
override val domain: String = Domain
override val key: String = "$domain://$server/$fileId"
override val isStoredInCloud: Boolean
get() = true
override val providerIconRes = R.drawable.ic_badge_nextcloud
private fun getLaunchIntent(context: Context): Intent {
return Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("$server/f/$fileId")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
`package` = getNextcloudAppPackage(context)
}
}
override fun launch(context: Context, options: Bundle?): Boolean {
return context.tryStartActivity(getLaunchIntent(context), options)
}
companion object {
const val Domain = "nextcloud"
private fun getNextcloudAppPackage(context: Context): String? {
val candidates = listOf("com.nextcloud.client", "com.nextcloud.android.beta")
for (c in candidates) {
try {
context.packageManager.getPackageInfo(c, 0)
return c
} catch (e: PackageManager.NameNotFoundException) {
}
}
return null
}
}
}
@@ -0,0 +1,48 @@
package de.mm20.launcher2.search.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import de.mm20.launcher2.files.R
import de.mm20.launcher2.ktx.tryStartActivity
data class OneDriveFile(
val fileId: String,
override val label: String,
override val path: String,
override val mimeType: String,
override val size: Long,
override val isDirectory: Boolean,
override val metaData: List<Pair<Int, String>>,
val webUrl: String,
override val labelOverride: String? = null,
) : File {
override fun overrideLabel(label: String): OneDriveFile {
return this.copy(labelOverride = label)
}
override val domain: String = Domain
override val key: String = "$domain://$fileId"
override val providerIconRes = R.drawable.ic_badge_onedrive
override val isStoredInCloud = true
private fun getLaunchIntent(): Intent {
return Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(webUrl)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
}
override fun launch(context: Context, options: Bundle?): Boolean {
return context.tryStartActivity(getLaunchIntent(), options)
}
companion object {
const val Domain = "onedrive"
}
}
@@ -0,0 +1,48 @@
package de.mm20.launcher2.search.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import de.mm20.launcher2.files.R
import de.mm20.launcher2.ktx.tryStartActivity
data class OwncloudFile(
val fileId: Long,
override val label: String,
override val path: String,
override val mimeType: String,
override val size: Long,
override val isDirectory: Boolean,
val server: String,
override val metaData: List<Pair<Int, String>>,
override val labelOverride: String? = null,
) : File {
override fun overrideLabel(label: String): OwncloudFile {
return this.copy(labelOverride = label)
}
override val domain: String = Domain
override val key: String = "$domain://$server/$fileId"
override val isStoredInCloud: Boolean
get() = true
override val providerIconRes = R.drawable.ic_badge_owncloud
private fun getLaunchIntent(): Intent {
return Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("$server/f/$fileId")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
}
override fun launch(context: Context, options: Bundle?): Boolean {
return context.tryStartActivity(getLaunchIntent(), options)
}
companion object {
const val Domain = "owncloud"
}
}
+1
View File
@@ -0,0 +1 @@
/build
+47
View File
@@ -0,0 +1,47 @@
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.notifications"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.koin.android)
implementation(project(":core:permissions"))
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# 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

Some files were not shown because too many files have changed in this diff Show More