Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ kotlin {
implementation(libs.ktor.client.darwin)
}
}

compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
}

android {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,10 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import com.daniebeler.pfpixelix.utils.LocalKmpContext
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
Expand All @@ -29,11 +27,7 @@ class AppActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
CompositionLocalProvider(
LocalKmpContext provides this
) {
App(MyApplication.appComponent) { finish() }
}
App(MyApplication.appComponent) { finish() }
}
if (savedInstanceState == null) {
handleNewIntent(intent)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ package com.daniebeler.pfpixelix
import android.app.Activity
import android.app.Application
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.work.Configuration
import androidx.work.ListenableWorker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import coil3.SingletonImageLoader
import com.daniebeler.pfpixelix.di.AppComponent
import com.daniebeler.pfpixelix.di.create
import com.daniebeler.pfpixelix.domain.service.file.AndroidFileService
import com.daniebeler.pfpixelix.domain.service.icon.AndroidAppIconManager
import com.daniebeler.pfpixelix.utils.configureLogger
import com.daniebeler.pfpixelix.widget.notifications.work_manager.LatestImageTask
import com.daniebeler.pfpixelix.widget.notifications.work_manager.NotificationsTask
Expand All @@ -23,7 +26,11 @@ class MyApplication : Application(), Configuration.Provider {
get() = Configuration.Builder().setWorkerFactory(workerFactory).build()

override fun onCreate() {
appComponent = AppComponent.create(this)
appComponent = AppComponent.create(
this,
AndroidFileService(this),
AndroidAppIconManager(this)
)
SingletonImageLoader.setSafe {
appComponent.provideImageLoader()
}
Expand All @@ -34,7 +41,7 @@ class MyApplication : Application(), Configuration.Provider {
companion object {
lateinit var appComponent: AppComponent
private set
var currentActivity: WeakReference<Activity>? = null
var currentActivity: WeakReference<ComponentActivity>? = null
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package com.daniebeler.pfpixelix.domain.service.file

import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import android.widget.Toast
import co.touchlab.kermit.Logger
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.toBitmap
import coil3.video.videoFrameMillis
import com.daniebeler.pfpixelix.utils.KmpContext
import com.daniebeler.pfpixelix.utils.KmpUri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import okio.Path
import okio.Path.Companion.toPath
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileNotFoundException

class AndroidFileService(
private val context: KmpContext
) : FileService {
override val dataStoreDir: Path = context.filesDir.path.toPath().resolve("datastore")
override val imageCacheDir: Path = context.cacheDir.path.toPath().resolve("image_cache")

override fun getFile(uri: KmpUri): PlatformFile? {
return AndroidFile(uri, context).takeIf { it.isExist() }
}

override fun downloadFile(name: String?, url: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var uri: Uri? = null
val saveImageRoutine = CoroutineScope(Dispatchers.Default).launch {

val bitmap: Bitmap? = urlToBitmap(url, context)
if (bitmap == null) {
cancel("an error occured when downloading the image")
return@launch
}

println(bitmap.toString())

uri = saveImageToMediaStore(
context,
generateUniqueName(name, false, context),
bitmap!!
)
if (uri == null) {
cancel("an error occured when saving the image")
return@launch
}
}

saveImageRoutine.invokeOnCompletion { throwable ->
CoroutineScope(Dispatchers.Main).launch {
uri?.let {
Toast.makeText(context, "Stored at: " + uri.toString(), Toast.LENGTH_LONG)
.show()
} ?: throwable?.let {
Toast.makeText(
context, "an error occurred downloading the image", Toast.LENGTH_LONG
).show()
}
}
}
}
}

override fun getCacheSizeInBytes(): Long {
return imageCacheDir.toFile().walkBottomUp().fold(0L) { acc, file -> acc + file.length() }
}

override fun cleanCache() {
imageCacheDir.toFile().deleteRecursively()
}

private fun generateUniqueName(
imageName: String?, returnFullPath: Boolean, context: KmpContext
): String {

val filename = "${imageName}_${Clock.System.now().epochSeconds}"

if (returnFullPath) {
val directory: File = context.getDir("zest", Context.MODE_PRIVATE)
return "$directory/$filename"
} else {
return filename
}
}

private suspend fun urlToBitmap(
imageURL: String,
context: KmpContext,
): Bitmap? {
val loader = ImageLoader(context)
val request = ImageRequest.Builder(context).data(imageURL).allowHardware(false).build()
val result = loader.execute(request)
if (result is SuccessResult) {
return result.image.toBitmap()
}
return null
}

private fun saveImageToMediaStore(
context: KmpContext,
displayName: String,
bitmap: Bitmap
): Uri? {
val imageCollections = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
} else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}

val imageDetails = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}

val resolver = context.applicationContext.contentResolver
val imageContentUri = resolver.insert(imageCollections, imageDetails) ?: return null

return try {
resolver.openOutputStream(imageContentUri, "w").use { os ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, os!!)
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
imageDetails.clear()
imageDetails.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(imageContentUri, imageDetails, null, null)
}

imageContentUri
} catch (e: FileNotFoundException) {
// Some legacy devices won't create directory for the Uri if dir not exist, resulting in
// a FileNotFoundException. To resolve this issue, we should use the File API to save the
// image, which allows us to create the directory ourselves.
null
}
}
}

