diff --git a/app/android/src/main/kotlin/AndroidModules.kt b/app/android/src/main/kotlin/AndroidModules.kt index 457d933a6e..a81ee4fe68 100644 --- a/app/android/src/main/kotlin/AndroidModules.kt +++ b/app/android/src/main/kotlin/AndroidModules.kt @@ -21,12 +21,12 @@ import me.him188.ani.app.tools.update.AndroidUpdateInstaller import me.him188.ani.app.tools.update.UpdateInstaller import me.him188.ani.app.videoplayer.ExoPlayerStateFactory import me.him188.ani.app.videoplayer.ui.state.PlayerStateFactory +import me.him188.ani.utils.io.SystemPath import org.koin.android.ext.koin.androidContext import org.koin.dsl.module -import java.io.File fun getAndroidModules( - torrentCacheDir: File, + torrentCacheDir: SystemPath, coroutineScope: CoroutineScope, ) = module { single { diff --git a/app/android/src/main/kotlin/AniApplication.kt b/app/android/src/main/kotlin/AniApplication.kt index f7bab25d19..3181b5f8c6 100644 --- a/app/android/src/main/kotlin/AniApplication.kt +++ b/app/android/src/main/kotlin/AniApplication.kt @@ -35,6 +35,8 @@ import me.him188.ani.app.platform.createAppRootCoroutineScope import me.him188.ani.app.platform.getCommonKoinModule import me.him188.ani.app.platform.startCommonKoinModule import me.him188.ani.app.tools.torrent.TorrentManager +import me.him188.ani.utils.io.inSystem +import me.him188.ani.utils.io.toKtPath import org.koin.android.ext.android.getKoin import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin @@ -126,7 +128,7 @@ class AniApplication : Application() { startKoin { androidContext(this@AniApplication) modules(getCommonKoinModule({ this@AniApplication }, scope)) - modules(getAndroidModules(torrentCaches, scope)) + modules(getAndroidModules(torrentCaches.toKtPath().inSystem, scope)) }.startCommonKoinModule(scope) getKoin().get() // start sharing, connect to DHT now diff --git a/app/desktop/src/main/kotlin/AniDesktop.kt b/app/desktop/src/main/kotlin/AniDesktop.kt index c0d428eb4a..deb9955203 100644 --- a/app/desktop/src/main/kotlin/AniDesktop.kt +++ b/app/desktop/src/main/kotlin/AniDesktop.kt @@ -46,6 +46,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.io.files.Path import me.him188.ani.app.data.repository.SettingsRepository import me.him188.ani.app.data.source.UpdateManager import me.him188.ani.app.data.source.media.resolver.DesktopWebVideoSourceResolver @@ -95,6 +96,9 @@ import me.him188.ani.app.videoplayer.ui.VlcjVideoPlayerState import me.him188.ani.app.videoplayer.ui.state.PlayerStateFactory import me.him188.ani.desktop.generated.resources.Res import me.him188.ani.desktop.generated.resources.a_round +import me.him188.ani.utils.io.inSystem +import me.him188.ani.utils.io.resolve +import me.him188.ani.utils.io.toKtPath import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger @@ -199,10 +203,10 @@ object AniDesktop { saveDir = { val saveDir = runBlocking { get().mediaCacheSettings.flow.first().saveDir - ?.let(::File) - } ?: projectDirectories.torrentCacheDir + ?.let(::Path) + } ?: projectDirectories.torrentCacheDir.toKtPath() toplevelLogger.info { "TorrentManager saveDir: $saveDir" } - saveDir.resolve(it.id) + saveDir.inSystem.resolve(it.id) }, ) } diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 7639c2075f..4077a97b9b 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -67,6 +67,9 @@ composeCompiler { enableStrongSkippingMode = true } +atomicfu { + transformJvm = false // 这东西很不靠谱, 等 atomicfu 正式版了可能可以考虑下 +} kotlin { sourceSets.commonMain.dependencies { diff --git a/app/shared/src/androidMain/kotlin/data/persistent/SettingsStore.android.kt b/app/shared/src/androidMain/kotlin/data/persistent/SettingsStore.android.kt index dd096b26d7..ddf3853772 100644 --- a/app/shared/src/androidMain/kotlin/data/persistent/SettingsStore.android.kt +++ b/app/shared/src/androidMain/kotlin/data/persistent/SettingsStore.android.kt @@ -23,7 +23,9 @@ import androidx.datastore.dataStoreFile import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore import me.him188.ani.app.platform.Context -import java.io.File +import me.him188.ani.utils.io.SystemPath +import me.him188.ani.utils.io.inSystem +import me.him188.ani.utils.io.toKtPath actual val Context.preferencesStore: DataStore by preferencesDataStore("preferences") actual val Context.tokenStore: DataStore by preferencesDataStore("tokens") @@ -33,7 +35,7 @@ actual val Context.dataStoresImpl: PlatformDataStoreManager internal class PlatformDataStoreManagerAndroid( private val context: Context, ) : PlatformDataStoreManager() { - override fun resolveDataStoreFile(name: String): File { - return context.applicationContext.dataStoreFile(name) + override fun resolveDataStoreFile(name: String): SystemPath { + return context.applicationContext.dataStoreFile(name).toKtPath().inSystem } } diff --git a/app/shared/src/androidMain/kotlin/platform/Context.android.kt b/app/shared/src/androidMain/kotlin/platform/Context.android.kt index f10d3e62ce..bdc3c26c11 100644 --- a/app/shared/src/androidMain/kotlin/platform/Context.android.kt +++ b/app/shared/src/androidMain/kotlin/platform/Context.android.kt @@ -37,6 +37,9 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import me.him188.ani.app.platform.window.PlatformWindowMP +import me.him188.ani.utils.io.SystemPath +import me.him188.ani.utils.io.inSystem +import me.him188.ani.utils.io.toKtPath import java.io.File @@ -115,8 +118,10 @@ actual fun Context.setRequestFullScreen(window: PlatformWindowMP, fullscreen: Bo internal actual val Context.filesImpl: ContextFiles get() = object : ContextFiles { - override val cacheDir: File get() = this@filesImpl.cacheDir ?: File("") // can be null when previewing - override val dataDir: File get() = this@filesImpl.filesDir ?: File("") // can be null when previewing + override val cacheDir: SystemPath + get() = (this@filesImpl.cacheDir ?: File("")).toKtPath().inSystem // can be null when previewing + override val dataDir: SystemPath + get() = (this@filesImpl.filesDir ?: File("")).toKtPath().inSystem // can be null when previewing } // TODO: isSystemInFullscreen is written by ChatGPT, not tested diff --git a/app/shared/src/androidMain/kotlin/tools/update/AndroidUpdateInstaller.kt b/app/shared/src/androidMain/kotlin/tools/update/AndroidUpdateInstaller.kt index 520dbfea03..0ce64fba72 100644 --- a/app/shared/src/androidMain/kotlin/tools/update/AndroidUpdateInstaller.kt +++ b/app/shared/src/androidMain/kotlin/tools/update/AndroidUpdateInstaller.kt @@ -10,6 +10,8 @@ import androidx.core.content.ContextCompat.startActivity import androidx.core.content.FileProvider import me.him188.ani.BuildConfig import me.him188.ani.app.platform.ContextMP +import me.him188.ani.utils.io.SystemPath +import me.him188.ani.utils.io.toFile import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger import me.him188.ani.utils.logging.warn @@ -21,7 +23,7 @@ class AndroidUpdateInstaller : UpdateInstaller { private val logger = logger() } - override fun install(file: File, context: ContextMP): InstallationResult { + override fun install(file: SystemPath, context: ContextMP): InstallationResult { logger.info { "Requesting install APK" } if (!context.packageManager.canRequestPackageInstalls()) { // Request permission from the user @@ -34,7 +36,7 @@ class AndroidUpdateInstaller : UpdateInstaller { } } else { kotlin.runCatching { - installApk(context, file) + installApk(context, file.toFile()) }.onFailure { logger.warn(it) { "Failed to install update APK using installApkLegacy" } } diff --git a/app/shared/src/commonMain/kotlin/data/models/ApiResponse.kt b/app/shared/src/commonMain/kotlin/data/models/ApiResponse.kt index da42d2ad28..8ead9150dc 100644 --- a/app/shared/src/commonMain/kotlin/data/models/ApiResponse.kt +++ b/app/shared/src/commonMain/kotlin/data/models/ApiResponse.kt @@ -4,9 +4,9 @@ import io.ktor.client.plugins.ClientRequestException import io.ktor.client.plugins.ServerResponseException import io.ktor.http.HttpStatusCode import io.ktor.utils.io.errors.IOException +import kotlinx.coroutines.CancellationException import kotlin.contracts.InvocationKind import kotlin.contracts.contract -import kotlin.coroutines.cancellation.CancellationException sealed interface ApiFailure { data object Unauthorized : ApiFailure diff --git a/app/shared/src/commonMain/kotlin/data/models/PackedDate.kt b/app/shared/src/commonMain/kotlin/data/models/PackedDate.kt index a6b893f84b..c4c4f51f8c 100644 --- a/app/shared/src/commonMain/kotlin/data/models/PackedDate.kt +++ b/app/shared/src/commonMain/kotlin/data/models/PackedDate.kt @@ -4,10 +4,13 @@ package me.him188.ani.app.data.models import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.Serializable import me.him188.ani.app.data.models.PackedDate.Companion.Invalid -import java.util.Calendar -import java.util.TimeZone import kotlin.contracts.InvocationKind import kotlin.contracts.contract import kotlin.time.Duration @@ -68,14 +71,15 @@ value class PackedDate @PublishedApi internal constructor( ) } + private val UTC8 = TimeZone.of("UTC+8") + fun now(): PackedDate { - val currentTimeMillis = System.currentTimeMillis() - val timeZone = TimeZone.getTimeZone("UTC+8") // bangumi 是固定 UTC+8 - val calendar = Calendar.getInstance(timeZone).apply { timeInMillis = currentTimeMillis } + val timeZone = UTC8 // bangumi 是固定 UTC+8 + val calendar = Clock.System.now().toLocalDateTime(timeZone) - val year = calendar.get(Calendar.YEAR) - val month = calendar.get(Calendar.MONTH) + 1 // Calendar.MONTH is zero-based - val day = calendar.get(Calendar.DAY_OF_MONTH) + val year = calendar.year + val month = calendar.monthNumber + val day = calendar.dayOfMonth return PackedDate(year, month, day) } } @@ -125,15 +129,14 @@ inline val PackedDate.seasonMonth: Int */ operator fun PackedDate.minus(other: PackedDate): Duration { if (this.isInvalid || other.isInvalid) return Duration.INFINITE - val thisCalendar = Calendar.getInstance().apply { - clear() - set(year, month - 1, day) - } - val otherCalendar = Calendar.getInstance().apply { - clear() - set(other.year, other.month - 1, other.day) - } - return (thisCalendar.timeInMillis - otherCalendar.timeInMillis).milliseconds + + val thisDate = LocalDate(this.year, this.month, this.day) + val otherDate = LocalDate(other.year, other.month, other.day) + + val thisInstant = thisDate.atStartOfDayIn(TimeZone.UTC) + val otherInstant = otherDate.atStartOfDayIn(TimeZone.UTC) + + return (thisInstant.toEpochMilliseconds() - otherInstant.toEpochMilliseconds()).milliseconds } @Stable @@ -144,7 +147,7 @@ inline fun PackedDate( ): PackedDate = if (year in 0..9999 && month in 1..12 && day in 1..31) { PackedDate(DatePacker.pack(year, month, day)) } else { - PackedDate.Invalid // invalid + Invalid // invalid } @Suppress("NOTHING_TO_INLINE") diff --git a/app/shared/src/commonMain/kotlin/data/models/subject/SubjectManager.kt b/app/shared/src/commonMain/kotlin/data/models/subject/SubjectManager.kt index 4bacd69b1e..48efd6a26c 100644 --- a/app/shared/src/commonMain/kotlin/data/models/subject/SubjectManager.kt +++ b/app/shared/src/commonMain/kotlin/data/models/subject/SubjectManager.kt @@ -68,6 +68,7 @@ import me.him188.ani.datasources.bangumi.processing.toEpisodeCollectionType import me.him188.ani.datasources.bangumi.processing.toSubjectCollectionType import me.him188.ani.utils.coroutines.flows.runOrEmitEmptyList import me.him188.ani.utils.coroutines.runUntilSuccess +import me.him188.ani.utils.io.toFile import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -265,7 +266,7 @@ class SubjectManagerImpl( ReplaceFileCorruptionHandler { LazyDataCacheSave.empty() }, migrations = listOf(), produceFile = { - context.dataStores.resolveDataStoreFile("collectionsByType-${type.name}") + context.dataStores.resolveDataStoreFile("collectionsByType-${type.name}").toFile() }, ), ) diff --git a/app/shared/src/commonMain/kotlin/data/persistent/SettingsStore.kt b/app/shared/src/commonMain/kotlin/data/persistent/SettingsStore.kt index 1a28842ec1..d3c1acfdc1 100644 --- a/app/shared/src/commonMain/kotlin/data/persistent/SettingsStore.kt +++ b/app/shared/src/commonMain/kotlin/data/persistent/SettingsStore.kt @@ -31,7 +31,8 @@ import kotlinx.serialization.json.encodeToStream import me.him188.ani.app.data.repository.MediaSourceSaves import me.him188.ani.app.data.repository.MikanIndexes import me.him188.ani.app.platform.Context -import java.io.File +import me.him188.ani.utils.io.SystemPath +import me.him188.ani.utils.io.toFile import java.io.InputStream import java.io.OutputStream @@ -53,7 +54,7 @@ abstract class PlatformDataStoreManager { val mikanIndexStore: DataStore get() = DataStoreFactory.create( serializer = MikanIndexes.serializer().asDataStoreSerializer({ MikanIndexes.Empty }), - produceFile = { resolveDataStoreFile("mikanIndexes") }, + produceFile = { resolveDataStoreFile("mikanIndexes").toFile() }, corruptionHandler = ReplaceFileCorruptionHandler { MikanIndexes.Empty }, @@ -63,14 +64,14 @@ abstract class PlatformDataStoreManager { DataStoreFactory.create( serializer = MediaSourceSaves.serializer() .asDataStoreSerializer({ MediaSourceSaves.Default }), - produceFile = { resolveDataStoreFile("mediaSourceSaves") }, + produceFile = { resolveDataStoreFile("mediaSourceSaves").toFile() }, corruptionHandler = ReplaceFileCorruptionHandler { MediaSourceSaves.Default }, ) } - abstract fun resolveDataStoreFile(name: String): File + abstract fun resolveDataStoreFile(name: String): SystemPath } fun KSerializer.asDataStoreSerializer( diff --git a/app/shared/src/commonMain/kotlin/data/repository/MediaSourceInstanceRepository.kt b/app/shared/src/commonMain/kotlin/data/repository/MediaSourceInstanceRepository.kt index 9b50a10930..414a8e81fc 100644 --- a/app/shared/src/commonMain/kotlin/data/repository/MediaSourceInstanceRepository.kt +++ b/app/shared/src/commonMain/kotlin/data/repository/MediaSourceInstanceRepository.kt @@ -12,7 +12,7 @@ import me.him188.ani.datasources.mxdongman.MxdongmanMediaSource import me.him188.ani.datasources.ntdm.GugufanMediaSource import me.him188.ani.datasources.ntdm.NtdmMediaSource import me.him188.ani.datasources.nyafun.NyafunMediaSource -import java.util.UUID +import me.him188.ani.utils.platform.Uuid interface MediaSourceInstanceRepository : Repository { val flow: Flow> @@ -39,7 +39,7 @@ data class MediaSourceSaves( val Empty = MediaSourceSaves(emptyList()) val Default: MediaSourceSaves by lazy { fun createSave(it: String, isEnabled: Boolean) = MediaSourceSave( - instanceId = UUID.randomUUID().toString(), + instanceId = Uuid.randomString(), mediaSourceId = it, isEnabled = isEnabled, config = MediaSourceConfig.Default, diff --git a/app/shared/src/commonMain/kotlin/data/source/UpdateManager.kt b/app/shared/src/commonMain/kotlin/data/source/UpdateManager.kt index 1ce149e50c..470600740f 100644 --- a/app/shared/src/commonMain/kotlin/data/source/UpdateManager.kt +++ b/app/shared/src/commonMain/kotlin/data/source/UpdateManager.kt @@ -1,13 +1,19 @@ package me.him188.ani.app.data.source import me.him188.ani.app.platform.currentAniBuildConfig +import me.him188.ani.utils.io.SystemPath +import me.him188.ani.utils.io.delete +import me.him188.ani.utils.io.exists +import me.him188.ani.utils.io.inSystem +import me.him188.ani.utils.io.list +import me.him188.ani.utils.io.name +import me.him188.ani.utils.io.resolveSibling import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger import org.koin.core.component.KoinComponent -import java.io.File class UpdateManager( - val saveDir: File, + val saveDir: SystemPath, ) : KoinComponent { companion object { private val logger = logger() @@ -16,24 +22,24 @@ class UpdateManager( /** * 如果此版本与 [file] 版本相同, 则删除 [file] */ - fun deleteInstalled(file: File, currentVersion: String) { + fun deleteInstalled(file: SystemPath, currentVersion: String) { if (file.name.contains(currentVersion)) { file.delete() } } - fun deleteInstaller(file: File) { + fun deleteInstaller(file: SystemPath) { file.delete() file.resolveSibling(file.name + ".sha256").delete() } fun deleteInstalledFiles() { if (!saveDir.exists()) return - saveDir.listFiles()?.forEach { + saveDir.list().forEach { val version = currentAniBuildConfig.versionName if (it.name.contains(version)) { logger.info { "Deleting old installer file because it matches current version ${version}: $it" } - deleteInstaller(it) + deleteInstaller(it.inSystem) } } } diff --git a/app/shared/src/commonMain/kotlin/data/source/media/MediaCacheManager.kt b/app/shared/src/commonMain/kotlin/data/source/media/MediaCacheManager.kt index 684c1d9ac1..15d13d41e5 100644 --- a/app/shared/src/commonMain/kotlin/data/source/media/MediaCacheManager.kt +++ b/app/shared/src/commonMain/kotlin/data/source/media/MediaCacheManager.kt @@ -1,6 +1,7 @@ package me.him188.ani.app.data.source.media import androidx.compose.runtime.Stable +import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -35,7 +36,6 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.atomic.AtomicInteger abstract class MediaCacheManager( val storagesIncludingDisabled: List, @@ -190,7 +190,7 @@ abstract class MediaCacheManager( priority = NotifPriority.MIN } - val visibleCount = AtomicInteger() + val visibleCount = atomic(0) for (storage in list) { launch { if (System.currentTimeMillis() - startTime < 5000) { @@ -260,7 +260,7 @@ abstract class MediaCacheManager( // }.sampleWithInitial(1000) combine(stats.downloadRate.sampleWithInitial(3000)) { downloadRate -> // if (anyCaching) { - if (visibleCount.get() == 0) { + if (visibleCount.value == 0) { summaryNotif.cancel() } else { summaryNotif.run { diff --git a/app/shared/src/commonMain/kotlin/data/source/media/MediaSourceManager.kt b/app/shared/src/commonMain/kotlin/data/source/media/MediaSourceManager.kt index 34d4757769..2b17a5924a 100644 --- a/app/shared/src/commonMain/kotlin/data/source/media/MediaSourceManager.kt +++ b/app/shared/src/commonMain/kotlin/data/source/media/MediaSourceManager.kt @@ -36,10 +36,10 @@ import me.him188.ani.utils.coroutines.onReplacement import me.him188.ani.utils.ktor.ClientProxyConfig import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.logger +import me.him188.ani.utils.platform.Uuid import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.util.ServiceLoader -import java.util.UUID import kotlin.coroutines.CoroutineContext interface MediaSourceManager { // available by inject @@ -166,7 +166,7 @@ class MediaSourceManagerImpl( override suspend fun addInstance(mediaSourceId: String, config: MediaSourceConfig) { val save = MediaSourceSave( - instanceId = UUID.randomUUID().toString(), + instanceId = Uuid.randomString(), mediaSourceId = mediaSourceId, isEnabled = true, config = config, diff --git a/app/shared/src/commonMain/kotlin/data/source/media/TorrentMediaCacheEngine.kt b/app/shared/src/commonMain/kotlin/data/source/media/TorrentMediaCacheEngine.kt index 280524fc44..448ef005d7 100644 --- a/app/shared/src/commonMain/kotlin/data/source/media/TorrentMediaCacheEngine.kt +++ b/app/shared/src/commonMain/kotlin/data/source/media/TorrentMediaCacheEngine.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.job import kotlinx.coroutines.withContext +import kotlinx.io.files.Path import me.him188.ani.app.data.source.media.cache.AbstractMediaStats import me.him188.ani.app.data.source.media.cache.MediaCache import me.him188.ani.app.data.source.media.cache.MediaCacheEngine @@ -36,13 +37,13 @@ import me.him188.ani.datasources.api.MediaCacheMetadata import me.him188.ani.datasources.api.topic.FileSize import me.him188.ani.datasources.api.topic.FileSize.Companion.bytes import me.him188.ani.datasources.api.topic.ResourceLocation +import me.him188.ani.utils.io.exists +import me.him188.ani.utils.io.inSystem import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger import me.him188.ani.utils.logging.warn -import java.nio.file.Paths import kotlin.coroutines.CoroutineContext -import kotlin.io.path.exists private const val EXTRA_TORRENT_DATA = "torrentData" private const val EXTRA_TORRENT_CACHE_DIR = "torrentCacheDir" // 种子的缓存目录, 注意, 一个 MediaCache 可能只对应该种子资源的其中一个文件 @@ -113,7 +114,7 @@ class TorrentMediaCacheEngine( override fun isValid(): Boolean { return metadata.extra[EXTRA_TORRENT_CACHE_DIR]?.let { - Paths.get(it).exists() + Path(it).inSystem.exists() } ?: false } diff --git a/app/shared/src/commonMain/kotlin/data/source/media/cache/DirectoryMediaCacheStorage.kt b/app/shared/src/commonMain/kotlin/data/source/media/cache/DirectoryMediaCacheStorage.kt index dfa468efaf..a9fef5cd9d 100644 --- a/app/shared/src/commonMain/kotlin/data/source/media/cache/DirectoryMediaCacheStorage.kt +++ b/app/shared/src/commonMain/kotlin/data/source/media/cache/DirectoryMediaCacheStorage.kt @@ -36,22 +36,23 @@ import me.him188.ani.datasources.api.source.MediaSourceLocation import me.him188.ani.datasources.api.source.matches import me.him188.ani.datasources.api.topic.FileSize import me.him188.ani.datasources.api.topic.FileSize.Companion.bytes +import me.him188.ani.utils.io.SystemPath +import me.him188.ani.utils.io.createDirectories +import me.him188.ani.utils.io.delete +import me.him188.ani.utils.io.exists +import me.him188.ani.utils.io.extension +import me.him188.ani.utils.io.moveTo +import me.him188.ani.utils.io.name +import me.him188.ani.utils.io.readText +import me.him188.ani.utils.io.resolve +import me.him188.ani.utils.io.useDirectoryEntries +import me.him188.ani.utils.io.writeText import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger import me.him188.ani.utils.logging.warn -import java.nio.file.Path import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext -import kotlin.io.path.createDirectories -import kotlin.io.path.deleteIfExists -import kotlin.io.path.exists -import kotlin.io.path.extension -import kotlin.io.path.moveTo -import kotlin.io.path.name -import kotlin.io.path.readText -import kotlin.io.path.useDirectoryEntries -import kotlin.io.path.writeText private const val METADATA_FILE_EXTENSION = "metadata" @@ -60,7 +61,7 @@ private const val METADATA_FILE_EXTENSION = "metadata" */ class DirectoryMediaCacheStorage( override val mediaSourceId: String, - private val metadataDir: Path, + private val metadataDir: SystemPath, private val engine: MediaCacheEngine, parentCoroutineContext: CoroutineContext = EmptyCoroutineContext, ) : MediaCacheStorage { @@ -125,7 +126,7 @@ class DirectoryMediaCacheStorage( } private suspend fun restoreFile( - file: Path, + file: SystemPath, reportRecovered: suspend (MediaCache) -> Unit, ) { if (file.extension != METADATA_FILE_EXTENSION) return @@ -134,7 +135,7 @@ class DirectoryMediaCacheStorage( json.decodeFromString(MediaCacheSave.serializer(), file.readText()) } catch (e: Exception) { logger.error(e) { "Failed to deserialize metadata file ${file.name}" } - file.deleteIfExists() + file.delete() return } @@ -228,9 +229,7 @@ class DirectoryMediaCacheStorage( val cache = listFlow.value.firstOrNull(predicate) ?: return false listFlow.value -= cache withContext(Dispatchers.IO) { - if (!metadataDir.resolve(getSaveFilename(cache)).deleteIfExists()) { - logger.error { "Attempting to delete media cache '${cache.cacheId}' but its corresponding metadata file does not exist" } - } + metadataDir.resolve(getSaveFilename(cache)).delete() } cache.deleteFiles() return true diff --git a/app/shared/src/commonMain/kotlin/data/source/media/cache/MediaCache.kt b/app/shared/src/commonMain/kotlin/data/source/media/cache/MediaCache.kt index 76bad5431c..a4fb5ffa2a 100644 --- a/app/shared/src/commonMain/kotlin/data/source/media/cache/MediaCache.kt +++ b/app/shared/src/commonMain/kotlin/data/source/media/cache/MediaCache.kt @@ -1,5 +1,6 @@ package me.him188.ani.app.data.source.media.cache +import kotlinx.atomicfu.atomic import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -9,7 +10,7 @@ import me.him188.ani.datasources.api.Media import me.him188.ani.datasources.api.MediaCacheMetadata import me.him188.ani.datasources.api.topic.FileSize import me.him188.ani.datasources.api.topic.FileSize.Companion.bytes -import java.util.concurrent.atomic.AtomicInteger +import me.him188.ani.utils.platform.annotations.TestOnly import kotlin.math.absoluteValue /** @@ -139,7 +140,10 @@ open class TestMediaCache( override val uploadSpeed: Flow = MutableStateFlow(1.bytes) override val finished: Flow by lazy { progress.map { it == 1f } } - val resumeCalled = AtomicInteger(0) + private val resumeCalled = atomic(0) + + @TestOnly + fun getResumeCalled() = resumeCalled.value override suspend fun pause() { println("pause") diff --git a/app/shared/src/commonMain/kotlin/data/source/media/instance/MediaSourceInstance.kt b/app/shared/src/commonMain/kotlin/data/source/media/instance/MediaSourceInstance.kt index 0679821f11..0f9a61928c 100644 --- a/app/shared/src/commonMain/kotlin/data/source/media/instance/MediaSourceInstance.kt +++ b/app/shared/src/commonMain/kotlin/data/source/media/instance/MediaSourceInstance.kt @@ -4,8 +4,7 @@ import androidx.compose.runtime.Stable import kotlinx.serialization.Serializable import me.him188.ani.datasources.api.source.MediaSource import me.him188.ani.datasources.api.source.MediaSourceConfig -import java.io.Closeable -import java.util.UUID +import me.him188.ani.utils.platform.Uuid /** * [MediaSource], 以及它的配置, 统称为 [MediaSourceInstance]. @@ -17,7 +16,7 @@ class MediaSourceInstance( val isEnabled: Boolean, val config: MediaSourceConfig, val source: MediaSource, -) : Closeable { +) : AutoCloseable { override fun close() { source.close() } @@ -34,7 +33,7 @@ data class MediaSourceSave( fun createTestMediaSourceInstance( source: MediaSource, - instanceId: String = UUID.randomUUID().toString(), + instanceId: String = Uuid.randomString(), mediaSourceId: String = source.mediaSourceId, isEnabled: Boolean = true, config: MediaSourceConfig = MediaSourceConfig.Default, diff --git a/app/shared/src/commonMain/kotlin/data/source/media/resolver/LocalFileVideoSourceResolver.kt b/app/shared/src/commonMain/kotlin/data/source/media/resolver/LocalFileVideoSourceResolver.kt index 869cf960eb..532dd2e1d6 100644 --- a/app/shared/src/commonMain/kotlin/data/source/media/resolver/LocalFileVideoSourceResolver.kt +++ b/app/shared/src/commonMain/kotlin/data/source/media/resolver/LocalFileVideoSourceResolver.kt @@ -1,10 +1,11 @@ package me.him188.ani.app.data.source.media.resolver +import kotlinx.io.files.Path import me.him188.ani.app.videoplayer.data.VideoSource import me.him188.ani.app.videoplayer.torrent.FileVideoSource import me.him188.ani.datasources.api.Media import me.him188.ani.datasources.api.topic.ResourceLocation -import java.io.File +import me.him188.ani.utils.io.inSystem class LocalFileVideoSourceResolver : VideoSourceResolver { override suspend fun supports(media: Media): Boolean { @@ -15,7 +16,7 @@ class LocalFileVideoSourceResolver : VideoSourceResolver { when (val download = media.download) { is ResourceLocation.LocalFile -> { return FileVideoSource( - File(download.filePath), + Path(download.filePath).inSystem, media.extraFiles, ) } diff --git a/app/shared/src/commonMain/kotlin/data/source/media/resolver/VideoSourceResolver.kt b/app/shared/src/commonMain/kotlin/data/source/media/resolver/VideoSourceResolver.kt index 76c4dcad2a..0af909bbf2 100644 --- a/app/shared/src/commonMain/kotlin/data/source/media/resolver/VideoSourceResolver.kt +++ b/app/shared/src/commonMain/kotlin/data/source/media/resolver/VideoSourceResolver.kt @@ -1,10 +1,10 @@ package me.him188.ani.app.data.source.media.resolver import androidx.compose.runtime.Composable +import kotlinx.coroutines.CancellationException import me.him188.ani.app.videoplayer.data.VideoSource import me.him188.ani.datasources.api.EpisodeSort import me.him188.ani.datasources.api.Media -import kotlin.coroutines.cancellation.CancellationException /** * 根据 [EpisodeMetadata] 中的集数信息和 [Media.location] 中的下载方式, diff --git a/app/shared/src/commonMain/kotlin/platform/CommonKoinModule.kt b/app/shared/src/commonMain/kotlin/platform/CommonKoinModule.kt index 5b0ecb00e1..cc2678349e 100644 --- a/app/shared/src/commonMain/kotlin/platform/CommonKoinModule.kt +++ b/app/shared/src/commonMain/kotlin/platform/CommonKoinModule.kt @@ -89,17 +89,17 @@ import me.him188.ani.datasources.bangumi.DelegateBangumiClient import me.him188.ani.utils.coroutines.childScope import me.him188.ani.utils.coroutines.childScopeContext import me.him188.ani.utils.coroutines.onReplacement +import me.him188.ani.utils.io.resolve import me.him188.ani.utils.ktor.ClientProxyConfig import me.him188.ani.utils.ktor.proxy import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger import me.him188.ani.utils.logging.warn +import me.him188.ani.utils.platform.Uuid import org.koin.core.KoinApplication import org.koin.dsl.module -import java.util.UUID import kotlin.coroutines.CoroutineContext -@Suppress("UnusedReceiverParameter") // bug fun KoinApplication.getCommonKoinModule(getContext: () -> Context, coroutineScope: CoroutineScope) = module { // Repositories single { TokenRepositoryImpl(getContext().tokenStore) } @@ -171,7 +171,7 @@ fun KoinApplication.getCommonKoinModule(getContext: () -> Context, coroutineScop add( DirectoryMediaCacheStorage( mediaSourceId = "test-in-memory", - metadataDir = getMediaMetadataDir("test-in-memory").toPath(), + metadataDir = getMediaMetadataDir("test-in-memory"), engine = DummyMediaCacheEngine("test-in-memory"), coroutineScope.childScopeContext(), ), @@ -181,7 +181,7 @@ fun KoinApplication.getCommonKoinModule(getContext: () -> Context, coroutineScop add( DirectoryMediaCacheStorage( mediaSourceId = id, - metadataDir = getMediaMetadataDir(engine.type.id).toPath(), + metadataDir = getMediaMetadataDir(engine.type.id), engine = TorrentMediaCacheEngine( mediaSourceId = id, torrentEngine = engine, @@ -240,7 +240,7 @@ fun KoinApplication.startCommonKoinModule(coroutineScope: CoroutineScope): KoinA } mediaSourceInstanceRepository.add( mediaSourceSave = MediaSourceSave( - instanceId = UUID.randomUUID().toString(), + instanceId = Uuid.randomString(), mediaSourceId = id, isEnabled = true, config = MediaSourceConfig.Default, diff --git a/app/shared/src/commonMain/kotlin/platform/Context.kt b/app/shared/src/commonMain/kotlin/platform/Context.kt index 64c2f0957e..084a27828b 100644 --- a/app/shared/src/commonMain/kotlin/platform/Context.kt +++ b/app/shared/src/commonMain/kotlin/platform/Context.kt @@ -26,7 +26,7 @@ import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.Stable import androidx.compose.ui.unit.dp import me.him188.ani.app.platform.window.PlatformWindowMP -import java.io.File +import me.him188.ani.utils.io.SystemPath expect val LocalContext: ProvidableCompositionLocal @@ -38,12 +38,12 @@ val Context.files: ContextFiles get() = filesImpl internal expect val Context.filesImpl: ContextFiles interface ContextFiles { - val cacheDir: File + val cacheDir: SystemPath /** * filesDir on Android. */ - val dataDir: File + val dataDir: SystemPath } /** diff --git a/app/shared/src/commonMain/kotlin/tools/MonoTasker.kt b/app/shared/src/commonMain/kotlin/tools/MonoTasker.kt index b411607a1a..aa7f9a4c82 100644 --- a/app/shared/src/commonMain/kotlin/tools/MonoTasker.kt +++ b/app/shared/src/commonMain/kotlin/tools/MonoTasker.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred @@ -17,7 +18,6 @@ import me.him188.ani.app.ui.foundation.HasBackgroundScope import me.him188.ani.app.ui.foundation.produceState import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext -import kotlin.coroutines.cancellation.CancellationException @Stable interface MonoTasker { diff --git a/app/shared/src/commonMain/kotlin/tools/TimeFormatter.kt b/app/shared/src/commonMain/kotlin/tools/TimeFormatter.kt index 0f20fcd8dc..489abc743f 100644 --- a/app/shared/src/commonMain/kotlin/tools/TimeFormatter.kt +++ b/app/shared/src/commonMain/kotlin/tools/TimeFormatter.kt @@ -1,27 +1,43 @@ package me.him188.ani.app.tools -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.temporal.ChronoUnit +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format.DateTimeFormat +import kotlinx.datetime.format.char +import kotlinx.datetime.toLocalDateTime + +private val yyyyMMdd = LocalDateTime.Format { + year() + char('-') + monthNumber() + char('-') + dayOfMonth() + char(' ') + hour() + char(':') + minute() +} /** * @see formatDateTime */ // TimeFormatterTest class TimeFormatter( - private val formatterWithTime: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") - .withZone(ZoneId.systemDefault()), - private val formatterWithoutTime: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") - .withZone(ZoneId.systemDefault()), - private val getTimeNow: () -> Long = { System.currentTimeMillis() }, + private val formatterWithTime: DateTimeFormat = yyyyMMdd, + private val formatterWithoutTime: DateTimeFormat = yyyyMMdd, + private val getTimeNow: () -> Instant = { Clock.System.now() }, ) { fun format(timestamp: Long, showTime: Boolean = true): String { + return format(Instant.fromEpochMilliseconds(timestamp), showTime) + } + + fun format(instant: Instant, showTime: Boolean = true): String { val now = getTimeNow() - val differenceInSeconds = ChronoUnit.SECONDS.between(Instant.ofEpochMilli(timestamp), Instant.ofEpochMilli(now)) // written by ChatGPT - return when (differenceInSeconds) { + return when (val differenceInSeconds = (now - instant).inWholeSeconds) { in 0..1L -> "刚刚" in 0..59 -> "$differenceInSeconds 秒前" in -60..0 -> "${-differenceInSeconds} 秒后" @@ -31,10 +47,10 @@ class TimeFormatter( in -86400..<-3600 -> "${-differenceInSeconds / 3600} 小时后" in 86400..<86400 * 2 -> "${differenceInSeconds / 86400} 天前" in -86400 * 2..<-86400 -> "${differenceInSeconds / 86400} 天后" - else -> getFormatter(showTime).format(Instant.ofEpochMilli(timestamp)) + else -> getFormatter(showTime).format(instant.toLocalDateTime(TimeZone.currentSystemDefault())) } } private fun getFormatter(showTime: Boolean) = if (showTime) formatterWithTime else formatterWithoutTime -} \ No newline at end of file +} diff --git a/app/shared/src/commonMain/kotlin/tools/torrent/TorrentEngine.kt b/app/shared/src/commonMain/kotlin/tools/torrent/TorrentEngine.kt index fd721c3d8f..58712e5eed 100644 --- a/app/shared/src/commonMain/kotlin/tools/torrent/TorrentEngine.kt +++ b/app/shared/src/commonMain/kotlin/tools/torrent/TorrentEngine.kt @@ -1,6 +1,7 @@ package me.him188.ani.app.tools.torrent import androidx.annotation.CallSuper +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -16,7 +17,6 @@ import me.him188.ani.datasources.api.source.MediaSourceLocation import me.him188.ani.utils.coroutines.onReplacement import me.him188.ani.utils.logging.logger import me.him188.ani.utils.logging.warn -import kotlin.coroutines.cancellation.CancellationException /** diff --git a/app/shared/src/commonMain/kotlin/tools/torrent/TorrentManager.kt b/app/shared/src/commonMain/kotlin/tools/torrent/TorrentManager.kt index ec124574ad..46108b871a 100644 --- a/app/shared/src/commonMain/kotlin/tools/torrent/TorrentManager.kt +++ b/app/shared/src/commonMain/kotlin/tools/torrent/TorrentManager.kt @@ -8,9 +8,9 @@ import me.him188.ani.app.data.repository.SettingsRepository import me.him188.ani.app.platform.Platform import me.him188.ani.app.tools.torrent.engines.AnitorrentEngine import me.him188.ani.utils.coroutines.childScope +import me.him188.ani.utils.io.SystemPath import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import java.io.File import kotlin.coroutines.CoroutineContext /** @@ -43,7 +43,7 @@ class TorrentDownloaderManagerError( class DefaultTorrentManager( parentCoroutineContext: CoroutineContext, - private val saveDir: (type: TorrentEngineType) -> File, + private val saveDir: (type: TorrentEngineType) -> SystemPath, ) : TorrentManager, KoinComponent { private val settingsRepository: SettingsRepository by inject() diff --git a/app/shared/src/commonMain/kotlin/tools/torrent/engines/AnitorrentEngine.kt b/app/shared/src/commonMain/kotlin/tools/torrent/engines/AnitorrentEngine.kt index 6c9dae7062..a7cb67631f 100644 --- a/app/shared/src/commonMain/kotlin/tools/torrent/engines/AnitorrentEngine.kt +++ b/app/shared/src/commonMain/kotlin/tools/torrent/engines/AnitorrentEngine.kt @@ -23,12 +23,12 @@ import me.him188.ani.app.torrent.api.HttpFileDownloader import me.him188.ani.app.torrent.api.TorrentDownloader import me.him188.ani.app.torrent.api.TorrentDownloaderConfig import me.him188.ani.datasources.api.source.MediaSourceLocation +import me.him188.ani.utils.io.SystemPath import me.him188.ani.utils.ktor.createDefaultHttpClient import me.him188.ani.utils.ktor.proxy import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.warn -import java.io.File @Serializable class AnitorrentConfig( @@ -49,7 +49,7 @@ class AnitorrentEngine( scope: CoroutineScope, config: Flow, private val proxySettings: Flow, - private val saveDir: File, + private val saveDir: SystemPath, ) : AbstractTorrentEngine( scope = scope, type = TorrentEngineType.Anitorrent, diff --git a/app/shared/src/commonMain/kotlin/tools/update/FileDownloader.kt b/app/shared/src/commonMain/kotlin/tools/update/FileDownloader.kt index 55cf2c1202..6bef34d5b1 100644 --- a/app/shared/src/commonMain/kotlin/tools/update/FileDownloader.kt +++ b/app/shared/src/commonMain/kotlin/tools/update/FileDownloader.kt @@ -19,13 +19,22 @@ import me.him188.ani.app.platform.getAniUserAgent import me.him188.ani.datasources.api.topic.FileSize.Companion.bytes import me.him188.ani.utils.coroutines.cancellableCoroutineScope import me.him188.ani.utils.coroutines.withExceptionCollector +import me.him188.ani.utils.io.DigestAlgorithm +import me.him188.ani.utils.io.SystemPath +import me.him188.ani.utils.io.absolutePath +import me.him188.ani.utils.io.bufferedSink +import me.him188.ani.utils.io.bufferedSource +import me.him188.ani.utils.io.delete +import me.him188.ani.utils.io.exists +import me.him188.ani.utils.io.length +import me.him188.ani.utils.io.readAndDigest +import me.him188.ani.utils.io.readText +import me.him188.ani.utils.io.resolve +import me.him188.ani.utils.io.writeText import me.him188.ani.utils.ktor.createDefaultHttpClient import me.him188.ani.utils.ktor.userAgent import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger -import java.io.File -import java.io.InputStream -import java.security.MessageDigest import kotlin.time.Duration.Companion.seconds /** @@ -51,7 +60,7 @@ interface FileDownloader { suspend fun download( alternativeUrls: List, filenameProvider: (url: String) -> String, - saveDir: File, + saveDir: SystemPath, ): Boolean } @@ -79,7 +88,7 @@ sealed class FileDownloaderState { /** * 下载完成的文件 */ - val file: File, + val file: SystemPath, // 校验和文件总是 `${file.name}.sha256` ) : Completed() @@ -96,10 +105,11 @@ class DefaultFileDownloader : FileDownloader { private val _progress = MutableStateFlow(0f) override val progress get() = _progress + @OptIn(ExperimentalStdlibApi::class) override suspend fun download( alternativeUrls: List, filenameProvider: (url: String) -> String, - saveDir: File, + saveDir: SystemPath, ): Boolean { require(alternativeUrls.isNotEmpty()) { "alternatives must not be empty" } state.update { @@ -126,8 +136,8 @@ class DefaultFileDownloader : FileDownloader { if (targetFile.exists() && checksumFile.exists()) { logger.info { "File $filename already exists, size=${targetFile.length().bytes}, checking checksum" } val checksum = checksumFile.readText() - val actualChecksum = targetFile.inputStream().use { - it.sha256() + val actualChecksum = targetFile.bufferedSource().use { + it.readAndDigest(DigestAlgorithm.SHA256).toHexString() } if (checksum == actualChecksum) { logger.info { "File $filename already exists and checksum matches, skipping download" } @@ -141,9 +151,6 @@ class DefaultFileDownloader : FileDownloader { } } } - withContext(Dispatchers.IO) { - targetFile.createNewFile() // fail-fast, check permission - } tryDownload( client, url, @@ -151,8 +158,8 @@ class DefaultFileDownloader : FileDownloader { ) // 下载完成, 更新 checksum checksumFile.writeText( - targetFile.inputStream().use { - it.sha256() + targetFile.bufferedSource().use { + it.readAndDigest(DigestAlgorithm.SHA256).toHexString() }, ) state.value = FileDownloaderState.Succeed(url, targetFile) @@ -176,7 +183,7 @@ class DefaultFileDownloader : FileDownloader { private suspend fun tryDownload( client: HttpClient, url: String, - file: File, + file: SystemPath, ) { cancellableCoroutineScope { logger.info { "Attempting $url" } @@ -195,7 +202,7 @@ class DefaultFileDownloader : FileDownloader { } } } - file.outputStream().use { output -> + file.bufferedSink().use { output -> while (!input.isClosedForRead) { val read = input.readAvailable(buffer, 0, buffer.size) if (read == -1) { @@ -218,13 +225,3 @@ class DefaultFileDownloader : FileDownloader { } } } - -@OptIn(ExperimentalStdlibApi::class) -private fun InputStream.sha256(): String = MessageDigest.getInstance("SHA-256").apply { - val buffer = ByteArray(8192) - var read: Int - while (read(buffer).also { read = it } != -1) { - update(buffer, 0, read) - } -}.digest().toHexString() - diff --git a/app/shared/src/commonMain/kotlin/tools/update/UpdateInstaller.kt b/app/shared/src/commonMain/kotlin/tools/update/UpdateInstaller.kt index bfef53b28c..2a7c5e028f 100644 --- a/app/shared/src/commonMain/kotlin/tools/update/UpdateInstaller.kt +++ b/app/shared/src/commonMain/kotlin/tools/update/UpdateInstaller.kt @@ -3,7 +3,7 @@ package me.him188.ani.app.tools.update import androidx.compose.runtime.Stable import me.him188.ani.app.platform.ContextMP import me.him188.ani.app.platform.Platform -import java.io.File +import me.him188.ani.utils.io.SystemPath /** * 安装包安装器 @@ -16,9 +16,9 @@ interface UpdateInstaller { /** * 如果 [install] 可能返回 [InstallationResult.Failed], 则需实现 */ - fun openForManualInstallation(file: File, context: ContextMP) {} + fun openForManualInstallation(file: SystemPath, context: ContextMP) {} - fun install(file: File, context: ContextMP): InstallationResult + fun install(file: SystemPath, context: ContextMP): InstallationResult } sealed class InstallationResult { diff --git a/app/shared/src/commonMain/kotlin/ui/foundation/Debug.kt b/app/shared/src/commonMain/kotlin/ui/foundation/Debug.kt index dff67fad6a..255cce948b 100644 --- a/app/shared/src/commonMain/kotlin/ui/foundation/Debug.kt +++ b/app/shared/src/commonMain/kotlin/ui/foundation/Debug.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.setValue import me.him188.ani.app.data.models.preference.DebugSettings import me.him188.ani.app.data.repository.SettingsRepository import me.him188.ani.app.ui.settings.framework.AbstractSettingsViewModel -import me.him188.ani.utils.coroutines.TestOnly +import me.him188.ani.utils.platform.annotations.TestOnly import org.koin.core.component.KoinComponent import org.koin.core.component.inject diff --git a/app/shared/src/commonMain/kotlin/ui/foundation/Previewing.kt b/app/shared/src/commonMain/kotlin/ui/foundation/Previewing.kt index e8c763745e..58e9935126 100644 --- a/app/shared/src/commonMain/kotlin/ui/foundation/Previewing.kt +++ b/app/shared/src/commonMain/kotlin/ui/foundation/Previewing.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.DpSize import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.io.files.Path import me.him188.ani.app.data.source.media.resolver.HttpStreamingVideoSourceResolver import me.him188.ani.app.data.source.media.resolver.LocalFileVideoSourceResolver import me.him188.ani.app.data.source.media.resolver.TorrentVideoSourceResolver @@ -55,11 +56,11 @@ import me.him188.ani.app.ui.foundation.layout.LocalLayoutMode import me.him188.ani.app.ui.main.AniApp import me.him188.ani.app.videoplayer.ui.state.DummyPlayerState import me.him188.ani.app.videoplayer.ui.state.PlayerStateFactory +import me.him188.ani.utils.io.inSystem import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.core.module.Module import org.koin.dsl.module -import java.io.File val LocalIsPreviewing = staticCompositionLocalOf { false @@ -97,7 +98,7 @@ fun ProvideCompositionLocalsForPreview( ) } single { - DefaultTorrentManager(globalScope.coroutineContext) { File("preview-cache") } + DefaultTorrentManager(globalScope.coroutineContext) { Path("preview-cache").inSystem } } single { GrantedPermissionManager } single { NoopNotifManager } diff --git a/app/shared/src/commonMain/kotlin/ui/foundation/avatar/AvatarImage.kt b/app/shared/src/commonMain/kotlin/ui/foundation/avatar/AvatarImage.kt index cf402a91b6..b12a84e094 100644 --- a/app/shared/src/commonMain/kotlin/ui/foundation/avatar/AvatarImage.kt +++ b/app/shared/src/commonMain/kotlin/ui/foundation/avatar/AvatarImage.kt @@ -10,13 +10,11 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import me.him188.ani.app.ui.foundation.AsyncImage -import java.io.File @Composable fun AvatarImage( url: String?, modifier: Modifier = Modifier, - filePath: String? = null, colorFilter: ColorFilter? = null, contentScale: ContentScale = ContentScale.Crop, alignment: Alignment = Alignment.Center, @@ -25,7 +23,7 @@ fun AvatarImage( Icon(Icons.Rounded.Person, null, modifier) } else { AsyncImage( - model = if (filePath != null) File(filePath) else url, + model = url, contentDescription = "Avatar", modifier = modifier, error = rememberVectorPainter(Icons.Rounded.Person), diff --git a/app/shared/src/commonMain/kotlin/ui/profile/BangumiOAuthViewModel.kt b/app/shared/src/commonMain/kotlin/ui/profile/BangumiOAuthViewModel.kt index 11894bd07e..62c6246fe5 100644 --- a/app/shared/src/commonMain/kotlin/ui/profile/BangumiOAuthViewModel.kt +++ b/app/shared/src/commonMain/kotlin/ui/profile/BangumiOAuthViewModel.kt @@ -38,9 +38,9 @@ import me.him188.ani.app.ui.foundation.AbstractViewModel import me.him188.ani.app.ui.foundation.feedback.ErrorMessage import me.him188.ani.utils.logging.debug import me.him188.ani.utils.logging.info +import me.him188.ani.utils.platform.Uuid import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import java.util.UUID @Stable class BangumiOAuthViewModel : AbstractViewModel(), KoinComponent { @@ -57,7 +57,7 @@ class BangumiOAuthViewModel : AbstractViewModel(), KoinComponent { */ val needAuth by sessionManager.isSessionVerified.map { !it }.produceState(true) - var requestIdFlow = MutableStateFlow(UUID.randomUUID().toString()) + var requestIdFlow = MutableStateFlow(Uuid.randomString()) /** * 当前是第几次尝试 @@ -122,7 +122,7 @@ class BangumiOAuthViewModel : AbstractViewModel(), KoinComponent { @UiThread fun refresh() { logger.debug { "refresh" } - requestIdFlow.value = UUID.randomUUID().toString() + requestIdFlow.value = Uuid.randomString() } fun onCancel() { diff --git a/app/shared/src/commonMain/kotlin/ui/settings/tabs/app/AppSettingsTab.kt b/app/shared/src/commonMain/kotlin/ui/settings/tabs/app/AppSettingsTab.kt index 6ece5f2695..ccfea91646 100644 --- a/app/shared/src/commonMain/kotlin/ui/settings/tabs/app/AppSettingsTab.kt +++ b/app/shared/src/commonMain/kotlin/ui/settings/tabs/app/AppSettingsTab.kt @@ -54,7 +54,6 @@ import me.him188.ani.app.ui.update.UpdateChecker import me.him188.ani.danmaku.protocol.ReleaseClass import org.koin.core.component.inject import org.koin.core.context.GlobalContext -import java.util.Locale sealed class CheckVersionResult { @@ -449,7 +448,7 @@ private fun ReleaseClassIcon(releaseClass: ReleaseClass) { private fun guessReleaseClass(version: String): ReleaseClass { val metadata = version .substringAfter("-", "") - .lowercase(Locale.ENGLISH) + .lowercase() return when { metadata.isEmpty() -> ReleaseClass.STABLE "alpha" in metadata || "dev" in metadata -> ReleaseClass.ALPHA diff --git a/app/shared/src/commonMain/kotlin/ui/subject/cache/EpisodeCacheListState.kt b/app/shared/src/commonMain/kotlin/ui/subject/cache/EpisodeCacheListState.kt index 987238b840..5a9bc97ec6 100644 --- a/app/shared/src/commonMain/kotlin/ui/subject/cache/EpisodeCacheListState.kt +++ b/app/shared/src/commonMain/kotlin/ui/subject/cache/EpisodeCacheListState.kt @@ -3,6 +3,7 @@ package me.him188.ani.app.ui.subject.cache import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import me.him188.ani.app.data.source.media.cache.MediaCacheEngine import me.him188.ani.app.data.source.media.cache.MediaCacheStorage @@ -14,7 +15,6 @@ import me.him188.ani.app.ui.foundation.BackgroundScope import me.him188.ani.app.ui.foundation.HasBackgroundScope import me.him188.ani.datasources.api.Media import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.cancellation.CancellationException @Stable interface EpisodeCacheListState { diff --git a/app/shared/src/commonMain/kotlin/ui/subject/episode/video/PlayerLauncher.kt b/app/shared/src/commonMain/kotlin/ui/subject/episode/video/PlayerLauncher.kt index 5061b18704..13e5616393 100644 --- a/app/shared/src/commonMain/kotlin/ui/subject/episode/video/PlayerLauncher.kt +++ b/app/shared/src/commonMain/kotlin/ui/subject/episode/video/PlayerLauncher.kt @@ -1,5 +1,6 @@ package me.him188.ani.app.ui.subject.episode.video +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -32,7 +33,6 @@ import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.cancellation.CancellationException /** * 将 [MediaSelector] 和 [videoSourceResolver] 结合, 为 [playerState] 提供视频源. diff --git a/app/shared/src/commonMain/kotlin/ui/update/AutoUpdateViewModel.kt b/app/shared/src/commonMain/kotlin/ui/update/AutoUpdateViewModel.kt index f6939452e5..91b6d50379 100644 --- a/app/shared/src/commonMain/kotlin/ui/update/AutoUpdateViewModel.kt +++ b/app/shared/src/commonMain/kotlin/ui/update/AutoUpdateViewModel.kt @@ -19,6 +19,10 @@ import me.him188.ani.app.tools.MonoTasker import me.him188.ani.app.tools.update.DefaultFileDownloader import me.him188.ani.app.tools.update.FileDownloaderState import me.him188.ani.app.ui.foundation.AbstractViewModel +import me.him188.ani.utils.io.createDirectories +import me.him188.ani.utils.io.exists +import me.him188.ani.utils.io.inSystem +import me.him188.ani.utils.io.list import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.warn import org.koin.core.component.KoinComponent @@ -155,17 +159,17 @@ class AutoUpdateViewModel : AbstractViewModel(), KoinComponent { val allowedFilenames = ver.downloadUrlAlternatives.map { it.substringAfterLast("/", "") } - for (file in dir.listFiles().orEmpty()) { - if (file.name.equals(".DS_Store")) continue + for (file in dir.list()) { + if (file.name == ".DS_Store") continue if (allowedFilenames.none { file.name.contains(it) }) { logger.info { "Deleting old installer: $file" } - updateManager.deleteInstaller(file) + updateManager.deleteInstaller(file.inSystem) } } } - withContext(Dispatchers.IO) { dir.mkdirs() } + withContext(Dispatchers.IO) { dir.createDirectories() } fileDownloader.download( alternativeUrls = ver.downloadUrlAlternatives, filenameProvider = { it.substringAfterLast("/", "") }, diff --git a/app/shared/src/commonMain/kotlin/ui/update/UpdateChecker.kt b/app/shared/src/commonMain/kotlin/ui/update/UpdateChecker.kt index c1c49ce159..4f14ac1256 100644 --- a/app/shared/src/commonMain/kotlin/ui/update/UpdateChecker.kt +++ b/app/shared/src/commonMain/kotlin/ui/update/UpdateChecker.kt @@ -8,6 +8,7 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.appendPathSegments import io.ktor.utils.io.jvm.javaio.toInputStream import kotlinx.coroutines.CancellationException +import kotlinx.datetime.Instant import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import me.him188.ani.app.platform.Platform @@ -21,7 +22,6 @@ import me.him188.ani.utils.coroutines.withExceptionCollector import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger -import java.time.Instant class CheckVersionFailedException( @@ -62,7 +62,7 @@ class UpdateChecker { val version = tag.substringAfter("v") val publishedAt = kotlin.runCatching { TimeFormatter().format( - Instant.parse(release.publishedAt).toEpochMilli(), + Instant.parse(release.publishedAt), ) }.getOrElse { release.publishedAt } val downloadUrl = release.assets diff --git a/app/shared/src/commonMain/kotlin/ui/update/UpdateLogoState.kt b/app/shared/src/commonMain/kotlin/ui/update/UpdateLogoState.kt index e79207994e..ccbc29f7ce 100644 --- a/app/shared/src/commonMain/kotlin/ui/update/UpdateLogoState.kt +++ b/app/shared/src/commonMain/kotlin/ui/update/UpdateLogoState.kt @@ -6,8 +6,8 @@ import me.him188.ani.app.platform.ContextMP import me.him188.ani.app.tools.update.InstallationFailureReason import me.him188.ani.app.tools.update.InstallationResult import me.him188.ani.app.tools.update.UpdateInstaller +import me.him188.ani.utils.io.SystemPath import org.koin.core.context.GlobalContext -import java.io.File /** * UI 的"有新版本"标识的状态 @@ -62,7 +62,7 @@ sealed interface UpdateLogoState { @Immutable data class Downloaded( override val version: NewVersion, - val file: File, + val file: SystemPath, ) : HasNewVersion companion object diff --git a/app/shared/src/desktopMain/kotlin/data/persistent/SettingsStore.desktop.kt b/app/shared/src/desktopMain/kotlin/data/persistent/SettingsStore.desktop.kt index a1612d0cad..ec80359f3f 100644 --- a/app/shared/src/desktopMain/kotlin/data/persistent/SettingsStore.desktop.kt +++ b/app/shared/src/desktopMain/kotlin/data/persistent/SettingsStore.desktop.kt @@ -22,7 +22,9 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import me.him188.ani.app.platform.Context import me.him188.ani.app.platform.DesktopContext -import java.io.File +import me.him188.ani.utils.io.SystemPath +import me.him188.ani.utils.io.inSystem +import me.him188.ani.utils.io.toKtPath actual val Context.preferencesStore: DataStore get() { @@ -44,5 +46,5 @@ actual val Context.dataStoresImpl: PlatformDataStoreManager internal class PlatformDataStoreManagerDesktop( private val context: DesktopContext, ) : PlatformDataStoreManager() { - override fun resolveDataStoreFile(name: String): File = context.dataStoreDir.resolve(name) + override fun resolveDataStoreFile(name: String): SystemPath = context.dataStoreDir.resolve(name).toKtPath().inSystem } diff --git a/app/shared/src/desktopMain/kotlin/platform/Context.desktop.kt b/app/shared/src/desktopMain/kotlin/platform/Context.desktop.kt index fa4a6f7495..4035aafaf5 100644 --- a/app/shared/src/desktopMain/kotlin/platform/Context.desktop.kt +++ b/app/shared/src/desktopMain/kotlin/platform/Context.desktop.kt @@ -37,6 +37,9 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import me.him188.ani.app.platform.window.PlatformWindow import me.him188.ani.app.platform.window.WindowUtils +import me.him188.ani.utils.io.SystemPath +import me.him188.ani.utils.io.inSystem +import me.him188.ani.utils.io.toKtPath import java.io.File import kotlin.contracts.contract @@ -105,8 +108,8 @@ actual fun Context.setRequestFullScreen(window: PlatformWindow, fullscreen: Bool internal actual val Context.filesImpl: ContextFiles get() = object : ContextFiles { - override val cacheDir: File = (this@filesImpl as DesktopContext).cacheDir - override val dataDir: File = (this@filesImpl as DesktopContext).dataDir + override val cacheDir: SystemPath = (this@filesImpl as DesktopContext).cacheDir.toKtPath().inSystem + override val dataDir: SystemPath = (this@filesImpl as DesktopContext).dataDir.toKtPath().inSystem } @Composable diff --git a/app/shared/src/desktopMain/kotlin/platform/FileOpener.kt b/app/shared/src/desktopMain/kotlin/platform/FileOpener.kt index 04a84b3f0f..8e59ef8f8c 100644 --- a/app/shared/src/desktopMain/kotlin/platform/FileOpener.kt +++ b/app/shared/src/desktopMain/kotlin/platform/FileOpener.kt @@ -18,10 +18,15 @@ package me.him188.ani.app.platform +import me.him188.ani.utils.io.SystemPath +import me.him188.ani.utils.io.toFile import java.awt.Desktop import java.io.File object FileOpener { + fun openInFileBrowser(file: SystemPath) { + return openInFileBrowser(file.toFile()) + } /** * 在 Windows 资源管理器或 macOS Finder 中打开文件所在目录, 并高亮该文件 diff --git a/app/shared/src/desktopMain/kotlin/tools/update/DesktopUpdateInstaller.kt b/app/shared/src/desktopMain/kotlin/tools/update/DesktopUpdateInstaller.kt index 6195dbf053..80aed553f2 100644 --- a/app/shared/src/desktopMain/kotlin/tools/update/DesktopUpdateInstaller.kt +++ b/app/shared/src/desktopMain/kotlin/tools/update/DesktopUpdateInstaller.kt @@ -3,6 +3,8 @@ package me.him188.ani.app.tools.update import me.him188.ani.app.platform.ContextMP import me.him188.ani.app.platform.FileOpener import me.him188.ani.app.platform.Platform +import me.him188.ani.utils.io.SystemPath +import me.him188.ani.utils.io.toFile import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger import org.intellij.lang.annotations.Language @@ -11,7 +13,7 @@ import java.io.File import kotlin.system.exitProcess interface DesktopUpdateInstaller : UpdateInstaller { - override fun openForManualInstallation(file: File, context: ContextMP) { + override fun openForManualInstallation(file: SystemPath, context: ContextMP) { FileOpener.openInFileBrowser(file) } @@ -28,8 +30,8 @@ interface DesktopUpdateInstaller : UpdateInstaller { } object MacOSUpdateInstaller : DesktopUpdateInstaller { - override fun install(file: File, context: ContextMP): InstallationResult { - Desktop.getDesktop().open(file) + override fun install(file: SystemPath, context: ContextMP): InstallationResult { + Desktop.getDesktop().open(file.toFile()) exitProcess(0) } } @@ -37,7 +39,7 @@ object MacOSUpdateInstaller : DesktopUpdateInstaller { object WindowsUpdateInstaller : DesktopUpdateInstaller { private val logger = logger() - override fun install(file: File, context: ContextMP): InstallationResult { + override fun install(file: SystemPath, context: ContextMP): InstallationResult { logger.info { "Installing update for Windows" } val appDir = File(System.getProperty("user.dir") ?: throw IllegalStateException("Cannot get app directory")) logger.info { "Current app dir: ${appDir.absolutePath}" } @@ -47,7 +49,7 @@ object WindowsUpdateInstaller : DesktopUpdateInstaller { } val installerScriptFile = appDir.resolve("install.cmd") - installerScriptFile.writeText(getInstallerScript(file)) + installerScriptFile.writeText(getInstallerScript(file.toFile())) logger.info { "Installer script written to ${installerScriptFile.absolutePath}" } val processBuilder = ProcessBuilder("cmd", "/c", "start", "cmd", "/c", installerScriptFile.name) diff --git a/app/shared/src/desktopTest/kotlin/data/models/ApiResponseTest.kt b/app/shared/src/desktopTest/kotlin/data/models/ApiResponseTest.kt index f1161ca122..33acf3bda1 100644 --- a/app/shared/src/desktopTest/kotlin/data/models/ApiResponseTest.kt +++ b/app/shared/src/desktopTest/kotlin/data/models/ApiResponseTest.kt @@ -1,8 +1,8 @@ package me.him188.ani.app.data.models import io.ktor.utils.io.errors.IOException +import kotlinx.coroutines.CancellationException import org.junit.jupiter.api.assertThrows -import kotlin.coroutines.cancellation.CancellationException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith diff --git a/app/shared/src/desktopTest/kotlin/data/source/media/cache/DirectoryMediaCacheStorageTest.kt b/app/shared/src/desktopTest/kotlin/data/source/media/cache/DirectoryMediaCacheStorageTest.kt index 8a63a103ee..66935c27fd 100644 --- a/app/shared/src/desktopTest/kotlin/data/source/media/cache/DirectoryMediaCacheStorageTest.kt +++ b/app/shared/src/desktopTest/kotlin/data/source/media/cache/DirectoryMediaCacheStorageTest.kt @@ -12,6 +12,8 @@ import me.him188.ani.datasources.api.source.MediaSourceLocation import me.him188.ani.datasources.api.topic.EpisodeRange import me.him188.ani.datasources.api.topic.FileSize.Companion.megaBytes import me.him188.ani.datasources.api.topic.ResourceLocation +import me.him188.ani.utils.io.inSystem +import me.him188.ani.utils.io.toKtPath import org.junit.jupiter.api.io.TempDir import java.io.File import kotlin.test.Test @@ -48,7 +50,8 @@ class DirectoryMediaCacheStorageTest { @Test fun `create and fine with resume`() = runTest { - val storage = DirectoryMediaCacheStorage(CACHE_MEDIA_SOURCE_ID, dir.toPath(), engine, this.coroutineContext) + val storage = + DirectoryMediaCacheStorage(CACHE_MEDIA_SOURCE_ID, dir.toKtPath().inSystem, engine, this.coroutineContext) val cache = storage.cache( media, @@ -61,20 +64,21 @@ class DirectoryMediaCacheStorageTest { ), resume = true, ) as TestMediaCache - assertEquals(1, cache.resumeCalled.get()) + assertEquals(1, cache.getResumeCalled()) assertSame( cache, storage.listFlow.first().single(), ) - assertEquals(1, cache.resumeCalled.get()) + assertEquals(1, cache.getResumeCalled()) storage.close() } @Test fun `create and find without resume`() = runTest { - val storage = DirectoryMediaCacheStorage(CACHE_MEDIA_SOURCE_ID, dir.toPath(), engine, this.coroutineContext) + val storage = + DirectoryMediaCacheStorage(CACHE_MEDIA_SOURCE_ID, dir.toKtPath().inSystem, engine, this.coroutineContext) val cache = storage.cache( media, @@ -87,20 +91,21 @@ class DirectoryMediaCacheStorageTest { ), resume = false, ) as TestMediaCache - assertEquals(0, cache.resumeCalled.get()) + assertEquals(0, cache.getResumeCalled()) assertSame( cache, storage.listFlow.first().single(), ) - assertEquals(0, cache.resumeCalled.get()) + assertEquals(0, cache.getResumeCalled()) storage.close() } @Test fun `can delete while not using`() = runTest { - val storage = DirectoryMediaCacheStorage(CACHE_MEDIA_SOURCE_ID, dir.toPath(), engine, this.coroutineContext) + val storage = + DirectoryMediaCacheStorage(CACHE_MEDIA_SOURCE_ID, dir.toKtPath().inSystem, engine, this.coroutineContext) val cache = storage.cache( media, @@ -114,20 +119,21 @@ class DirectoryMediaCacheStorageTest { resume = false, ) as TestMediaCache - assertEquals(0, cache.resumeCalled.get()) + assertEquals(0, cache.getResumeCalled()) assertEquals(cache, storage.listFlow.first().single()) assertEquals(true, storage.delete(cache)) assertEquals(null, storage.listFlow.first().firstOrNull()) - assertEquals(0, cache.resumeCalled.get()) + assertEquals(0, cache.getResumeCalled()) storage.close() } @Test fun `cached media id`() = runTest { - val storage = DirectoryMediaCacheStorage(CACHE_MEDIA_SOURCE_ID, dir.toPath(), engine, this.coroutineContext) + val storage = + DirectoryMediaCacheStorage(CACHE_MEDIA_SOURCE_ID, dir.toKtPath().inSystem, engine, this.coroutineContext) val cache = storage.cache( media, diff --git a/app/shared/src/desktopTest/kotlin/tools/TimeFormatterTest.kt b/app/shared/src/desktopTest/kotlin/tools/TimeFormatterTest.kt index 38db8b7a6b..0c6047f4c9 100644 --- a/app/shared/src/desktopTest/kotlin/tools/TimeFormatterTest.kt +++ b/app/shared/src/desktopTest/kotlin/tools/TimeFormatterTest.kt @@ -1,15 +1,27 @@ package me.him188.ani.app.tools -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.format.char import kotlin.test.Test import kotlin.test.assertEquals class TimeFormatterTest { - private val fixedTime = Instant.parse("2020-01-01T10:00:00Z").toEpochMilli() - private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("UTC")) + private val fixedTime = Instant.parse("2020-01-01T10:00:00Z") + private val formatter = LocalDateTime.Format { + year() + char('-') + monthNumber() + char('-') + dayOfMonth() + char(' ') + hour() + char(':') + minute() + char(':') + second() + } private val timeFormatter = TimeFormatter( formatterWithTime = formatter, @@ -23,49 +35,49 @@ class TimeFormatterTest { @Test fun testSecondsAgo() { - val timestamp = Instant.parse("2020-01-01T09:59:30Z").toEpochMilli() + val timestamp = Instant.parse("2020-01-01T09:59:30Z") assertEquals("30 秒前", timeFormatter.format(timestamp)) } @Test fun testSecondsLater() { - val timestamp = Instant.parse("2020-01-01T10:00:30Z").toEpochMilli() + val timestamp = Instant.parse("2020-01-01T10:00:30Z") assertEquals("30 秒后", timeFormatter.format(timestamp)) } @Test fun testMinutesAgo() { - val timestamp = Instant.parse("2020-01-01T09:58:00Z").toEpochMilli() + val timestamp = Instant.parse("2020-01-01T09:58:00Z") assertEquals("2 分钟前", timeFormatter.format(timestamp)) } @Test fun testMinutesLater() { - val timestamp = Instant.parse("2020-01-01T10:02:00Z").toEpochMilli() + val timestamp = Instant.parse("2020-01-01T10:02:00Z") assertEquals("2 分钟后", timeFormatter.format(timestamp)) } @Test fun testHoursAgo() { - val timestamp = Instant.parse("2020-01-01T08:00:00Z").toEpochMilli() + val timestamp = Instant.parse("2020-01-01T08:00:00Z") assertEquals("2 小时前", timeFormatter.format(timestamp)) } @Test fun testHoursLater() { - val timestamp = Instant.parse("2020-01-01T12:00:00Z").toEpochMilli() + val timestamp = Instant.parse("2020-01-01T12:00:00Z") assertEquals("2 小时后", timeFormatter.format(timestamp)) } @Test fun testDaysAgo() { - val timestamp = Instant.parse("2019-12-31T10:00:00Z").toEpochMilli() + val timestamp = Instant.parse("2019-12-31T10:00:00Z") assertEquals("1 天前", timeFormatter.format(timestamp)) } @Test fun testUsingFormatter() { - val timestamp = Instant.parse("2019-12-30T10:00:00Z").toEpochMilli() + val timestamp = Instant.parse("2019-12-30T10:00:00Z") assertEquals("2019-12-30 10:00:00", timeFormatter.format(timestamp)) } } diff --git a/app/shared/video-player/common/torrent/FileVideoSource.kt b/app/shared/video-player/common/torrent/FileVideoSource.kt index 16cf11e373..6f17390636 100644 --- a/app/shared/video-player/common/torrent/FileVideoSource.kt +++ b/app/shared/video-player/common/torrent/FileVideoSource.kt @@ -8,13 +8,20 @@ import me.him188.ani.app.videoplayer.data.VideoData import me.him188.ani.app.videoplayer.data.VideoSource import me.him188.ani.datasources.api.MediaExtraFiles import me.him188.ani.datasources.api.topic.FileSize +import me.him188.ani.utils.io.DigestAlgorithm import me.him188.ani.utils.io.SeekableInput +import me.him188.ani.utils.io.SystemPath +import me.him188.ani.utils.io.absolutePath +import me.him188.ani.utils.io.bufferedSource +import me.him188.ani.utils.io.exists +import me.him188.ani.utils.io.length +import me.him188.ani.utils.io.name +import me.him188.ani.utils.io.readAndDigest import me.him188.ani.utils.io.toSeekableInput -import java.io.File import java.io.IOException class FileVideoData( - val file: File, + val file: SystemPath, ) : VideoData { override val filename: String get() = file.name @@ -22,11 +29,12 @@ class FileVideoData( private var hashCache: String? = null + @OptIn(ExperimentalStdlibApi::class) @Throws(IOException::class) override fun computeHash(): String { var hash = hashCache if (hash == null) { - hash = md5Hash(file) + hash = file.bufferedSource().use { it.readAndDigest(DigestAlgorithm.MD5).toHexString() } hashCache = hash } return hash @@ -42,7 +50,7 @@ class FileVideoData( } class FileVideoSource( - private val file: File, + private val file: SystemPath, override val extraFiles: MediaExtraFiles, ) : VideoSource { init { @@ -56,18 +64,3 @@ class FileVideoSource( override fun toString(): String = "FileVideoSource(uri=$uri)" } - -@OptIn(ExperimentalStdlibApi::class) -private fun md5Hash(file: File): String { - return file.inputStream().use { - val digest = java.security.MessageDigest.getInstance("MD5") - val buffer = ByteArray(81920) - var read = it.read(buffer) - while (read > 0) { - digest.update(buffer, 0, read) - read = it.read(buffer) - } - - digest.digest().toHexString() - } -} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/ani-mpp-lib-targets.gradle.kts b/buildSrc/src/main/kotlin/ani-mpp-lib-targets.gradle.kts new file mode 100644 index 0000000000..af81692425 --- /dev/null +++ b/buildSrc/src/main/kotlin/ani-mpp-lib-targets.gradle.kts @@ -0,0 +1,7 @@ +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.kotlinExtension + +(kotlinExtension as? KotlinMultiplatformExtension)?.run { + jvm() + iosArm64() +} diff --git a/buildSrc/src/main/kotlin/build.kt b/buildSrc/src/main/kotlin/build.kt index 84cc0ac207..d0b9297d3b 100644 --- a/buildSrc/src/main/kotlin/build.kt +++ b/buildSrc/src/main/kotlin/build.kt @@ -52,7 +52,7 @@ val testOptInAnnotations = arrayOf( "kotlin.io.path.ExperimentalPathApi", "kotlinx.coroutines.ExperimentalCoroutinesApi", "kotlinx.serialization.ExperimentalSerializationApi", - "me.him188.ani.utils.coroutines.TestOnly", + "me.him188.ani.utils.platform.annotations.TestOnly", ) val optInAnnotations = arrayOf( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7cb268dbb2..e9920b04b1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,8 @@ kotlinx-io = "0.5.1" # https://github.com/Kotlin/kotlinx-io/releases kotlinpoet = "1.16.0" log4j-core = "2.20.0" okhttp = "4.12.0" +okio = "3.9.0" +korlibs = "6.0.0" playwright = "1.44.0" reorderable = "0.9.6" ktor = "2.3.10" @@ -73,6 +75,11 @@ atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } +jetbrains-annotations = { module = "org.jetbrains:annotations", version = "23.0.0" } + +# Korlibs +korlibs-crypto = { module = "com.soywiz:korlibs-crypto", version.ref = "korlibs" } + # Ktor ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } @@ -115,6 +122,7 @@ logback-android = { module = "com.github.tony19:logback-android", version = "3.0 logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } # OkHttp +okio = { module = "com.squareup.okio:okio", version.ref = "okio" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 899cdcb41d..5a1ce3b656 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,7 @@ fun includeProject(projectPath: String, dir: String? = null) { } // Utilities shared by client and server (targeting JVM) +includeProject(":utils:platform") // 适配各个平台的基础 API includeProject(":utils:slf4j-kt", "utils/slf4j-kt") // shared by client and server (targets JVM) includeProject(":utils:serialization", "utils/serialization") includeProject(":utils:coroutines", "utils/coroutines") diff --git a/torrent/api/src/api/TorrentLibraryLoader.kt b/torrent/api/src/api/TorrentLibraryLoader.kt index 0e75bcedf3..04871503e5 100644 --- a/torrent/api/src/api/TorrentLibraryLoader.kt +++ b/torrent/api/src/api/TorrentLibraryLoader.kt @@ -1,6 +1,6 @@ package me.him188.ani.app.torrent.api -import java.io.File +import me.him188.ani.utils.io.SystemPath import kotlin.coroutines.CoroutineContext interface TorrentLibraryLoader { @@ -13,7 +13,7 @@ interface TorrentDownloaderFactory { // SPI val libraryLoader: TorrentLibraryLoader fun createDownloader( - rootDataDirectory: File, + rootDataDirectory: SystemPath, httpFileDownloader: HttpFileDownloader, torrentDownloaderConfig: TorrentDownloaderConfig, parentCoroutineContext: CoroutineContext, diff --git a/torrent/impl/anitorrent/common/AnitorrentDownloaderFactory.kt b/torrent/impl/anitorrent/common/AnitorrentDownloaderFactory.kt index 2de13f006a..48d35ef0ef 100644 --- a/torrent/impl/anitorrent/common/AnitorrentDownloaderFactory.kt +++ b/torrent/impl/anitorrent/common/AnitorrentDownloaderFactory.kt @@ -5,7 +5,8 @@ import me.him188.ani.app.torrent.api.TorrentDownloader import me.him188.ani.app.torrent.api.TorrentDownloaderConfig import me.him188.ani.app.torrent.api.TorrentDownloaderFactory import me.him188.ani.app.torrent.api.TorrentLibraryLoader -import java.io.File +import me.him188.ani.utils.io.SystemPath +import me.him188.ani.utils.io.toFile import kotlin.coroutines.CoroutineContext class AnitorrentDownloaderFactory : TorrentDownloaderFactory { @@ -14,13 +15,13 @@ class AnitorrentDownloaderFactory : TorrentDownloaderFactory { override val libraryLoader: TorrentLibraryLoader get() = AnitorrentLibraryLoader override fun createDownloader( - rootDataDirectory: File, + rootDataDirectory: SystemPath, httpFileDownloader: HttpFileDownloader, torrentDownloaderConfig: TorrentDownloaderConfig, parentCoroutineContext: CoroutineContext ): TorrentDownloader = AnitorrentTorrentDownloader( - rootDataDirectory, + rootDataDirectory.toFile(), httpFileDownloader, torrentDownloaderConfig, parentCoroutineContext, diff --git a/utils/coroutines/build.gradle.kts b/utils/coroutines/build.gradle.kts index b16cff4130..b9da8d9d0d 100644 --- a/utils/coroutines/build.gradle.kts +++ b/utils/coroutines/build.gradle.kts @@ -17,11 +17,15 @@ */ plugins { - kotlin("jvm") - `flatten-source-sets` + kotlin("multiplatform") + `ani-mpp-lib-targets` + id("org.jetbrains.kotlinx.atomicfu") } -dependencies { - api(libs.kotlinx.coroutines.core) - testImplementation(libs.kotlinx.coroutines.test) +kotlin { + sourceSets.commonMain.dependencies { + api(libs.kotlinx.coroutines.core) + implementation(projects.utils.platform) + implementation(libs.atomicfu) + } } diff --git a/utils/coroutines/src/CoroutineScopes.kt b/utils/coroutines/src/commonMain/kotlin/CoroutineScopes.kt similarity index 100% rename from utils/coroutines/src/CoroutineScopes.kt rename to utils/coroutines/src/commonMain/kotlin/CoroutineScopes.kt diff --git a/utils/coroutines/src/ExceptionCollector.kt b/utils/coroutines/src/commonMain/kotlin/ExceptionCollector.kt similarity index 76% rename from utils/coroutines/src/ExceptionCollector.kt rename to utils/coroutines/src/commonMain/kotlin/ExceptionCollector.kt index a5b8ed398e..51cf3dba67 100644 --- a/utils/coroutines/src/ExceptionCollector.kt +++ b/utils/coroutines/src/commonMain/kotlin/ExceptionCollector.kt @@ -1,10 +1,12 @@ package me.him188.ani.utils.coroutines -import org.jetbrains.annotations.TestOnly +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import me.him188.ani.utils.platform.annotations.TestOnly +import kotlin.concurrent.Volatile import kotlin.contracts.InvocationKind import kotlin.contracts.contract - open class ExceptionCollector { constructor() @@ -25,19 +27,21 @@ open class ExceptionCollector { private var last: Throwable? = null private val hashCodes = mutableSetOf() private val suppressedList = mutableListOf() + private val lock = SynchronizedObject() /** * @return `true` if [e] is new. */ - @Synchronized fun collect(e: Throwable?): Boolean { - if (e == null) return false - if (!hashCodes.add(hash(e))) return false // filter out duplications - // we can also check suppressed exceptions of [e] but actual influence would be slight. - beforeCollect(e) - this.last?.let { addSuppressed(e, it) } - this.last = e - return true + synchronized(lock) { + if (e == null) return false + if (!hashCodes.add(hashException(e))) return false // filter out duplications + // we can also check suppressed exceptions of [e] but actual influence would be slight. + beforeCollect(e) + this.last?.let { addSuppressed(e, it) } + this.last = e + return true + } } protected open fun addSuppressed(receiver: Throwable, e: Throwable) { @@ -59,14 +63,15 @@ open class ExceptionCollector { /** * Adds [suppressedList] to suppressed exceptions of [last] */ - @Synchronized private fun bake() { - last?.let { last -> - for (suppressed in suppressedList.asReversed()) { - last.addSuppressed(suppressed) + synchronized(lock) { + last?.let { last -> + for (suppressed in suppressedList.asReversed()) { + last.addSuppressed(suppressed) + } } + suppressedList.clear() } - suppressedList.clear() } fun getLast(): Throwable? { @@ -99,11 +104,12 @@ open class ExceptionCollector { return Sequence { last.itr() } } - @Synchronized fun dispose() { // help gc - this.last = null - this.hashCodes.clear() - this.suppressedList.clear() + synchronized(lock) { + this.last = null + this.hashCodes.clear() + this.suppressedList.clear() + } } companion object { @@ -143,12 +149,4 @@ inline fun ExceptionCollector.withExceptionCollector(action: ExceptionCollec } } -private fun hash(e: Throwable): Long { - return e.stackTrace.fold(0L) { acc, stackTraceElement -> - acc * 31 + hash(stackTraceElement).toUInt().toLong() - } -} - -private fun hash(element: StackTraceElement): Int { - return element.lineNumber.hashCode() xor element.className.hashCode() xor element.methodName.hashCode() -} +internal expect fun hashException(e: Throwable): Long diff --git a/utils/coroutines/src/FlowAtomics.kt b/utils/coroutines/src/commonMain/kotlin/FlowAtomics.kt similarity index 100% rename from utils/coroutines/src/FlowAtomics.kt rename to utils/coroutines/src/commonMain/kotlin/FlowAtomics.kt diff --git a/utils/coroutines/src/Flows.kt b/utils/coroutines/src/commonMain/kotlin/Flows.kt similarity index 99% rename from utils/coroutines/src/Flows.kt rename to utils/coroutines/src/commonMain/kotlin/Flows.kt index a738f88d80..55a06f24fa 100644 --- a/utils/coroutines/src/Flows.kt +++ b/utils/coroutines/src/commonMain/kotlin/Flows.kt @@ -1,5 +1,6 @@ package me.him188.ani.utils.coroutines +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope @@ -9,7 +10,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.retry import kotlinx.coroutines.flow.runningFold -import java.util.concurrent.CancellationException import kotlin.coroutines.CoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds diff --git a/utils/coroutines/src/ReentrantMutex.kt b/utils/coroutines/src/commonMain/kotlin/ReentrantMutex.kt similarity index 100% rename from utils/coroutines/src/ReentrantMutex.kt rename to utils/coroutines/src/commonMain/kotlin/ReentrantMutex.kt diff --git a/utils/coroutines/src/RunUntilSuccess.kt b/utils/coroutines/src/commonMain/kotlin/RunUntilSuccess.kt similarity index 82% rename from utils/coroutines/src/RunUntilSuccess.kt rename to utils/coroutines/src/commonMain/kotlin/RunUntilSuccess.kt index a1c32cf05c..f9e6cf5e15 100644 --- a/utils/coroutines/src/RunUntilSuccess.kt +++ b/utils/coroutines/src/commonMain/kotlin/RunUntilSuccess.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.yield import kotlin.contracts.InvocationKind import kotlin.contracts.contract -import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -68,3 +67,15 @@ internal fun backoffDelay(failureCount: Int): Duration { else -> 8.seconds } } + +// 解决 ios ambiguity 和 common 里不能直接构造 +@Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress") +inline fun CancellationException( + message: String? = null, + cause: Throwable? = null +): kotlinx.coroutines.CancellationException { + return kotlinx.coroutines.CancellationException( + message = message, + cause, + ) // 加名字后就只能 resolve 到 Kotlin, 否则会在 JVM ambiguity +} diff --git a/utils/coroutines/src/SampleWithInitial.kt b/utils/coroutines/src/commonMain/kotlin/SampleWithInitial.kt similarity index 96% rename from utils/coroutines/src/SampleWithInitial.kt rename to utils/coroutines/src/commonMain/kotlin/SampleWithInitial.kt index c86f6080a7..3f7dc3c28a 100644 --- a/utils/coroutines/src/SampleWithInitial.kt +++ b/utils/coroutines/src/commonMain/kotlin/SampleWithInitial.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.sample import kotlinx.coroutines.selects.select -import java.util.concurrent.CancellationException +import kotlin.jvm.JvmField /** @@ -131,8 +131,8 @@ internal val DONE = Symbol("DONE") class Symbol(@JvmField val symbol: String) { override fun toString(): String = "<$symbol>" - @Suppress("UNCHECKED_CAST", "NOTHING_TO_INLINE") - inline fun unbox(value: Any?): T = if (value === this) null as T else value as T + @Suppress("UNCHECKED_CAST") + fun unbox(value: Any?): T = if (value === this) null as T else value as T } internal fun CoroutineScope.fixedPeriodTicker( diff --git a/utils/coroutines/src/SuspendLazy.kt b/utils/coroutines/src/commonMain/kotlin/SuspendLazy.kt similarity index 98% rename from utils/coroutines/src/SuspendLazy.kt rename to utils/coroutines/src/commonMain/kotlin/SuspendLazy.kt index 8958e8a4b7..d3208cb785 100644 --- a/utils/coroutines/src/SuspendLazy.kt +++ b/utils/coroutines/src/commonMain/kotlin/SuspendLazy.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlin.concurrent.Volatile interface SuspendLazy { val isInitialized: Boolean diff --git a/utils/coroutines/src/flows/Catching.kt b/utils/coroutines/src/commonMain/kotlin/flows/Catching.kt similarity index 100% rename from utils/coroutines/src/flows/Catching.kt rename to utils/coroutines/src/commonMain/kotlin/flows/Catching.kt diff --git a/utils/coroutines/src/flows/Combine.kt b/utils/coroutines/src/commonMain/kotlin/flows/Combine.kt similarity index 100% rename from utils/coroutines/src/flows/Combine.kt rename to utils/coroutines/src/commonMain/kotlin/flows/Combine.kt diff --git a/utils/coroutines/src/flows/ResetOnEvery.kt b/utils/coroutines/src/commonMain/kotlin/flows/ResetOnEvery.kt similarity index 86% rename from utils/coroutines/src/flows/ResetOnEvery.kt rename to utils/coroutines/src/commonMain/kotlin/flows/ResetOnEvery.kt index e6141e760e..69927b23a3 100644 --- a/utils/coroutines/src/flows/ResetOnEvery.kt +++ b/utils/coroutines/src/commonMain/kotlin/flows/ResetOnEvery.kt @@ -7,6 +7,8 @@ import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import me.him188.ani.utils.platform.currentTimeMillis +import kotlin.concurrent.Volatile /** * 创建一个 flow, 当 [durationMillis] 时间内没有新的元素时, 会调用 [reset] 方法. @@ -26,7 +28,7 @@ fun Flow.resetStale( launch { while (isActive) { delay(durationMillis) - val now = System.currentTimeMillis() + val now = currentTimeMillis() if (now - time.value >= durationMillis) { time.value = Long.MAX_VALUE reset(collector) @@ -34,9 +36,9 @@ fun Flow.resetStale( } } upstream.collect { - time.value = System.currentTimeMillis() + time.value = currentTimeMillis() send(it) } } } -} \ No newline at end of file +} diff --git a/utils/coroutines/src/jvmMain/kotlin/ExceptionCollector.jvm.kt b/utils/coroutines/src/jvmMain/kotlin/ExceptionCollector.jvm.kt new file mode 100644 index 0000000000..9783b4fa7c --- /dev/null +++ b/utils/coroutines/src/jvmMain/kotlin/ExceptionCollector.jvm.kt @@ -0,0 +1,11 @@ +package me.him188.ani.utils.coroutines + +internal actual fun hashException(e: Throwable): Long { + return e.stackTrace.fold(0L) { acc, stackTraceElement -> + acc * 31 + hash(stackTraceElement).toUInt().toLong() + } +} + +private fun hash(element: StackTraceElement): Int { + return element.lineNumber.hashCode() xor element.className.hashCode() xor element.methodName.hashCode() +} diff --git a/utils/coroutines/src/nativeMain/kotlin/ExceptionCollector.native.kt b/utils/coroutines/src/nativeMain/kotlin/ExceptionCollector.native.kt new file mode 100644 index 0000000000..f3fa95d469 --- /dev/null +++ b/utils/coroutines/src/nativeMain/kotlin/ExceptionCollector.native.kt @@ -0,0 +1,3 @@ +package me.him188.ani.utils.coroutines + +internal actual fun hashException(e: Throwable): Long = e.hashCode().toLong() diff --git a/utils/io/build.gradle.kts b/utils/io/build.gradle.kts index 12ccfc73c3..7c4466fb34 100644 --- a/utils/io/build.gradle.kts +++ b/utils/io/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi + /* * Ani * Copyright (C) 2022-2024 Him188 @@ -17,16 +19,24 @@ */ plugins { - kotlin("jvm") + kotlin("multiplatform") + `ani-mpp-lib-targets` + id("org.jetbrains.kotlinx.atomicfu") kotlin("plugin.serialization") - `flatten-source-sets` } kotlin { - explicitApi() -} + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions.freeCompilerArgs.add("-Xdont-warn-on-error-suppression") + + sourceSets.commonMain.dependencies { + api(projects.utils.platform) + api(libs.kotlinx.io.core) + implementation(libs.atomicfu) +// implementation(libs.okio) // 仅用于读文件 + } -dependencies { - api(libs.kotlinx.coroutines.core) - testImplementation(libs.kotlinx.coroutines.test) + sourceSets.nativeMain.dependencies { + api(libs.korlibs.crypto) // JVM 用 JDK 就够了 + } } diff --git a/utils/io/src/BufferedFileInput.kt b/utils/io/src/commonMain/kotlin/BufferedInput.kt similarity index 73% rename from utils/io/src/BufferedFileInput.kt rename to utils/io/src/commonMain/kotlin/BufferedInput.kt index d38ab4a71b..caa7e7264f 100644 --- a/utils/io/src/BufferedFileInput.kt +++ b/utils/io/src/commonMain/kotlin/BufferedInput.kt @@ -1,92 +1,16 @@ package me.him188.ani.utils.io -import org.jetbrains.annotations.TestOnly -import java.io.File -import java.io.IOException -import java.io.RandomAccessFile +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import me.him188.ani.utils.platform.annotations.TestOnly +import kotlin.concurrent.Volatile import kotlin.math.min - -/** - * Adapts this [RandomAccessFile] to a [SeekableInput]. - * - * File reads are buffered. - * - * **The file length must not change** while it is created as a [SeekableInput], otherwise the behavior is undefined - it is not checked. - * - * By closing the returned [SeekableInput], you also close this [RandomAccessFile]. - * Conversely, by closing this [RandomAccessFile], you also close the returned [SeekableInput], - * though it is not recommended to close the [RandomAccessFile] directly. - * - * The file is not open until first read. - */ -@Throws(IOException::class) -public fun File.toSeekableInput( - bufferSize: Int = BufferedInput.DEFAULT_BUFFER_PER_DIRECTION, - onFillBuffer: (() -> Unit)? = null, -): SeekableInput = BufferedFileInput( - RandomAccessFile(this, "r"), - bufferSize, - onFillBuffer, -) - -@JvmInline -public value class OffsetRange private constructor( - private val packed: Long, -) { - public constructor(start: Int, end: Int) : this(start.toLong() or (end.toLong() shl 32)) - - public val start: Int get() = packed.toInt() - public val end: Int get() = (packed ushr 32).toInt() -} - -internal open class BufferedFileInput( - private val file: RandomAccessFile, - private val bufferSize: Int = DEFAULT_BUFFER_PER_DIRECTION, - private val onFillBuffer: (() -> Unit)? = null, -) : BufferedInput(bufferSize) { - override val size: Long get() = file.length() - - override fun fillBuffer() { - onFillBuffer?.invoke() - - val fileLength = this.size - val pos = this.position - - val readStart = (pos - bufferSize).coerceAtLeast(0) - val readEnd = (pos + bufferSize).coerceAtMost(fileLength) - - fillBufferRange(readStart, readEnd) - } - - override fun readFileToBuffer(fileOffset: Long, bufferOffset: Int, length: Int): Int { - val file = this.file - file.seek(fileOffset) - file.readFully(buf, bufferOffset, length) - return length - -// var read = bufferOffset -// while (read <= bufferOffset + length) { -// read += file.read(buf, read, length - read) -// } -// return read - } - - override fun toString(): String { - return "BufferedFileInput(file=$file, position=$position, bytesRemaining=$bytesRemaining)" - } - - override fun close() { - super.close() - file.close() - } -} - -public abstract class BufferedInput( +abstract class BufferedInput( bufferSize: Int, ) : SeekableInput { - public companion object { - public const val DEFAULT_BUFFER_PER_DIRECTION: Int = 8192 * 16 + companion object { + const val DEFAULT_BUFFER_PER_DIRECTION: Int = 8192 * 16 protected fun Long.coerceToInt(): Int { if (this > Int.MAX_VALUE) return Int.MAX_VALUE @@ -112,8 +36,8 @@ public abstract class BufferedInput( /** * view offsets */ - @get:TestOnly - public val bufferedOffsetRange: LongRange get() = bufferedOffsetStart.. = SystemFileSystem.list(path) + +/** + * @see FileSystem.list + */ +@Deprecated("For migration. Use list() instead", ReplaceWith("this.list()"), level = DeprecationLevel.ERROR) +fun SystemPath.listFiles(): Collection = SystemFileSystem.list(path) + +/** + * @see FileSystem.createDirectories + */ +fun SystemPath.createDirectories(mustCreate: Boolean = false): Unit = + SystemFileSystem.createDirectories(path, mustCreate) + +/** + * @see FileSystem.atomicMove + */ +fun SystemPath.moveTo(target: Path): Unit = SystemFileSystem.atomicMove(path, target) + +/** + * @see FileSystem.atomicMove + */ +fun SystemPath.moveTo(target: SystemPath): Unit = moveTo(target.path) + +/** + * @see FileSystem.source + */ +fun SystemPath.source() = SystemFileSystem.source(path) + +/** + * @see Source.buffer + */ +fun SystemPath.bufferedSource() = this.source().buffered() + +/** + * @see FileSystem.sink + */ +fun SystemPath.sink(append: Boolean = false) = SystemFileSystem.sink(path, append) + +/** + * @see Sink.buffer + */ +fun SystemPath.bufferedSink(append: Boolean = false) = sink(append).buffered() + +/** + * @see FileSystem.metadataOrNull + */ +fun SystemPath.metadataOrNull() = SystemFileSystem.metadataOrNull(path) + +/** + * @see FileSystem.resolve + */ +fun SystemPath.resolveToAbsolute() = SystemFileSystem.resolve(path) + +val SystemPath.absolutePath: String get() = resolveToAbsolute().toString() + +/////////////////////////////////////////////////////////////////////////// +// Extensions +/////////////////////////////////////////////////////////////////////////// + +expect inline fun SystemPath.useDirectoryEntries(block: (Sequence) -> T): T + +/** + * 以 UTF-8 读取文件的所有内容 + */ +fun SystemPath.readText(): String { + return this.bufferedSource().use { source -> + source.readString() + } +} + +/** + * 读取文件的所有内容 + */ +fun SystemPath.readBytes(): ByteArray { + return this.bufferedSource().use { + it.readByteArray() + } +} + +/** + * 写入 UTF-8 字符串到文件, 覆盖文件内容. + */ +fun SystemPath.writeText(string: String, startIndex: Int = 0, endIndex: Int = string.length) { + this.bufferedSink(append = false).use { + it.writeString(string, startIndex, endIndex) + } +} + +/** + * 写入 UTF-8 字符串到文件, 覆盖文件内容. + */ +fun SystemPath.appendText(string: String, startIndex: Int = 0, endIndex: Int = string.length) { + this.bufferedSink(append = true).use { + it.writeString(string, startIndex, endIndex) + } +} + +/** + * 写入 [ByteArray] 到文件, 覆盖文件内容. + */ +fun SystemPath.writeBytes(array: ByteArray, startIndex: Int = 0, endIndex: Int = array.size) { + this.bufferedSink(append = false).use { + it.write(array, startIndex, endIndex) + } +} + +/** + * 追加 [ByteArray] 字符串到文件. + */ +fun SystemPath.appendBytes(array: ByteArray, startIndex: Int = 0, endIndex: Int = array.size) { + this.bufferedSink(append = true).use { + it.write(array, startIndex, endIndex) + } +} + +/** + * 复制文件到目标路径, 将会覆盖目标文件. + */ +fun SystemPath.copyTo(target: SystemPath) { + this.bufferedSource().use { source -> + target.bufferedSink(append = false).use { sink -> + source.transferTo(sink) + } + } +} + +/////////////////////////////////////////////////////////////////////////// +// 在 kotlinx-io 实现这些功能之前, 我们使用 okio +/////////////////////////////////////////////////////////////////////////// +// +//private fun SystemPath.toOkioPath(): okio.Path { +// return this.toString().toPath() +//} + diff --git a/utils/io/src/SeekableInput.kt b/utils/io/src/commonMain/kotlin/SeekableInput.kt similarity index 85% rename from utils/io/src/SeekableInput.kt rename to utils/io/src/commonMain/kotlin/SeekableInput.kt index e90d042846..46192918fd 100644 --- a/utils/io/src/SeekableInput.kt +++ b/utils/io/src/commonMain/kotlin/SeekableInput.kt @@ -1,30 +1,33 @@ package me.him188.ani.utils.io -import org.jetbrains.annotations.Range -import java.io.EOFException -import java.io.IOException +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.io.EOFException +import kotlinx.io.IOException +import me.him188.ani.utils.platform.annotations.Range +import kotlin.concurrent.Volatile /** * A **asynchronous** source of bytes from which you can seek to a position and read sequentially. * * Note: this class is not thread-safe. */ -public interface SeekableInput : AutoCloseable { +interface SeekableInput : AutoCloseable { /** * The current position in bytes from the start of the input source. * * Does not throw even if the input source is closed. */ - public val position: @Range(from = 0L, to = Long.MAX_VALUE) Long // get must be fast + val position: @Range(from = 0L, to = Long.MAX_VALUE) Long // get must be fast /** * The number of bytes remaining from the current position to the end of the input source. * * Does not throw even if the input source is closed. */ - public val bytesRemaining: @Range(from = 0L, to = Long.MAX_VALUE) Long // get must be fast + val bytesRemaining: @Range(from = 0L, to = Long.MAX_VALUE) Long // get must be fast - public val size: @Range(from = 0L, to = Long.MAX_VALUE) Long + val size: @Range(from = 0L, to = Long.MAX_VALUE) Long /** * Seeks to the given offset in bytes from the start of the input source. @@ -39,11 +42,11 @@ public interface SeekableInput : AutoCloseable { * @throws IllegalStateException if the input source is closed. */ @Throws(IOException::class) - public fun seek( + fun seek( position: @Range(from = 0L, to = Long.MAX_VALUE) Long, ) - public fun prepareBuffer() {} + fun prepareBuffer() {} /** * Reads up to [length] bytes from the input source into [buffer] starting at [offset]. @@ -77,7 +80,7 @@ public interface SeekableInput : AutoCloseable { * @throws IOException if an I/O error occurs while reading from the input source. */ @Throws(IOException::class) - public fun read( + fun read( buffer: ByteArray, offset: Int = 0, length: Int = buffer.size - offset @@ -88,11 +91,15 @@ public interface SeekableInput : AutoCloseable { * * Does nothing if this [SeekableInput] is already closed. */ - @Throws(IOException::class) - public override fun close() + override fun close() } -public fun emptySeekableInput(): SeekableInput = EmptySeekableInput +fun emptySeekableInput(): SeekableInput = EmptySeekableInput + +expect fun SystemPath.toSeekableInput( + bufferSize: Int = BufferedInput.DEFAULT_BUFFER_PER_DIRECTION, + onFillBuffer: (() -> Unit)? = null, +): SeekableInput /** * Reads max [maxLength] bytes from this [SeekableInput], and advances the current position by the number of bytes read. @@ -101,7 +108,7 @@ public fun emptySeekableInput(): SeekableInput = EmptySeekableInput * * See [SeekableInput.read] for more details about the behaviour of asynchronous reading. */ -public fun SeekableInput.readBytes(maxLength: Int = 4096): ByteArray { +fun SeekableInput.readBytes(maxLength: Int = 4096): ByteArray { val buffer = ByteArray(maxLength) val actualLength = read(buffer, 0, maxLength) if (actualLength == -1) return ByteArray(0) @@ -115,7 +122,7 @@ public fun SeekableInput.readBytes(maxLength: Int = 4096): ByteArray { /** * 读取所剩的所有字节. 如果文件已经关闭, 会抛出异常 [IllegalStateException] */ -public fun SeekableInput.readAllBytes(): ByteArray { +fun SeekableInput.readAllBytes(): ByteArray { val buffer = ByteArray(bytesRemaining.toInt()) var offset = 0 while (true) { @@ -134,7 +141,7 @@ public fun SeekableInput.readAllBytes(): ByteArray { * @throws IllegalStateException if the input source is closed. */ @Throws(EOFException::class) -public fun SeekableInput.readExactBytes( +fun SeekableInput.readExactBytes( n: Int ): ByteArray { val buffer = ByteArray(n) @@ -164,7 +171,7 @@ public fun SeekableInput.readExactBytes( // } //} -private object EmptySeekableInput : SeekableInput { +private object EmptySeekableInput : SynchronizedObject(), SeekableInput { override val position: Long get() = 0 override val bytesRemaining: Long get() = 0 override val size: Long get() = 0 diff --git a/utils/io/src/jvmMain/kotlin/BufferedFileInput.kt b/utils/io/src/jvmMain/kotlin/BufferedFileInput.kt new file mode 100644 index 0000000000..ac653561ab --- /dev/null +++ b/utils/io/src/jvmMain/kotlin/BufferedFileInput.kt @@ -0,0 +1,71 @@ +package me.him188.ani.utils.io + +import kotlinx.io.IOException +import java.io.File +import java.io.RandomAccessFile + + +/** + * Adapts this [RandomAccessFile] to a [SeekableInput]. + * + * File reads are buffered. + * + * **The file length must not change** while it is created as a [SeekableInput], otherwise the behavior is undefined - it is not checked. + * + * By closing the returned [SeekableInput], you also close this [RandomAccessFile]. + * Conversely, by closing this [RandomAccessFile], you also close the returned [SeekableInput], + * though it is not recommended to close the [RandomAccessFile] directly. + * + * The file is not open until first read. + */ +@Throws(IOException::class) +public fun File.toSeekableInput( + bufferSize: Int = BufferedInput.DEFAULT_BUFFER_PER_DIRECTION, + onFillBuffer: (() -> Unit)? = null, +): SeekableInput = BufferedFileInput( + RandomAccessFile(this, "r"), + bufferSize, + onFillBuffer, +) + +internal open class BufferedFileInput( + private val file: RandomAccessFile, + private val bufferSize: Int = DEFAULT_BUFFER_PER_DIRECTION, + private val onFillBuffer: (() -> Unit)? = null, +) : BufferedInput(bufferSize) { + override val size: Long get() = file.length() + + override fun fillBuffer() { + onFillBuffer?.invoke() + + val fileLength = this.size + val pos = this.position + + val readStart = (pos - bufferSize).coerceAtLeast(0) + val readEnd = (pos + bufferSize).coerceAtMost(fileLength) + + fillBufferRange(readStart, readEnd) + } + + override fun readFileToBuffer(fileOffset: Long, bufferOffset: Int, length: Int): Int { + val file = this.file + file.seek(fileOffset) + file.readFully(buf, bufferOffset, length) + return length + +// var read = bufferOffset +// while (read <= bufferOffset + length) { +// read += file.read(buf, read, length - read) +// } +// return read + } + + override fun toString(): String { + return "BufferedFileInput(file=$file, position=$position, bytesRemaining=$bytesRemaining)" + } + + override fun close() { + super.close() + file.close() + } +} diff --git a/utils/io/src/jvmMain/kotlin/Digest.jvm.kt b/utils/io/src/jvmMain/kotlin/Digest.jvm.kt new file mode 100644 index 0000000000..0fcae01077 --- /dev/null +++ b/utils/io/src/jvmMain/kotlin/Digest.jvm.kt @@ -0,0 +1,29 @@ +package me.him188.ani.utils.io + +import kotlinx.io.Source +import java.security.MessageDigest + +actual fun Source.readAndDigest(algorithm: DigestAlgorithm): ByteArray { + return when (algorithm) { + DigestAlgorithm.MD5 -> { + digest("MD5") + } + + DigestAlgorithm.SHA256 -> { + digest("SHA-256") + } + } +} + +private fun Source.digest(algorithm: String): ByteArray { + val digest = MessageDigest.getInstance(algorithm) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var read = 0 + while (read != -1) { + read = readAtMostTo(buffer) + if (read != -1) { + digest.update(buffer, 0, read) + } + } + return digest.digest() +} diff --git a/utils/io/src/jvmMain/kotlin/Path.jvm.kt b/utils/io/src/jvmMain/kotlin/Path.jvm.kt new file mode 100644 index 0000000000..0322ad53dd --- /dev/null +++ b/utils/io/src/jvmMain/kotlin/Path.jvm.kt @@ -0,0 +1,37 @@ +package me.him188.ani.utils.io + +import kotlinx.io.files.Path +import java.io.File +import java.nio.file.Paths +import kotlin.io.path.useDirectoryEntries +import java.nio.file.Path as NioPath + +fun Path.toFile(): File = File(this.toString()) +fun SystemPath.toFile(): File = path.toFile() + +fun Path.toNioPath(): NioPath = Paths.get(this.toString()) +fun SystemPath.toNioPath(): NioPath = path.toNioPath() + +fun NioPath.toKtPath(): Path = Path(this.toString()) +fun File.toKtPath(): Path = Path(this.toString()) + +actual inline fun SystemPath.useDirectoryEntries(block: (Sequence) -> T): T { + return this.toNioPath().useDirectoryEntries { seq -> + block(seq.map { SystemPath(it.toKtPath()) }) + } +} + +actual fun SystemPath.length(): Long { + @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + return path.file.length() +} + +actual fun SystemPath.isDirectory(): Boolean { + @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + return path.file.isDirectory +} + +actual fun SystemPath.isRegularFile(): Boolean { + @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + return path.file.isFile +} diff --git a/utils/io/src/jvmMain/kotlin/SeekableInput.jvm.kt b/utils/io/src/jvmMain/kotlin/SeekableInput.jvm.kt new file mode 100644 index 0000000000..408cb105fd --- /dev/null +++ b/utils/io/src/jvmMain/kotlin/SeekableInput.jvm.kt @@ -0,0 +1,6 @@ +package me.him188.ani.utils.io + +actual fun SystemPath.toSeekableInput( + bufferSize: Int, + onFillBuffer: (() -> Unit)?, +): SeekableInput = this.toFile().toSeekableInput(bufferSize, onFillBuffer) diff --git a/utils/io/test/BufferedFileInputTest.kt b/utils/io/src/jvmTest/kotlin/BufferedFileInputTest.kt similarity index 86% rename from utils/io/test/BufferedFileInputTest.kt rename to utils/io/src/jvmTest/kotlin/BufferedFileInputTest.kt index dec117875a..93f57c05e3 100644 --- a/utils/io/test/BufferedFileInputTest.kt +++ b/utils/io/src/jvmTest/kotlin/BufferedFileInputTest.kt @@ -1,6 +1,5 @@ package me.him188.ani.utils.io -import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.io.TempDir @@ -36,39 +35,39 @@ class BufferedFileInputTest { } @Test - fun `read sequentially once`() = runTest { + fun `read sequentially once`() { assertEquals(sampleText, input.readAllBytes().decodeToString()) } @Test - fun `read sequentially multiple`() = runTest { + fun `read sequentially multiple`() { assertEquals(sampleText.take(4), input.readBytes(maxLength = 4).decodeToString()) assertEquals(sampleText.drop(4).take(4), input.readBytes(maxLength = 4).decodeToString()) assertEquals(sampleText.drop(8), input.readAllBytes().decodeToString()) } @Test - fun `seek then read fully`() = runTest { + fun `seek then read fully`() { input.seek(4) assertEquals(sampleText.drop(4), input.readAllBytes().decodeToString()) } @Test - fun `read, seek forward, read fully`() = runTest { + fun `read, seek forward, read fully`() { assertEquals(sampleText.take(4), input.readBytes(maxLength = 4).decodeToString()) input.seek(8) assertEquals(sampleText.drop(8), input.readAllBytes().decodeToString()) } @Test - fun `read, seek back, read fully`() = runTest { + fun `read, seek back, read fully`() { assertEquals(sampleText.take(4), input.readBytes(maxLength = 4).decodeToString()) input.seek(0) assertEquals(sampleText, input.readAllBytes().decodeToString()) } @Test - fun `double seek same`() = runTest { + fun `double seek same`() { assertEquals(sampleText.take(4), input.readBytes(maxLength = 4).decodeToString()) input.seek(0) input.seek(0) @@ -76,7 +75,7 @@ class BufferedFileInputTest { } @Test - fun `seek forward then back`() = runTest { + fun `seek forward then back`() { assertEquals(sampleText.take(4), input.readBytes(maxLength = 4).decodeToString()) input.seek(8) input.seek(4) @@ -84,19 +83,19 @@ class BufferedFileInputTest { } @Test - fun `seek over length, read return -1`() = runTest { + fun `seek over length, read return -1`() { input.seek(999999) assertEquals(-1, input.read(ByteArray(1), 0, 1)) } @Test - fun `seek over length, bytesRemaining is zero`() = runTest { + fun `seek over length, bytesRemaining is zero`() { input.seek(999999) assertEquals(0, input.bytesRemaining) } @Test - fun `seek over length, readBytes return empty`() = runTest { + fun `seek over length, readBytes return empty`() { input.seek(999999) assertEquals(0, input.readAllBytes().size) } @@ -107,7 +106,7 @@ class BufferedFileInputTest { /////////////////////////////////////////////////////////////////////////// @Test - fun `reuse buffer from previous start`() = runTest { + fun `reuse buffer from previous start`() { // buffer size is 20 input.seek(30) @@ -124,7 +123,7 @@ class BufferedFileInputTest { } @Test - fun `reuse buffer from previous end - second half`() = runTest { + fun `reuse buffer from previous end - second half`() { // buffer size is 20 input.seek(30) @@ -141,7 +140,7 @@ class BufferedFileInputTest { } @Test - fun `reuse buffer from previous end - first half`() = runTest { + fun `reuse buffer from previous end - first half`() { // buffer size is 20 input.seek(30) @@ -173,21 +172,21 @@ class BufferedFileInputTest { /////////////////////////////////////////////////////////////////////////// @Test - fun `seek negative fails`() = runTest { + fun `seek negative fails`() { assertFailsWith { input.seek(-1) } } @Test - fun `read negative length fails`() = runTest { + fun `read negative length fails`() { assertFailsWith { input.read(ByteArray(1), 1, -1) } } @Test - fun `read negative offset fails`() = runTest { + fun `read negative offset fails`() { assertFailsWith { input.read(ByteArray(1), -1, 1) } diff --git a/utils/io/src/nativeMain/kotlin/Digest.native.kt b/utils/io/src/nativeMain/kotlin/Digest.native.kt new file mode 100644 index 0000000000..b8433d5343 --- /dev/null +++ b/utils/io/src/nativeMain/kotlin/Digest.native.kt @@ -0,0 +1,31 @@ +package me.him188.ani.utils.io + +import korlibs.crypto.Hasher +import korlibs.crypto.MD5 +import korlibs.crypto.SHA256 +import kotlinx.io.Source + +actual fun Source.readAndDigest(algorithm: DigestAlgorithm): ByteArray { + return when (algorithm) { + DigestAlgorithm.MD5 -> { + digest(MD5()) + } + + DigestAlgorithm.SHA256 -> { + digest(SHA256()) + } + } +} + +@Suppress("SpellCheckingInspection") +private fun Source.digest(hasher: Hasher): ByteArray { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var read = 0 + while (read != -1) { + read = readAtMostTo(buffer) + if (read != -1) { + hasher.update(buffer, 0, read) + } + } + return hasher.digest().bytes +} diff --git a/utils/io/src/nativeMain/kotlin/Path.native.kt b/utils/io/src/nativeMain/kotlin/Path.native.kt new file mode 100644 index 0000000000..88300295dc --- /dev/null +++ b/utils/io/src/nativeMain/kotlin/Path.native.kt @@ -0,0 +1,13 @@ +package me.him188.ani.utils.io + +import kotlinx.io.files.SystemFileSystem + +actual fun SystemPath.length(): Long = SystemFileSystem.metadataOrNull(path)?.size ?: 0 + +actual fun SystemPath.isDirectory(): Boolean = SystemFileSystem.metadataOrNull(path)?.isDirectory ?: false + +actual fun SystemPath.isRegularFile(): Boolean = SystemFileSystem.metadataOrNull(path)?.isRegularFile ?: false + +actual inline fun SystemPath.useDirectoryEntries(block: (Sequence) -> T): T { + return block(SystemFileSystem.list(path).asSequence().map { SystemPath(it) }) +} diff --git a/utils/io/src/nativeMain/kotlin/SeekableInput.native.kt b/utils/io/src/nativeMain/kotlin/SeekableInput.native.kt new file mode 100644 index 0000000000..765ba59b14 --- /dev/null +++ b/utils/io/src/nativeMain/kotlin/SeekableInput.native.kt @@ -0,0 +1,8 @@ +package me.him188.ani.utils.io + +actual fun SystemPath.toSeekableInput( + bufferSize: Int, + onFillBuffer: (() -> Unit)?, +): SeekableInput { + TODO("Not yet implemented") +} diff --git a/utils/io/src/package.kt b/utils/io/src/package.kt deleted file mode 100644 index 59fada95dc..0000000000 --- a/utils/io/src/package.kt +++ /dev/null @@ -1 +0,0 @@ -package me.him188.ani.utils.io \ No newline at end of file diff --git a/utils/io/test/package.kt b/utils/io/test/package.kt deleted file mode 100644 index c517fef73d..0000000000 --- a/utils/io/test/package.kt +++ /dev/null @@ -1 +0,0 @@ -package me.him188.ani.utils.io diff --git a/utils/platform/build.gradle.kts b/utils/platform/build.gradle.kts new file mode 100644 index 0000000000..5a5e8247bd --- /dev/null +++ b/utils/platform/build.gradle.kts @@ -0,0 +1,23 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi + +plugins { + kotlin("multiplatform") + `ani-mpp-lib-targets` + kotlin("plugin.serialization") +} + +kotlin { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") + + sourceSets.commonMain.dependencies { + } + + sourceSets.jvmMain.dependencies { + api(libs.jetbrains.annotations) + } + + sourceSets.nativeMain.dependencies { + implementation(libs.kotlinx.datetime) + } +} diff --git a/utils/platform/src/commonMain/kotlin/Time.kt b/utils/platform/src/commonMain/kotlin/Time.kt new file mode 100644 index 0000000000..1a53597916 --- /dev/null +++ b/utils/platform/src/commonMain/kotlin/Time.kt @@ -0,0 +1,3 @@ +package me.him188.ani.utils.platform + +expect fun currentTimeMillis(): Long \ No newline at end of file diff --git a/utils/platform/src/commonMain/kotlin/Uuid.kt b/utils/platform/src/commonMain/kotlin/Uuid.kt new file mode 100644 index 0000000000..ad4e9da2cd --- /dev/null +++ b/utils/platform/src/commonMain/kotlin/Uuid.kt @@ -0,0 +1,50 @@ +package me.him188.ani.utils.platform + +import kotlin.jvm.JvmInline +import kotlin.random.Random + +internal expect class UuidDelegate + +@JvmInline +expect value class Uuid internal constructor( + internal val delegate: UuidDelegate +) { + override fun toString(): String + + companion object { + fun random(random: Random = Random): Uuid + fun randomString(random: Random = Random): String + } +} + + +internal fun generateRandomUuid(random: Random = Random): String { + val randomBytes = ByteArray(16) + random.nextBytes(randomBytes) + + // Set the version to 4 (0b0100) + randomBytes[6] = (randomBytes[6].toInt() and 0x0F or 0x40).toByte() + + // Set the variant to 2 (0b10xx) + randomBytes[8] = (randomBytes[8].toInt() and 0x3F or 0x80).toByte() + + fun byteArrayToHex(byteArray: ByteArray): String { + val hexChars = "0123456789abcdef".toCharArray() + val result = StringBuilder(byteArray.size * 2) + + byteArray.forEachIndexed { index, byte -> + val intVal = byte.toInt() and 0xff + result.append(hexChars[intVal shr 4]) + result.append(hexChars[intVal and 0x0f]) + + // Insert dashes at appropriate positions + if (index == 3 || index == 5 || index == 7 || index == 9) { + result.append('-') + } + } + + return result.toString() + } + + return byteArrayToHex(randomBytes) +} diff --git a/utils/platform/src/commonMain/kotlin/annotations/Range.kt b/utils/platform/src/commonMain/kotlin/annotations/Range.kt new file mode 100644 index 0000000000..5f439b61dd --- /dev/null +++ b/utils/platform/src/commonMain/kotlin/annotations/Range.kt @@ -0,0 +1,17 @@ +package me.him188.ani.utils.platform.annotations + +/** + * Jetbrains Annotations range + */ +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.TYPE) +expect annotation class Range( + /** + * @return minimal allowed value (inclusive) + */ + val from: Long, + /** + * @return maximal allowed value (inclusive) + */ + val to: Long +) diff --git a/utils/coroutines/src/TestOnly.kt b/utils/platform/src/commonMain/kotlin/annotations/TestOnly.kt similarity index 72% rename from utils/coroutines/src/TestOnly.kt rename to utils/platform/src/commonMain/kotlin/annotations/TestOnly.kt index e585aec670..4623b85463 100644 --- a/utils/coroutines/src/TestOnly.kt +++ b/utils/platform/src/commonMain/kotlin/annotations/TestOnly.kt @@ -1,4 +1,4 @@ -package me.him188.ani.utils.coroutines +package me.him188.ani.utils.platform.annotations @RequiresOptIn( "This can only be used in test sourceSets", diff --git a/utils/platform/src/commonMain/kotlin/package.kt b/utils/platform/src/commonMain/kotlin/package.kt new file mode 100644 index 0000000000..802c044c4a --- /dev/null +++ b/utils/platform/src/commonMain/kotlin/package.kt @@ -0,0 +1,2 @@ +package me.him188.ani.utils.platform + diff --git a/utils/platform/src/commonTest/kotlin/UuidTest.kt b/utils/platform/src/commonTest/kotlin/UuidTest.kt new file mode 100644 index 0000000000..1eff28eb27 --- /dev/null +++ b/utils/platform/src/commonTest/kotlin/UuidTest.kt @@ -0,0 +1,19 @@ +package me.him188.ani.utils.platform + +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals + +class UuidTest { + @Test + fun random() { + assertEquals("026e07f2-752f-46f3-9b5b-7680d00b6d25", Uuid.random(Random(100L)).toString()) + assertEquals("1a2ac523-b005-41a4-a148-93e06d33b6a0", Uuid.random(Random(1L)).toString()) + } + + @Test + fun randomString() { + assertEquals("026e07f2-752f-46f3-9b5b-7680d00b6d25", Uuid.randomString(Random(100L))) + assertEquals("1a2ac523-b005-41a4-a148-93e06d33b6a0", Uuid.randomString(Random(1L))) + } +} diff --git a/utils/platform/src/jvmMain/kotlin/Time.jvm.kt b/utils/platform/src/jvmMain/kotlin/Time.jvm.kt new file mode 100644 index 0000000000..e5f165dc21 --- /dev/null +++ b/utils/platform/src/jvmMain/kotlin/Time.jvm.kt @@ -0,0 +1,5 @@ +package me.him188.ani.utils.platform + +actual fun currentTimeMillis(): Long { + return System.currentTimeMillis() +} diff --git a/utils/platform/src/jvmMain/kotlin/UUID.jvm.kt b/utils/platform/src/jvmMain/kotlin/UUID.jvm.kt new file mode 100644 index 0000000000..747bb1a460 --- /dev/null +++ b/utils/platform/src/jvmMain/kotlin/UUID.jvm.kt @@ -0,0 +1,24 @@ +package me.him188.ani.utils.platform + +import java.util.UUID +import kotlin.random.Random + +@JvmInline +actual value class Uuid internal constructor( + internal val delegate: UuidDelegate, +) { + actual override fun toString(): String = delegate.toString() + + actual companion object { + actual fun random(random: Random): Uuid { + return Uuid(UUID.fromString(generateRandomUuid(random))) + } + + actual fun randomString(random: Random): String { + return generateRandomUuid(random) + } + } +} + +@Suppress("ACTUAL_WITHOUT_EXPECT") +internal actual typealias UuidDelegate = UUID diff --git a/utils/platform/src/jvmMain/kotlin/annotations/Range.jvm.kt b/utils/platform/src/jvmMain/kotlin/annotations/Range.jvm.kt new file mode 100644 index 0000000000..4c4b1ec25a --- /dev/null +++ b/utils/platform/src/jvmMain/kotlin/annotations/Range.jvm.kt @@ -0,0 +1,5 @@ +package me.him188.ani.utils.platform.annotations + +import org.jetbrains.annotations.Range + +actual typealias Range = Range \ No newline at end of file diff --git a/utils/platform/src/jvmMain/kotlin/package.kt b/utils/platform/src/jvmMain/kotlin/package.kt new file mode 100644 index 0000000000..0525f8e8ca --- /dev/null +++ b/utils/platform/src/jvmMain/kotlin/package.kt @@ -0,0 +1 @@ +package me.him188.ani.utils.platform diff --git a/utils/platform/src/nativeMain/kotlin/Time.native.kt b/utils/platform/src/nativeMain/kotlin/Time.native.kt new file mode 100644 index 0000000000..cde915f65a --- /dev/null +++ b/utils/platform/src/nativeMain/kotlin/Time.native.kt @@ -0,0 +1,7 @@ +package me.him188.ani.utils.platform + +import kotlinx.datetime.Clock + +actual fun currentTimeMillis(): Long { + return Clock.System.now().toEpochMilliseconds() +} diff --git a/utils/platform/src/nativeMain/kotlin/UUID.native.kt b/utils/platform/src/nativeMain/kotlin/UUID.native.kt new file mode 100644 index 0000000000..10d539bc7e --- /dev/null +++ b/utils/platform/src/nativeMain/kotlin/UUID.native.kt @@ -0,0 +1,23 @@ +package me.him188.ani.utils.platform + +import kotlin.random.Random + +@Suppress("ACTUAL_ANNOTATIONS_NOT_MATCH_EXPECT") // JvmInline +actual value class Uuid internal constructor( + @Suppress("MemberVisibilityCanBePrivate") internal val delegate: UuidDelegate +) { + actual override fun toString(): String = delegate + + actual companion object { + actual fun random(random: Random): Uuid { + return Uuid(generateRandomUuid(random)) + } + + actual fun randomString(random: Random): String { + return generateRandomUuid(random) + } + } +} + +@Suppress("ACTUAL_WITHOUT_EXPECT") +internal actual typealias UuidDelegate = String diff --git a/utils/platform/src/nativeMain/kotlin/annotations/Range.native.kt b/utils/platform/src/nativeMain/kotlin/annotations/Range.native.kt new file mode 100644 index 0000000000..80036d40c0 --- /dev/null +++ b/utils/platform/src/nativeMain/kotlin/annotations/Range.native.kt @@ -0,0 +1,6 @@ +package me.him188.ani.utils.platform.annotations + +@Suppress("unused") +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.TYPE) +actual annotation class Range actual constructor(actual val from: Long, actual val to: Long) diff --git a/utils/platform/src/nativeMain/kotlin/package.kt b/utils/platform/src/nativeMain/kotlin/package.kt new file mode 100644 index 0000000000..802c044c4a --- /dev/null +++ b/utils/platform/src/nativeMain/kotlin/package.kt @@ -0,0 +1,2 @@ +package me.him188.ani.utils.platform +