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