Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a custom content provider that can download icons #1449

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions app-icon-loader/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.cxx
.externalNativeBuild
60 changes: 60 additions & 0 deletions app-icon-loader/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'

android {
compileSdkVersion 32
buildToolsVersion "32.0.0"
ndkVersion "21.4.7075529"

defaultConfig {
applicationId "com.kunzisoft.keepass.icons"
minSdkVersion 15
targetSdkVersion 32
versionCode = 114
versionName = "3.4.5"

kapt {
arguments {
arg("room.incremental", "true")
arg("room.schemaLocation", "$projectDir/schemas".toString())
}
}
}

buildTypes {
release {
minifyEnabled = false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}

testOptions {
unitTests.includeAndroidResources = true
}

compileOptions {
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_1_8
}

kotlinOptions {
jvmTarget = "1.8"
}
}

def room_version = "2.4.2"

dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"

// Androidx Core Graphics
implementation "androidx.core:core-ktx:$android_core_version"

// Database
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"

implementation(platform("com.squareup.okhttp3:okhttp-bom:4.10.0"))
implementation("com.squareup.okhttp3:okhttp")
}
6 changes: 6 additions & 0 deletions app-icon-loader/lint.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<lint>
<issue id="Assert">
<ignore path="src/org" />
</issue>
</lint>
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "59d0e9ab5e46349b7903321c59da5e1f",
"entities": [
{
"tableName": "Icon",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` BLOB NOT NULL, `name` TEXT NOT NULL, `sourceKey` TEXT NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`uuid`))",
"fields": [
{
"fieldPath": "uuid",
"columnName": "uuid",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sourceKey",
"columnName": "sourceKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"uuid"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_Icon_sourceKey_source",
"unique": true,
"columnNames": [
"sourceKey",
"source"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Icon_sourceKey_source` ON `${TABLE_NAME}` (`sourceKey`, `source`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '59d0e9ab5e46349b7903321c59da5e1f')"
]
}
}
29 changes: 29 additions & 0 deletions app-icon-loader/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.kunzisoft.keepass.icons.loader"
android:installLocation="auto">

<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />

<uses-permission android:name="android.permission.INTERNET" />

<permission
android:name="com.kunzisoft.keepass.icons.loader.ACCESS"
android:protectionLevel="signature" />

<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round">

<provider
android:name=".KeePassIconsProvider"
android:authorities="com.kunzisoft.keepass.icons.loader"
android:enabled="true"
android:exported="true"
android:permission="com.kunzisoft.keepass.icons.loader.ACCESS" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.kunzisoft.keepass.icons.loader

import android.database.Cursor
import androidx.room.*
import java.util.*

@Database(version = 1, entities = [Icon::class])
abstract class IconDatabase : RoomDatabase() {
abstract fun icons(): IconDao
}

@Dao
interface IconDao {

@Insert
fun insert(icons: List<Icon>)

@Query("SELECT sourceKey FROM icon WHERE source = :source")
fun getSourceKeys(source: IconSource): List<String>

@Query("SELECT * FROM icon WHERE uuid = :uuid")
fun get(uuid: UUID): Icon?

@Query("SELECT uuid, name, source FROM icon WHERE sourceKey IN (:packageNames) OR sourceKey IN (:hosts)")
fun search(packageNames: Set<String>, hosts: Set<String>): Cursor
}

@Entity(
indices = [
Index(value = ["sourceKey", "source"], unique = true),
]
)
data class Icon(
@PrimaryKey
val uuid: UUID,
val name: String,
val sourceKey: String,
val source: IconSource,
)