private class AndroidFile(
private val uri: Uri,
private val context: Context
) : PlatformFile {
override fun isExist(): Boolean =
getName() != "AndroidFile:unknown"

override fun getName(): String = when (uri.scheme) {
ContentResolver.SCHEME_FILE -> uri.pathSegments.last().substringBeforeLast('.')
ContentResolver.SCHEME_CONTENT -> context.contentResolver.query(
uri, null, null, null, null
)?.use {
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
it.moveToFirst()
it.getString(nameIndex)
}

else -> null
} ?: "AndroidFile:unknown"

override fun getSize(): Long = when (uri.scheme) {
ContentResolver.SCHEME_FILE -> context.contentResolver.openFileDescriptor(uri, "r")
?.use { it.statSize }

ContentResolver.SCHEME_CONTENT -> context.contentResolver.query(
uri, null, null, null, null
)?.use {
val nameIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.moveToFirst()
it.getLong(nameIndex)
}

else -> null
} ?: 0L

override fun getMimeType(): String = when (uri.scheme) {
ContentResolver.SCHEME_FILE -> {
val fileExtension = MimeTypeMap.getFileExtensionFromUrl(uri.toString())
MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.lowercase())
}

ContentResolver.SCHEME_CONTENT -> {
context.contentResolver.getType(uri)
}

else -> null
} ?: "image/*"

override suspend fun readBytes(): ByteArray = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)!!.readBytes()
}

override suspend fun getThumbnail(): ByteArray? = withContext(Dispatchers.IO) {
val bm = try {
val req = ImageRequest.Builder(context).data(uri).videoFrameMillis(0).build()
val img = SingletonImageLoader.get(context).execute(req)
img.image?.toBitmap()
} catch (e: Exception) {
Logger.e("AndroidFile.getThumbnail error", e)
null
} ?: return@withContext null

val stream = ByteArrayOutputStream()
bm.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.toByteArray()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.daniebeler.pfpixelix.domain.service.icon

import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import co.touchlab.kermit.Logger
import org.jetbrains.compose.resources.DrawableResource
import pixelix.app.generated.resources.Res
import pixelix.app.generated.resources.app_icon_00
import pixelix.app.generated.resources.app_icon_01
import pixelix.app.generated.resources.app_icon_02
import pixelix.app.generated.resources.app_icon_03
import pixelix.app.generated.resources.app_icon_05
import pixelix.app.generated.resources.app_icon_06
import pixelix.app.generated.resources.app_icon_07
import pixelix.app.generated.resources.app_icon_08
import pixelix.app.generated.resources.app_icon_09

class AndroidAppIconManager(
private val context: Context
) : AppIconManager {
private val iconIds = mapOf(
Res.drawable.app_icon_00 to "com.daniebeler.pfpixelix.Icon04",
Res.drawable.app_icon_01 to "com.daniebeler.pfpixelix.Icon01",
Res.drawable.app_icon_02 to "com.daniebeler.pfpixelix.AppActivity",
Res.drawable.app_icon_03 to "com.daniebeler.pfpixelix.Icon03",
Res.drawable.app_icon_05 to "com.daniebeler.pfpixelix.Icon05",
Res.drawable.app_icon_06 to "com.daniebeler.pfpixelix.Icon06",
Res.drawable.app_icon_07 to "com.daniebeler.pfpixelix.Icon07",
Res.drawable.app_icon_08 to "com.daniebeler.pfpixelix.Icon08",
Res.drawable.app_icon_09 to "com.daniebeler.pfpixelix.Icon09",
)

override fun getCurrentIcon(): DrawableResource {
for ((res, id) in iconIds.entries) {
val i = context.packageManager.getComponentEnabledSetting(ComponentName(context, id))
if (i == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
return res
}
}
return Res.drawable.app_icon_02
}

override fun setCustomIcon(icon: DrawableResource) {
try {
val pm = context.packageManager
for ((res, id) in iconIds.entries) {
if (res != icon) {
val i = pm.getComponentEnabledSetting(ComponentName(context, id))
if (i == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
pm.setComponentEnabledSetting(
ComponentName(context, id),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP
)
}
} else {
pm.setComponentEnabledSetting(
ComponentName(context, iconIds[icon]!!),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP
)
}
}
} catch (e: Error) {
Logger.e("enableCustomIcon", e)
}
}
}
Loading