diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e46f2efb..5a075876 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { defaultConfig { targetSdk = Vers.stTargetSdkVersion minSdk = Vers.stMinSdkVersion - versionCode = 545 - versionName = "5.545" + versionCode = 546 + versionName = "5.546" applicationId = "jp.juggler.subwaytooter" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/jp/juggler/subwaytooter/ui/ossLicense/ActOSSLicenseViewModel.kt b/app/src/main/java/jp/juggler/subwaytooter/ui/ossLicense/ActOSSLicenseViewModel.kt new file mode 100644 index 00000000..ca950252 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/ui/ossLicense/ActOSSLicenseViewModel.kt @@ -0,0 +1,63 @@ +package jp.juggler.subwaytooter.ui.ossLicense + +import android.app.Application +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import jp.juggler.subwaytooter.R +import jp.juggler.subwaytooter.util.StColorScheme +import jp.juggler.util.coroutine.AppDispatchers +import jp.juggler.util.data.decodeJsonObject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ActOSSLicenseViewModel( + application: Application, +) : AndroidViewModel(application) { + val context: Context + get() = getApplication().applicationContext + + private val loadError = Channel(capacity = Channel.CONFLATED) + + private val _libraries = MutableStateFlow>(emptyList()) + val libraries = _libraries.asStateFlow() + + private val _isProgressShown = MutableStateFlow(false) + val isProgressShown = _isProgressShown.asStateFlow() + + fun load(stColorScheme: StColorScheme) = viewModelScope.launch { + try { + _isProgressShown.value = true + _libraries.value = withContext(AppDispatchers.IO) { + val root = context.resources.openRawResource(R.raw.dep_list) + .use { it.readBytes() } + .decodeToString() + .decodeJsonObject() + val licenses = root.jsonArray("licenses") + ?.objectList() + ?.associateBy { it.string("shortName") } + ?: emptyMap() + root.jsonArray("libs") + ?.objectList() + ?.map { + parseLibText( + it, + licenses, + stColorScheme = stColorScheme, + ) + } + ?.sortedBy { it.nameSort } + ?: emptyList() + } + } catch (ex: Throwable) { + if (ex is CancellationException) return@launch + loadError.send(ex) + } finally { + _isProgressShown.value = false + } + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/ui/ossLicense/LibText.kt b/app/src/main/java/jp/juggler/subwaytooter/ui/ossLicense/LibText.kt new file mode 100644 index 00000000..7b6937a7 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/ui/ossLicense/LibText.kt @@ -0,0 +1,92 @@ +package jp.juggler.subwaytooter.ui.ossLicense + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import jp.juggler.subwaytooter.util.StColorScheme +import jp.juggler.subwaytooter.util.annotateUrl +import jp.juggler.subwaytooter.util.isNotEmpty +import jp.juggler.subwaytooter.util.joinAnnotatedString +import jp.juggler.subwaytooter.util.toAnnotatedString +import jp.juggler.util.data.JsonObject +import jp.juggler.util.data.notEmpty +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** + * 依存ライブラリの装飾付きテキスト + */ +class LibText( + val nameBig: AnnotatedString, + val nameSmall: AnnotatedString?, + val desc: AnnotatedString, +) { + val nameSort = nameBig.toString().lowercase() +} + +/** + * レシーバの文字列がカラでなければdstに追加する + * @receiver 追加先 + * @param text 装飾付き文字列 + * @param prefix nullや空でなければtextより先に追加される + */ +private fun AnnotatedString.Builder.appendLine( + text: CharSequence?, + prefix: CharSequence? = null, +) { + if (text.isNullOrBlank()) return + if (isNotEmpty()) append("\n") + if (!prefix.isNullOrBlank()) append(prefix) + append(text) +} + +/** + * ライブラリ情報をLibTextに変換する + */ +fun parseLibText( + lib: JsonObject, + licenses: Map, + stColorScheme: StColorScheme, +): LibText { + val colorLink = stColorScheme.colorTextLink + + val webSite = lib.string("website")?.toHttpUrlOrNull()?.toString() + val name = lib.string("name")?.notEmpty() + val id = lib.string("id")?.notEmpty() + + val nameBig: CharSequence + val nameSmall: CharSequence? + if (name.isNullOrBlank()) { + // nameがない場合はnameBigはidを大きく表示する + nameBig = (id ?: "(no name, no id)").annotateUrl(webSite, colorLink) + nameSmall = null + } else { + nameBig = name.annotateUrl(webSite, colorLink) + nameSmall = id + // idがない場合はnameSmallはnullとなる + } + + val licenseText = lib.jsonArray("licenses")?.stringList() + ?.asSequence() + ?.mapNotNull { licenses[it] } + ?.map { + val uri = + it.jsonArray("urls")?.stringList()?.firstOrNull()?.toHttpUrlOrNull()?.toString() + it.string("name")!!.annotateUrl(uri, colorLink) + } + ?.toList()?.joinAnnotatedString(AnnotatedString(", ")) + + val devNames = lib.jsonArray("developers")?.objectList() + ?.map { it.string("name") } + ?.filter { !it.isNullOrBlank() } + ?.joinToString(", ") + + val libDesc = buildAnnotatedString { + appendLine(lib.string("description")?.takeIf { it != name }) + appendLine(devNames, "- Developers: ") + appendLine(licenseText, "- License: ") + } + return LibText( + nameBig = nameBig.toAnnotatedString(), + nameSmall = nameSmall?.toAnnotatedString(), + desc = libDesc.toAnnotatedString(), + ) +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/AnnotatedStringUtils.kt b/app/src/main/java/jp/juggler/subwaytooter/util/AnnotatedStringUtils.kt new file mode 100644 index 00000000..12aabf74 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/util/AnnotatedStringUtils.kt @@ -0,0 +1,59 @@ +package jp.juggler.subwaytooter.util + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.TextUnit + +fun CharSequence.toAnnotatedString() = when (this) { + is AnnotatedString -> this + else -> AnnotatedString(toString()) +} + +fun AnnotatedString.Builder.isEmpty() = length == 0 +fun AnnotatedString.Builder.isNotEmpty() = length != 0 + +/** + * 装飾付き文字列のリストを連結する + * @receiver 装飾付き文字列のリスト + * @param separator 文字列と文字列の間に挿入される区切り + */ +fun List.joinAnnotatedString( + separator: CharSequence, +): AnnotatedString = AnnotatedString.Builder().also { dst -> + for (item in this) { + if (dst.isNotEmpty()) dst.append(separator) + dst.append(item) + } +}.toAnnotatedString() + +/** + * クリック可能な装飾付き文字列を作る + * @receiver 表示文字列 + * @param uri クリックしたら開くUri。nullなら装飾しない + * @return CharSequence, 実際には Stringまたは AnnotatedString + */ +fun String.annotateUrl( + uri: String?, + colorLink: Color, + fontSize: TextUnit = TextUnit.Unspecified, +): CharSequence = when (uri) { + null -> this + else -> AnnotatedString.Builder().apply { + append(this@annotateUrl) + addStyle( + style = SpanStyle( + color = colorLink, + fontSize = fontSize, + textDecoration = TextDecoration.Underline + ), start = 0, end = length + ) + addStringAnnotation( + tag = "URL", + annotation = uri.toString(), + start = 0, + end = length, + ) + }.toAnnotatedString() +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/ComposeUtils.kt b/app/src/main/java/jp/juggler/subwaytooter/util/ComposeUtils.kt new file mode 100644 index 00000000..6ed996fc --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/util/ComposeUtils.kt @@ -0,0 +1,116 @@ +package jp.juggler.subwaytooter.util + +import android.app.Activity +import android.content.Context +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import jp.juggler.subwaytooter.App1 +import jp.juggler.subwaytooter.R +import jp.juggler.subwaytooter.pref.PrefI +import jp.juggler.util.ui.resColor + +class StColorScheme( + val materialColorScheme: ColorScheme, + val colorTextLink: Color, +) + +fun Context.createStColorSchemeLight(): StColorScheme { + val colorTextContent = Color(resColor(R.color.Light_colorTextContent)) + val colorTextError = Color(resColor(R.color.Light_colorRegexFilterError)) + val colorTextLink = Color(resColor(R.color.Light_colorLink)) + return StColorScheme( + materialColorScheme = lightColorScheme( + error = colorTextError, + + background = Color.White, + onBackground = colorTextContent, + + primary = colorTextLink, + onPrimary = Color.White, + + secondary = colorTextLink, + onSecondary = Color.White, + + surface = Color(resColor(R.color.Light_colorColumnSettingBackground)), + onSurface = colorTextContent, + onTertiary = colorTextContent, + + onSurfaceVariant = Color(resColor(R.color.Light_colorTextHint)), + ), + colorTextLink = colorTextLink, + ) +} + +fun Context.createStColorSchemeDark(): StColorScheme { + val colorBackground = Color(resColor(R.color.Dark_colorBackground)) + val colorTextContent = Color(resColor(R.color.Dark_colorTextContent)) + val colorTextError = Color(resColor(R.color.Dark_colorRegexFilterError)) + val colorTextLink = Color(resColor(R.color.Dark_colorLink)) + return StColorScheme( + materialColorScheme = darkColorScheme( + error = colorTextError, + + background = colorBackground, + onBackground = colorTextContent, + + primary = colorTextLink, + onPrimary = colorTextContent, + + secondary = colorTextLink, + onSecondary = colorTextContent, + + surface = Color(resColor(R.color.Dark_colorColumnSettingBackground)), + onSurface = colorTextContent, + onTertiary = colorTextContent, + + onSurfaceVariant = Color(resColor(R.color.Dark_colorTextHint)), + ), + colorTextLink = colorTextLink, + ) +} + +fun Context.createStColorSchemeMastodonDark(): StColorScheme { + val colorBackground = Color(resColor(R.color.Mastodon_colorBackground)) + val colorTextContent = Color(resColor(R.color.Mastodon_colorTextContent)) + val colorTextError = Color(resColor(R.color.Mastodon_colorRegexFilterError)) + val colorTextLink = Color(resColor(R.color.Mastodon_colorLink)) + return StColorScheme( + materialColorScheme = darkColorScheme( + error = colorTextError, + + background = colorBackground, + onBackground = colorTextContent, + + primary = Color(resColor(R.color.Mastodon_colorAppCompatAccent)), + onPrimary = colorTextContent, + + secondary = colorTextLink, + onSecondary = colorTextContent, + + surface = Color(resColor(R.color.Mastodon_colorColumnSettingBackground)), + onSurface = colorTextContent, + onTertiary = colorTextContent, + + onSurfaceVariant = Color(resColor(R.color.Mastodon_colorTextHint)), + ), + colorTextLink = colorTextLink, + ) +} + +fun Activity.getStColorTheme(forceDark: Boolean = false): StColorScheme { + App1.prepare(applicationContext, "getStColorTheme") + var nTheme = PrefI.ipUiTheme.value + if (forceDark && nTheme == 0) nTheme = 1 + return when (nTheme) { + 2 -> createStColorSchemeMastodonDark() + 1 -> createStColorSchemeDark() + else -> createStColorSchemeLight() + } +} + +fun dummyStColorTheme() = StColorScheme( + materialColorScheme = darkColorScheme(), + colorTextLink = Color.Cyan, +) diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/ViewModelUtils.kt b/app/src/main/java/jp/juggler/subwaytooter/util/ViewModelUtils.kt new file mode 100644 index 00000000..530f2e53 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/util/ViewModelUtils.kt @@ -0,0 +1,63 @@ +package jp.juggler.subwaytooter.util + +import androidx.activity.ComponentActivity +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +// ViewModelのfactoryを毎回書くのが面倒 +// あと使わない場合にはViewModelの引数を生成したくない +fun viewModelFactory(vmClass: Class, creator: () -> VM) = + object : ViewModelProvider.NewInstanceFactory() { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (!modelClass.isAssignableFrom(vmClass)) { + error("unexpected modelClass. ${modelClass.simpleName}") + } + return creator() as T + } + } + +// ViewModelProvider(…).get を毎回書くのが面倒 +inline fun provideViewModel( + owner: ViewModelStoreOwner, + noinline creator: () -> T, +) = ViewModelProvider(owner, viewModelFactory(T::class.java, creator))[T::class.java] + +inline fun provideViewModel( + owner: ViewModelStoreOwner, + key: String, + noinline creator: () -> T, +) = ViewModelProvider(owner, viewModelFactory(T::class.java, creator))[key, T::class.java] + +fun AppCompatActivity.collectOnLifeCycle( + flow: Flow, + state: Lifecycle.State = Lifecycle.State.STARTED, + block: suspend (T) -> Unit, +) = lifecycleScope.launch { + lifecycle.repeatOnLifecycle(state = state) { + flow.collect { + block(it) + // Viewの更新 + } + } +} + +fun ComponentActivity.collectOnLifeCycle( + flow: Flow, + state: Lifecycle.State = Lifecycle.State.STARTED, + block: suspend (T) -> Unit, +) = lifecycleScope.launch { + lifecycle.repeatOnLifecycle(state = state) { + flow.collect { + block(it) + // Viewの更新 + } + } +} diff --git a/colorpicker/src/main/java/com/jrummyapps/android/colorpicker/Utils.kt b/colorpicker/src/main/java/com/jrummyapps/android/colorpicker/Utils.kt new file mode 100644 index 00000000..5955b270 --- /dev/null +++ b/colorpicker/src/main/java/com/jrummyapps/android/colorpicker/Utils.kt @@ -0,0 +1,47 @@ +package com.jrummyapps.android.colorpicker + +import android.content.Context +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.util.TypedValue + +internal fun Context.dpToPx(dipValue: Float): Int { + val metrics = resources.displayMetrics + val v = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dipValue, metrics) + val res = (v + 0.5).toInt() // Round + // Ensure at least 1 pixel if val was > 0 + return if (res == 0 && v > 0) 1 else res +} + +internal fun Context.dpToPx(dipValue: Int): Int = + dpToPx(dipValue.toFloat()) + +internal inline fun Bundle.getParcelableCompat(key: String) = + if (Build.VERSION.SDK_INT >= 33) { + getParcelable(key, T::class.java) + } else { + @Suppress("DEPRECATION") + getParcelable(key) + } + +// 単体テストするのでpublic +fun String.parseColor(): Int { + val start = if (startsWith("#")) 1 else 0 + fun c1(offset: Int) = substring(start + offset, start + offset + 1).toInt(16) * 0x11 + fun c2(offset: Int) = substring(start + offset, start + offset + 2).toInt(16) + + return when (length - start) { + 0 -> Color.BLACK + 1 -> Color.argb(255, c1(0), c1(0), c1(0)) + 2 -> Color.argb(255, c1(0), c1(1), 0x80) + 3 -> Color.argb(255, c1(0), c1(1), c1(2)) + 4 -> Color.argb(c1(0), c1(1), c1(2), c1(3)) + 5 -> Color.argb(255, c2(0), c2(2), c1(4)) + 6 -> Color.argb(255, c2(0), c2(2), c2(4)) + 7 -> Color.argb(c2(0), c2(2), c2(4), c1(6)) + 8 -> Color.argb(c2(0), c2(2), c2(4), c2(6)) + else -> Color.WHITE + } +}