diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index eb909059..4c984a94 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -4,7 +4,11 @@ on: push: branches: [ dev ] workflow_dispatch: - + +permissions: + contents: write + pull-requests: write + jobs: synchronize-with-crowdin: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 2335732c..6e1535aa 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,12 @@ Pixelix is an Android client for [Pixelfed](https://pixelfed.org/), the federated image-sharing social network. It's designed to provide a seamless and high-performance user experience. With Pixelix, you can easily browse, post, and interact with your Pixelfed network on the go. + +Get it on Google Play +Get it on Google Play + Get it on F-Droid Get it on Obtainium banner -Get it on Google Play Get it from IzzyOnDroid ## Please donate diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b2f9b5d8..2b20fed0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import com.google.devtools.ksp.gradle.KspAATask import org.jetbrains.compose.desktop.application.dsl.TargetFormat.* plugins { @@ -93,6 +94,9 @@ kotlin { //image crop implementation(libs.krop) + + //video player + implementation(libs.composemediaplayer) } androidMain.dependencies { @@ -111,12 +115,8 @@ kotlin { implementation(libs.material) //media - implementation(libs.androidx.media3.exoplayer) - implementation(libs.androidx.media3.exoplayer.dash) - implementation(libs.androidx.media3.ui) - implementation(libs.android.image.cropper) - implementation(libs.coil.video) implementation(libs.coil.gif) + implementation(libs.coil.video) // widget implementation(libs.androidx.glance.appwidget) @@ -133,11 +133,7 @@ kotlin { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutines.swing) implementation(libs.ktor.client.okhttp) - implementation(libs.appdirs) implementation(libs.slf4j.simple) - implementation(libs.vlcj) - implementation(libs.jna) - implementation(libs.jna.platform) } } @@ -154,8 +150,8 @@ android { applicationId = "com.daniebeler.pfpixelix" minSdk = 26 targetSdk = 35 - versionCode = 31 - versionName = "4.1.0" + versionCode = 32 + versionName = "4.1.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -183,6 +179,9 @@ android { isDebuggable = false isProfileable = false isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + ) } } packaging.resources { @@ -243,3 +242,8 @@ compose.desktop { } } } + +tasks.configureEach { + if (this is KspAATask && name != "kspCommonMainKotlinMetadata") + dependsOn("kspCommonMainKotlinMetadata") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb434..16f03358 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,5 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile +-dontobfuscate \ No newline at end of file diff --git a/app/src/androidMain/AndroidManifest.xml b/app/src/androidMain/AndroidManifest.xml index 6b3791a3..7250b5a3 100644 --- a/app/src/androidMain/AndroidManifest.xml +++ b/app/src/androidMain/AndroidManifest.xml @@ -41,6 +41,11 @@ + + + + + @@ -64,20 +69,10 @@ - - - - - - - - + + + + { intent.dataString?.let { onExternalUrl(it) } } Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE -> { - val imageUris = handleSharePhotoIntent(intent, contentResolver, cacheDir) + val imageUris = handleSharePhotoIntent(intent, contentResolver, cacheDir, appActivity) if (imageUris.isNotEmpty()) { imageUris.forEach { uri -> try { @@ -85,7 +81,7 @@ actual fun EdgeToEdgeDialogProperties( decorFitsSystemWindows = false ) -private fun saveUriToCache(uri: Uri, contentResolver: ContentResolver, cacheDir: File): Uri? { +private fun saveUriToCache(uri: Uri, contentResolver: ContentResolver, cacheDir: File, appActivity: AppActivity): Uri? { try { val inputStream: InputStream? = contentResolver.openInputStream(uri) inputStream?.use { input -> @@ -93,7 +89,11 @@ private fun saveUriToCache(uri: Uri, contentResolver: ContentResolver, cacheDir: FileOutputStream(file).use { output -> input.copyTo(output) } - return Uri.fromFile(file) // Return the new cached URI + return FileProvider.getUriForFile( + appActivity, + "${appActivity.packageName}.provider", + file + ) } } catch (e: Exception) { e.printStackTrace() @@ -102,7 +102,7 @@ private fun saveUriToCache(uri: Uri, contentResolver: ContentResolver, cacheDir: } private fun handleSharePhotoIntent( - intent: Intent, contentResolver: ContentResolver, cacheDir: File + intent: Intent, contentResolver: ContentResolver, cacheDir: File, appActivity: AppActivity ): List { val action = intent.action val type = intent.type @@ -122,7 +122,7 @@ private fun handleSharePhotoIntent( ) as? Uri } singleUri?.let { uri -> - val cachedUri = saveUriToCache(uri, contentResolver, cacheDir) + val cachedUri = saveUriToCache(uri, contentResolver, cacheDir, appActivity) imageUris = cachedUri?.let { listOf(it) } ?: emptyList() // Wrap single image in a list } @@ -141,7 +141,7 @@ private fun handleSharePhotoIntent( } imageUris = receivedUris?.mapNotNull { saveUriToCache( - it, contentResolver, cacheDir + it, contentResolver, cacheDir, appActivity ) } ?: emptyList() } diff --git a/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/MyApplication.kt b/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/MyApplication.kt index f4d84c86..a050e35b 100644 --- a/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/MyApplication.kt +++ b/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/MyApplication.kt @@ -1,6 +1,5 @@ package com.daniebeler.pfpixelix -import android.app.Activity import android.app.Application import android.content.Context import androidx.activity.ComponentActivity @@ -11,7 +10,6 @@ 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 @@ -28,7 +26,6 @@ class MyApplication : Application(), Configuration.Provider { override fun onCreate() { appComponent = AppComponent.create( this, - AndroidFileService(this), AndroidAppIconManager(this) ) SingletonImageLoader.setSafe { diff --git a/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/domain/service/file/AndroidFileService.kt b/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/domain/service/file/AndroidFileService.kt deleted file mode 100644 index 1a31ef1d..00000000 --- a/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/domain/service/file/AndroidFileService.kt +++ /dev/null @@ -1,226 +0,0 @@ -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 - } - - 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() - } -} \ No newline at end of file diff --git a/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/domain/service/icon/AndroidAppIconManager.kt b/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/domain/service/icon/AndroidAppIconManager.kt index 9b8c4c07..5982aac5 100644 --- a/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/domain/service/icon/AndroidAppIconManager.kt +++ b/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/domain/service/icon/AndroidAppIconManager.kt @@ -22,7 +22,7 @@ class AndroidAppIconManager( 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_02 to "com.daniebeler.pfpixelix.Icon02", 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", @@ -45,22 +45,15 @@ class AndroidAppIconManager( 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 - ) - } + val state = + if (res == icon) PackageManager.COMPONENT_ENABLED_STATE_ENABLED + else PackageManager.COMPONENT_ENABLED_STATE_DISABLED + + pm.setComponentEnabledSetting( + ComponentName(context, id), + state, + PackageManager.DONT_KILL_APP + ) } } catch (e: Error) { Logger.e("enableCustomIcon", e) diff --git a/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.android.kt b/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.android.kt index f6ee25f4..d104a70f 100644 --- a/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.android.kt +++ b/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.android.kt @@ -1,13 +1,10 @@ package com.daniebeler.pfpixelix.domain.service.platform -import android.os.Build - actual object PlatformFeatures { actual val notificationWidgets = true actual val inAppBrowser = true - actual val downloadToGallery = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + actual val downloadToGallery = true actual val customAppIcon = true - actual val autoplayVideosPref = true actual val addCollection = true actual val customAccentColors = false } \ No newline at end of file diff --git a/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.android.kt b/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.android.kt index 3ce9c589..cd476237 100644 --- a/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.android.kt +++ b/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.android.kt @@ -1,16 +1,32 @@ package com.daniebeler.pfpixelix.utils +import android.content.ContentResolver import android.content.Context import android.net.Uri +import android.webkit.MimeTypeMap import androidx.core.net.toUri import coil3.PlatformContext -import io.github.vinceglb.filekit.core.PlatformFile +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.uri actual typealias KmpUri = Uri actual val EmptyKmpUri: KmpUri = Uri.EMPTY actual fun KmpUri.getPlatformUriObject(): Any = this actual fun String.toKmpUri(): KmpUri = this.toUri() actual fun PlatformFile.toKmpUri(): KmpUri = this.uri +actual fun KmpUri.toPlatformFile(): PlatformFile = PlatformFile(this) actual typealias KmpContext = Context actual val KmpContext.coilContext: PlatformContext get() = this +actual fun KmpContext.getMimeType(uri: KmpUri): String = when (uri.scheme) { + ContentResolver.SCHEME_FILE -> { + val fileExtension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) + MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.lowercase()) + } + + ContentResolver.SCHEME_CONTENT -> { + contentResolver.getType(uri) + } + + else -> null +} ?: "image/*" diff --git a/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/utils/VideoPlayer.android.kt b/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/utils/VideoPlayer.android.kt deleted file mode 100644 index 86860231..00000000 --- a/app/src/androidMain/kotlin/com/daniebeler/pfpixelix/utils/VideoPlayer.android.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.daniebeler.pfpixelix.utils - -import androidx.annotation.OptIn -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidView -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.Tracks -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.ui.AspectRatioFrameLayout -import androidx.media3.ui.PlayerView -import com.daniebeler.pfpixelix.MyApplication -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -actual class VideoPlayer actual constructor( - context: KmpContext, - coroutineScope: CoroutineScope -) { - actual var progress: ((current: Long, duration: Long) -> Unit)? = null - actual var hasAudio: ((Boolean) -> Unit)? = null - actual var isVideoPlaying: ((Boolean) -> Unit)? = null - - private val audioAttributes = - AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) - .build() - private val player = ExoPlayer.Builder(context).build().apply { - repeatMode = Player.REPEAT_MODE_ONE - setAudioAttributes(audioAttributes, false) - addListener(object : Player.Listener { - @UnstableApi - override fun onTracksChanged(tracks: Tracks) { - tracks.groups.forEach { trackGroup -> - trackGroup.mediaTrackGroup.let { mediaTrackGroup -> - for (i in 0 until mediaTrackGroup.length) { - val format = mediaTrackGroup.getFormat(i) - if (format.sampleMimeType?.startsWith("audio/") == true) { - hasAudio?.invoke(true) - break - } - } - } - } - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - isVideoPlaying?.invoke(isPlaying) - } - }) - } - - init { - coroutineScope.launch { - while (isActive) { - val duration = player.contentDuration - val currentTime = player.currentPosition - if (duration > 0 && currentTime <= duration) { - progress?.invoke(currentTime, duration) - } - delay(300) - } - } - } - - @OptIn(UnstableApi::class) - @Composable - actual fun view(modifier: Modifier) { - LaunchedEffect(player) { - player.isPlaying - } - AndroidView( - modifier = modifier, - factory = { ctx -> - PlayerView(ctx).apply { - player = this@VideoPlayer.player - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH - setShowPreviousButton(false) - useController = false - } - } - ) - } - - actual fun prepare(url: String) { - val item = MediaItem.fromUri(url) - player.setMediaItem(item) - player.prepare() - } - - actual fun play() { - player.play() - } - - actual fun pause() { - player.pause() - } - - actual fun release() { - player.release() - } - - actual fun audio(enable: Boolean) { - player.volume = if (enable) 1f else 0f - player.setAudioAttributes(audioAttributes, enable) - } -} \ No newline at end of file diff --git a/app/src/androidMain/res/xml/file_paths.xml b/app/src/androidMain/res/xml/file_paths.xml new file mode 100644 index 00000000..8ad3114d --- /dev/null +++ b/app/src/androidMain/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/commonMain/composeResources/values-en-rUS/strings.xml b/app/src/commonMain/composeResources/values-en-rUS/strings.xml index 260b83ab..706fd559 100644 --- a/app/src/commonMain/composeResources/values-en-rUS/strings.xml +++ b/app/src/commonMain/composeResources/values-en-rUS/strings.xml @@ -66,7 +66,7 @@ yearly monthly daily - I don\'t have a profile + I don’t have a profile Server URL Are you sure you want to log out? Logout? diff --git a/app/src/commonMain/composeResources/values-pl-rPL/strings.xml b/app/src/commonMain/composeResources/values-pl-rPL/strings.xml index 7345ef66..f08a3434 100644 --- a/app/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/app/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -298,5 +298,5 @@ Scam Terroryzm Zgłoszono - Delete Account + Usuń konto diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/App.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/App.kt index edf6c469..bb1612a5 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/App.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/App.kt @@ -2,7 +2,6 @@ package com.daniebeler.pfpixelix import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues @@ -14,6 +13,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.UnfoldMore import androidx.compose.material3.DrawerValue @@ -27,15 +28,15 @@ import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -54,7 +55,6 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import co.touchlab.kermit.Logger import coil3.compose.AsyncImage import com.daniebeler.pfpixelix.di.AppComponent import com.daniebeler.pfpixelix.di.LocalAppComponent @@ -89,6 +89,10 @@ import pixelix.app.generated.resources.profile import pixelix.app.generated.resources.search import pixelix.app.generated.resources.search_outline +val LocalSnackbarPresenter = compositionLocalOf<(String) -> Unit> { + error("No LocalSnackbarPresenter provided") +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun App( @@ -107,12 +111,6 @@ fun App( LocalAppComponent provides appComponent ) { PixelixTheme { - val navController = rememberNavController() - val scope = rememberCoroutineScope() - val drawerState = rememberDrawerState(DrawerValue.Closed) - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var showAccountSwitchBottomSheet by remember { mutableStateOf(false) } - var activeUser by remember { mutableStateOf("unknown") } LaunchedEffect(Unit) { val authService = appComponent.authService @@ -123,86 +121,107 @@ fun App( } if (activeUser == "unknown") return@PixelixTheme - ReverseModalNavigationDrawer( - gesturesEnabled = drawerState.isOpen, - drawerState = drawerState, - drawerContent = { - ModalDrawerSheet( - drawerState = drawerState, - drawerShape = shapes.extraLarge.end(0.dp), - ) { - PreferencesComposable(navController, drawerState, { - scope.launch { - drawerState.close() - } - }) + key(activeUser) { + val scope = rememberCoroutineScope() + val drawerState = rememberDrawerState(DrawerValue.Closed) + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showAccountSwitchBottomSheet by remember { mutableStateOf(false) } + val navController = rememberNavController() + + val snackbarHostState = remember { SnackbarHostState() } + val snackBarPresenter: (String) -> Unit = { msg -> + scope.launch { + snackbarHostState.showSnackbar(msg) } } - ) { - Scaffold( - contentWindowInsets = WindowInsets(0), - bottomBar = { - BottomBar( - navController = navController, - openAccountSwitchBottomSheet = { - showAccountSwitchBottomSheet = true + //Note that wrapping something in key + // won't actually clean up any ViewModel instances associated with destinations - + // they'll continue to exist and run for the entire lifetime of the containing + // Activity/Fragment because you didn't actually destroy them properly, + // you just dropped any access to them + LaunchedEffect(activeUser) { + navController.clearBackStack() + navController.clearBackStack() + navController.clearBackStack() + navController.clearBackStack() + navController.clearBackStack() + } + + CompositionLocalProvider( + LocalSnackbarPresenter provides snackBarPresenter + ) { + ReverseModalNavigationDrawer( + gesturesEnabled = drawerState.isOpen, + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet( + drawerState = drawerState, + drawerShape = shapes.extraLarge.end(0.dp), + ) { + PreferencesComposable(navController, drawerState, { + scope.launch { + drawerState.close() + } + }) + } + } + ) { + Scaffold( + contentWindowInsets = WindowInsets(0), + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { + BottomBar( + navController = navController, + openAccountSwitchBottomSheet = { + showAccountSwitchBottomSheet = true + }, + ) }, - ) - }, - content = { paddingValues -> - val startDestination = - if (activeUser == null) Destination.FirstLogin - else Destination.HomeTabFeeds - NavHost( - modifier = Modifier.fillMaxSize().padding(paddingValues) - .consumeWindowInsets(WindowInsets.navigationBars), - navController = navController, - startDestination = startDestination, - builder = { - appGraph( - navController, - { scope.launch { drawerState.open() } }, - exitApp + content = { paddingValues -> + val startDestination = + if (activeUser == null) Destination.FirstLogin + else Destination.HomeTabFeeds + NavHost( + modifier = Modifier.fillMaxSize().padding(paddingValues) + .consumeWindowInsets(WindowInsets.navigationBars), + navController = navController, + startDestination = startDestination, + builder = { + appGraph( + navController, + { scope.launch { drawerState.open() } }, + exitApp + ) + } ) } ) - val launchUser = remember { activeUser } - LaunchedEffect(activeUser) { - if (launchUser == activeUser) return@LaunchedEffect - val rootScreen = - if (activeUser == null) Destination.FirstLogin else Destination.HomeTabFeeds - navController.navigate(rootScreen) { - val root = navController.currentBackStack.value - .firstOrNull { it.destination.route != null } - ?.destination?.route - if (root != null) { - popUpTo(root) { inclusive = true } - } - } + } + } - if (activeUser != null) { - appComponent.systemFileShare.shareFilesRequests.collect { uris -> - navController.navigate( - Destination.NewPost(uris.map { it.toString() }) - ) - } - } + LaunchedEffect(Unit) { + appComponent.systemFileShare.shareFilesRequests.collect { uris -> + if (activeUser != null) { + navController.navigate( + Destination.NewPost(uris.map { it.toString() }) + ) } } - ) - } - if (showAccountSwitchBottomSheet) { - ModalBottomSheet( - onDismissRequest = { - showAccountSwitchBottomSheet = false - }, sheetState = sheetState - ) { - AccountSwitchBottomSheet( - navController = navController, - closeBottomSheet = { showAccountSwitchBottomSheet = false }, - null - ) + } + + if (showAccountSwitchBottomSheet) { + ModalBottomSheet( + onDismissRequest = { + showAccountSwitchBottomSheet = false + }, sheetState = sheetState + ) { + AccountSwitchBottomSheet( + navController = navController, + closeBottomSheet = { showAccountSwitchBottomSheet = false }, + null + ) + } } } } diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/di/AppComponent.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/di/AppComponent.kt index 60378d82..12290f09 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/di/AppComponent.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/di/AppComponent.kt @@ -15,6 +15,7 @@ import com.daniebeler.pfpixelix.domain.repository.PixelfedApi import com.daniebeler.pfpixelix.domain.repository.createPixelfedApi import com.daniebeler.pfpixelix.domain.repository.serializers.SavedSearchesSerializer import com.daniebeler.pfpixelix.domain.service.file.FileService +import com.daniebeler.pfpixelix.domain.service.file.toOkIoPath import com.daniebeler.pfpixelix.domain.service.icon.AppIconManager import com.daniebeler.pfpixelix.domain.service.preferences.UserPreferences import com.daniebeler.pfpixelix.domain.service.search.SearchFieldFocus @@ -32,6 +33,8 @@ import com.russhwolf.settings.ExperimentalSettingsImplementation import com.russhwolf.settings.datastore.DataStoreSettings import de.jensklingenberg.ktorfit.Ktorfit import de.jensklingenberg.ktorfit.converter.CallConverterFactory +import io.github.vinceglb.filekit.resolve +import io.github.vinceglb.filekit.toKotlinxIoPath import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpSend import io.ktor.client.plugins.HttpTimeout @@ -56,7 +59,6 @@ annotation class AppSingleton @Component abstract class AppComponent( @get:Provides val context: KmpContext, - @get:Provides val fileService: FileService, @get:Provides val iconManager: AppIconManager, ) { abstract val systemUrlHandler: SystemUrlHandler @@ -88,7 +90,7 @@ abstract class AppComponent( logger = object : io.ktor.client.plugins.logging.Logger { override fun log(message: String) { Logger.v("Pixelix HttpClient") { - message.lines().joinToString { "\n\t\t$it"} + message.lines().joinToString { "\n\t\t$it" } } } } @@ -121,7 +123,9 @@ abstract class AppComponent( PreferenceDataStoreFactory.createWithPath( corruptionHandler = null, migrations = emptyList(), - produceFile = { fileService.dataStoreDir.resolve("settings.preferences_pb") }, + produceFile = { + FileService.dataStoreDir.resolve("settings.preferences_pb").toOkIoPath() + }, ) @Provides @@ -130,7 +134,9 @@ abstract class AppComponent( DataStoreFactory.create( storage = OkioStorage( fileSystem = FileSystem.SYSTEM, - producePath = { fileService.dataStoreDir.resolve("saved_searches.json") }, + producePath = { + FileService.dataStoreDir.resolve("saved_searches.json").toOkIoPath() + }, serializer = SavedSearchesSerializer, ) ) @@ -141,7 +147,9 @@ abstract class AppComponent( DataStoreFactory.create( storage = OkioStorage( fileSystem = FileSystem.SYSTEM, - producePath = { fileService.dataStoreDir.resolve("session_storage_datastore.json") }, + producePath = { + FileService.dataStoreDir.resolve("session_storage_datastore.json").toOkIoPath() + }, serializer = SessionStorageDataSerializer, ) ) @@ -165,7 +173,7 @@ abstract class AppComponent( .diskCache( DiskCache.Builder() .maxSizeBytes(50L * 1024L * 1024L) - .directory(fileService.imageCacheDir) + .directory(FileService.imageCacheDir.toOkIoPath()) .build() ) .build() @@ -176,6 +184,5 @@ abstract class AppComponent( @KmpComponentCreate expect fun AppComponent.Companion.create( context: KmpContext, - fileService: FileService, iconManager: AppIconManager, ): AppComponent \ No newline at end of file diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/domain/service/editor/PostEditorService.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/domain/service/editor/PostEditorService.kt index d72947f4..d7840aa0 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/domain/service/editor/PostEditorService.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/domain/service/editor/PostEditorService.kt @@ -4,8 +4,15 @@ import com.daniebeler.pfpixelix.domain.model.NewPost import com.daniebeler.pfpixelix.domain.model.UpdatePost import com.daniebeler.pfpixelix.domain.repository.PixelfedApi import com.daniebeler.pfpixelix.domain.service.file.FileService +import com.daniebeler.pfpixelix.domain.service.file.PlatformFile import com.daniebeler.pfpixelix.domain.service.utils.loadResource import com.daniebeler.pfpixelix.utils.KmpUri +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.ImageFormat +import io.github.vinceglb.filekit.compressImage +import io.github.vinceglb.filekit.exists +import io.github.vinceglb.filekit.nameWithoutExtension +import io.github.vinceglb.filekit.readBytes import io.ktor.client.request.forms.MultiPartFormDataContent import io.ktor.client.request.forms.formData import io.ktor.http.Headers @@ -21,16 +28,26 @@ class PostEditorService( ) { fun uploadMedia(uri: KmpUri, description: String) = loadResource { - val file = fileService.getFile(uri) ?: error("File doesn't exist") + val file = PlatformFile(uri) + if (!file.exists()) error("File doesn't exist") val bytes = file.readBytes() - val thumbnail = file.getThumbnail() + val mimeType = fileService.getMimeType(file) + val thumbnail = if (mimeType.startsWith("image")) { + FileKit.compressImage( + bytes = bytes, + quality = 85, + maxWidth = 400, + maxHeight = 400, + imageFormat = ImageFormat.PNG + ) + } else null val data = MultiPartFormDataContent( parts = formData { append("description", description) append("file", bytes, Headers.build { - append(HttpHeaders.ContentType, file.getMimeType()) - append(HttpHeaders.ContentDisposition, "filename=${file.getName()}") + append(HttpHeaders.ContentType, mimeType) + append(HttpHeaders.ContentDisposition, "filename=${file.nameWithoutExtension}") }) if (thumbnail != null) { append("thumbnail", thumbnail, Headers.build { diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/domain/service/file/FileService.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/domain/service/file/FileService.kt index d08fb0cb..6e88a56e 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/domain/service/file/FileService.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/domain/service/file/FileService.kt @@ -1,23 +1,82 @@ package com.daniebeler.pfpixelix.domain.service.file +import co.touchlab.kermit.Logger +import com.daniebeler.pfpixelix.utils.KmpContext import com.daniebeler.pfpixelix.utils.KmpUri +import com.daniebeler.pfpixelix.utils.getMimeType +import com.daniebeler.pfpixelix.utils.toKmpUri +import com.daniebeler.pfpixelix.utils.toPlatformFile +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.cacheDir +import io.github.vinceglb.filekit.delete +import io.github.vinceglb.filekit.exists +import io.github.vinceglb.filekit.filesDir +import io.github.vinceglb.filekit.isRegularFile +import io.github.vinceglb.filekit.list +import io.github.vinceglb.filekit.path +import io.github.vinceglb.filekit.resolve +import io.github.vinceglb.filekit.saveImageToGallery +import io.github.vinceglb.filekit.size +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsBytes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import me.tatarka.inject.annotations.Inject import okio.Path +import okio.Path.Companion.toPath -interface FileService { - val dataStoreDir: Path - val imageCacheDir: Path +@Inject +class FileService( + private val context: KmpContext, + private val httpClient: HttpClient +) { + companion object { + val dataStoreDir = FileKit.filesDir.resolve("datastore") + val imageCacheDir = FileKit.cacheDir.resolve("image_cache") + } + private val client = httpClient.config { followRedirects = true } - fun getFile(uri: KmpUri): PlatformFile? - fun downloadFile(name: String?, url: String) - fun getCacheSizeInBytes(): Long - fun cleanCache() + suspend fun getCacheSizeInBytes(): Long = imageCacheDir.sizeRecursively() + suspend fun cleanCache() { + imageCacheDir.deleteRecursively() + } + + suspend fun download(url: String) { + with(Dispatchers.IO) { + val bytes = client.get(url).bodyAsBytes() + val name = url.substringAfterLast('/') + Logger.d { "Downloading: $name" } + FileKit.saveImageToGallery(bytes, name) + } + } + + fun getMimeType(file: PlatformFile): String = context.getMimeType(file.toKmpUri()) + + private suspend fun PlatformFile.sizeRecursively(): Long { + return when { + !exists() -> 0L + isRegularFile() -> size() + else -> list().sumOf { it.sizeRecursively() } + } + } + + private suspend fun PlatformFile.deleteRecursively() { + when { + !exists() -> { + return + } + isRegularFile() -> { + delete(false) + } + else -> { + list().forEach { it.deleteRecursively() } + delete(false) + } + } + } } -interface PlatformFile { - fun isExist(): Boolean - fun getName(): String - fun getSize(): Long - fun getMimeType(): String - suspend fun readBytes(): ByteArray - suspend fun getThumbnail(): ByteArray? -} \ No newline at end of file +internal fun PlatformFile(kmpUri: KmpUri): PlatformFile = kmpUri.toPlatformFile() +internal fun PlatformFile.toOkIoPath(): Path = path.toPath() diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.kt index 53abb5dd..259b43d8 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.kt @@ -5,7 +5,6 @@ expect object PlatformFeatures { val inAppBrowser: Boolean val downloadToGallery: Boolean val customAppIcon: Boolean - val autoplayVideosPref: Boolean val addCollection: Boolean val customAccentColors: Boolean } \ No newline at end of file diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/edit_profile/EditProfileComposable.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/edit_profile/EditProfileComposable.kt index cb4c14be..846fabb5 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/edit_profile/EditProfileComposable.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/edit_profile/EditProfileComposable.kt @@ -66,9 +66,10 @@ import com.attafitamim.krop.ui.DefaultControls import com.daniebeler.pfpixelix.EdgeToEdgeDialogProperties import com.daniebeler.pfpixelix.di.injectViewModel import com.daniebeler.pfpixelix.utils.imeAwareInsets -import io.github.vinceglb.filekit.compose.rememberFilePickerLauncher -import io.github.vinceglb.filekit.core.PickerMode -import io.github.vinceglb.filekit.core.PickerType +import io.github.vinceglb.filekit.dialogs.FileKitMode +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher +import io.github.vinceglb.filekit.readBytes import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource @@ -182,7 +183,7 @@ fun EditProfileComposable( } val filePicker = rememberFilePickerLauncher( - type = PickerType.Image, mode = PickerMode.Single + type = FileKitType.Image, mode = FileKitMode.Single ) { file -> file ?: return@rememberFilePickerLauncher coroutineScope.launch { diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/newpost/NewPostComposable.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/newpost/NewPostComposable.kt index 29e2d839..34043e74 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/newpost/NewPostComposable.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/newpost/NewPostComposable.kt @@ -79,9 +79,9 @@ import com.daniebeler.pfpixelix.utils.KmpUri import com.daniebeler.pfpixelix.utils.getPlatformUriObject import com.daniebeler.pfpixelix.utils.imeAwareInsets import com.daniebeler.pfpixelix.utils.toKmpUri -import io.github.vinceglb.filekit.compose.rememberFilePickerLauncher -import io.github.vinceglb.filekit.core.PickerMode -import io.github.vinceglb.filekit.core.PickerType +import io.github.vinceglb.filekit.dialogs.FileKitMode +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource @@ -314,7 +314,7 @@ fun ImagesPager( Spacer(Modifier.height(48.dp)) val launcher = rememberFilePickerLauncher( - type = PickerType.ImageAndVideo, mode = PickerMode.Multiple() + type = FileKitType.ImageAndVideo, mode = FileKitMode.Multiple() ) { files -> files?.forEach { file -> addImage(file.toKmpUri()) diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/newpost/NewPostViewModel.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/newpost/NewPostViewModel.kt index e8679d61..56c5cba3 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/newpost/NewPostViewModel.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/newpost/NewPostViewModel.kt @@ -12,11 +12,13 @@ import com.daniebeler.pfpixelix.domain.model.NewPost import com.daniebeler.pfpixelix.domain.model.Visibility import com.daniebeler.pfpixelix.domain.service.editor.PostEditorService import com.daniebeler.pfpixelix.domain.service.file.FileService +import com.daniebeler.pfpixelix.domain.service.file.PlatformFile import com.daniebeler.pfpixelix.domain.service.instance.InstanceService import com.daniebeler.pfpixelix.domain.service.utils.Resource import com.daniebeler.pfpixelix.ui.navigation.Destination -import com.daniebeler.pfpixelix.utils.EmptyKmpUri import com.daniebeler.pfpixelix.utils.KmpUri +import io.github.vinceglb.filekit.exists +import io.github.vinceglb.filekit.size import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.flowOn @@ -96,8 +98,9 @@ class NewPostViewModel @Inject constructor( } fun addImage(uri: KmpUri) { - val file = fileService.getFile(uri) ?: return - val fileType = file.getMimeType() + val file = PlatformFile(uri) + if (!file.exists()) return + val fileType = fileService.getMimeType(file) if (instance != null && !instance!!.configuration.mediaAttachmentConfig.supportedMimeTypes.contains( fileType ) @@ -108,7 +111,7 @@ class NewPostViewModel @Inject constructor( ) return } - val size = file.getSize() + val size = file.size() if (fileType.take(5) == "image") { if (instance != null && size > instance!!.configuration.mediaAttachmentConfig.imageSizeLimit) { @@ -284,8 +287,13 @@ class NewPostViewModel @Inject constructor( postEditorService.createPost(createPostDto).onEach { result -> createPostState = when (result) { is Resource.Success -> { - navController.navigate(Destination.OwnProfile) - CreatePostState(post = result.data, isLoading = true) + navController.navigate(Destination.HomeTabOwnProfile) { + restoreState = false + popUpTo { + inclusive = true + } + } + CreatePostState() } is Resource.Error -> { diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/notifications/CustomNotification.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/notifications/CustomNotification.kt index 710639eb..2682f648 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/notifications/CustomNotification.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/notifications/CustomNotification.kt @@ -171,10 +171,10 @@ fun CustomNotification( navController.navigate( Destination.Post( id = if (doesMediaAttachmentExsist) { - notification.account.id + notification.post!!.id } else { viewModel.ancestor!!.id - }, openReplies = true + }, openReplies = !doesMediaAttachmentExsist ) ) }) diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/PostComposable.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/PostComposable.kt index 2bbb9827..0812eb7a 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/PostComposable.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/PostComposable.kt @@ -617,7 +617,8 @@ fun PostComposable( viewModel, post, pagerState.currentPage, - navController + navController, + { showBottomSheet = 0 } ) } else { ShareBottomSheet( @@ -626,7 +627,8 @@ fun PostComposable( viewModel, post, pagerState.currentPage, - navController + navController, + { showBottomSheet = 0 } ) } } else if (showBottomSheet == 3) { @@ -729,7 +731,7 @@ fun PostImage( showMediaDialog = mediaAttachment }) }) { - if (mediaAttachment.type == "image") { + if (mediaAttachment.type != "video") { ImageWrapper( mediaAttachment, { zoomState.setContentSize(it.painter.intrinsicSize) }, @@ -846,7 +848,7 @@ fun MediaDialog( contentAlignment = Alignment.Center ) { Box(modifier = Modifier.zIndex(2f).zoomable(zoomState).clickable { }) { - if (mediaAttachment.type == "image") { + if (mediaAttachment.type != "video") { ImageWrapper( mediaAttachment, { zoomState.setContentSize(it.painter.intrinsicSize) }, diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/PostViewModel.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/PostViewModel.kt index ce250f83..f1f3e2d1 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/PostViewModel.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/PostViewModel.kt @@ -433,8 +433,10 @@ class PostViewModel @Inject constructor( platform.openUrl(url) } - fun saveImage(name: String?, url: String) { - fileService.downloadFile(name, url) + fun saveImage(url: String) { + viewModelScope.launch { + fileService.download(url) + } } fun shareText(text: String) { diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/ShareBottomSheet.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/ShareBottomSheet.kt index 20aacb7f..47434c46 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/ShareBottomSheet.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/ShareBottomSheet.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import com.daniebeler.pfpixelix.LocalSnackbarPresenter import com.daniebeler.pfpixelix.domain.model.MediaAttachment import com.daniebeler.pfpixelix.domain.model.Post import com.daniebeler.pfpixelix.domain.model.Visibility @@ -42,13 +43,13 @@ import pixelix.app.generated.resources.license import pixelix.app.generated.resources.open_in_browser import pixelix.app.generated.resources.open_outline import pixelix.app.generated.resources.pencil_outline +import pixelix.app.generated.resources.report_this_post import pixelix.app.generated.resources.share_social_outline import pixelix.app.generated.resources.share_this_post import pixelix.app.generated.resources.trash_outline import pixelix.app.generated.resources.unlisted import pixelix.app.generated.resources.visibility_x import pixelix.app.generated.resources.warning -import pixelix.app.generated.resources.report_this_post @Composable fun ShareBottomSheet( @@ -57,7 +58,8 @@ fun ShareBottomSheet( viewModel: PostViewModel, post: Post, currentMediaAttachmentNumber: Int, - navController: NavController + navController: NavController, + closeBottomSheet: () -> Unit ) { var humanReadableVisibility by remember { @@ -106,6 +108,7 @@ fun ShareBottomSheet( Res.string.license, mediaAttachment.license.title ), onClick = { viewModel.openUrl(mediaAttachment.license.url) + closeBottomSheet() }) } @@ -116,6 +119,7 @@ fun ShareBottomSheet( Res.string.open_in_browser ), onClick = { viewModel.openUrl(url) + closeBottomSheet() }) ButtonRowElement( @@ -123,19 +127,23 @@ fun ShareBottomSheet( text = stringResource(Res.string.share_this_post), onClick = { viewModel.shareText(url) + closeBottomSheet() }) - if (mediaAttachment != null && PlatformFeatures.downloadToGallery && mediaAttachment.type == "image") { + if ( + PlatformFeatures.downloadToGallery && + mediaAttachment?.url != null + ) { + val snackbarPresenter = LocalSnackbarPresenter.current ButtonRowElement( icon = Res.drawable.cloud_download_outline, text = stringResource(Res.string.download_image), onClick = { - - viewModel.saveImage( - post.account.username, - viewModel.post!!.mediaAttachments[currentMediaAttachmentNumber].url!! - ) - }) + viewModel.saveImage(mediaAttachment.url) + snackbarPresenter("Image saved to the gallery") + closeBottomSheet() + } + ) } if (minePost) { diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/VideoAttachment.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/VideoAttachment.kt index 7b6bbf7b..74f20175 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/VideoAttachment.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/post/VideoAttachment.kt @@ -19,10 +19,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -30,9 +28,9 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import com.daniebeler.pfpixelix.di.LocalAppComponent import com.daniebeler.pfpixelix.domain.model.MediaAttachment -import com.daniebeler.pfpixelix.utils.VideoPlayer +import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface +import io.github.kdroidfilter.composemediaplayer.rememberVideoPlayerState @Composable fun VideoAttachment( @@ -40,24 +38,26 @@ fun VideoAttachment( viewModel: PostViewModel, onReady: () -> Unit ) { - val coroutineScope = rememberCoroutineScope() - val context = LocalAppComponent.current.context - val player = remember { VideoPlayer(context, coroutineScope) } - var progress by remember { mutableFloatStateOf(0f) } - var hasAudio by remember { mutableStateOf(false) } - var isPlaying by remember { mutableStateOf(false) } + val player = rememberVideoPlayerState().apply { + loop = true + userDragging = false + } + LaunchedEffect(attachment) { + player.openUri(attachment.url.orEmpty()) + } var videoFrameIsVisible by remember { mutableStateOf(false) } Column { Box(Modifier.clickable { - if (isPlaying) { + if (player.isPlaying) { player.pause() } else { player.play() } }) { - player.view( + VideoPlayerSurface( + playerState = player, modifier = Modifier .fillMaxWidth() .run { @@ -66,6 +66,7 @@ fun VideoAttachment( } .isVisible(threshold = 50) { videoFrameIsVisible = it } ) + val hasAudio = (player.metadata.audioChannels ?: 0) > 0 if (hasAudio) { IconButton( modifier = Modifier @@ -93,38 +94,22 @@ fun VideoAttachment( } } LinearProgressIndicator( - progress = { progress }, + progress = { player.sliderPos / 1000 }, modifier = Modifier.fillMaxWidth(), trackColor = MaterialTheme.colorScheme.background ) } - LaunchedEffect(attachment) { - player.prepare(attachment.url.orEmpty()) - } - val started = progress > 0 - LaunchedEffect(started) { onReady() } + val started = player.sliderPos > 0 + LaunchedEffect(started) { if (started) onReady() } LaunchedEffect(viewModel.volume) { - player.audio(viewModel.volume) - } - - DisposableEffect(Unit) { - player.progress = { current, duration -> - progress = current.toFloat() / duration.toFloat() - } - player.hasAudio = { hasAudio = it } - player.isVideoPlaying = { isPlaying = it } - - onDispose { - player.progress = null - player.hasAudio = null - player.release() - } + player.volume = if (viewModel.volume) 1f else 0f } - LaunchedEffect(videoFrameIsVisible) { - if (videoFrameIsVisible && viewModel.isAutoplayVideos) { + val autoPlay = videoFrameIsVisible && viewModel.isAutoplayVideos + LaunchedEffect(autoPlay) { + if (autoPlay) { player.play() } else { player.pause() diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/session/LoginViewModel.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/session/LoginViewModel.kt index a0c15234..3a196db9 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/session/LoginViewModel.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/session/LoginViewModel.kt @@ -6,18 +6,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel import com.daniebeler.pfpixelix.domain.model.Server import com.daniebeler.pfpixelix.domain.service.instance.InstanceService import com.daniebeler.pfpixelix.domain.service.platform.Platform import com.daniebeler.pfpixelix.domain.service.session.AuthService import com.daniebeler.pfpixelix.domain.service.utils.Resource -import com.daniebeler.pfpixelix.ui.composables.settings.about_instance.InstanceState import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import me.tatarka.inject.annotations.Inject -import pixelix.app.generated.resources.Res @Inject class LoginViewModel( diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/icon_selection/IconSelectionComposable.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/icon_selection/IconSelectionComposable.kt index 280fde85..e9e3ea4b 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/icon_selection/IconSelectionComposable.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/icon_selection/IconSelectionComposable.kt @@ -96,17 +96,6 @@ fun IconSelectionComposable( state = lazyGridState, columns = GridCells.Fixed(3) ) { - item(span = { GridItemSpan(3) }) { - Column { - Row { - Text(text = stringResource(Res.string.two_icons_info)) - } - - HorizontalDivider(Modifier.padding(vertical = 12.dp)) - } - - } - items(viewModel.icons) { icon -> Image( painterResource(icon), diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/preferences/prefs/PreferencesComposable.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/preferences/prefs/PreferencesComposable.kt index 8db93f34..ab1083e2 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/preferences/prefs/PreferencesComposable.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/preferences/prefs/PreferencesComposable.kt @@ -97,9 +97,7 @@ fun PreferencesComposable( UseInAppBrowserPref() } - if (PlatformFeatures.autoplayVideosPref) { - AutoplayVideoPref() - } + AutoplayVideoPref() RepostSettingsPref { viewModel.openRepostSettings() } diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/preferences/prefs/prefs/ClearCachePref.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/preferences/prefs/prefs/ClearCachePref.kt index 6e484bd4..7494e946 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/preferences/prefs/prefs/ClearCachePref.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/preferences/prefs/prefs/ClearCachePref.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daniebeler.pfpixelix.di.injectViewModel import com.daniebeler.pfpixelix.ui.composables.settings.preferences.basic.SettingPref import org.jetbrains.compose.resources.stringResource @@ -15,10 +16,10 @@ import pixelix.app.generated.resources.save_outline @Composable fun ClearCachePref(drawerState: DrawerState) { val viewModel = injectViewModel("ClearCacheViewModel") { clearCacheViewModel } - val cacheSize = remember { mutableStateOf("") } + val cacheSize = viewModel.cacheSize.collectAsStateWithLifecycle("") LaunchedEffect(drawerState.isOpen) { - cacheSize.value = humanReadableByteCountSI(viewModel.getCacheSizeInBytes()) + viewModel.refresh() } SettingPref( @@ -26,25 +27,6 @@ fun ClearCachePref(drawerState: DrawerState) { title = stringResource(Res.string.clear_cache), desc = cacheSize.value, trailingContent = null, - onClick = { - viewModel.cleanCache() - cacheSize.value = humanReadableByteCountSI(viewModel.getCacheSizeInBytes()) - } + onClick = { viewModel.cleanCache() } ) } - -private fun humanReadableByteCountSI(bytes: Long): String { - var bytes = bytes - if (-1000 < bytes && bytes < 1000) { - return "$bytes B" - } - val chars = "kMGTPE".toCharArray() - var ci = 0 - while (bytes <= -999950 || bytes >= 999950) { - bytes /= 1000 - ci++ - } - - val valueRounded = (bytes / 100.0).toInt() / 10.0 // Round down to one decimal place - return "$valueRounded ${chars[ci]}B" -} diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/preferences/prefs/prefs/ClearCacheViewModel.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/preferences/prefs/prefs/ClearCacheViewModel.kt index 24320948..9fb22ff2 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/preferences/prefs/prefs/ClearCacheViewModel.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/ui/composables/settings/preferences/prefs/prefs/ClearCacheViewModel.kt @@ -1,15 +1,53 @@ package com.daniebeler.pfpixelix.ui.composables.settings.preferences.prefs.prefs import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.daniebeler.pfpixelix.domain.service.file.FileService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch import me.tatarka.inject.annotations.Inject @Inject class ClearCacheViewModel( private val fileService: FileService -): ViewModel() { - fun getCacheSizeInBytes() = fileService.getCacheSizeInBytes() +) : ViewModel() { + + private val cacheSizeState = MutableStateFlow(0L) + val cacheSize = cacheSizeState.onStart { + cacheSizeState.value = fileService.getCacheSizeInBytes() + }.map { bytes -> + humanReadableByteCountSI(bytes) + } + + fun refresh() { + viewModelScope.launch { + cacheSizeState.value = fileService.getCacheSizeInBytes() + } + } + fun cleanCache() { - fileService.cleanCache() + viewModelScope.launch { + fileService.cleanCache() + cacheSizeState.value = fileService.getCacheSizeInBytes() + } + } + + private fun humanReadableByteCountSI(bytes: Long): String { + var bytes = bytes + if (-1000 < bytes && bytes < 1000) { + return "$bytes B" + } + val chars = "kMGTPE".toCharArray() + var ci = 0 + while (bytes <= -999950 || bytes >= 999950) { + bytes /= 1000 + ci++ + } + + val valueRounded = (bytes / 100.0).toInt() / 10.0 // Round down to one decimal place + return "$valueRounded ${chars[ci]}B" } } \ No newline at end of file diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.kt index f6a4e070..59021680 100644 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.kt +++ b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.kt @@ -1,7 +1,7 @@ package com.daniebeler.pfpixelix.utils import coil3.PlatformContext -import io.github.vinceglb.filekit.core.PlatformFile +import io.github.vinceglb.filekit.PlatformFile expect abstract class KmpUri { abstract override fun toString(): String @@ -10,7 +10,9 @@ expect val EmptyKmpUri: KmpUri expect fun KmpUri.getPlatformUriObject(): Any expect fun String.toKmpUri(): KmpUri expect fun PlatformFile.toKmpUri(): KmpUri +expect fun KmpUri.toPlatformFile(): PlatformFile expect abstract class KmpContext expect val KmpContext.coilContext: PlatformContext +expect fun KmpContext.getMimeType(uri: KmpUri): String diff --git a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/utils/VideoPlayer.kt b/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/utils/VideoPlayer.kt deleted file mode 100644 index 588d321d..00000000 --- a/app/src/commonMain/kotlin/com/daniebeler/pfpixelix/utils/VideoPlayer.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.daniebeler.pfpixelix.utils - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import kotlinx.coroutines.CoroutineScope - -expect class VideoPlayer( - context: KmpContext, - coroutineScope: CoroutineScope -) { - var progress: ((current: Long, duration: Long) -> Unit)? - var hasAudio: ((Boolean) -> Unit)? - var isVideoPlaying: ((Boolean) -> Unit)? - - @Composable - fun view(modifier: Modifier) - - fun prepare(url: String) - fun play() - fun pause() - fun release() - fun audio(enable: Boolean) -} \ No newline at end of file diff --git a/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/AppViewController.kt b/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/AppViewController.kt index 9b796e37..30eb5481 100644 --- a/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/AppViewController.kt +++ b/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/AppViewController.kt @@ -7,7 +7,6 @@ import androidx.compose.ui.window.ComposeUIViewController import coil3.SingletonImageLoader import com.daniebeler.pfpixelix.di.AppComponent import com.daniebeler.pfpixelix.di.create -import com.daniebeler.pfpixelix.domain.service.file.IosFileService import com.daniebeler.pfpixelix.domain.service.icon.IosAppIconManager import com.daniebeler.pfpixelix.utils.KmpContext import com.daniebeler.pfpixelix.utils.configureLogger @@ -27,7 +26,6 @@ fun AppViewController(urlCallback: IosUrlCallback): UIViewController { object : KmpContext() { override val viewController get() = viewController!! }, - IosFileService(), IosAppIconManager() ) diff --git a/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/domain/service/file/IosFileService.kt b/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/domain/service/file/IosFileService.kt deleted file mode 100644 index 6b7fda13..00000000 --- a/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/domain/service/file/IosFileService.kt +++ /dev/null @@ -1,175 +0,0 @@ -package com.daniebeler.pfpixelix.domain.service.file - -import com.daniebeler.pfpixelix.utils.KmpUri -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.get -import kotlinx.cinterop.refTo -import kotlinx.cinterop.usePinned -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.withContext -import okio.Path -import okio.Path.Companion.toPath -import platform.CoreFoundation.CFDataGetBytePtr -import platform.CoreFoundation.CFDataGetLength -import platform.CoreFoundation.CFDictionaryAddValue -import platform.CoreFoundation.CFDictionaryCreateMutable -import platform.CoreFoundation.CFRelease -import platform.CoreFoundation.CFStringRef -import platform.CoreFoundation.CFURLRef -import platform.CoreGraphics.CGDataProviderCopyData -import platform.CoreGraphics.CGImageGetDataProvider -import platform.CoreServices.UTTypeCopyPreferredTagWithClass -import platform.CoreServices.UTTypeCreatePreferredIdentifierForTag -import platform.CoreServices.kUTTagClassFilenameExtension -import platform.CoreServices.kUTTagClassMIMEType -import platform.Foundation.CFBridgingRelease -import platform.Foundation.CFBridgingRetain -import platform.Foundation.NSData -import platform.Foundation.NSDictionary -import platform.Foundation.NSDocumentDirectory -import platform.Foundation.NSFileManager -import platform.Foundation.NSFileSize -import platform.Foundation.NSNumber -import platform.Foundation.NSString -import platform.Foundation.NSUserDomainMask -import platform.Foundation.dataWithContentsOfURL -import platform.Foundation.fileSize -import platform.ImageIO.CGImageSourceCreateThumbnailAtIndex -import platform.ImageIO.CGImageSourceCreateWithURL -import platform.ImageIO.kCGImageSourceCreateThumbnailFromImageAlways -import platform.ImageIO.kCGImageSourceCreateThumbnailWithTransform -import platform.ImageIO.kCGImageSourceThumbnailMaxPixelSize -import platform.posix.memcpy - -@OptIn(ExperimentalForeignApi::class) -class IosFileService : FileService { - private fun appDocDir() = NSFileManager.defaultManager.URLForDirectory( - directory = NSDocumentDirectory, - inDomain = NSUserDomainMask, - appropriateForURL = null, - create = false, - error = null, - )!!.path!!.toPath() - - override val dataStoreDir: Path = appDocDir().resolve("dataStore") - override val imageCacheDir: Path = appDocDir().resolve("imageCache") - - - override fun getFile(uri: KmpUri): PlatformFile? { - return IosFile(uri).takeIf { it.isExist() } - } - - override fun downloadFile(name: String?, url: String) { - } - - override fun getCacheSizeInBytes(): Long { - val fm = NSFileManager.defaultManager() - val files = fm.subpathsOfDirectoryAtPath(imageCacheDir.toString(), null).orEmpty() - var result = 0uL - files.map { file -> - val dict = fm.fileAttributesAtPath( - imageCacheDir.resolve(file.toString()).toString(), - true - ) as NSDictionary - result += dict.fileSize() - } - return result.toLong() - } - - override fun cleanCache() { - val fm = NSFileManager.defaultManager() - fm.removeItemAtPath(imageCacheDir.toString(), null) - } -} - -@OptIn(ExperimentalForeignApi::class) -private class IosFile( - private val uri: KmpUri -) : PlatformFile { - override fun isExist(): Boolean = - getName() != "IosFile:unknown" - - override fun getName(): String { - return uri.url.lastPathComponent() ?: "IosFile:unknown" - } - - override fun getSize(): Long { - val path = uri.url.path ?: return 0L - val fm = NSFileManager.defaultManager - val attr = fm.attributesOfItemAtPath(path, null) ?: return 0L - return attr.getValue(NSFileSize) as Long - } - - override fun getMimeType(): String { - val fileExtension = uri.url.pathExtension() - @Suppress("UNCHECKED_CAST", "CAST_NEVER_SUCCEEDS") - val fileExtensionRef = CFBridgingRetain(fileExtension as NSString) as CFStringRef - val uti = UTTypeCreatePreferredIdentifierForTag( - kUTTagClassFilenameExtension, - fileExtensionRef, - null - ) - CFRelease(fileExtensionRef) - val mimeType = UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType) - CFRelease(uti) - return CFBridgingRelease(mimeType) as String - } - - override suspend fun readBytes(): ByteArray = withContext(Dispatchers.IO) { - val data = NSData.dataWithContentsOfURL(uri.url)!! - ByteArray(data.length.toInt()).apply { - data.usePinned { - memcpy(refTo(0), data.bytes, data.length) - } - } - } - - override suspend fun getThumbnail(): ByteArray? = withContext(Dispatchers.IO) { - @Suppress("UNCHECKED_CAST") - val urlRef = CFBridgingRetain(uri.url) as CFURLRef - val imageSource = CGImageSourceCreateWithURL(urlRef, null)!! - val thumbnailOptions = CFDictionaryCreateMutable( - null, - 3, - null, - null - ).apply { - CFDictionaryAddValue( - this, - kCGImageSourceCreateThumbnailWithTransform, - CFBridgingRetain(NSNumber(bool = true)) - ) - CFDictionaryAddValue( - this, - kCGImageSourceCreateThumbnailFromImageAlways, - CFBridgingRetain(NSNumber(bool = true)) - ) - CFDictionaryAddValue( - this, - kCGImageSourceThumbnailMaxPixelSize, - CFBridgingRetain(NSNumber(512)) - ) - } - - val thumbnailSource = CGImageSourceCreateThumbnailAtIndex( - imageSource, - 0u, - thumbnailOptions - ) - - val data = CGDataProviderCopyData(CGImageGetDataProvider(thumbnailSource)) - val bytePointer = CFDataGetBytePtr(data)!! - val length = CFDataGetLength(data) - - val byteArray = ByteArray(length.toInt()) { index -> - bytePointer[index].toByte() - } - - CFRelease(urlRef) - CFRelease(data) - CFRelease(thumbnailSource) - - byteArray - } -} \ No newline at end of file diff --git a/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.ios.kt b/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.ios.kt index d44c2ceb..fb16777e 100644 --- a/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.ios.kt +++ b/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.ios.kt @@ -3,9 +3,8 @@ package com.daniebeler.pfpixelix.domain.service.platform actual object PlatformFeatures { actual val notificationWidgets = false actual val inAppBrowser = true - actual val downloadToGallery = false + actual val downloadToGallery = false //https://github.com/vinceglb/FileKit/issues/215 actual val customAppIcon = true - actual val autoplayVideosPref = false actual val addCollection = false actual val customAccentColors = true } \ No newline at end of file diff --git a/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.ios.kt b/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.ios.kt index a17bae93..7018ec51 100644 --- a/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.ios.kt +++ b/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.ios.kt @@ -1,7 +1,17 @@ package com.daniebeler.pfpixelix.utils import coil3.PlatformContext -import io.github.vinceglb.filekit.core.PlatformFile +import io.github.vinceglb.filekit.PlatformFile +import kotlinx.cinterop.ExperimentalForeignApi +import platform.CoreFoundation.CFRelease +import platform.CoreFoundation.CFStringRef +import platform.CoreServices.UTTypeCopyPreferredTagWithClass +import platform.CoreServices.UTTypeCreatePreferredIdentifierForTag +import platform.CoreServices.kUTTagClassFilenameExtension +import platform.CoreServices.kUTTagClassMIMEType +import platform.Foundation.CFBridgingRelease +import platform.Foundation.CFBridgingRetain +import platform.Foundation.NSString import platform.Foundation.NSURL import platform.UIKit.UIViewController @@ -17,8 +27,25 @@ actual val EmptyKmpUri: KmpUri = IosUri(NSURL(string = "")) actual fun KmpUri.getPlatformUriObject(): Any = url actual fun String.toKmpUri(): KmpUri = IosUri(NSURL(string = this)) actual fun PlatformFile.toKmpUri(): KmpUri = IosUri(nsUrl) +actual fun KmpUri.toPlatformFile(): PlatformFile = PlatformFile(url) actual abstract class KmpContext { abstract val viewController: UIViewController } actual val KmpContext.coilContext get() = PlatformContext.INSTANCE + +@OptIn(ExperimentalForeignApi::class) +actual fun KmpContext.getMimeType(uri: KmpUri): String { + val fileExtension = uri.url.pathExtension() + @Suppress("UNCHECKED_CAST", "CAST_NEVER_SUCCEEDS") + val fileExtensionRef = CFBridgingRetain(fileExtension as NSString) as CFStringRef + val uti = UTTypeCreatePreferredIdentifierForTag( + kUTTagClassFilenameExtension, + fileExtensionRef, + null + ) + CFRelease(fileExtensionRef) + val mimeType = UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType) + CFRelease(uti) + return CFBridgingRelease(mimeType) as String +} diff --git a/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/utils/VideoPlayer.ios.kt b/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/utils/VideoPlayer.ios.kt deleted file mode 100644 index 4d667e5b..00000000 --- a/app/src/iosMain/kotlin/com/daniebeler/pfpixelix/utils/VideoPlayer.ios.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.daniebeler.pfpixelix.utils - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.UIKitView -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import platform.AVFoundation.AVLayerVideoGravityResize -import platform.AVFoundation.AVMediaTypeAudio -import platform.AVFoundation.AVPlayer -import platform.AVFoundation.AVPlayerItem -import platform.AVFoundation.AVPlayerLayer -import platform.AVFoundation.asset -import platform.AVFoundation.currentItem -import platform.AVFoundation.currentTime -import platform.AVFoundation.duration -import platform.AVFoundation.muted -import platform.AVFoundation.pause -import platform.AVFoundation.play -import platform.AVFoundation.replaceCurrentItemWithPlayerItem -import platform.AVFoundation.tracksWithMediaType -import platform.AVKit.AVPlayerViewController -import platform.CoreMedia.CMTimeGetSeconds -import platform.Foundation.NSURL -import platform.Foundation.observeValueForKeyPath -import platform.UIKit.UIView - -@OptIn(ExperimentalForeignApi::class) -actual class VideoPlayer actual constructor( - context: KmpContext, - private val coroutineScope: CoroutineScope -) { - actual var progress: ((current: Long, duration: Long) -> Unit)? = null - actual var hasAudio: ((Boolean) -> Unit)? = null - actual var isVideoPlaying: ((Boolean) -> Unit)? = null - - private var extractAudioInfoJob: Job? = null - - private val player = AVPlayer() - private val playerViewController = AVPlayerViewController().apply { - this.player = this@VideoPlayer.player - this.videoGravity = AVLayerVideoGravityResize - this.showsPlaybackControls = false - this.view.userInteractionEnabled = false - } - private val playerLayer = AVPlayerLayer().apply { - this.player = playerViewController.player - } - - init { - coroutineScope.launch { - while (isActive) { - player.currentItem?.let { - val duration = CMTimeGetSeconds(it.duration()).toLong() - val currentTime = CMTimeGetSeconds(it.currentTime()).toLong() - if (duration > 0 && currentTime <= duration) { - progress?.invoke(currentTime, duration) - } - } - delay(300) - } - } - } - - @Composable - actual fun view(modifier: Modifier) { - UIKitView( - modifier = modifier, - factory = { - UIView().apply { - playerLayer.setFrame(frame) - playerViewController.view.setFrame(frame) - addSubview(playerViewController.view) - } - }, - update = { view: UIView -> - playerLayer.setFrame(view.frame) - playerViewController.view.setFrame(view.frame) - } - ) - } - - actual fun prepare(url: String) { - release() - val item = AVPlayerItem(NSURL(string = url)) - player.replaceCurrentItemWithPlayerItem(item) - - extractAudioInfoJob = coroutineScope.launch { - val withAudio = withContext(Dispatchers.Default) { - item.asset.tracksWithMediaType(AVMediaTypeAudio).isNotEmpty() - } - hasAudio?.invoke(withAudio) - } - } - - actual fun play() { - player.play() - } - actual fun pause() { - player.pause() - } - actual fun release() { - player.replaceCurrentItemWithPlayerItem(null) - extractAudioInfoJob?.cancel() - } - actual fun audio(enable: Boolean) { - player.muted = !enable - } -} \ No newline at end of file diff --git a/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/Main.kt b/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/Main.kt index ffc09aca..a647aa85 100644 --- a/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/Main.kt +++ b/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/Main.kt @@ -1,33 +1,31 @@ package com.daniebeler.pfpixelix +import androidx.compose.ui.Alignment import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import coil3.SingletonImageLoader import com.daniebeler.pfpixelix.di.AppComponent import com.daniebeler.pfpixelix.di.create -import com.daniebeler.pfpixelix.domain.service.file.DesktopFileService import com.daniebeler.pfpixelix.domain.service.icon.DesktopAppIconManager import com.daniebeler.pfpixelix.utils.KmpContext import com.daniebeler.pfpixelix.utils.configureJavaLogger -import com.daniebeler.pfpixelix.utils.configureLogger +import io.github.vinceglb.filekit.FileKit import java.awt.Desktop import java.awt.Dimension fun main() { - //https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-swing-interoperability.html - System.setProperty("compose.swing.render.on.graphics", "true") - System.setProperty("compose.interop.blending", "true") application { + FileKit.init("com.daniebeler.pfpixelix") + configureJavaLogger() + val appComponent = AppComponent.Companion.create( object : KmpContext() {}, - DesktopFileService(), DesktopAppIconManager() ) - configureJavaLogger() - SingletonImageLoader.setSafe { appComponent.provideImageLoader() } @@ -40,7 +38,11 @@ fun main() { Window( title = "Pixelix", - state = rememberWindowState(width = 600.dp, height = 1000.dp), + state = rememberWindowState( + width = 400.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center) + ), onCloseRequest = ::exitApplication, ) { window.minimumSize = Dimension(400, 600) diff --git a/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/domain/service/file/DesktopFileService.kt b/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/domain/service/file/DesktopFileService.kt deleted file mode 100644 index 580bd0ae..00000000 --- a/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/domain/service/file/DesktopFileService.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.daniebeler.pfpixelix.domain.service.file - -import ca.gosyer.appdirs.AppDirs -import co.touchlab.kermit.Logger -import com.daniebeler.pfpixelix.utils.KmpUri -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okio.Path -import okio.Path.Companion.toPath -import java.awt.Image -import java.awt.image.BufferedImage -import java.io.ByteArrayOutputStream -import java.io.File -import java.nio.file.Files -import javax.imageio.ImageIO - -class DesktopFileService : FileService { - private val appDirs = AppDirs("com.daniebeler.pfpixelix", null) - private fun appDocDir() = appDirs.getUserDataDir().toPath() - - override val dataStoreDir: Path = appDocDir().resolve("dataStore") - override val imageCacheDir: Path = appDocDir().resolve("imageCache") - - override fun getFile(uri: KmpUri): PlatformFile? { - return DesktopFile(uri).takeIf { it.isExist() } - } - - override fun downloadFile(name: String?, url: String) { - } - - override fun getCacheSizeInBytes(): Long { - return imageCacheDir.toFile().walkBottomUp().fold(0L) { acc, file -> acc + file.length() } - } - - override fun cleanCache() { - imageCacheDir.toFile().deleteRecursively() - } -} - -private class DesktopFile( - uri: KmpUri -) : PlatformFile { - private val file = File(uri.uri) - - override fun isExist(): Boolean = file.exists() - override fun getName(): String = file.name - override fun getSize(): Long = file.length() - override fun getMimeType(): String = Files.probeContentType(file.toPath()) - - override suspend fun readBytes(): ByteArray = withContext(Dispatchers.IO) { - file.readBytes() - } - - override suspend fun getThumbnail(): ByteArray? = withContext(Dispatchers.IO) { - val thumbnail = try { - val size = 512 - val originalImage = ImageIO.read(file) - val aspectRatio = originalImage.width.toDouble() / originalImage.height - val (width, height) = if (aspectRatio > 1) { - size to (size / aspectRatio).toInt() - } else { - (size * aspectRatio).toInt() to size - } - val image = originalImage.getScaledInstance(width, height, Image.SCALE_SMOOTH) - val bufferedImage = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) - val graphics = bufferedImage.createGraphics() - graphics.drawImage(image, 0, 0, null) - graphics.dispose() - bufferedImage - } catch (e: Exception) { - Logger.e("Failed to create thumbnail for file: ${file.name}", e) - null - } ?: return@withContext null - - val outputStream = ByteArrayOutputStream() - ImageIO.write(thumbnail, "png", outputStream) - outputStream.toByteArray() - } -} \ No newline at end of file diff --git a/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.jvm.kt b/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.jvm.kt index a44074ef..bc794239 100644 --- a/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.jvm.kt +++ b/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/domain/service/platform/PlatformFeatures.jvm.kt @@ -3,9 +3,8 @@ package com.daniebeler.pfpixelix.domain.service.platform actual object PlatformFeatures { actual val notificationWidgets = false actual val inAppBrowser = false - actual val downloadToGallery = false + actual val downloadToGallery = true actual val customAppIcon = false - actual val autoplayVideosPref = false actual val addCollection = true actual val customAccentColors = true } \ No newline at end of file diff --git a/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.jvm.kt b/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.jvm.kt index c945d542..d085a1ce 100644 --- a/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.jvm.kt +++ b/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/utils/KmpPlatform.jvm.kt @@ -1,8 +1,11 @@ package com.daniebeler.pfpixelix.utils import coil3.PlatformContext -import io.github.vinceglb.filekit.core.PlatformFile +import io.github.vinceglb.filekit.PlatformFile +import java.io.File import java.net.URI +import java.nio.file.Files +import kotlin.io.path.toPath private data class DesktopUri(override val uri: URI) : KmpUri() { override fun toString(): String = uri.toString() @@ -16,6 +19,8 @@ actual val EmptyKmpUri: KmpUri = DesktopUri(URI("")) actual fun KmpUri.getPlatformUriObject(): Any = uri.toString() actual fun String.toKmpUri(): KmpUri = DesktopUri(URI(this)) actual fun PlatformFile.toKmpUri(): KmpUri = DesktopUri(file.toURI()) +actual fun KmpUri.toPlatformFile(): PlatformFile = PlatformFile(File(uri)) actual abstract class KmpContext actual val KmpContext.coilContext get() = PlatformContext.INSTANCE +actual fun KmpContext.getMimeType(uri: KmpUri): String = Files.probeContentType(uri.uri.toPath()) diff --git a/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/utils/VideoPlayer.jvm.kt b/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/utils/VideoPlayer.jvm.kt deleted file mode 100644 index f46fd721..00000000 --- a/app/src/jvmMain/kotlin/com/daniebeler/pfpixelix/utils/VideoPlayer.jvm.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.daniebeler.pfpixelix.utils - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.SwingPanel -import androidx.compose.ui.graphics.Color -import kotlinx.coroutines.CoroutineScope -import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery -import uk.co.caprica.vlcj.factory.discovery.strategy.OsxNativeDiscoveryStrategy -import uk.co.caprica.vlcj.player.base.MediaPlayer -import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter -import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent -import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent -import uk.co.caprica.vlcj.player.component.InputEvents -import java.awt.Component -import java.util.Locale - -actual class VideoPlayer actual constructor( - context: KmpContext, - private val coroutineScope: CoroutineScope -) { - private val mpComponent = initializeMediaPlayerComponent() - private val player = mpComponent.mediaPlayer() - actual var isVideoPlaying: ((Boolean) -> Unit)? = null - - actual var progress: ((current: Long, duration: Long) -> Unit)? = null - actual var hasAudio: ((Boolean) -> Unit)? = null - - private val listener = object : MediaPlayerEventAdapter() { - override fun mediaPlayerReady(mediaPlayer: MediaPlayer?) { - hasAudio?.invoke(player.audio().trackCount() > 0) - } - - override fun positionChanged(mediaPlayer: MediaPlayer?, newPosition: Float) { - val status = player.status() - progress?.invoke((status.length() * status.position()).toLong(), status.length()) - } - - override fun playing(mediaPlayer: MediaPlayer?) { - isVideoPlaying?.invoke(true) - } - - override fun paused(mediaPlayer: MediaPlayer?) { - isVideoPlaying?.invoke(false) - } - } - - init { - player.events().addMediaPlayerEventListener(listener) - } - - @Composable - actual fun view(modifier: Modifier) { - SwingPanel( - factory = { mpComponent }, - background = Color.Transparent, - modifier = modifier - ) - } - - actual fun prepare(url: String) { - player.media().prepare(url) - } - - actual fun play() { - player.controls().play() - } - - actual fun pause() { - player.controls().pause() - } - - actual fun release() { - player.events().removeMediaPlayerEventListener(listener) - player.release() - } - - actual fun audio(enable: Boolean) { - player.audio().isMute = !enable - } - - private fun Component.mediaPlayer() = when (this) { - is CallbackMediaPlayerComponent -> mediaPlayer() - is EmbeddedMediaPlayerComponent -> mediaPlayer() - else -> error("mediaPlayer() can only be called on vlcj player components") - } - - private fun initializeMediaPlayerComponent(): Component { - NativeDiscovery(OsxNativeDiscoveryStrategy()).discover() - return if (isMacOS()) { - CallbackMediaPlayerComponent(null, null, InputEvents.NONE, true, null) - } else { - EmbeddedMediaPlayerComponent(null, null, null, InputEvents.NONE, null) - } - } - - private fun isMacOS(): Boolean { - val os = System.getProperty("os.name", "generic").lowercase(Locale.ENGLISH) - return "mac" in os || "darwin" in os - } -} \ No newline at end of file diff --git a/appstorebadgewhite.svg b/appstorebadgewhite.svg new file mode 100644 index 00000000..16c0496c --- /dev/null +++ b/appstorebadgewhite.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_wht_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle.properties b/gradle.properties index 413a6066..f906e1c8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ #Gradle -org.gradle.jvmargs=-Xmx8G +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=1G org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.daemon=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3022f950..b8203b79 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,49 +1,45 @@ [versions] -kotlin = "2.1.20" -ksp = "2.1.20-1.0.31" -agp = "8.9.1" +kotlin = "2.1.21" +ksp = "2.1.21-2.0.1" +agp = "8.10.0" #https://github.com/JetBrains/compose-multiplatform/releases -composeMultiplatform = "1.8.0-beta02" -lifecycleMultiplatform = "2.9.0-alpha06" -navigationMultiplatform = "2.9.0-alpha16" +composeMultiplatform = "1.8.0" +lifecycleMultiplatform = "2.9.0-beta01" +navigationMultiplatform = "2.9.0-beta01" #JetBrains -kotlinx-coroutines = "1.10.1" -kotlinxCollectionsImmutable = "0.3.8" -kotlinxSerializationJson = "1.8.0" -ktor = "3.1.1" +kotlinx-coroutines = "1.10.2" +kotlinxCollectionsImmutable = "0.4.0" +kotlinxSerializationJson = "1.8.1" +ktor = "3.1.3" kotlinx-datetime = "0.6.2" #multiplatform -ksoup = "0.2.2" +ksoup = "0.2.3" kermit = "2.0.5" -ktorfit = "2.4.1" -kotlinInject = "0.7.2" +ktorfit = "2.5.2" +kotlinInject = "0.8.0" androidx-annotation = "1.9.1" -coil = "3.1.0" -datastorePreferences = "1.1.4" +coil = "3.2.0" +datastorePreferences = "1.1.6" multiplatformSettings = "1.3.0" -filekitCompose = "0.8.8" -krop = "0.2.0-alpha01" +filekitCompose = "0.10.0-beta04" +krop = "0.2.0" +composemediaplayer = "0.7.4" #android accompanistSystemuicontroller = "0.36.0" activityCompose = "1.10.1" -androidImageCropper = "4.6.0" browser = "1.8.0" coreKtx = "1.16.0" glance = "1.1.1" material = "1.12.0" -media3 = "1.6.0" -okio = "3.10.2" -workRuntimeKtx = "2.10.0" +okio = "3.11.0" +workRuntimeKtx = "2.10.1" #desktop -appdirs = "1.2.0" slf4jSimple = "2.0.17" -vlcj = "4.10.1" -jna = "5.17.0" [libraries] @@ -57,7 +53,7 @@ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version. compose-ui-graphics = { module = "org.jetbrains.compose.ui:ui-graphics", version.ref = "composeMultiplatform" } androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleMultiplatform" } -androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycleMultiplatform" } +androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleMultiplatform" } androidx-lifecycle-viewmodel-savedstate = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "lifecycleMultiplatform" } androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationMultiplatform" } @@ -85,19 +81,12 @@ coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } ktorfit = { module = "de.jensklingenberg.ktorfit:ktorfit-lib", version.ref = "ktorfit" } ktorfit-call = { module = "de.jensklingenberg.ktorfit:ktorfit-converters-call", version.ref = "ktorfit" } -jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } -jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } - accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" } -android-image-cropper = { module = "com.vanniktech:android-image-cropper", version.ref = "androidImageCropper" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" } -androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } -androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "media3" } -androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } material = { module = "com.google.android.material:material", version.ref = "material" } ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } @@ -106,11 +95,10 @@ multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", vers multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformSettings" } multiplatform-settings-datastore = { module = "com.russhwolf:multiplatform-settings-datastore", version.ref = "multiplatformSettings" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } -filekit-compose = { module = "io.github.vinceglb:filekit-compose", version.ref = "filekitCompose" } +filekit-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekitCompose" } krop = { module = "com.attafitamim.krop:ui", version.ref = "krop" } -appdirs = { module = "ca.gosyer:kotlin-multiplatform-appdirs", version.ref = "appdirs" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4jSimple" } -vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" } +composemediaplayer = { module = "io.github.kdroidfilter:composemediaplayer", version.ref = "composemediaplayer" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 33918ab3..2b55fa47 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Dec 11 12:09:16 CET 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 9921c142..3a06fde5 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -296,7 +296,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = 4FA7X6639Y; + DEVELOPMENT_TEAM = 6KS4K8Z64D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -308,7 +308,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.1; - PRODUCT_BUNDLE_IDENTIFIER = com.daniebeler.pfpixelix.iosApp; + PRODUCT_BUNDLE_IDENTIFIER = com.terrakok.pfpixelix.iosApp; PRODUCT_NAME = Pixelix; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -326,7 +326,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = 4FA7X6639Y; + DEVELOPMENT_TEAM = 6KS4K8Z64D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -338,7 +338,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.1; - PRODUCT_BUNDLE_IDENTIFIER = com.daniebeler.pfpixelix.iosApp; + PRODUCT_BUNDLE_IDENTIFIER = com.terrakok.pfpixelix.iosApp; PRODUCT_NAME = Pixelix; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme new file mode 100644 index 00000000..c44e9e2d --- /dev/null +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/metadata/de/changelogs/32.txt b/metadata/de/changelogs/32.txt new file mode 100644 index 00000000..d16ce97b --- /dev/null +++ b/metadata/de/changelogs/32.txt @@ -0,0 +1,4 @@ +- Bild teilen gefixt +- Benutzerdefiniertes App Icon gefixt +- Benachrichtigungen gefixt +- Weitere Bugs gefixt \ No newline at end of file diff --git a/metadata/en-US/changelogs/32.txt b/metadata/en-US/changelogs/32.txt new file mode 100644 index 00000000..a99875b0 --- /dev/null +++ b/metadata/en-US/changelogs/32.txt @@ -0,0 +1,4 @@ +- Fix image sharing +- Fix custom App icon +- Fix notifications +- Other bug fixes \ No newline at end of file