diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5e9866ba8..56261b311 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -387,6 +387,13 @@ android:resource="@xml/file_provider_paths"/> + + diff --git a/app/src/main/java/org/tasks/widget/WidgetChipProvider.kt b/app/src/main/java/org/tasks/widget/WidgetChipProvider.kt index 37a54832a..2fb10afd7 100644 --- a/app/src/main/java/org/tasks/widget/WidgetChipProvider.kt +++ b/app/src/main/java/org/tasks/widget/WidgetChipProvider.kt @@ -3,7 +3,6 @@ package org.tasks.widget import android.content.Context import android.widget.RemoteViews import androidx.annotation.ColorInt -import com.mikepenz.iconics.IconicsDrawable import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.runBlocking import org.tasks.BuildConfig @@ -19,7 +18,6 @@ import org.tasks.filters.Filter import org.tasks.filters.PlaceFilter import org.tasks.filters.TagFilter import org.tasks.filters.getIcon -import org.tasks.icons.OutlinedGoogleMaterial import org.tasks.kmp.org.tasks.time.getRelativeDateTime import org.tasks.kmp.org.tasks.time.getTimeString import org.tasks.time.startOfDay @@ -115,14 +113,16 @@ class WidgetChipProvider @Inject constructor( setTextViewText(R.id.chip_text, filter.title) filter .getIcon(inventory) - ?.let { + ?.let { iconName -> try { - OutlinedGoogleMaterial.getIcon("gmo_$it") - } catch (_: IllegalArgumentException) { - null + val iconUri = WidgetIconProvider.getIconUri( + iconName = iconName, + ) + setImageViewUri(R.id.chip_icon, iconUri) + } catch (_: Exception) { + setImageViewResource(R.id.chip_icon, defaultIcon) } } - ?.let { setImageViewBitmap(R.id.chip_icon, IconicsDrawable(context, it).toBitmap()) } ?: setImageViewResource(R.id.chip_icon, defaultIcon) } @@ -138,4 +138,4 @@ class WidgetChipProvider @Inject constructor( setColorFilter(R.id.chip_background, tint) setTextColor(R.id.chip_text, tint) } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/tasks/widget/WidgetIconProvider.kt b/app/src/main/java/org/tasks/widget/WidgetIconProvider.kt new file mode 100644 index 000000000..86f0335b2 --- /dev/null +++ b/app/src/main/java/org/tasks/widget/WidgetIconProvider.kt @@ -0,0 +1,118 @@ +package org.tasks.widget + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.graphics.Bitmap +import android.graphics.Canvas +import android.net.Uri +import android.os.ParcelFileDescriptor +import androidx.core.graphics.createBitmap +import androidx.core.net.toUri +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.utils.sizeDp +import org.tasks.BuildConfig +import org.tasks.icons.OutlinedGoogleMaterial +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream + +class WidgetIconProvider : ContentProvider() { + + override fun onCreate() = true + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { + if (mode != "r") { + throw SecurityException("Only read access allowed") + } + + return try { + val segments = uri.pathSegments + if (segments.size != 2) return null + + val iconName = segments[1] + + if (!iconName.matches(Regex("^[a-zA-Z0-9_]+$"))) return null + + val cacheFile = getCacheFile(iconName) + + if (!cacheFile.exists()) { + generateIcon(cacheFile, iconName) + } + + if (cacheFile.exists()) { + ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY) + } else { + null + } + } catch (e: Exception) { + Timber.e(e, "Failed to open icon file for URI: $uri") + null + } + } + + private fun generateIcon(file: File, iconName: String) { + try { + val icon = OutlinedGoogleMaterial.getIcon("gmo_$iconName") + val context = context ?: return + + val drawable = IconicsDrawable(context, icon).apply { + this.sizeDp = 24 + } + + val bitmap = createBitmap( + drawable.intrinsicWidth.coerceAtLeast(1), + drawable.intrinsicHeight.coerceAtLeast(1) + ) + + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + + file.parentFile?.mkdirs() + FileOutputStream(file).use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + bitmap.recycle() + } catch (e: Exception) { + Timber.e(e, "Failed to generate icon: $iconName") + file.delete() + } + } + + private fun getCacheFile(iconName: String): File { + val context = context ?: throw IllegalStateException("Context is null") + val cacheDir = File(context.cacheDir, "widget_icons") + cacheDir.mkdirs() + return File(cacheDir, "${iconName}.png") + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun getType(uri: Uri): String = "image/png" + + companion object { + const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.widgeticons" + + fun getIconUri(iconName: String): Uri { + return "content://$AUTHORITY/icon/$iconName".toUri() + } + } +}