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
+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"
}
}