enum class IconSource {
App, DuckDuckGo, Google
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.kunzisoft.keepass.icons.loader

interface IconDownloader<T> {
fun download(item: T): Icon?
}

interface IconsDownloader<T> {
fun download(items: Set<T>): List<Icon>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.kunzisoft.keepass.icons.loader

import android.graphics.Bitmap
import java.io.File
import java.io.FileOutputStream

/**
* Write downloaded icon to cache directory.
*/
class IconWriter(
private val iconsDir: File,
) {

init {
iconsDir.deleteRecursively()
iconsDir.mkdirs()
}

fun write(icon: Icon, bitmap: Bitmap) {
FileOutputStream(getFile(icon)).use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
}

fun getFile(icon: Icon) = File(iconsDir, "${icon.uuid}.png")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.kunzisoft.keepass.icons.loader

import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import androidx.core.content.ContentProviderCompat.requireContext
import androidx.room.Room
import com.kunzisoft.keepass.icons.loader.app.AppIconDownloader
import com.kunzisoft.keepass.icons.loader.web.DuckDuckGoIconDownloader
import com.kunzisoft.keepass.icons.loader.web.GoogleWebIconDownloader
import java.io.File
import java.util.*

/**
* Request icons for KeePassDX via this [ContentProvider].
*/
class KeePassIconsProvider : ContentProvider() {

private val pm by lazy {
requireContext(this).packageManager
}

private val db by lazy {
Room.inMemoryDatabaseBuilder(requireContext(this), IconDatabase::class.java).build()
}

private val icons by lazy {
db.icons()
}

private val appIconDownloader by lazy {
AppIconDownloader(pm, icons, writer)
}

private val duckDuckGoIconDownloader by lazy {
DuckDuckGoIconDownloader(icons, writer)
}

private val googleWebIconDownloader by lazy {
GoogleWebIconDownloader(icons, writer)
}

private val writer by lazy {
IconWriter(iconsDir = File(requireContext(this).cacheDir, "/icons/"))
}

override fun onCreate(): Boolean = true

override fun getType(uri: Uri): String? = null

override fun query(
uri: Uri,
projection: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?,
): Cursor {
val args = selectionArgs?.asSequence().orEmpty()
val packageNames = args.filter(prefix = "app:").toSet()
val hosts = args.filter(prefix = "host:").toSet()

// Download all icons
val appIcons = appIconDownloader.download(packageNames)
val duckDuckGoIcons = duckDuckGoIconDownloader.download(hosts)
val googleUrlIcons = googleWebIconDownloader.download(hosts)

// Update icon database
icons.insert(icons = appIcons + duckDuckGoIcons + googleUrlIcons)

// Query database
return icons.search(
packageNames = packageNames,
hosts = hosts,
)
}

override fun insert(uri: Uri, values: ContentValues?): Uri? = null

override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<String>?,
): Int = 0

override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0

override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? =
icons.get(
uuid = UUID.fromString(uri.pathSegments.last())
)?.let { icon ->
ParcelFileDescriptor.open(writer.getFile(icon), ParcelFileDescriptor.MODE_READ_ONLY)
}

private fun Sequence<String>.filter(prefix: String) = this
.filter { it.startsWith(prefix) }
.map { it.substring(prefix.length) }
.filter(String::isNotBlank)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.kunzisoft.keepass.icons.loader.app

import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.graphics.Bitmap
import androidx.core.graphics.drawable.toBitmap
import com.kunzisoft.keepass.icons.loader.*
import java.util.*

/**
* Download app icon from [PackageManager].
*/
class AppIconDownloader(
private val pm: PackageManager,
private val db: IconDao,
private val writer: IconWriter,
) : IconsDownloader<String>, IconDownloader<PackageInfo> {

private val source = IconSource.App

override fun download(items: Set<String>): List<Icon> {
val existingIcons = db.getSourceKeys(source)
return pm.getInstalledPackages(0)
.filter { items.contains(it.packageName) }
.filterNot { existingIcons.contains(it.packageName) }
.map { packageInfo -> download(packageInfo) }
}

override fun download(item: PackageInfo): Icon =
Icon(
uuid = UUID.randomUUID(),
name = item.applicationInfo?.loadLabel(pm)?.toString() ?: item.packageName,
sourceKey = item.packageName,
source = source,
).also { icon ->
val appIcon = pm.getApplicationIcon(item.packageName)
writer.write(icon, appIcon.toBitmap(config = Bitmap.Config.ARGB_8888))
}
}