diff --git a/anko/src/main/java/org/jetbrains/anko/ContextUtils.kt b/anko/src/main/java/org/jetbrains/anko/ContextUtils.kt index 99526dd6..6b28ad4b 100644 --- a/anko/src/main/java/org/jetbrains/anko/ContextUtils.kt +++ b/anko/src/main/java/org/jetbrains/anko/ContextUtils.kt @@ -34,14 +34,14 @@ inline val AnkoContext<*>.resources: Resources inline val AnkoContext<*>.assets: AssetManager get() = ctx.assets -inline val AnkoContext<*>.defaultSharedPreferences: SharedPreferences - get() = PreferenceManager.getDefaultSharedPreferences(ctx) - -inline val Context.defaultSharedPreferences: SharedPreferences - get() = PreferenceManager.getDefaultSharedPreferences(this) - -inline val Fragment.defaultSharedPreferences: SharedPreferences - get() = PreferenceManager.getDefaultSharedPreferences(requireContext()) +//inline val AnkoContext<*>.defaultSharedPreferences: SharedPreferences +// get() = PreferenceManager.getDefaultSharedPreferences(ctx) +// +//inline val Context.defaultSharedPreferences: SharedPreferences +// get() = PreferenceManager.getDefaultSharedPreferences(this) +// +//inline val Fragment.defaultSharedPreferences: SharedPreferences +// get() = PreferenceManager.getDefaultSharedPreferences(requireContext()) inline val Fragment.act: Activity? get() = activity diff --git a/app/build.gradle b/app/build.gradle index c16c0e80..ae61cd4f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,14 +1,17 @@ import io.gitlab.arturbosch.detekt.Detekt import java.text.SimpleDateFormat +import java.util.regex.Matcher +import java.util.regex.Pattern -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' -apply plugin: 'org.jetbrains.kotlin.plugin.serialization' -apply plugin: 'com.google.gms.google-services' -apply plugin: "io.gitlab.arturbosch.detekt" - +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.kapt") + id("org.jetbrains.kotlin.plugin.serialization") + id("com.google.devtools.ksp").version("1.8.0-1.0.9") + id("io.gitlab.arturbosch.detekt") +} android { compileSdkVersion stCompileSdkVersion @@ -58,25 +61,30 @@ android { buildTypes { release { - minifyEnabled true - shrinkResources true - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + minifyEnabled false + shrinkResources false + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" lintOptions { - disable 'MissingTranslation' + disable "MissingTranslation" } } - debug{ + debug { } } // Specifies comma-separated list of flavor dimensions. - flavorDimensions "rcOrDev" + flavorDimensions "fcmType" productFlavors { - rc { - dimension "rcOrDev" + nofcm { + dimension "fcmType" + versionNameSuffix "-noFcm" + } + fcm { + dimension "fcmType" + versionNameSuffix "-play" } } @@ -97,18 +105,18 @@ android { packagingOptions { resources { - excludes += ['/META-INF/{AL2.0,LGPL2.1}', 'META-INF/DEPENDENCIES'] + excludes += ["/META-INF/{AL2.0,LGPL2.1}", "META-INF/DEPENDENCIES"] // https://github.com/Kotlin/kotlinx.coroutines/issues/1064 - pickFirsts += ['META-INF/atomicfu.kotlin_module'] + pickFirsts += ["META-INF/atomicfu.kotlin_module"] } } - useLibrary 'android.test.base' - useLibrary 'android.test.mock' + useLibrary "android.test.base" + useLibrary "android.test.mock" lint { - warning 'DuplicatePlatformClasses' + warning "DuplicatePlatformClasses" } - namespace 'jp.juggler.subwaytooter' + namespace "jp.juggler.subwaytooter" } @@ -134,18 +142,20 @@ dependencies { coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$desugarLibVersion" implementation(project(":base")) - implementation project(':colorpicker') - implementation project(':emoji') - implementation project(':apng_android') - implementation project(':anko') - implementation fileTree(include: ['*.aar'], dir: 'src/main/libs') + implementation project(":colorpicker") + implementation project(":emoji") + implementation project(":apng_android") + implementation project(":anko") + implementation fileTree(include: ["*.aar"], dir: "src/main/libs") // implementation "org.conscrypt:conscrypt-android:$conscryptVersion" api "org.conscrypt:conscrypt-android:$conscryptVersion" + implementation "com.github.UnifiedPush:android-connector:2.1.1" kapt "androidx.annotation:annotation:$androidxAnnotationVersion" - kapt "androidx.room:room-compiler:$roomVersion" - kapt "com.github.bumptech.glide:compiler:$glideVersion" + + //kapt "com.github.bumptech.glide:compiler:$glideVersion" + ksp "com.github.bumptech.glide:ksp:$glideVersion" detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:$detektVersion") @@ -189,6 +199,29 @@ repositories { mavenCentral() } +def willApplyGoogleService() { + Gradle gradle = getGradle() + String tskReqStr = gradle.getStartParameter().getTaskRequests().toString() + Matcher matcher + + matcher = Pattern.compile("assemble|generate", Pattern.CASE_INSENSITIVE).matcher(tskReqStr) + if (!matcher.find()) { + // not assemble or generate task. + return false + } + + matcher = Pattern.compile("(?:assemble|generate)fcm\\w+", Pattern.CASE_INSENSITIVE).matcher(tskReqStr) + if (!matcher.find()) { + println "willApplyGoogleService=false. $tskReqStr" + return false + } else { + println "willApplyGoogleService=true. $tskReqStr" + return true + } +} + +if (willApplyGoogleService()) apply plugin: "com.google.gms.google-services" + tasks.register("detektAll", Detekt) { description = "Custom DETEKT build for all modules" @@ -247,3 +280,4 @@ tasks.register("detektAll", Detekt) { sarif.outputLocation = file("$buildDir/reports/detekt/st-${name}.sarif") } } + diff --git a/app/src/fcm/AndroidManifest.xml b/app/src/fcm/AndroidManifest.xml new file mode 100644 index 00000000..2301bacb --- /dev/null +++ b/app/src/fcm/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/app/src/fcm/java/jp/juggler/subwaytooter/push/FcmTokenLoader.kt b/app/src/fcm/java/jp/juggler/subwaytooter/push/FcmTokenLoader.kt new file mode 100644 index 00000000..165623ca --- /dev/null +++ b/app/src/fcm/java/jp/juggler/subwaytooter/push/FcmTokenLoader.kt @@ -0,0 +1,16 @@ +package jp.juggler.subwaytooter.push + +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.tasks.await + +@Suppress("unused") +class FcmTokenLoader { + // com.google.firebase:firebase-messaging.20.3.0 以降 + // implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$kotlinx_coroutines_version" + suspend fun getToken(): String? = + FirebaseMessaging.getInstance().token.await() + + suspend fun deleteToken(){ + FirebaseMessaging.getInstance().deleteToken().await() + } +} diff --git a/app/src/fcm/java/jp/juggler/subwaytooter/push/MyFcmService.kt b/app/src/fcm/java/jp/juggler/subwaytooter/push/MyFcmService.kt new file mode 100644 index 00000000..3a63edc8 --- /dev/null +++ b/app/src/fcm/java/jp/juggler/subwaytooter/push/MyFcmService.kt @@ -0,0 +1,49 @@ +package jp.juggler.subwaytooter.push + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import jp.juggler.util.log.LogCategory +import jp.juggler.util.os.checkAppForeground +import kotlinx.coroutines.runBlocking + +/** + * FCMのイベントを受け取るサービス。 + * - IntentServiceの一種なのでワーカースレッドから呼ばれる。runBlockingして良し。 + */ +class MyFcmService : FirebaseMessagingService() { + companion object{ + private val log = LogCategory("MyFcmService") + } + + /** + * FCMデバイストークンが更新された + */ + override fun onNewToken(token: String) { + try { + checkAppForeground("MyFcmService.onNewToken") + fcmHandler.onTokenChanged(token) + } catch (ex: Throwable) { + log.e(ex, "onNewToken failed.") + } finally { + checkAppForeground("MyFcmService.onNewToken") + } + } + + /** + * メッセージを受信した + * - ワーカースレッドから呼ばれる。runBlockingして良し。 + * - IntentServiceの一種なので、呼び出しの間はネットワークを使えるなどある + */ + override fun onMessageReceived(remoteMessage: RemoteMessage) { + try { + checkAppForeground("MyFcmService.onMessageReceived") + runBlocking { + fcmHandler.onMessageReceived( remoteMessage.data) + } + } catch (ex: Throwable) { + log.e(ex, "onMessageReceived failed.") + } finally { + checkAppForeground("MyFcmService.onMessageReceived") + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b0c665e7..c09c92b6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -84,16 +84,17 @@ - - - - - - - + + + + + + + + + + + + + diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActAccountSetting.kt b/app/src/main/java/jp/juggler/subwaytooter/ActAccountSetting.kt index 2630280e..c8d7ed29 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActAccountSetting.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActAccountSetting.kt @@ -2,6 +2,7 @@ package jp.juggler.subwaytooter import android.app.Activity import android.content.ContentValues +import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.net.Uri @@ -15,24 +16,29 @@ import android.view.View import android.widget.* import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import jp.juggler.subwaytooter.action.accountRemove import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.auth.AuthBase import jp.juggler.subwaytooter.api.entity.* +import jp.juggler.subwaytooter.auth.AuthRepo import jp.juggler.subwaytooter.databinding.ActAccountSettingBinding -import jp.juggler.subwaytooter.dialog.ActionsDialog +import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.notification.* import jp.juggler.subwaytooter.pref.PrefB -import jp.juggler.subwaytooter.table.AcctColor +import jp.juggler.subwaytooter.push.PushBase +import jp.juggler.subwaytooter.push.pushRepo import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoAcctColor +import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.util.* import jp.juggler.util.* import jp.juggler.util.coroutine.AppDispatchers +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain import jp.juggler.util.coroutine.launchProgress import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory import jp.juggler.util.log.showToast +import jp.juggler.util.log.withCaption import jp.juggler.util.media.ResizeConfig import jp.juggler.util.media.ResizeType import jp.juggler.util.media.createResizedBitmap @@ -124,6 +130,10 @@ class ActAccountSetting : AppCompatActivity(), ActAccountSettingBinding.inflate(layoutInflater, null, false) } + private val authRepo by lazy { + AuthRepo(this) + } + private lateinit var nameInvalidator: NetworkEmojiInvalidator private lateinit var noteInvalidator: NetworkEmojiInvalidator private lateinit var defaultTextInvalidator: NetworkEmojiInvalidator @@ -218,20 +228,22 @@ class ActAccountSetting : AppCompatActivity(), initUI() - val a = intent.long(KEY_ACCOUNT_DB_ID) - ?.let { SavedAccount.loadAccount(this, it) } - if (a == null) { - finish() - return + launchAndShowError { + val a = intent.long(KEY_ACCOUNT_DB_ID) + ?.let { daoSavedAccount.loadAccount(it) } + if (a == null) { + finish() + return@launchAndShowError + } + supportActionBar?.subtitle = a.acct.pretty + + loadUIFromData(a) + + initializeProfile() + + views.btnOpenBrowser.text = + getString(R.string.open_instance_website, account.apiHost.pretty) } - supportActionBar?.subtitle = a.acct.pretty - - loadUIFromData(a) - - initializeProfile() - - views.btnOpenBrowser.text = - getString(R.string.open_instance_website, account.apiHost.pretty) } override fun onSaveInstanceState(outState: Bundle) { @@ -336,7 +348,7 @@ class ActAccountSetting : AppCompatActivity(), R.id.etFieldValue4 ).map { findViewById(it) } - btnNotificationStyleEditReply.vg(PrefB.bpSeparateReplyNotificationGroup.invoke()) + btnNotificationStyleEditReply.vg(PrefB.bpSeparateReplyNotificationGroup.value) nameInvalidator = NetworkEmojiInvalidator(handler, etDisplayName) noteInvalidator = NetworkEmojiInvalidator(handler, etNote) @@ -504,72 +516,76 @@ class ActAccountSetting : AppCompatActivity(), } private fun showAcctColor() { + val sa = this.account - val ac = AcctColor.load(sa) + val ac = daoAcctColor.load(sa) views.tvUserCustom.apply { - backgroundColor = ac.color_bg + backgroundColor = ac.colorBg text = ac.nickname - textColor = ac.color_fg.notZero() ?: attrColor(R.attr.colorTimeSmall) + textColor = ac.colorFg.notZero() + ?: attrColor(R.attr.colorTimeSmall) } } private fun saveUIToData() { if (!::account.isInitialized) return if (loadingBusy) return - account.visibility = visibility + launchAndShowError { - views.apply { + account.visibility = visibility - account.dont_hide_nsfw = swNSFWOpen.isChecked - account.dont_show_timeout = swDontShowTimeout.isChecked - account.expand_cw = swExpandCW.isChecked - account.default_sensitive = swMarkSensitive.isChecked - account.notification_mention = cbNotificationMention.isChecked - account.notification_boost = cbNotificationBoost.isChecked - account.notification_favourite = cbNotificationFavourite.isChecked - account.notification_follow = cbNotificationFollow.isChecked - account.notification_follow_request = cbNotificationFollowRequest.isChecked - account.notification_reaction = cbNotificationReaction.isChecked - account.notification_vote = cbNotificationVote.isChecked - account.notification_post = cbNotificationPost.isChecked - account.notification_update = cbNotificationUpdate.isChecked - account.notification_status_reference = cbNotificationStatusReference.isChecked + views.apply { + account.dont_hide_nsfw = swNSFWOpen.isChecked + account.dont_show_timeout = swDontShowTimeout.isChecked + account.expand_cw = swExpandCW.isChecked + account.default_sensitive = swMarkSensitive.isChecked + account.notification_mention = cbNotificationMention.isChecked + account.notification_boost = cbNotificationBoost.isChecked + account.notification_favourite = cbNotificationFavourite.isChecked + account.notification_follow = cbNotificationFollow.isChecked + account.notification_follow_request = cbNotificationFollowRequest.isChecked + account.notification_reaction = cbNotificationReaction.isChecked + account.notification_vote = cbNotificationVote.isChecked + account.notification_post = cbNotificationPost.isChecked + account.notification_update = cbNotificationUpdate.isChecked + account.notification_status_reference = cbNotificationStatusReference.isChecked - account.confirm_follow = cbConfirmFollow.isChecked - account.confirm_follow_locked = cbConfirmFollowLockedUser.isChecked - account.confirm_unfollow = cbConfirmUnfollow.isChecked - account.confirm_boost = cbConfirmBoost.isChecked - account.confirm_favourite = cbConfirmFavourite.isChecked - account.confirm_unboost = cbConfirmUnboost.isChecked - account.confirm_unfavourite = cbConfirmUnfavourite.isChecked - account.confirm_post = cbConfirmToot.isChecked - account.confirm_reaction = cbConfirmReaction.isChecked - account.confirm_unbookmark = cbConfirmUnbookmark.isChecked + account.confirm_follow = cbConfirmFollow.isChecked + account.confirm_follow_locked = cbConfirmFollowLockedUser.isChecked + account.confirm_unfollow = cbConfirmUnfollow.isChecked + account.confirm_boost = cbConfirmBoost.isChecked + account.confirm_favourite = cbConfirmFavourite.isChecked + account.confirm_unboost = cbConfirmUnboost.isChecked + account.confirm_unfavourite = cbConfirmUnfavourite.isChecked + account.confirm_post = cbConfirmToot.isChecked + account.confirm_reaction = cbConfirmReaction.isChecked + account.confirm_unbookmark = cbConfirmUnbookmark.isChecked - account.sound_uri = "" - account.default_text = etDefaultText.text.toString() + account.sound_uri = "" + account.default_text = etDefaultText.text.toString() - account.max_toot_chars = etMaxTootChars.parseInt()?.takeIf { it > 0 } ?: 0 + account.max_toot_chars = etMaxTootChars.parseInt()?.takeIf { it > 0 } ?: 0 - account.movie_max_megabytes = etMovieSizeMax.text.toString().trim() - account.image_max_megabytes = etMediaSizeMax.text.toString().trim() - account.image_resize = ( - imageResizeItems.elementAtOrNull(spResizeImage.selectedItemPosition)?.config - ?: SavedAccount.defaultResizeConfig - ).spec + account.movie_max_megabytes = etMovieSizeMax.text.toString().trim() + account.image_max_megabytes = etMediaSizeMax.text.toString().trim() + account.image_resize = ( + imageResizeItems.elementAtOrNull(spResizeImage.selectedItemPosition)?.config + ?: SavedAccount.defaultResizeConfig + ).spec - account.push_policy = - pushPolicyItems.elementAtOrNull(spPushPolicy.selectedItemPosition)?.id + account.push_policy = + pushPolicyItems.elementAtOrNull(spPushPolicy.selectedItemPosition)?.id - account.movieTranscodeMode = spMovieTranscodeMode.selectedItemPosition - account.movieTranscodeBitrate = etMovieBitrate.text.toString() - account.movieTranscodeFramerate = etMovieFrameRate.text.toString() - account.movieTranscodeSquarePixels = etMovieSquarePixels.text.toString() - account.lang = languages.elementAtOrNull(spLanguageCode.selectedItemPosition)?.first - ?: SavedAccount.LANG_WEB + account.movieTranscodeMode = spMovieTranscodeMode.selectedItemPosition + account.movieTranscodeBitrate = etMovieBitrate.text.toString() + account.movieTranscodeFramerate = etMovieFrameRate.text.toString() + account.movieTranscodeSquarePixels = etMovieSquarePixels.text.toString() + account.lang = languages.elementAtOrNull(spLanguageCode.selectedItemPosition)?.first + ?: SavedAccount.LANG_WEB + } + + daoSavedAccount.saveSetting(account) } - - account.saveSetting() } override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { @@ -617,24 +633,20 @@ class ActAccountSetting : AppCompatActivity(), R.id.btnFields -> sendFields() R.id.btnNotificationStyleEdit -> - MessageNotification.openNotificationChannelSetting( - this, - account, - MessageNotification.TRACKING_NAME_DEFAULT + PullNotification.openNotificationChannelSetting( + this ) R.id.btnNotificationStyleEditReply -> - MessageNotification.openNotificationChannelSetting( - this, - account, - MessageNotification.TRACKING_NAME_REPLY + PullNotification.openNotificationChannelSetting( + this ) } } private fun showVisibility() { views.btnVisibility.text = - getVisibilityString(this, account.isMisskey, visibility) + visibility.getVisibilityString(account.isMisskey) } private fun performVisibility() { @@ -734,10 +746,11 @@ class ActAccountSetting : AppCompatActivity(), .setMessage(R.string.confirm_account_remove) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok) { _, _ -> - accountRemove(account) - finish() - } - .show() + launchAndShowError { + authRepo.accountRemove(account) + finish() + } + }.show() } /////////////////////////////////////////////////// @@ -823,7 +836,7 @@ class ActAccountSetting : AppCompatActivity(), result.jsonObject } else { // 承認待ち状態のチェック - account.checkConfirmed(this, client) + authRepo.checkConfirmed(account, client) val result = client.request( "/api/v1/accounts/verify_credentials" @@ -1259,21 +1272,21 @@ class ActAccountSetting : AppCompatActivity(), } private fun openPicker(permissionRequester: PermissionRequester) { - if (!permissionRequester.checkOrLaunch()) return - - val propName = when (permissionRequester) { - prPickHeader -> "header" - else -> "avatar" + launchAndShowError { + if (!permissionRequester.checkOrLaunch()) return@launchAndShowError + val propName = when (permissionRequester) { + prPickHeader -> "header" + else -> "avatar" + } + actionsDialog { + action(getString(R.string.pick_image)) { + performAttachment(propName) + } + action(getString(R.string.image_capture)) { + performCamera(propName) + } + } } - - val a = ActionsDialog() - a.addAction(getString(R.string.pick_image)) { - performAttachment(propName) - } - a.addAction(getString(R.string.image_capture)) { - performCamera(propName) - } - a.show(this, null) } private fun performAttachment(propName: String) { @@ -1416,19 +1429,57 @@ class ActAccountSetting : AppCompatActivity(), } private fun updatePushSubscription(force: Boolean) { - val wps = PushSubscriptionHelper(applicationContext, account, verbose = true) - launchMain { - runApiTask(account) { client -> - wps.updateSubscription(client, force = force) - }?.let { - val log = wps.logString - if (log.isNotEmpty()) { - AlertDialog.Builder(this@ActAccountSetting) - .setMessage(log) - .setPositiveButton(R.string.close, null) - .show() + val activity = this + launchAndShowError { + val anyNotificationWanted = account.notification_boost || + account.notification_favourite || + account.notification_follow || + account.notification_mention || + account.notification_reaction || + account.notification_vote || + account.notification_follow_request || + account.notification_post || + account.notification_update + + val lines = ArrayList() + val subLogger = object : PushBase.SubscriptionLogger { + override val context: Context + get() = activity + + override fun i(msg: String) { + log.w(msg) + synchronized(lines) { + lines.add(msg) + } + } + + override fun e(msg: String) { + log.e(msg) + synchronized(lines) { + lines.add(msg) + } + } + + override fun e(ex: Throwable, msg: String) { + log.e(ex, msg) + synchronized(lines) { + lines.add(ex.withCaption(msg)) + } } } + try { + pushRepo.updateSubscription( + subLogger, + account, + willRemoveSubscription = !anyNotificationWanted, + ) + } catch (ex: Throwable) { + subLogger.e(ex, "updateSubscription failed.") + } + AlertDialog.Builder(activity) + .setMessage("${account.acct}:\n${lines.joinToString("\n")}") + .setPositiveButton(android.R.string.ok, null) + .show() } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActAlert.kt b/app/src/main/java/jp/juggler/subwaytooter/ActAlert.kt new file mode 100644 index 00000000..7dbd0c03 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/ActAlert.kt @@ -0,0 +1,47 @@ +package jp.juggler.subwaytooter + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.net.toUri +import jp.juggler.subwaytooter.databinding.ActAlertBinding +import jp.juggler.util.data.encodePercent +import jp.juggler.util.data.notEmpty +import jp.juggler.util.ui.setNavigationBack + +class ActAlert : AppCompatActivity() { + companion object { + private const val EXTRA_MESSAGE = "message" + private const val EXTRA_TITLE = "title" + + fun Context.intentActAlert( + tag: String, + message: String, + title: String, + ) = Intent(this, ActAlert::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + data = "app://error/${tag.encodePercent()}".toUri() + putExtra(EXTRA_MESSAGE, message) + putExtra(EXTRA_TITLE, title) + } + } + + private val views by lazy { + ActAlertBinding.inflate(layoutInflater) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(views.root) + setSupportActionBar(views.toolbar) + setNavigationBack(views.toolbar) + + intent?.getStringExtra(EXTRA_TITLE).notEmpty() + ?.let { title = it } + + intent?.getStringExtra(EXTRA_MESSAGE).notEmpty() + ?.let { views.etMessage.setText(it) } + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActAppSetting.kt b/app/src/main/java/jp/juggler/subwaytooter/ActAppSetting.kt index 0cfa9361..130b2ac9 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActAppSetting.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActAppSetting.kt @@ -1,7 +1,6 @@ package jp.juggler.subwaytooter import android.content.Intent -import android.content.SharedPreferences import android.content.pm.ResolveInfo import android.graphics.Color import android.graphics.Typeface @@ -33,6 +32,7 @@ import jp.juggler.subwaytooter.appsetting.AppDataExporter import jp.juggler.subwaytooter.appsetting.AppSettingItem import jp.juggler.subwaytooter.appsetting.SettingType import jp.juggler.subwaytooter.appsetting.appSettingRoot +import jp.juggler.subwaytooter.auth.AuthRepo import jp.juggler.subwaytooter.databinding.ActAppSettingBinding import jp.juggler.subwaytooter.databinding.LvSettingItemBinding import jp.juggler.subwaytooter.dialog.DlgAppPicker @@ -41,11 +41,9 @@ import jp.juggler.subwaytooter.pref.impl.BooleanPref import jp.juggler.subwaytooter.pref.impl.FloatPref import jp.juggler.subwaytooter.pref.impl.IntPref import jp.juggler.subwaytooter.pref.impl.StringPref -import jp.juggler.subwaytooter.pref.pref -import jp.juggler.subwaytooter.pref.put -import jp.juggler.subwaytooter.pref.remove -import jp.juggler.subwaytooter.table.AcctColor +import jp.juggler.subwaytooter.pref.lazyPref import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoAcctColor import jp.juggler.subwaytooter.util.CustomShare import jp.juggler.subwaytooter.util.CustomShareTarget import jp.juggler.subwaytooter.util.cn @@ -92,7 +90,6 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli private var customShareTarget: CustomShareTarget? = null - lateinit var pref: SharedPreferences lateinit var handler: Handler val views by lazy { @@ -103,6 +100,10 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli MyAdapter() } + val authRepo by lazy { + AuthRepo(this) + } + private val arNoop = ActivityResultHandler(log) { } private val arImportAppData = ActivityResultHandler(log) { r -> @@ -159,7 +160,6 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli App1.setActivityTheme(this) this.handler = App1.getAppState(this).handler - this.pref = pref() // val intent = this.intent // val layoutId = intent.getIntExtra(EXTRA_LAYOUT_ID, 0) @@ -218,12 +218,12 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli } private fun removeDefaultPref() { - val e = pref.edit() + val e = lazyPref.edit() var changed = false appSettingRoot.scan { when { (it.pref as? IntPref)?.noRemove == true -> Unit - it.pref?.removeDefault(pref, e) == true -> changed = true + it.pref?.removeDefault(lazyPref, e) == true -> changed = true } } if (changed) e.apply() @@ -371,7 +371,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli SettingType.ColorAlpha -> newColor.notZero() ?: 1 else -> newColor or Color.BLACK } - pref.edit().put(ip, c).apply() + ip.value = c findItemViewHolder(colorTarget)?.showColor() colorTarget.changed(this) } @@ -512,8 +512,6 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli private val tvDesc = views.tvDesc private val tvError = views.tvError - private val pref = actAppSetting.pref - var item: AppSettingItem? = null private var bindingBusy = false @@ -575,7 +573,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli vg(false) // skip animation text = name isEnabledAlpha = item.enabled - isChecked = bp(pref) + isChecked = bp.value vg(true) } @@ -586,7 +584,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli vg(false) // skip animation actAppSetting.setSwitchColor(views.swSwitch) isEnabledAlpha = item.enabled - isChecked = bp(pref) + isChecked = bp.value vg(true) } @@ -608,12 +606,12 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli showCaption(name) views.llButtonBar.vg(true) views.vColor.vg(true) - views.vColor.setBackgroundColor(ip(pref)) + views.vColor.setBackgroundColor(ip.value) views.btnEdit.isEnabledAlpha = item.enabled views.btnReset.isEnabledAlpha = item.enabled views.btnEdit.setOnClickListener { actAppSetting.colorTarget = item - val color = ip(pref) + val color = ip.value val builder = ColorPickerDialog.newBuilder() .setDialogType(ColorPickerDialog.TYPE_CUSTOM) .setAllowPresets(true) @@ -623,7 +621,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli builder.show(actAppSetting) } views.btnReset.setOnClickListener { - pref.edit().remove(ip).apply() + ip.removeValue() showColor() item.changed.invoke(actAppSetting) } @@ -644,7 +642,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli argsInt?.map { actAppSetting.getString(it) } ?: item.spinnerArgsProc(actAppSetting) ) - views.spSpinner.setSelection(pi.invoke(pref)) + views.spSpinner.setSelection(pi.value) } else { item.spinnerInitializer.invoke(actAppSetting, views.spSpinner) } @@ -655,9 +653,9 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli views.etEditText.vg(true)?.let { etEditText -> val text = when (val pi = item.pref) { is FloatPref -> - item.fromFloat.invoke(actAppSetting, pi(pref)) + item.fromFloat.invoke(actAppSetting, pi.value) is StringPref -> - pi(pref) + pi.value else -> error("EditText has incorrect pref $pi") } @@ -736,7 +734,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli fun showColor() { val item = item ?: return val ip = item.pref.cast() ?: return - val c = ip(pref) + val c = ip.value views.vColor.setBackgroundColor(c) } @@ -753,15 +751,14 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli val sv = item.filter.invoke(p0?.toString() ?: "") when (val pi = item.pref) { - is StringPref -> - pref.edit().put(pi, sv).apply() + is StringPref -> pi.value = sv is FloatPref -> { val fv = item.toFloat.invoke(actAppSetting, sv) if (fv.isFinite()) { - pref.edit().put(pi, fv).apply() + pi.value = fv } else { - pref.edit().remove(pi.key).apply() + pi.removeValue() } } @@ -785,7 +782,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli if (bindingBusy) return val item = item ?: return when (val pi = item.pref) { - is IntPref -> pref.edit().put(pi, views.spSpinner.selectedItemPosition).apply() + is IntPref -> pi.value = views.spSpinner.selectedItemPosition else -> item.spinnerOnSelected.invoke(actAppSetting, views.spSpinner, position) } item.changed.invoke(actAppSetting) @@ -795,7 +792,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli if (bindingBusy) return val item = item ?: return when (val pi = item.pref) { - is BooleanPref -> pref.edit().put(pi, isChecked).apply() + is BooleanPref -> pi.value = isChecked else -> error("CompoundButton has no booleanPref $pi") } item.changed.invoke(actAppSetting) @@ -946,7 +943,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli data.handleGetContentResult(contentResolver).firstOrNull()?.uri?.let { val file = saveTimelineFont(it, fileName) if (file != null) { - pref.edit().put(item.pref.cast()!!, file.absolutePath).apply() + (item.pref as? StringPref)?.value = file.absolutePath showTimelineFont(item) } } @@ -959,19 +956,17 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli } fun showTimelineFont(item: AppSettingItem, tv: TextView) { - val fontUrl = item.pref.cast()!!.invoke(this) try { - if (fontUrl.isNotEmpty()) { + item.pref.cast()?.value.notEmpty()?.let { url -> tv.typeface = Typeface.DEFAULT - val face = Typeface.createFromFile(fontUrl) + val face = Typeface.createFromFile(url) tv.typeface = face - tv.text = fontUrl + tv.text = url return } } catch (ex: Throwable) { log.e(ex, "showTimelineFont failed.") } - // fallback tv.text = getString(R.string.not_selected) tv.typeface = Typeface.DEFAULT @@ -1026,17 +1021,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli ////////////////////////////////////////////////////// - inner class AccountAdapter internal constructor() : BaseAdapter() { - - internal val list = ArrayList() - - init { - for (a in SavedAccount.loadAccountList(this@ActAppSetting)) { - if (a.isPseudo) continue - list.add(a) - } - SavedAccount.sort(list) - } + inner class AccountAdapter(val list: List) : BaseAdapter() { override fun getCount(): Int { return 1 + list.size @@ -1058,7 +1043,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli ) view.findViewById(android.R.id.text1).text = when (position) { 0 -> getString(R.string.ask_always) - else -> AcctColor.getNickname(list[position - 1]) + else -> daoAcctColor.getNickname(list[position - 1]) } return view } @@ -1068,7 +1053,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli viewOld ?: layoutInflater.inflate(R.layout.lv_spinner_dropdown, parent, false) view.findViewById(android.R.id.text1).text = when (position) { 0 -> getString(R.string.ask_always) - else -> AcctColor.getNickname(list[position - 1]) + else -> daoAcctColor.getNickname(list[position - 1]) } return view } @@ -1201,7 +1186,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli fun setCustomShare(appSettingItem: AppSettingItem, target: CustomShareTarget, value: String) { val sp: StringPref = appSettingItem.pref.cast() ?: error("$target: not StringPref") - pref.edit().put(sp, value).apply() + sp.value = value showCustomShareIcon(findItemViewHolder(appSettingItem)?.views?.textView1, target) } @@ -1238,7 +1223,7 @@ class ActAppSetting : AppCompatActivity(), ColorPickerDialogListener, View.OnCli private fun setWebBrowser(appSettingItem: AppSettingItem, value: String) { val sp: StringPref = appSettingItem.pref.cast() ?: error("${getString(appSettingItem.caption)}: not StringPref") - pref.edit().put(sp, value).apply() + sp.value = value showWebBrowser(findItemViewHolder(appSettingItem)?.views?.textView1, value) } diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActFavMute.kt b/app/src/main/java/jp/juggler/subwaytooter/ActFavMute.kt index 7f0912de..738bbbc1 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActFavMute.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActFavMute.kt @@ -9,12 +9,14 @@ import jp.juggler.subwaytooter.api.entity.Acct import jp.juggler.subwaytooter.databinding.ActWordListBinding import jp.juggler.subwaytooter.databinding.LvMuteAppBinding import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm -import jp.juggler.subwaytooter.table.FavMute +import jp.juggler.subwaytooter.table.daoFavMute import jp.juggler.util.backPressed +import jp.juggler.util.coroutine.AppDispatchers import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.cast import jp.juggler.util.log.LogCategory import jp.juggler.util.ui.setNavigationBack +import kotlinx.coroutines.withContext class ActFavMute : AppCompatActivity() { @@ -52,27 +54,20 @@ class ActFavMute : AppCompatActivity() { item ?: return launchAndShowError { confirm(R.string.delete_confirm, item.acct.pretty) - FavMute.delete(item.acct) + daoFavMute.delete(item.acct) listAdapter.remove(item) } } private fun loadData() { - listAdapter.items = buildList { - try { - FavMute.createCursor().use { cursor -> - val idxId = cursor.getColumnIndex(FavMute.COL_ID) - val idxName = cursor.getColumnIndex(FavMute.COL_ACCT) - while (cursor.moveToNext()) { - val item = MyItem( - id = cursor.getLong(idxId), - acct = Acct.parse(cursor.getString(idxName)), - ) - add(item) - } + launchAndShowError { + listAdapter.items = withContext(AppDispatchers.IO) { + daoFavMute.listAll().map { + MyItem( + id = it.id, + acct = Acct.parse(it.acct), + ) } - } catch (ex: Throwable) { - log.e(ex, "loadData failed.") } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActHighlightWordEdit.kt b/app/src/main/java/jp/juggler/subwaytooter/ActHighlightWordEdit.kt index 614992ab..10a8711a 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActHighlightWordEdit.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActHighlightWordEdit.kt @@ -12,7 +12,9 @@ import com.jrummyapps.android.colorpicker.ColorPickerDialog import com.jrummyapps.android.colorpicker.ColorPickerDialogListener import jp.juggler.subwaytooter.databinding.ActHighlightEditBinding import jp.juggler.subwaytooter.table.HighlightWord +import jp.juggler.subwaytooter.table.daoHighlightWord import jp.juggler.util.backPressed +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.decodeJsonObject import jp.juggler.util.data.mayUri import jp.juggler.util.data.notEmpty @@ -83,32 +85,34 @@ class ActHighlightWordEdit setResult(RESULT_CANCELED) - fun loadData(): HighlightWord? { - savedInstanceState?.getString(STATE_ITEM) - ?.decodeJsonObject() - ?.let { return HighlightWord(it) } + launchAndShowError { + fun loadData(): HighlightWord? { + savedInstanceState?.getString(STATE_ITEM) + ?.decodeJsonObject() + ?.let { return HighlightWord(it) } - intent?.string(EXTRA_INITIAL_TEXT) - ?.let { return HighlightWord(it) } + intent?.string(EXTRA_INITIAL_TEXT) + ?.let { return HighlightWord(it) } - intent?.long(EXTRA_ITEM_ID) - ?.let { return HighlightWord.load(it) } + intent?.long(EXTRA_ITEM_ID) + ?.let { return daoHighlightWord.load(it) } - return null + return null + } + + val item = loadData() + if (item == null) { + log.d("missing source data") + finish() + return@launchAndShowError + } + + this@ActHighlightWordEdit.item = item + + views.etName.setText(item.name) + showSound() + showColor() } - - val item = loadData() - if (item == null) { - log.d("missing source data") - finish() - return - } - - this.item = item - - views.etName.setText(item.name) - showSound() - showColor() } override fun onSaveInstanceState(outState: Bundle) { @@ -254,22 +258,26 @@ class ActHighlightWordEdit } private fun save() { - uiToData() + launchAndShowError { + uiToData() + val name = item.name - if (item.name.isEmpty()) { - showToast(true, R.string.cant_leave_empty_keyword) - return + if (name.isNullOrBlank()) { + showToast(true, R.string.cant_leave_empty_keyword) + return@launchAndShowError + } + + val other = daoHighlightWord.load(name) + if (other != null && other.id != item.id) { + showToast(true, R.string.cant_save_duplicated_keyword) + return@launchAndShowError + } + + daoHighlightWord.save(applicationContext, item) + App1.getAppState(applicationContext).enableSpeech() + showToast(false, R.string.saved) + setResult(RESULT_OK) + finish() } - - val other = HighlightWord.load(item.name) - if (other != null && other.id != item.id) { - showToast(true, R.string.cant_save_duplicated_keyword) - return - } - - item.save(this) - showToast(false, R.string.saved) - setResult(RESULT_OK) - finish() } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActHighlightWordList.kt b/app/src/main/java/jp/juggler/subwaytooter/ActHighlightWordList.kt index 67ce8b93..a0d74824 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActHighlightWordList.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActHighlightWordList.kt @@ -14,13 +14,16 @@ import jp.juggler.subwaytooter.databinding.ActHighlightListBinding import jp.juggler.subwaytooter.databinding.LvHighlightWordBinding import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.table.HighlightWord +import jp.juggler.subwaytooter.table.daoHighlightWord +import jp.juggler.util.coroutine.AppDispatchers import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.cast import jp.juggler.util.data.mayUri +import jp.juggler.util.data.notBlank import jp.juggler.util.data.notZero import jp.juggler.util.log.LogCategory -import jp.juggler.util.log.errorEx import jp.juggler.util.ui.* +import kotlinx.coroutines.withContext import java.lang.ref.WeakReference class ActHighlightWordList : AppCompatActivity() { @@ -40,7 +43,7 @@ class ActHighlightWordList : AppCompatActivity() { } } - fun tryRingTone(context: Context, uri: Uri?): Boolean { + private fun tryRingTone(context: Context, uri: Uri?): Boolean { try { uri?.let { RingtoneManager.getRingtone(context, it) } ?.let { ringtone -> @@ -129,21 +132,10 @@ class ActHighlightWordList : AppCompatActivity() { } private fun loadData() { - try { - listAdapter.items = buildList { - HighlightWord.createCursor().use { cursor -> - val colIdx = HighlightWord.ColIdx(cursor) - while (cursor.moveToNext()) { - try { - add(HighlightWord(cursor, colIdx)) - } catch (ex: Throwable) { - log.e(ex, "load error.") - } - } - } + launchAndShowError { + listAdapter.items = withContext(AppDispatchers.IO) { + daoHighlightWord.listAll() } - } catch (ex: Throwable) { - errorEx(ex, "query error.") } } @@ -161,15 +153,17 @@ class ActHighlightWordList : AppCompatActivity() { val activity = this launchAndShowError { confirm(getString(R.string.delete_confirm, item.name)) - item.delete(activity) + daoHighlightWord.delete(applicationContext, item) listAdapter.remove(item) + App1.getAppState(activity).enableSpeech() } } private fun speech(item: HighlightWord?) { - item ?: return - App1.getAppState(this@ActHighlightWordList) - .addSpeech(item.name, dedupMode = DedupMode.None) + item?.name?.notBlank()?.let { + App1.getAppState(this@ActHighlightWordList) + .addSpeech(it, dedupMode = DedupMode.None) + } } // リスト要素のViewHolder diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActKeywordFilter.kt b/app/src/main/java/jp/juggler/subwaytooter/ActKeywordFilter.kt index dd9d5464..931f407d 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActKeywordFilter.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActKeywordFilter.kt @@ -12,12 +12,15 @@ import jp.juggler.subwaytooter.api.ApiPath import jp.juggler.subwaytooter.api.TootApiResult import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.runApiTask +import jp.juggler.subwaytooter.auth.AuthRepo import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.databinding.ActKeywordFilterBinding import jp.juggler.subwaytooter.databinding.LvKeywordFilterBinding -import jp.juggler.subwaytooter.table.AcctColor import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoAcctColor +import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.util.backPressed +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.* import jp.juggler.util.int @@ -96,59 +99,64 @@ class ActKeywordFilter : AppCompatActivity() { private val deleteIds = HashSet() + val authRepo by lazy { + AuthRepo(this) + } + /////////////////////////////////////////////////// override fun onCreate(savedInstanceState: Bundle?) { backPressed { confirmBack() } super.onCreate(savedInstanceState) App1.setActivityTheme(this) - - val intent = this.intent - - // filter ID の有無はUIに影響するのでinitUIより先に初期化する - this.filterId = EntityId.from(intent, EXTRA_FILTER_ID) - - val a = intent.long(EXTRA_ACCOUNT_DB_ID) - ?.let { SavedAccount.loadAccount(this, it) } - if (a == null) { - finish() - return - } - this.account = a - initUI() - showAccount() + launchAndShowError { - if (savedInstanceState == null) { - if (filterId != null) { - startLoading() - } else { - views.spExpire.setSelection(1) - val initialText = intent.string(EXTRA_INITIAL_PHRASE)?.trim() ?: "" - views.etTitle.setText(initialText) - addKeywordArea(TootFilterKeyword(keyword = initialText)) + // filter ID の有無はUIに影響するのでinitUIより先に初期化する + filterId = EntityId.from(intent, EXTRA_FILTER_ID) + + val a = intent.long(EXTRA_ACCOUNT_DB_ID) + ?.let { daoSavedAccount.loadAccount(it) } + if (a == null) { + finish() + return@launchAndShowError } - } else { + account = a - savedInstanceState.getStringArrayList(STATE_DELETE_IDS) - ?.let { deleteIds.addAll(it) } - savedInstanceState.getStringArrayList(STATE_KEYWORDS) - ?.mapNotNull { it?.decodeJsonObject() } - ?.forEach { - try { - addKeywordArea(TootFilterKeyword(it)) - } catch (ex: Throwable) { - log.e(ex, "can't decode TootFilterKeyword") - } + showAccount() + + if (savedInstanceState == null) { + if (filterId != null) { + startLoading() + } else { + views.spExpire.setSelection(1) + val initialText = intent.string(EXTRA_INITIAL_PHRASE)?.trim() ?: "" + views.etTitle.setText(initialText) + addKeywordArea(TootFilterKeyword(keyword = initialText)) } + } else { - savedInstanceState.int(STATE_EXPIRE_SPINNER) - ?.let { views.spExpire.setSelection(it) } + savedInstanceState.getStringArrayList(STATE_DELETE_IDS) + ?.let { deleteIds.addAll(it) } - savedInstanceState.long(STATE_EXPIRE_AT) - ?.let { filterExpire = it } + savedInstanceState.getStringArrayList(STATE_KEYWORDS) + ?.mapNotNull { it?.decodeJsonObject() } + ?.forEach { + try { + addKeywordArea(TootFilterKeyword(it)) + } catch (ex: Throwable) { + log.e(ex, "can't decode TootFilterKeyword") + } + } + + savedInstanceState.int(STATE_EXPIRE_SPINNER) + ?.let { views.spExpire.setSelection(it) } + + savedInstanceState.long(STATE_EXPIRE_AT) + ?.let { filterExpire = it } + } } } @@ -218,7 +226,7 @@ class ActKeywordFilter : AppCompatActivity() { } private fun showAccount() { - views.tvAccount.text = AcctColor.getNicknameWithColor(account.acct) + views.tvAccount.text = daoAcctColor.getNicknameWithColor(account.acct) } private fun startLoading() { diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActLanguageFilter.kt b/app/src/main/java/jp/juggler/subwaytooter/ActLanguageFilter.kt index cbc6f19b..3705ea6d 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActLanguageFilter.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActLanguageFilter.kt @@ -17,8 +17,9 @@ import androidx.core.content.FileProvider import jp.juggler.subwaytooter.api.entity.TootStatus import jp.juggler.subwaytooter.column.Column import jp.juggler.subwaytooter.databinding.ActLanguageFilterBinding -import jp.juggler.subwaytooter.dialog.ActionsDialog +import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.util.* +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchProgress import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory @@ -263,15 +264,17 @@ class ActLanguageFilter : AppCompatActivity(), View.OnClickListener { R.id.btnAdd -> edit(null) R.id.btnMore -> { - ActionsDialog() - .addAction(getString(R.string.clear_all)) { - languageList.clear() - languageList.add(MyItem(TootStatus.LANGUAGE_CODE_DEFAULT, true)) - adapter.notifyDataSetChanged() + launchAndShowError { + actionsDialog { + action(getString(R.string.clear_all)) { + languageList.clear() + languageList.add(MyItem(TootStatus.LANGUAGE_CODE_DEFAULT, true)) + adapter.notifyDataSetChanged() + } + action(getString(R.string.export)) { export() } + action(getString(R.string.import_)) { import() } } - .addAction(getString(R.string.export)) { export() } - .addAction(getString(R.string.import_)) { import() } - .show(this) + } } } } @@ -338,14 +341,18 @@ class ActLanguageFilter : AppCompatActivity(), View.OnClickListener { val languageList = activity.languageNameMap.map { MyItem(it.key, true) }.sortedWith(languageComparator) btnPresets.setOnClickListener { - val ad = ActionsDialog() - for (a in languageList) { - ad.addAction("${a.code} ${activity.getDesc(a)}") { - etLanguage.setText(a.code) - updateDesc() + activity.run { + launchAndShowError { + actionsDialog(getString(R.string.presets)) { + for (a in languageList) { + action("${a.code} ${activity.getDesc(a)}") { + etLanguage.setText(a.code) + updateDesc() + } + } + } } } - ad.show(activity, activity.getString(R.string.presets)) } etLanguage.setText(item?.code ?: "") diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt b/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt index 07e8b7a8..43d2c0eb 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActMain.kt @@ -3,7 +3,6 @@ package jp.juggler.subwaytooter import android.app.Activity import android.app.Dialog import android.content.Intent -import android.content.SharedPreferences import android.content.res.Configuration import android.graphics.Typeface import android.os.Build @@ -19,6 +18,7 @@ import android.widget.LinearLayout import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.drawerlayout.widget.DrawerLayout +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import androidx.viewpager.widget.ViewPager import jp.juggler.subwaytooter.action.accessTokenPrompt @@ -32,17 +32,15 @@ import jp.juggler.subwaytooter.column.* import jp.juggler.subwaytooter.dialog.DlgQuickTootMenu import jp.juggler.subwaytooter.itemviewholder.StatusButtonsPopup import jp.juggler.subwaytooter.notification.checkNotificationImmediateAll -import jp.juggler.subwaytooter.pref.PrefB -import jp.juggler.subwaytooter.pref.PrefI -import jp.juggler.subwaytooter.pref.PrefS -import jp.juggler.subwaytooter.pref.put +import jp.juggler.subwaytooter.pref.* import jp.juggler.subwaytooter.span.MyClickableSpan import jp.juggler.subwaytooter.span.MyClickableSpanHandler -import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.util.* import jp.juggler.subwaytooter.view.MyDrawerLayout import jp.juggler.subwaytooter.view.MyEditText import jp.juggler.util.backPressed +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.notEmpty import jp.juggler.util.int import jp.juggler.util.log.LogCategory @@ -53,6 +51,9 @@ import jp.juggler.util.string import jp.juggler.util.ui.ActivityResultHandler import jp.juggler.util.ui.attrColor import jp.juggler.util.ui.isNotOk +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import okhttp3.internal.toHexString import java.lang.ref.WeakReference import java.util.* @@ -152,7 +153,6 @@ class ActMain : AppCompatActivity(), lateinit var completionHelper: CompletionHelper - lateinit var pref: SharedPreferences lateinit var handler: Handler lateinit var appState: AppState @@ -196,7 +196,7 @@ class ActMain : AppCompatActivity(), override fun run() { handler.removeCallbacks(this) if (!isStartedEx) return - if (PrefB.bpRelativeTimestamp(pref)) { + if (PrefB.bpRelativeTimestamp.value) { appState.columnList.forEach { it.fireRelativeTime() } handler.postDelayed(this, 10000L) } @@ -209,7 +209,7 @@ class ActMain : AppCompatActivity(), set(value) { if (value != quickPostVisibility) { quickPostVisibility = value - pref.edit().put(PrefS.spQuickTootVisibility, value.id.toString()).apply() + PrefS.spQuickTootVisibility.value = value.id.toString() showQuickPostVisibility() } } @@ -262,7 +262,7 @@ class ActMain : AppCompatActivity(), } val arAppSetting = ActivityResultHandler(log) { r -> - Column.reloadDefaultColor(this, pref) + Column.reloadDefaultColor(this) showFooterColor() updateColumnStrip() if (r.resultCode == RESULT_APP_DATA_IMPORT) { @@ -282,15 +282,17 @@ class ActMain : AppCompatActivity(), } val arAccountSetting = ActivityResultHandler(log) { r -> - updateColumnStrip() - appState.columnList.forEach { it.fireShowColumnHeader() } - when (r.resultCode) { - RESULT_OK -> r.data?.data?.let { openBrowser(it) } + launchAndShowError { + updateColumnStrip() + appState.columnList.forEach { it.fireShowColumnHeader() } + when (r.resultCode) { + RESULT_OK -> r.data?.data?.let { openBrowser(it) } - ActAccountSetting.RESULT_INPUT_ACCESS_TOKEN -> - r.data?.long(ActAccountSetting.EXTRA_DB_ID) - ?.let { SavedAccount.loadAccount(this, it) } - ?.let { accessTokenPrompt(it.apiHost) } + ActAccountSetting.RESULT_INPUT_ACCESS_TOKEN -> + r.data?.long(ActAccountSetting.EXTRA_DB_ID) + ?.let { daoSavedAccount.loadAccount(it) } + ?.let { accessTokenPrompt(it.apiHost) } + } } } @@ -323,10 +325,12 @@ class ActMain : AppCompatActivity(), } } - private val prNotification = permissionSpecNotification.requester { + val prNotification = permissionSpecNotification.requester { // 特に何もしない } + private var startAfterJob: WeakReference? = null + ////////////////////////////////////////////////////////////////// // ライフサイクルイベント @@ -352,11 +356,10 @@ class ActMain : AppCompatActivity(), appState = App1.getAppState(this) handler = appState.handler - pref = appState.pref density = appState.density - completionHelper = CompletionHelper(this, pref, appState.handler) + completionHelper = CompletionHelper(this, appState.handler) - EmojiDecoder.useTwemoji = PrefB.bpUseTwemoji(pref) + EmojiDecoder.useTwemoji = PrefB.bpUseTwemoji.value acctPadLr = (0.5f + 4f * density).toInt() reloadTextSize() @@ -373,8 +376,6 @@ class ActMain : AppCompatActivity(), if (savedInstanceState != null) { sharedIntent2?.let { handleSharedIntent(it) } } - - checkPrivacyPolicy() } override fun onDestroy() { @@ -452,91 +453,94 @@ class ActMain : AppCompatActivity(), sideMenuAdapter.onActivityStart() + launchDialogs() + // 残りの処理はActivityResultの処理より後回しにしたい - handler.postDelayed(onStartAfter, 1L) - - prNotification.checkOrLaunch() - themeDefaultChangedDialog() - } - } - - private val onStartAfter = Runnable { - benchmark("onStartAfter total") { - - benchmark("sweepBuggieData") { - // バグいアカウントデータを消す + lifecycleScope.launch { try { - SavedAccount.sweepBuggieData() - } catch (ex: Throwable) { - log.e(ex, "sweepBuggieData failed.") - } - } + delay(1L) + benchmark("onStartAfter total") { - val newAccounts = benchmark("loadAccountList") { - SavedAccount.loadAccountList(this) - } - - benchmark("removeColumnByAccount") { - val setDbId = newAccounts.map { it.db_id }.toSet() - // アカウント設定から戻ってきたら、カラムを消す必要があるかもしれない - appState.columnList - .mapIndexedNotNull { index, column -> - when { - column.accessInfo.isNA -> index - setDbId.contains(column.accessInfo.db_id) -> index - else -> null + benchmark("sweepBuggieData") { + // バグいアカウントデータを消す + try { + daoSavedAccount.sweepBuggieData() + } catch (ex: Throwable) { + log.e(ex, "sweepBuggieData failed.") + } } - }.takeIf { it.size != appState.columnCount } - ?.let { setColumnsOrder(it) } - } - benchmark("fireColumnColor") { - // 背景画像を表示しない設定が変更された時にカラムの背景を設定しなおす - appState.columnList.forEach { column -> - column.viewHolder?.lastAnnouncementShown = 0L - column.fireColumnColor() + val newAccounts = benchmark("loadAccountList") { + daoSavedAccount.loadAccountList() + } + + benchmark("removeColumnByAccount") { + val setDbId = newAccounts.map { it.db_id }.toSet() + // アカウント設定から戻ってきたら、カラムを消す必要があるかもしれない + appState.columnList + .mapIndexedNotNull { index, column -> + when { + column.accessInfo.isNA -> index + setDbId.contains(column.accessInfo.db_id) -> index + else -> null + } + }.takeIf { it.size != appState.columnCount } + ?.let { setColumnsOrder(it) } + } + + benchmark("fireColumnColor") { + // 背景画像を表示しない設定が変更された時にカラムの背景を設定しなおす + appState.columnList.forEach { column -> + column.viewHolder?.lastAnnouncementShown = 0L + column.fireColumnColor() + } + } + benchmark("reloadAccountSetting") { + // 各カラムのアカウント設定を読み直す + reloadAccountSetting(newAccounts) + } + benchmark("refreshAfterPost") { + // 投稿直後ならカラムの再取得を行う + refreshAfterPost() + } + benchmark("column.onActivityStart") { + // 画面復帰時に再取得などを行う + appState.columnList.forEach { it.onActivityStart() } + } + benchmark("streamManager.onScreenStart") { + // 画面復帰時にストリーミング接続を開始する + appState.streamManager.onScreenStart() + } + benchmark("updateColumnStripSelection") { + // カラムの表示範囲インジケータを更新 + updateColumnStripSelection(-1, -1f) + } + benchmark("fireShowContent") { + appState.columnList.forEach { + it.fireShowContent(reason = "ActMain onStart", reset = true) + } + } + benchmark("proc_updateRelativeTime") { + // 相対時刻表示の更新 + procUpdateRelativeTime.run() + } + benchmark("enableSpeech") { + // スピーチの開始 + appState.enableSpeech() + } + } + } catch (ex: Throwable) { + log.e(ex, "startAfter failed.") } - } - benchmark("reloadAccountSetting") { - // 各カラムのアカウント設定を読み直す - reloadAccountSetting(newAccounts) - } - benchmark("refreshAfterPost") { - // 投稿直後ならカラムの再取得を行う - refreshAfterPost() - } - benchmark("column.onActivityStart") { - // 画面復帰時に再取得などを行う - appState.columnList.forEach { it.onActivityStart() } - } - benchmark("streamManager.onScreenStart") { - // 画面復帰時にストリーミング接続を開始する - appState.streamManager.onScreenStart() - } - benchmark("updateColumnStripSelection") { - // カラムの表示範囲インジケータを更新 - updateColumnStripSelection(-1, -1f) - } - benchmark("fireShowContent") { - appState.columnList.forEach { - it.fireShowContent(reason = "ActMain onStart", reset = true) - } - } - benchmark("proc_updateRelativeTime") { - // 相対時刻表示の更新 - procUpdateRelativeTime.run() - } - benchmark("enableSpeech") { - // スピーチの開始 - appState.enableSpeech() - } + }.let { startAfterJob = WeakReference(it) } } } override fun onStop() { log.d("onStop") isStartedEx = false - handler.removeCallbacks(onStartAfter) + startAfterJob?.get()?.cancel() + startAfterJob = null handler.removeCallbacks(procUpdateRelativeTime) completionHelper.closeAcctPopup() @@ -583,7 +587,7 @@ class ActMain : AppCompatActivity(), at android.os.Binder.execTransact (Binder.java:739) */ - if (PrefB.bpDontScreenOff(pref)) { + if (PrefB.bpDontScreenOff.value) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) @@ -611,7 +615,7 @@ class ActMain : AppCompatActivity(), { env -> env.pager.currentItem }, { env -> env.visibleColumnsIndices.first }) log.d("ipLastColumnPos save $lastPos") - pref.edit().put(PrefI.ipLastColumnPos, lastPos).apply() + PrefI.ipLastColumnPos.value = lastPos appState.columnList.forEach { it.saveScrollPosition() } @@ -701,10 +705,10 @@ class ActMain : AppCompatActivity(), setContentView(R.layout.act_main) quickPostVisibility = - TootVisibility.parseSavedVisibility(PrefS.spQuickTootVisibility(pref)) + TootVisibility.parseSavedVisibility(PrefS.spQuickTootVisibility.value) ?: quickPostVisibility - Column.reloadDefaultColor(this, pref) + Column.reloadDefaultColor(this) galaxyBackgroundWorkaround() diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActMediaViewer.kt b/app/src/main/java/jp/juggler/subwaytooter/ActMediaViewer.kt index ec689ca0..1659e48f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActMediaViewer.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActMediaViewer.kt @@ -25,15 +25,14 @@ import com.google.android.exoplayer2.util.RepeatModeUtil.REPEAT_TOGGLE_MODE_ONE import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.databinding.ActMediaViewerBinding -import jp.juggler.subwaytooter.dialog.ActionsDialog +import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.drawable.MediaBackgroundDrawable -import jp.juggler.subwaytooter.global.appPref import jp.juggler.subwaytooter.pref.PrefI -import jp.juggler.subwaytooter.pref.put import jp.juggler.subwaytooter.util.permissionSpecMediaDownload import jp.juggler.subwaytooter.util.requester import jp.juggler.subwaytooter.view.PinchBitmapView import jp.juggler.util.* +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory @@ -304,7 +303,7 @@ class ActMediaViewer : AppCompatActivity(), View.OnClickListener { views.pbvImage.background = MediaBackgroundDrawable( context = views.root.context, tileStep = tileStep, - kind = MediaBackgroundDrawable.Kind.fromIndex(PrefI.ipMediaBackground(this)) + kind = MediaBackgroundDrawable.Kind.fromIndex(PrefI.ipMediaBackground.value) ) val enablePaging = mediaList.size > 1 @@ -444,7 +443,7 @@ class ActMediaViewer : AppCompatActivity(), View.OnClickListener { val url = when { forceLocalUrl -> ta.url - else -> ta.getLargeUrl(appPref) + else -> ta.getLargeUrl() } if (url == null) { showError("missing media attachment url.") @@ -573,7 +572,7 @@ class ActMediaViewer : AppCompatActivity(), View.OnClickListener { pbvImage.visible().setBitmap(null) } - val urlList = ta.getLargeUrlList(appPref) + val urlList = ta.getLargeUrlList() if (urlList.isEmpty()) { showError("missing media attachment url.") return @@ -650,7 +649,7 @@ class ActMediaViewer : AppCompatActivity(), View.OnClickListener { ?: error("missing DownloadManager system service") val url = if (ta is TootAttachment) { - ta.getLargeUrl(appPref) + ta.getLargeUrl() } else { null } ?: return @@ -781,65 +780,80 @@ class ActMediaViewer : AppCompatActivity(), View.OnClickListener { } private fun more(ta: TootAttachmentLike) { - val ad = ActionsDialog() - if (ta is TootAttachment) { - val url = ta.getLargeUrl(appPref) ?: return - ad.addAction(getString(R.string.open_in_browser)) { share(Intent.ACTION_VIEW, url) } - ad.addAction(getString(R.string.share_url)) { share(Intent.ACTION_SEND, url) } - ad.addAction(getString(R.string.copy_url)) { copy(url) } - addMoreMenu(ad, "url", ta.url, Intent.ACTION_VIEW) - addMoreMenu(ad, "remote_url", ta.remote_url, Intent.ACTION_VIEW) - addMoreMenu(ad, "preview_url", ta.preview_url, Intent.ACTION_VIEW) - addMoreMenu(ad, "preview_remote_url", ta.preview_remote_url, Intent.ACTION_VIEW) - addMoreMenu(ad, "text_url", ta.text_url, Intent.ACTION_VIEW) - } else if (ta is TootAttachmentMSP) { - val url = ta.preview_url - ad.addAction(getString(R.string.open_in_browser)) { share(Intent.ACTION_VIEW, url) } - ad.addAction(getString(R.string.share_url)) { share(Intent.ACTION_SEND, url) } - ad.addAction(getString(R.string.copy_url)) { copy(url) } - } + launchAndShowError { + actionsDialog { + fun addMoreMenu( + captionPrefix: String, + url: String?, + @Suppress("SameParameterValue") action: String, + ) { + val uri = url.mayUri() ?: return + val caption = getString(R.string.open_browser_of, captionPrefix) + action(caption) { + try { + val intent = Intent(action, uri) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } catch (ex: Throwable) { + showToast(ex, "can't open app.") + } + } + } + if (ta is TootAttachment) { + val url = ta.getLargeUrl() + if (url != null) { + action(getString(R.string.open_in_browser)) { + share(Intent.ACTION_VIEW, url) + } + action(getString(R.string.share_url)) { + share(Intent.ACTION_SEND, url) + } + action(getString(R.string.copy_url)) { + copy(url) + } + } + addMoreMenu("url", ta.url, Intent.ACTION_VIEW) + addMoreMenu("remote_url", ta.remote_url, Intent.ACTION_VIEW) + addMoreMenu("preview_url", ta.preview_url, Intent.ACTION_VIEW) + addMoreMenu("preview_remote_url", ta.preview_remote_url, Intent.ACTION_VIEW) + addMoreMenu("text_url", ta.text_url, Intent.ACTION_VIEW) + } else if (ta is TootAttachmentMSP) { + val url = ta.preview_url + action(getString(R.string.open_in_browser)) { + share(Intent.ACTION_VIEW, url) + } + action(getString(R.string.share_url)) { + share(Intent.ACTION_SEND, url) + } + action(getString(R.string.copy_url)) { + copy(url) + } + } - if (TootAttachmentType.Image == mediaList.elementAtOrNull(idx)?.type) { - ad.addAction(getString(R.string.background_pattern)) { mediaBackgroundDialog() } - } - - ad.show(this, null) - } - - private fun addMoreMenu( - ad: ActionsDialog, - captionPrefix: String, - url: String?, - @Suppress("SameParameterValue") action: String, - ) { - val uri = url.mayUri() ?: return - val caption = getString(R.string.open_browser_of, captionPrefix) - ad.addAction(caption) { - try { - val intent = Intent(action, uri) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) - } catch (ex: Throwable) { - showToast(ex, "can't open app.") + if (TootAttachmentType.Image == mediaList.elementAtOrNull(idx)?.type) { + action(getString(R.string.background_pattern)) { mediaBackgroundDialog() } + } } } } private fun mediaBackgroundDialog() { - val ad = ActionsDialog() - for (k in MediaBackgroundDrawable.Kind.values()) { - if (!k.isMediaBackground) continue - ad.addAction(k.name) { - val idx = k.toIndex() - appPref.edit().put(PrefI.ipMediaBackground, idx).apply() - views.pbvImage.background = MediaBackgroundDrawable( - context = views.root.context, - tileStep = tileStep, - kind = k - ) + launchAndShowError { + actionsDialog(getString(R.string.background_pattern)) { + for (k in MediaBackgroundDrawable.Kind.values()) { + if (!k.isMediaBackground) continue + action(k.name) { + val idx = k.toIndex() + PrefI.ipMediaBackground.value = idx + views.pbvImage.background = MediaBackgroundDrawable( + context = views.root.context, + tileStep = tileStep, + kind = k + ) + } + } } } - ad.show(this, getString(R.string.background_pattern)) } /** diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActMutedApp.kt b/app/src/main/java/jp/juggler/subwaytooter/ActMutedApp.kt index dd6693c7..b8a4743f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActMutedApp.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActMutedApp.kt @@ -9,11 +9,14 @@ import jp.juggler.subwaytooter.databinding.ActWordListBinding import jp.juggler.subwaytooter.databinding.LvMuteAppBinding import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.table.MutedApp +import jp.juggler.subwaytooter.table.appDatabase import jp.juggler.util.backPressed import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.cast import jp.juggler.util.log.LogCategory import jp.juggler.util.ui.setNavigationBack +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class ActMutedApp : AppCompatActivity() { @@ -27,6 +30,8 @@ class ActMutedApp : AppCompatActivity() { private val listAdapter by lazy { MyListAdapter() } + private val daoMutedApp by lazy { MutedApp.Access(appDatabase) } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) backPressed { @@ -47,48 +52,33 @@ class ActMutedApp : AppCompatActivity() { } private fun loadData() { - listAdapter.items = buildList { - try { - MutedApp.createCursor().use { cursor -> - val idxId = cursor.getColumnIndex(MutedApp.COL_ID) - val idxName = cursor.getColumnIndex(MutedApp.COL_NAME) - while (cursor.moveToNext()) { - val item = MyItem( - id = cursor.getLong(idxId), - name = cursor.getString(idxName) - ) - add(item) - } - } - } catch (ex: Throwable) { - log.e(ex, "loadData failed.") + launchAndShowError { + listAdapter.items = withContext(Dispatchers.IO) { + daoMutedApp.listAll() } } } - private fun delete(item: MyItem?) { + private fun delete(item: MutedApp?) { item ?: return launchAndShowError { confirm(R.string.delete_confirm, item.name) - MutedApp.delete(item.name) + daoMutedApp.delete(item.name) listAdapter.remove(item) } } - // リスト要素のデータ - private class MyItem(val id: Long, val name: String) - // リスト要素のViewHolder private inner class MyViewHolder(parent: ViewGroup?) { val views = LvMuteAppBinding.inflate(layoutInflater, parent, false) - var lastItem: MyItem? = null + var lastItem: MutedApp? = null init { views.root.tag = this views.btnDelete.setOnClickListener { delete(lastItem) } } - fun bind(item: MyItem?) { + fun bind(item: MutedApp?) { item ?: return lastItem = item views.tvName.text = item.name @@ -96,13 +86,13 @@ class ActMutedApp : AppCompatActivity() { } private inner class MyListAdapter : BaseAdapter() { - var items: List = emptyList() + var items: List = emptyList() set(value) { field = value notifyDataSetChanged() } - fun remove(item: MyItem) { + fun remove(item: MutedApp) { items = items.filter { it != item } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActMutedPseudoAccount.kt b/app/src/main/java/jp/juggler/subwaytooter/ActMutedPseudoAccount.kt index 8445a094..09260010 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActMutedPseudoAccount.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActMutedPseudoAccount.kt @@ -9,11 +9,14 @@ import jp.juggler.subwaytooter.databinding.ActWordListBinding import jp.juggler.subwaytooter.databinding.LvMuteAppBinding import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.table.UserRelation +import jp.juggler.subwaytooter.table.daoUserRelation import jp.juggler.util.backPressed +import jp.juggler.util.coroutine.AppDispatchers import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.cast import jp.juggler.util.log.LogCategory import jp.juggler.util.ui.setNavigationBack +import kotlinx.coroutines.withContext class ActMutedPseudoAccount : AppCompatActivity() { @@ -47,62 +50,47 @@ class ActMutedPseudoAccount : AppCompatActivity() { } private fun loadData() { - listAdapter.items = buildList { - try { - UserRelation.createCursorPseudoMuted().use { cursor -> - val idxId = UserRelation.COL_ID.getIndex(cursor) - val idxName = UserRelation.COL_WHO_ID.getIndex(cursor) - while (cursor.moveToNext()) { - val item = MyItem( - id = cursor.getLong(idxId), - name = cursor.getString(idxName) - ) - add(item) - } - } - } catch (ex: Throwable) { - log.e(ex, "loadData failed.") + launchAndShowError { + listAdapter.items = withContext(AppDispatchers.IO) { + daoUserRelation.listPseudoMuted() } } } - private fun delete(item: MyItem?) { + private fun delete(item: UserRelation?) { item ?: return launchAndShowError { - confirm(R.string.delete_confirm, item.name) - UserRelation.deletePseudo(item.id) + confirm(R.string.delete_confirm, item.whoId) + daoUserRelation.deletePseudo(item.id) listAdapter.remove(item) } } - // リスト要素のデータ - private class MyItem(val id: Long, val name: String) - // リスト要素のViewHolder private inner class MyViewHolder(parent: ViewGroup?) { val views = LvMuteAppBinding.inflate(layoutInflater, parent, false) - private var lastItem: MyItem? = null + private var lastItem: UserRelation? = null init { views.root.tag = this views.btnDelete.setOnClickListener { delete(lastItem) } } - fun bind(item: MyItem?) { + fun bind(item: UserRelation?) { item ?: return lastItem = item - views.tvName.text = item.name + views.tvName.text = item.whoId } } private inner class MyListAdapter : BaseAdapter() { - var items: List = emptyList() + var items: List = emptyList() set(value) { field = value notifyDataSetChanged() } - fun remove(item: MyItem) { + fun remove(item: UserRelation) { items = items.filter { it != item } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActMutedWord.kt b/app/src/main/java/jp/juggler/subwaytooter/ActMutedWord.kt index 6882b37f..91910422 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActMutedWord.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActMutedWord.kt @@ -9,11 +9,14 @@ import jp.juggler.subwaytooter.databinding.ActWordListBinding import jp.juggler.subwaytooter.databinding.LvMuteAppBinding import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.table.MutedWord +import jp.juggler.subwaytooter.table.daoMutedWord import jp.juggler.util.backPressed +import jp.juggler.util.coroutine.AppDispatchers import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.cast import jp.juggler.util.log.LogCategory import jp.juggler.util.ui.setNavigationBack +import kotlinx.coroutines.withContext class ActMutedWord : AppCompatActivity() { @@ -47,48 +50,33 @@ class ActMutedWord : AppCompatActivity() { } private fun loadData() { - listAdapter.items = buildList { - try { - MutedWord.createCursor().use { cursor -> - val idxId = cursor.getColumnIndex(MutedWord.COL_ID) - val idxName = cursor.getColumnIndex(MutedWord.COL_NAME) - while (cursor.moveToNext()) { - val item = MyItem( - id = cursor.getLong(idxId), - name = cursor.getString(idxName) - ) - add(item) - } - } - } catch (ex: Throwable) { - log.e(ex, "loadData failed.") + launchAndShowError { + listAdapter.items = withContext(AppDispatchers.IO) { + daoMutedWord.listAll() } } } - private fun delete(item: MyItem?) { + private fun delete(item: MutedWord?) { item ?: return launchAndShowError { confirm(R.string.delete_confirm, item.name) - MutedWord.delete(item.name) + daoMutedWord.delete(item.name) listAdapter.remove(item) } } - // リスト要素のデータ - private class MyItem(val id: Long, val name: String) - // リスト要素のViewHolder private inner class MyViewHolder(parent: ViewGroup?) { val views = LvMuteAppBinding.inflate(layoutInflater, parent, false) - private var lastItem: MyItem? = null + private var lastItem: MutedWord? = null init { views.root.tag = this views.btnDelete.setOnClickListener { delete(lastItem) } } - fun bind(item: MyItem?) { + fun bind(item: MutedWord?) { item ?: return lastItem = item views.tvName.text = item.name @@ -96,13 +84,13 @@ class ActMutedWord : AppCompatActivity() { } private inner class MyListAdapter : BaseAdapter() { - var items: List = emptyList() + var items: List = emptyList() set(value) { field = value notifyDataSetChanged() } - fun remove(item: MyItem?) { + fun remove(item: MutedWord?) { items = items.filter { it != item } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActNickname.kt b/app/src/main/java/jp/juggler/subwaytooter/ActNickname.kt index a8d16655..b4628a57 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActNickname.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActNickname.kt @@ -14,8 +14,10 @@ import com.jrummyapps.android.colorpicker.ColorPickerDialogListener import jp.juggler.subwaytooter.api.entity.Acct import jp.juggler.subwaytooter.databinding.ActNicknameBinding import jp.juggler.subwaytooter.table.AcctColor +import jp.juggler.subwaytooter.table.daoAcctColor import jp.juggler.util.backPressed import jp.juggler.util.boolean +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.mayUri import jp.juggler.util.data.notEmpty import jp.juggler.util.data.notZero @@ -134,11 +136,11 @@ class ActNickname : AppCompatActivity(), View.OnClickListener, ColorPickerDialog views.tvAcct.text = acctPretty - val ac = AcctColor.load(acctAscii, acctPretty) - colorBg = ac.color_bg - colorFg = ac.color_fg + val ac = daoAcctColor.load(acctAscii) + colorBg = ac.colorBg + colorFg = ac.colorFg views.etNickname.setText(ac.nickname) - notificationSoundUri = ac.notification_sound + notificationSoundUri = ac.notificationSound loadingBusy = false show() @@ -146,14 +148,17 @@ class ActNickname : AppCompatActivity(), View.OnClickListener, ColorPickerDialog private fun save() { if (loadingBusy) return - AcctColor( - acctAscii, - acctPretty, - views.etNickname.text.toString().trim { it <= ' ' }, - colorFg, - colorBg, - notificationSoundUri - ).save(System.currentTimeMillis()) + launchAndShowError { + daoAcctColor.save( + System.currentTimeMillis(), + AcctColor( + nicknameSave = views.etNickname.text.toString().trim { it <= ' ' }, + colorFg = colorFg, + colorBg = colorBg, + notificationSoundSaved = notificationSoundUri ?: "", + ) + ) + } } private fun show() { diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt b/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt index b0f0b32e..8bd995c8 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt @@ -2,7 +2,6 @@ package jp.juggler.subwaytooter import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.net.Uri import android.os.Bundle import android.os.Handler @@ -29,11 +28,14 @@ import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.util.* import jp.juggler.subwaytooter.view.MyEditText import jp.juggler.subwaytooter.view.MyNetworkImageView -import jp.juggler.util.* +import jp.juggler.util.backPressed +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchIO import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.GetContentResultEntry import jp.juggler.util.log.LogCategory +import jp.juggler.util.log.showToast +import jp.juggler.util.string import jp.juggler.util.ui.ActivityResultHandler import jp.juggler.util.ui.isNotOk import kotlinx.coroutines.Job @@ -111,7 +113,6 @@ class ActPost : AppCompatActivity(), lateinit var etChoices: List lateinit var handler: Handler - lateinit var pref: SharedPreferences lateinit var appState: AppState lateinit var attachmentUploader: AttachmentUploader lateinit var attachmentPicker: AttachmentPicker @@ -138,7 +139,7 @@ class ActPost : AppCompatActivity(), var states = ActPostStates() - var accountList: ArrayList = ArrayList() + var accountList: List = emptyList() var account: SavedAccount? = null var attachmentList = ArrayList() var isPostComplete: Boolean = false @@ -169,16 +170,17 @@ class ActPost : AppCompatActivity(), override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) backPressed { - finish() - // 戻るボタンを押したときとonPauseで2回保存することになるが、 - // 同じ内容はDB上は重複しないはず… - saveDraft() + launchAndShowError { + finish() + // 戻るボタンを押したときとonPauseで2回保存することになるが、 + // 同じ内容はDB上は重複しないはず… + saveDraft() + } } if (isMultiWindowPost) ActMain.refActMain?.get()?.closeList?.add(WeakReference(this)) App1.setActivityTheme(this) appState = App1.getAppState(this) handler = appState.handler - pref = appState.pref attachmentUploader = AttachmentUploader(this, handler) attachmentPicker = AttachmentPicker(this, this) density = resources.displayMetrics.density @@ -202,9 +204,11 @@ class ActPost : AppCompatActivity(), initUI() - when (savedInstanceState) { - null -> updateText(intent, confirmed = true, saveDraft = false) - else -> restoreState(savedInstanceState) + launchAndShowError { + when (savedInstanceState) { + null -> updateText(intent, saveDraft = false) + else -> restoreState(savedInstanceState) + } } } @@ -242,11 +246,18 @@ class ActPost : AppCompatActivity(), override fun onPause() { super.onPause() - // 編集中にホーム画面を押したり他アプリに移動する場合は下書きを保存する - // やや過剰な気がするが、自アプリに戻ってくるときにランチャーからアイコンタップされると - // メイン画面より上にあるアクティビティはすべて消されてしまうので - // このタイミングで保存するしかない - if (!isPostComplete) saveDraft() + if (!isPostComplete) launchMain { + try { + // 編集中にホーム画面を押したり他アプリに移動する場合は下書きを保存する + // やや過剰な気がするが、自アプリに戻ってくるときにランチャーからアイコンタップされると + // メイン画面より上にあるアクティビティはすべて消されてしまうので + // このタイミングで保存するしかない + saveDraft() + } catch (ex: Throwable) { + log.e(ex, "can't save draft.") + showToast(ex, "can't save draft.") + } + } } override fun onClick(v: View) { @@ -317,7 +328,7 @@ class ActPost : AppCompatActivity(), fun initUI() { setContentView(views.root) - if (PrefB.bpPostButtonBarTop(pref)) { + if (PrefB.bpPostButtonBarTop.value) { val bar = findViewById(R.id.llFooterBar) val parent = bar.parent as ViewGroup parent.removeView(bar) @@ -401,7 +412,7 @@ class ActPost : AppCompatActivity(), views.cbContentWarning.setOnCheckedChangeListener { _, _ -> showContentWarningEnabled() } - completionHelper = CompletionHelper(this, pref, appState.handler) + completionHelper = CompletionHelper(this, appState.handler) completionHelper.attachEditText( views.root, views.etContent, diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActText.kt b/app/src/main/java/jp/juggler/subwaytooter/ActText.kt index 96976e7d..394c1480 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActText.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActText.kt @@ -9,15 +9,18 @@ import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity import jp.juggler.subwaytooter.api.entity.TootAccount import jp.juggler.subwaytooter.api.entity.TootStatus +import jp.juggler.subwaytooter.auth.AuthRepo import jp.juggler.subwaytooter.databinding.ActTextBinding import jp.juggler.subwaytooter.dialog.pickAccount -import jp.juggler.subwaytooter.table.MutedWord import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoMutedWord +import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.util.CustomShare import jp.juggler.subwaytooter.util.CustomShareTarget import jp.juggler.subwaytooter.util.TootTextEncoder import jp.juggler.subwaytooter.util.copyToClipboard import jp.juggler.util.* +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory @@ -114,28 +117,34 @@ class ActText : AppCompatActivity() { return super.onCreateOptionsMenu(menu) } + private val authRepo by lazy { + AuthRepo(this) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) App1.setActivityTheme(this) - account = intent.long(EXTRA_ACCOUNT_DB_ID) - ?.let { SavedAccount.loadAccount(this, it) } - initUI() - if (savedInstanceState == null) { - val sv = intent.string(EXTRA_TEXT) ?: "" - val contentStart = intent.int(EXTRA_CONTENT_START) ?: 0 - val contentEnd = intent.int(EXTRA_CONTENT_END) ?: sv.length - views.etText.setText(sv) + launchAndShowError { + account = intent.long(EXTRA_ACCOUNT_DB_ID) + ?.let { daoSavedAccount.loadAccount(it) } - // Android 9 以降ではフォーカスがないとsetSelectionできない - if (Build.VERSION.SDK_INT >= 28) { - views.etText.requestFocus() - views.etText.hideKeyboard() + if (savedInstanceState == null) { + val sv = intent.string(EXTRA_TEXT) ?: "" + val contentStart = intent.int(EXTRA_CONTENT_START) ?: 0 + val contentEnd = intent.int(EXTRA_CONTENT_END) ?: sv.length + views.etText.setText(sv) + + // Android 9 以降ではフォーカスがないとsetSelectionできない + if (Build.VERSION.SDK_INT >= 28) { + views.etText.requestFocus() + views.etText.hideKeyboard() + } + + views.etText.setSelection(contentStart, contentEnd) } - - views.etText.setSelection(contentStart, contentEnd) } } @@ -192,14 +201,11 @@ class ActText : AppCompatActivity() { } private fun muteWord() { - selection.trim().notEmpty()?.let { - try { - MutedWord.save(it) - App1.getAppState(this).onMuteUpdated() + launchAndShowError { + selection.trim().notEmpty()?.let { + daoMutedWord.save(it) + App1.getAppState(this@ActText).onMuteUpdated() showToast(false, R.string.word_was_muted) - } catch (ex: Throwable) { - log.e(ex, "muteWord failed.") - showToast(ex, "muteWord failed.") } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/App1.kt b/app/src/main/java/jp/juggler/subwaytooter/App1.kt index a8f996f0..fc85ae40 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/App1.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/App1.kt @@ -3,6 +3,7 @@ package jp.juggler.subwaytooter import android.annotation.SuppressLint import android.app.Application import android.content.Context +import android.content.res.Configuration import android.os.Build import android.os.Handler import android.util.Log @@ -19,8 +20,7 @@ import com.bumptech.glide.load.model.GlideUrl import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.emoji.EmojiMap -import jp.juggler.subwaytooter.global.Global -import jp.juggler.subwaytooter.global.appPref +import jp.juggler.subwaytooter.pref.LazyContextHolder import jp.juggler.subwaytooter.pref.PrefI import jp.juggler.subwaytooter.pref.PrefS import jp.juggler.subwaytooter.table.HighlightWord @@ -30,10 +30,12 @@ import jp.juggler.subwaytooter.util.CustomEmojiLister import jp.juggler.subwaytooter.util.ProgressResponseBody import jp.juggler.util.* import jp.juggler.util.data.asciiPattern +import jp.juggler.util.data.notEmpty import jp.juggler.util.log.LogCategory import jp.juggler.util.log.initializeToastUtils import jp.juggler.util.network.MySslSocketFactory import jp.juggler.util.network.toPostRequestBuilder +import jp.juggler.util.os.applicationContextSafe import jp.juggler.util.ui.* import okhttp3.* import okhttp3.OkHttpClient @@ -55,11 +57,17 @@ class App1 : Application() { override fun onCreate() { log.d("onCreate") + LazyContextHolder.init(applicationContextSafe) super.onCreate() initializeToastUtils(this) prepare(applicationContext, "App1.onCreate") } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + LazyContextHolder.init(applicationContextSafe) + } + override fun onTerminate() { log.d("onTerminate") super.onTerminate() @@ -134,7 +142,7 @@ class App1 : Application() { "SubwayTooter/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.RELEASE}" private fun getUserAgent(): String { - val userAgentCustom = PrefS.spUserAgent(appPref) + val userAgentCustom = PrefS.spUserAgent.value return when { userAgentCustom.isNotEmpty() && !reNotAllowedInUserAgent.matcher(userAgentCustom) .find() -> userAgentCustom @@ -142,13 +150,14 @@ class App1 : Application() { } } - private val user_agent_interceptor = Interceptor { chain -> - chain.proceed( - chain.request().newBuilder() - .header("User-Agent", getUserAgent()) - .build() - ) - } + private fun userAgentInterceptor() = + Interceptor { chain -> + chain.proceed( + chain.request().newBuilder() + .header("User-Agent", getUserAgent()) + .build() + ) + } private var cookieManager: CookieManager? = null private var cookieJar: CookieJar? = null @@ -185,7 +194,7 @@ class App1 : Application() { .connectionSpecs(Collections.singletonList(spec)) .sslSocketFactory(MySslSocketFactory, MySslSocketFactory.trustManager) .addInterceptor(ProgressResponseBody.makeInterceptor()) - .addInterceptor(user_agent_interceptor) + .addInterceptor(userAgentInterceptor()) // クッキーの導入は検討中。とりあえずmstdn.jpではクッキー有効でも改善しなかったので現時点では追加しない // .cookieJar(cookieJar) @@ -228,8 +237,6 @@ class App1 : Application() { initializeFont() - Global.prepare(appContext, "App1.prepare($caller)") - // We want at least 2 threads and at most 4 threads in the core pool, // preferring to have 1 less than the CPU count to avoid saturating // the CPU with background work @@ -278,7 +285,7 @@ class App1 : Application() { Logger.getLogger(OkHttpClient::class.java.name).level = Level.FINE - val apiReadTimeout = max(3, PrefS.spApiReadTimeout.toInt(appPref)) + val apiReadTimeout = max(3, PrefS.spApiReadTimeout.toInt()) // API用のHTTP設定はキャッシュを使わない ok_http_client = prepareOkHttp(apiReadTimeout, apiReadTimeout) @@ -294,10 +301,11 @@ class App1 : Application() { .build() // 内蔵メディアビューア用のHTTP設定はタイムアウトを調整可能 - val mediaReadTimeout = max(3, PrefS.spMediaReadTimeout.toInt(appPref)) - ok_http_client_media_viewer = prepareOkHttp(mediaReadTimeout, mediaReadTimeout) - .cache(cache) - .build() + val mediaReadTimeout = max(3, PrefS.spMediaReadTimeout.toInt()) + ok_http_client_media_viewer = + prepareOkHttp(mediaReadTimeout, mediaReadTimeout) + .cache(cache) + .build() } val handler = Handler(appContext.mainLooper) @@ -310,7 +318,7 @@ class App1 : Application() { log.d("create AppState.") - state = AppState(appContext, handler, appPref) + state = AppState(appContext, handler) appStateX = state // getAppState()を使える状態にしてからカラム一覧をロードする @@ -421,7 +429,7 @@ class App1 : Application() { ) { prepare(activity.applicationContext, "setActivityTheme") - var nTheme = PrefI.ipUiTheme(appPref) + var nTheme = PrefI.ipUiTheme.value if (forceDark && nTheme == 0) nTheme = 1 activity.setTheme( when (nTheme) { @@ -484,9 +492,8 @@ class App1 : Application() { .url(url) .cacheControl(CACHE_CONTROL) .also { - val access_token = accessInfo?.getAccessToken() - if (access_token?.isNotEmpty() == true) { - it.header("Authorization", "Bearer $access_token") + accessInfo?.bearerAccessToken?.notEmpty()?.let { a -> + it.header("Authorization", "Bearer $a") } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/AppState.kt b/app/src/main/java/jp/juggler/subwaytooter/AppState.kt index 8b41c005..b290943c 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/AppState.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/AppState.kt @@ -17,10 +17,7 @@ import jp.juggler.subwaytooter.column.getBackgroundImageDir import jp.juggler.subwaytooter.column.onMuteUpdated import jp.juggler.subwaytooter.span.MyClickableSpan import jp.juggler.subwaytooter.streaming.StreamManager -import jp.juggler.subwaytooter.table.HighlightWord -import jp.juggler.subwaytooter.table.MutedApp -import jp.juggler.subwaytooter.table.MutedWord -import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.* import jp.juggler.subwaytooter.util.NetworkStateTracker import jp.juggler.subwaytooter.util.PostAttachment import jp.juggler.util.* @@ -49,7 +46,6 @@ class DedupItem( class AppState( internal val context: Context, internal val handler: Handler, - internal val pref: SharedPreferences, ) { companion object { @@ -190,7 +186,7 @@ class AppState( // TextToSpeech private val isTextToSpeechRequired: Boolean - get() = columnList.any { it.enableSpeech } || HighlightWord.hasTextToSpeechHighlightWord() + get() = columnList.any { it.enableSpeech } || daoHighlightWord.hasTextToSpeechHighlightWord() private val ttsReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { @@ -303,8 +299,8 @@ class AppState( if (list != null) editColumnList(save = false) { it.addAll(list) } // ミュートデータのロード - TootStatus.muted_app = MutedApp.nameSet - TootStatus.muted_word = MutedWord.nameSet + TootStatus.muted_app = daoMutedApp.nameSet() + TootStatus.muted_word = daoMutedWord.nameSet() // 背景フォルダの掃除 try { @@ -599,8 +595,8 @@ class AppState( } fun onMuteUpdated() { - TootStatus.muted_app = MutedApp.nameSet - TootStatus.muted_word = MutedWord.nameSet + TootStatus.muted_app = daoMutedApp.nameSet() + TootStatus.muted_word = daoMutedWord.nameSet() columnList.forEach { it.onMuteUpdated() } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/EventReceiver.kt b/app/src/main/java/jp/juggler/subwaytooter/EventReceiver.kt index 40a65e8a..cecc98b3 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/EventReceiver.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/EventReceiver.kt @@ -5,10 +5,11 @@ import android.content.Context import android.content.Intent import jp.juggler.subwaytooter.notification.TrackingType import jp.juggler.subwaytooter.notification.onNotificationDeleted -import jp.juggler.subwaytooter.table.NotificationTracking +import jp.juggler.subwaytooter.table.daoNotificationTracking import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.notEmpty import jp.juggler.util.log.LogCategory +import jp.juggler.util.os.applicationContextSafe class EventReceiver : BroadcastReceiver() { @@ -18,32 +19,36 @@ class EventReceiver : BroadcastReceiver() { } override fun onReceive(context: Context, intent: Intent?) { + launchMain { + try { + log.i("onReceive action=${intent?.action}") - log.i("onReceive action=${intent?.action}") + when (val action = intent?.action) { - when (val action = intent?.action) { - - Intent.ACTION_BOOT_COMPLETED, - Intent.ACTION_MY_PACKAGE_REPLACED, - -> { - App1.prepare(context.applicationContext, action) - NotificationTracking.resetPostAll() - } - - ACTION_NOTIFICATION_DELETE -> intent.data?.let { uri -> - val dbId = uri.getQueryParameter("db_id")?.toLongOrNull() - val type = TrackingType.parseStr(uri.getQueryParameter("type")) - val typeName = type.typeName - val id = uri.getQueryParameter("notificationId")?.notEmpty() - log.d("Notification deleted! db_id=$dbId,type=$type,id=$id") - if (dbId != null) { - launchMain { - onNotificationDeleted(dbId, typeName) + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_MY_PACKAGE_REPLACED, + -> { + App1.prepare(context.applicationContextSafe, action) + daoNotificationTracking.resetPostAll() } - } - } - else -> log.e("onReceive: unsupported action $action") + ACTION_NOTIFICATION_DELETE, + -> intent.data?.let { uri -> + val dbId = uri.getQueryParameter("db_id")?.toLongOrNull() + val type = TrackingType.parseStr(uri.getQueryParameter("type")) + val typeName = type.typeName + val id = uri.getQueryParameter("notificationId")?.notEmpty() + log.d("Notification deleted! db_id=$dbId,type=$type,id=$id") + if (dbId != null) { + onNotificationDeleted(dbId, typeName) + } + } + + else -> log.e("onReceive: unsupported action $action") + } + } catch (ex: Throwable) { + log.e(ex, "resetPostAll failed.") + } } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/MyFirebaseMessagingService.kt b/app/src/main/java/jp/juggler/subwaytooter/MyFirebaseMessagingService.kt index 29bf3db9..62c22ef2 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/MyFirebaseMessagingService.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/MyFirebaseMessagingService.kt @@ -1,99 +1,101 @@ package jp.juggler.subwaytooter - -import com.google.firebase.messaging.FirebaseMessagingService -import com.google.firebase.messaging.RemoteMessage -import jp.juggler.subwaytooter.notification.PollingChecker -import jp.juggler.subwaytooter.notification.restartAllWorker -import jp.juggler.subwaytooter.pref.PrefDevice -import jp.juggler.subwaytooter.table.NotificationCache -import jp.juggler.subwaytooter.table.SavedAccount -import jp.juggler.util.log.LogCategory -import kotlinx.coroutines.runBlocking -import java.util.* - -class MyFirebaseMessagingService : FirebaseMessagingService() { - - companion object { - internal val log = LogCategory("MyFirebaseMessagingService") - - private val pushMessageStatus = LinkedList() - - // Pushメッセージが処理済みか調べる - private fun isDuplicateMessage(messageId: String) = - synchronized(pushMessageStatus) { - when (pushMessageStatus.contains(messageId)) { - true -> true - else -> { - pushMessageStatus.addFirst(messageId) - while (pushMessageStatus.size > 100) { - pushMessageStatus.removeLast() - } - false - } - } - } - } - - override fun onNewToken(token: String) { - try { - log.w("onTokenRefresh: token=$token") - PrefDevice.from(this).edit().putString(PrefDevice.KEY_DEVICE_TOKEN, token).apply() - restartAllWorker(this) - } catch (ex: Throwable) { - log.e(ex, "onNewToken failed") - } - } - - override fun onMessageReceived(remoteMessage: RemoteMessage) { - val context = this - - val messageId = remoteMessage.messageId ?: return - if (isDuplicateMessage(messageId)) return - - val accounts = ArrayList() - for ((key, value) in remoteMessage.data) { - log.w("onMessageReceived: $key=$value") - when (key) { - "notification_tag" -> { - SavedAccount.loadByTag(context, value).forEach { sa -> - NotificationCache.resetLastLoad(sa.db_id) - accounts.add(sa) - } - } - "acct" -> { - SavedAccount.loadAccountByAcct(context, value)?.let { sa -> - NotificationCache.resetLastLoad(sa.db_id) - accounts.add(sa) - } - } - } - } - - if (accounts.isEmpty()) { - // タグにマッチする情報がなかった場合、全部読み直す - NotificationCache.resetLastLoad() - accounts.addAll(SavedAccount.loadAccountList(context)) - } - - log.i("accounts.size=${accounts.size} thred=${Thread.currentThread().name}") - runBlocking { - accounts.forEach { - check(it.db_id) - } - } - } - - private suspend fun check(accountDbId: Long) { - try { - PollingChecker( - context = this, - accountDbId = accountDbId - ).check { a, s -> - val text = "[${a.acct.pretty}]${s.desc}" - log.i(text) - } - } catch (ex: Throwable) { - log.e(ex, "check failed. accountDbId=$accountDbId") - } - } -} +// +//import com.google.firebase.messaging.FirebaseMessagingService +//import com.google.firebase.messaging.RemoteMessage +//import jp.juggler.subwaytooter.api.entity.Acct +//import jp.juggler.subwaytooter.notification.PollingChecker +//import jp.juggler.subwaytooter.notification.restartAllWorker +//import jp.juggler.subwaytooter.pref.PrefDevice +//import jp.juggler.subwaytooter.pref.prefDevice +//import jp.juggler.subwaytooter.table.SavedAccount +//import jp.juggler.subwaytooter.table.apiNotificationCache +//import jp.juggler.subwaytooter.table.apiSavedAccount +//import jp.juggler.util.log.LogCategory +//import kotlinx.coroutines.runBlocking +//import java.util.* +// +//class MyFirebaseMessagingService : FirebaseMessagingService() { +// +// companion object { +// internal val log = LogCategory("MyFirebaseMessagingService") +// +// private val pushMessageStatus = LinkedList() +// +// // Pushメッセージが処理済みか調べる +// private fun isDuplicateMessage(messageId: String) = +// synchronized(pushMessageStatus) { +// when (pushMessageStatus.contains(messageId)) { +// true -> true +// else -> { +// pushMessageStatus.addFirst(messageId) +// while (pushMessageStatus.size > 100) { +// pushMessageStatus.removeLast() +// } +// false +// } +// } +// } +// } +// +// override fun onNewToken(token: String) { +// try { +// log.w("onTokenRefresh: token=$token") +// prefDevice.device +// pollingWorker2IntervalPrefDevice.from(this).edit().putString(PrefDevice.KEY_DEVICE_TOKEN, token).apply() +// restartAllWorker(this) +// } catch (ex: Throwable) { +// log.e(ex, "onNewToken failed") +// } +// } +// +// override fun onMessageReceived(remoteMessage: RemoteMessage) { +// val messageId = remoteMessage.messageId ?: return +// if (isDuplicateMessage(messageId)) return +// +// val accounts = ArrayList() +// for ((key, value) in remoteMessage.data) { +// log.w("onMessageReceived: $key=$value") +// when (key) { +//// "notification_tag" -> { +//// apiSavedAccount.(context, value).forEach { sa -> +//// apiNotificationCache.resetLastLoad(sa.db_id) +//// accounts.add(sa) +//// } +//// } +// "acct" -> { +// apiSavedAccount.loadAccountByAcct(Acct.parse(value))?.let { sa -> +// apiNotificationCache.resetLastLoad(sa.db_id) +// accounts.add(sa) +// } +// } +// } +// } +// +// if (accounts.isEmpty()) { +// // タグにマッチする情報がなかった場合、全部読み直す +// apiNotificationCache.resetLastLoad() +// accounts.addAll(apiSavedAccount.loadAccountList()) +// } +// +// log.i("accounts.size=${accounts.size} thred=${Thread.currentThread().name}") +// runBlocking { +// accounts.forEach { +// check(it.db_id) +// } +// } +// } +// +// private suspend fun check(accountDbId: Long) { +// try { +// PollingChecker( +// context = this, +// accountDbId = accountDbId +// ).check { a, s -> +// val text = "[${a.acct.pretty}]${s.desc}" +// log.i(text) +// } +// } catch (ex: Throwable) { +// log.e(ex, "check failed. accountDbId=$accountDbId") +// } +// } +//} diff --git a/app/src/main/java/jp/juggler/subwaytooter/Styler.kt b/app/src/main/java/jp/juggler/subwaytooter/Styler.kt index 14771dda..20dbcf08 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/Styler.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/Styler.kt @@ -23,7 +23,7 @@ import jp.juggler.subwaytooter.api.entity.TootVisibility import jp.juggler.subwaytooter.emoji.EmojiMap import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefI -import jp.juggler.subwaytooter.pref.pref +import jp.juggler.subwaytooter.pref.lazyContext import jp.juggler.subwaytooter.span.EmojiImageSpan import jp.juggler.subwaytooter.span.createSpan import jp.juggler.subwaytooter.table.UserRelation @@ -43,14 +43,14 @@ fun defaultColorIcon(context: Context, iconId: Int): Drawable? = it.setTintMode(PorterDuff.Mode.SRC_IN) } -fun getVisibilityIconId(isMisskeyData: Boolean, visibility: TootVisibility): Int { - val isMisskey = when (PrefI.ipVisibilityStyle()) { +fun TootVisibility.getVisibilityIconId(isMisskeyData: Boolean): Int { + val isMisskey = when (PrefI.ipVisibilityStyle.value) { PrefI.VS_MASTODON -> false PrefI.VS_MISSKEY -> true else -> isMisskeyData } return when { - isMisskey -> when (visibility) { + isMisskey -> when (this) { TootVisibility.Public -> R.drawable.ic_public TootVisibility.UnlistedHome -> R.drawable.ic_home TootVisibility.PrivateFollowers -> R.drawable.ic_lock_open @@ -67,7 +67,7 @@ fun getVisibilityIconId(isMisskeyData: Boolean, visibility: TootVisibility): Int TootVisibility.Limited -> R.drawable.ic_account_circle TootVisibility.Mutual -> R.drawable.ic_bidirectional } - else -> when (visibility) { + else -> when (this) { TootVisibility.Public -> R.drawable.ic_public TootVisibility.UnlistedHome -> R.drawable.ic_lock_open TootVisibility.PrivateFollowers -> R.drawable.ic_lock @@ -87,19 +87,15 @@ fun getVisibilityIconId(isMisskeyData: Boolean, visibility: TootVisibility): Int } } -fun getVisibilityString( - context: Context, - isMisskeyData: Boolean, - visibility: TootVisibility, -): String { - val isMisskey = when (PrefI.ipVisibilityStyle()) { +fun TootVisibility.getVisibilityString(isMisskeyData: Boolean): String { + val isMisskey = when (PrefI.ipVisibilityStyle.value) { PrefI.VS_MASTODON -> false PrefI.VS_MISSKEY -> true else -> isMisskeyData } - return context.getString( + return lazyContext.getString( when { - isMisskey -> when (visibility) { + isMisskey -> when (this) { TootVisibility.Public -> R.string.visibility_public TootVisibility.UnlistedHome -> R.string.visibility_home TootVisibility.PrivateFollowers -> R.string.visibility_followers @@ -116,7 +112,7 @@ fun getVisibilityString( TootVisibility.Limited -> R.string.visibility_limited TootVisibility.Mutual -> R.string.visibility_mutual } - else -> when (visibility) { + else -> when (this) { TootVisibility.Public -> R.string.visibility_public TootVisibility.UnlistedHome -> R.string.visibility_unlisted TootVisibility.PrivateFollowers -> R.string.visibility_followers @@ -144,8 +140,8 @@ fun getVisibilityCaption( visibility: TootVisibility, ): CharSequence { - val iconId = getVisibilityIconId(isMisskeyData, visibility) - val sv = getVisibilityString(context, isMisskeyData, visibility) + val iconId = visibility.getVisibilityIconId(isMisskeyData) + val sv = visibility.getVisibilityString(isMisskeyData) val color = context.attrColor(R.attr.colorTextContent) val sb = SpannableStringBuilder() @@ -182,11 +178,11 @@ fun setFollowIcon( alphaMultiplier: Float, ) { val colorFollowed = - PrefI.ipButtonFollowingColor(context.pref()).notZero() + PrefI.ipButtonFollowingColor.value.notZero() ?: context.attrColor(R.attr.colorButtonAccentFollow) val colorFollowRequest = - PrefI.ipButtonFollowRequestColor(context.pref()).notZero() + PrefI.ipButtonFollowRequestColor.value.notZero() ?: context.attrColor(R.attr.colorButtonAccentFollowRequest) val colorError = context.attrColor(R.attr.colorRegexFilterError) @@ -309,7 +305,7 @@ fun fixHorizontalPadding(v: View, dpDelta: Float = 12f) { val widthDp = dm.widthPixels / dm.density if (widthDp >= 640f && v.resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) { val padLr = (0.5f + dpDelta * dm.density).toInt() - when (PrefI.ipJustifyWindowContentPortrait()) { + when (PrefI.ipJustifyWindowContentPortrait.value) { PrefI.JWCP_START -> { v.setPaddingRelative(padLr, padT, padLr + dm.widthPixels / 2, padB) return @@ -338,7 +334,7 @@ fun fixHorizontalMargin(v: View) { log.d("fixHorizontalMargin: orientation=$orientationString, w=${widthDp}dp, h=${dm.heightPixels / dm.density}") if (widthDp >= 640f && v.resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) { - when (PrefI.ipJustifyWindowContentPortrait()) { + when (PrefI.ipJustifyWindowContentPortrait.value) { PrefI.JWCP_START -> { lp.marginStart = 0 lp.marginEnd = dm.widthPixels / 2 @@ -397,7 +393,7 @@ fun SpannableStringBuilder.appendMisskeyReaction( emoji == null -> append("text") - PrefB.bpUseTwemoji(context) -> { + PrefB.bpUseTwemoji.value -> { val start = this.length append(text) val end = this.length @@ -414,9 +410,10 @@ fun SpannableStringBuilder.appendMisskeyReaction( } fun Context.setSwitchColor(root: View?) { + root ?: return val colorBg = attrColor(R.attr.colorWindowBackground) val colorOff = attrColor(R.attr.colorSwitchOff) - val colorOn = PrefI.ipSwitchOnColor() + val colorOn = PrefI.ipSwitchOnColor.value val colorDisabled = mixColor(colorBg, colorOff) @@ -451,7 +448,7 @@ fun Context.setSwitchColor(root: View?) { ) ) - root?.scan { + root.scan { (it as? SwitchCompat)?.apply { thumbTintList = thumbStates trackTintList = trackStates @@ -487,13 +484,14 @@ fun AppCompatActivity.setStatusBarColor(forceDark: Boolean = false) { var c = when { forceDark -> Color.BLACK - else -> PrefI.ipStatusBarColor.invoke().notZero() ?: attrColor(R.attr.colorPrimaryDark) + else -> PrefI.ipStatusBarColor.value.notZero() + ?: attrColor(R.attr.colorPrimaryDark) } setStatusBarColorCompat(c) c = when { forceDark -> Color.BLACK - else -> PrefI.ipNavigationBarColor() + else -> PrefI.ipNavigationBarColor.value } setNavigationBarColorCompat(c) } diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/ActionUtils.kt b/app/src/main/java/jp/juggler/subwaytooter/action/ActionUtils.kt index 1afe172a..d0e5e6a8 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/ActionUtils.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/ActionUtils.kt @@ -1,12 +1,13 @@ package jp.juggler.subwaytooter.action import androidx.appcompat.app.AppCompatActivity -import jp.juggler.subwaytooter.api.TootParser -import jp.juggler.subwaytooter.api.entity.* +import jp.juggler.subwaytooter.api.entity.Acct +import jp.juggler.subwaytooter.api.entity.Host +import jp.juggler.subwaytooter.api.entity.TootInstance import jp.juggler.subwaytooter.api.runApiTask2 import jp.juggler.subwaytooter.api.showApiError import jp.juggler.subwaytooter.table.SavedAccount -import jp.juggler.subwaytooter.table.UserRelation +import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.util.matchHost import jp.juggler.util.data.JsonObject import jp.juggler.util.data.buildJsonObject @@ -22,7 +23,6 @@ internal suspend fun AppCompatActivity.addPseudoAccount( host: Host, instanceInfoArg: TootInstance? = null, ): SavedAccount? { - try { suspend fun AppCompatActivity.getInstanceInfo(): TootInstance? { return try { @@ -35,7 +35,7 @@ internal suspend fun AppCompatActivity.addPseudoAccount( val acct = Acct.parse("?", host) - var account = SavedAccount.loadAccountByAcct(this, acct.ascii) + var account = daoSavedAccount.loadAccountByAcct(acct) if (account != null) return account val instanceInfo = instanceInfoArg @@ -47,7 +47,7 @@ internal suspend fun AppCompatActivity.addPseudoAccount( put("acct", acct.username) // ローカルから参照した場合なのでshort acct } - val rowId = SavedAccount.insert( + val rowId = daoSavedAccount.saveNew( acct = acct.ascii, host = host.ascii, domain = instanceInfo.apDomain.ascii, @@ -56,7 +56,7 @@ internal suspend fun AppCompatActivity.addPseudoAccount( misskeyVersion = instanceInfo.misskeyVersionMajor ) - account = SavedAccount.loadAccount(applicationContext, rowId) + account = daoSavedAccount.loadAccount(rowId) ?: error("loadAccount returns null.") account.notification_follow = false @@ -68,7 +68,7 @@ internal suspend fun AppCompatActivity.addPseudoAccount( account.notification_vote = false account.notification_post = false account.notification_update = false - account.saveSetting() + daoSavedAccount.saveSetting(account) return account } catch (ex: Throwable) { log.e(ex, "addPseudoAccount failed.") @@ -77,22 +77,6 @@ internal suspend fun AppCompatActivity.addPseudoAccount( } } -internal fun SavedAccount.saveUserRelation(src: TootRelationShip?): UserRelation? { - src ?: return null - val now = System.currentTimeMillis() - return UserRelation.save1Mastodon(now, db_id, src) -} - -internal fun SavedAccount.saveUserRelationMisskey( - whoId: EntityId, - parser: TootParser, -): UserRelation? { - val now = System.currentTimeMillis() - val relation = parser.getMisskeyUserRelation(whoId) - UserRelation.save1Misskey(now, db_id, whoId.toString(), relation) - return relation -} - //// relationshipを取得 //internal fun loadRelation1Mastodon( // client : TootApiClient, diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Account.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Account.kt index 86bbd5c7..569911c5 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Account.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Account.kt @@ -1,9 +1,7 @@ package jp.juggler.subwaytooter.action import android.app.Dialog -import android.content.Context import android.os.Build -import androidx.appcompat.app.AppCompatActivity import jp.juggler.subwaytooter.* import jp.juggler.subwaytooter.actmain.addColumn import jp.juggler.subwaytooter.actmain.afterAccountVerify @@ -16,13 +14,11 @@ import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.dialog.* import jp.juggler.subwaytooter.dialog.DlgCreateAccount.Companion.showUserCreateDialog import jp.juggler.subwaytooter.dialog.LoginForm.Companion.showLoginForm -import jp.juggler.subwaytooter.notification.APP_SERVER import jp.juggler.subwaytooter.pref.* import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.util.LinkHelper import jp.juggler.subwaytooter.util.openBrowser import jp.juggler.util.* -import jp.juggler.util.coroutine.launchIO import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.JsonObject import jp.juggler.util.data.encodePercent @@ -32,7 +28,6 @@ import jp.juggler.util.network.toFormRequestBody import jp.juggler.util.network.toPost import jp.juggler.util.ui.dismissSafe import kotlinx.coroutines.* -import ru.gildor.coroutines.okhttp.await private val log = LogCategory("Action_Account") @@ -195,53 +190,6 @@ fun ActMain.accessTokenPrompt( ) } -fun AppCompatActivity.accountRemove(account: SavedAccount) { - // if account is default account of tablet mode, - // reset default. - val pref = pref() - if (account.db_id == PrefL.lpTabletTootDefaultAccount(pref)) { - pref.edit().put(PrefL.lpTabletTootDefaultAccount, -1L).apply() - } - - account.delete() - appServerUnregister(applicationContext, account) -} - -private fun appServerUnregister(context: Context, account: SavedAccount) { - launchIO { - try { - val installId = PrefDevice.from(context).getString(PrefDevice.KEY_INSTALL_ID, null) - if (installId?.isEmpty() != false) { - error("missing install_id") - } - - val tag = account.notification_tag - if (tag?.isEmpty() != false) { - error("missing notification_tag") - } - - val call = App1.ok_http_client.newCall( - "instance_url=${ - "https://${account.apiHost.ascii}".encodePercent() - }&app_id=${ - context.packageName.encodePercent() - }&tag=$tag" - .toFormRequestBody() - .toPost() - .url("$APP_SERVER/unregister") - .build() - ) - - val response = call.await() - if (!response.isSuccessful) { - log.e("appServerUnregister: $response") - } - } catch (ex: Throwable) { - log.e(ex, "appServerUnregister failed.") - } - } -} - // アカウント設定 fun ActMain.accountOpenSetting() { launchMain { @@ -284,105 +232,3 @@ fun ActMain.accountResendConfirmMail(accessInfo: SavedAccount) { }.show() } -// -fun accountListReorder( - src: List, - pickupHost: Host?, - filter: (SavedAccount) -> Boolean = { true }, -): MutableList { - val listSameHost = java.util.ArrayList() - val listOtherHost = java.util.ArrayList() - for (a in src) { - if (!filter(a)) continue - when (pickupHost) { - null, a.apDomain, a.apiHost -> listSameHost - else -> listOtherHost - }.add(a) - } - SavedAccount.sort(listSameHost) - SavedAccount.sort(listOtherHost) - listSameHost.addAll(listOtherHost) - return listSameHost -} - -// 疑似アカ以外のアカウントのリスト -fun Context.accountListNonPseudo( - pickupHost: Host?, -) = accountListReorder( - SavedAccount.loadAccountList(this), - pickupHost -) { !it.isPseudo } - -// 条件でフィルタする。サーバ情報を読む場合がある。 -suspend fun Context.accountListWithFilter( - pickupHost: Host?, - check: suspend (TootApiClient, SavedAccount) -> Boolean, -): MutableList? { - var resultList: MutableList? = null - runApiTask { client -> - supervisorScope { - resultList = SavedAccount.loadAccountList(this@accountListWithFilter) - .map { - async { - try { - if (check(client, it)) it else null - } catch (ex: Throwable) { - log.e(ex, "accountListWithFilter failed.") - null - } - } - } - .mapNotNull { it.await() } - .let { accountListReorder(it, pickupHost) } - } - if (client.isApiCancelled()) null else TootApiResult() - } - return resultList -} - -suspend fun ActMain.accountListCanQuote(pickupHost: Host? = null) = - accountListWithFilter(pickupHost) { client, a -> - when { - client.isApiCancelled() -> false - a.isPseudo -> false - a.isMisskey -> true - else -> { - val (ti, ri) = TootInstance.getEx(client.copy(), account = a) - if (ti == null) { - ri?.error?.let { log.w(it) } - false - } else InstanceCapability.quote(ti) - } - } - } - -suspend fun ActMain.accountListCanReaction(pickupHost: Host? = null) = - accountListWithFilter(pickupHost) { client, a -> - when { - client.isApiCancelled() -> false - a.isPseudo -> false - a.isMisskey -> true - else -> { - val (ti, ri) = TootInstance.getEx(client.copy(), account = a) - if (ti == null) { - ri?.error?.let { log.w(it) } - false - } else InstanceCapability.emojiReaction(a, ti) - } - } - } - -suspend fun ActMain.accountListCanSeeMyReactions(pickupHost: Host? = null) = - accountListWithFilter(pickupHost) { client, a -> - when { - client.isApiCancelled() -> false - a.isPseudo -> false - else -> { - val (ti, ri) = TootInstance.getEx(client.copy(), account = a) - if (ti == null) { - ri?.error?.let { log.w(it) } - false - } else InstanceCapability.listMyReactions(a, ti) - } - } - } diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_App.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_App.kt index a32a664d..fb348860 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_App.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_App.kt @@ -1,6 +1,5 @@ package jp.juggler.subwaytooter.action -import android.app.AlertDialog import android.net.Uri import jp.juggler.subwaytooter.ActColumnList import jp.juggler.subwaytooter.ActMain @@ -8,8 +7,10 @@ import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.actmain.currentColumn import jp.juggler.subwaytooter.actmain.handleOtherUri import jp.juggler.subwaytooter.api.entity.TootApplication +import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.dialog.DlgOpenUrl -import jp.juggler.subwaytooter.table.MutedApp +import jp.juggler.subwaytooter.table.daoMutedApp +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.log.showToast import jp.juggler.util.ui.dismissSafe @@ -18,22 +19,10 @@ fun ActMain.openColumnList() = arColumnList.launch(ActColumnList.createIntent(this, currentColumn)) // アプリをミュートする -fun ActMain.appMute( - application: TootApplication?, - confirmed: Boolean = false, -) { - application ?: return - if (!confirmed) { - AlertDialog.Builder(this) - .setMessage(getString(R.string.mute_application_confirm, application.name)) - .setPositiveButton(R.string.ok) { _, _ -> - appMute(application, confirmed = true) - } - .setNegativeButton(R.string.cancel, null) - .show() - return - } - MutedApp.save(application.name) +fun ActMain.appMute(application: TootApplication?) = launchAndShowError { + application ?: return@launchAndShowError + confirm(R.string.mute_application_confirm, application.name) + daoMutedApp.save(application.name) appState.onMuteUpdated() showToast(false, R.string.app_was_muted) } diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Boost.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Boost.kt index d1d7aa33..eb4ea9e6 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Boost.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Boost.kt @@ -17,8 +17,10 @@ import jp.juggler.subwaytooter.column.findStatus import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.dialog.pickAccount import jp.juggler.subwaytooter.getVisibilityCaption -import jp.juggler.subwaytooter.table.AcctColor import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.accountListNonPseudo +import jp.juggler.subwaytooter.table.daoAcctColor +import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.util.emptyCallback import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain @@ -212,7 +214,7 @@ private class BoostImpl( visibility == TootVisibility.PrivateFollowers -> R.string.confirm_private_boost_from else -> R.string.confirm_boost_from }, - AcctColor.getNickname(accessInfo) + daoAcctColor.getNickname(accessInfo) ), when (bSet) { true -> accessInfo.confirm_boost @@ -223,7 +225,7 @@ private class BoostImpl( true -> accessInfo.confirm_boost = newConfirmEnabled else -> accessInfo.confirm_unboost = newConfirmEnabled } - accessInfo.saveSetting() + daoSavedAccount.saveSetting(accessInfo) activity.reloadAccountSetting(accessInfo) } } @@ -288,7 +290,7 @@ fun ActMain.boostFromAnotherAccount( if (isPrivateToot) { val list = ArrayList() - for (a in SavedAccount.loadAccountList(applicationContext)) { + for (a in daoSavedAccount.loadAccountList()) { if (a.acct == statusOwner) list.add(a) } if (list.isEmpty()) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Conversation.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Conversation.kt index 0def3935..7ad6cddb 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Conversation.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Conversation.kt @@ -10,11 +10,14 @@ import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.column.findStatus import jp.juggler.subwaytooter.columnviewholder.ItemListAdapter -import jp.juggler.subwaytooter.dialog.ActionsDialog -import jp.juggler.subwaytooter.table.AcctColor +import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoAcctColor +import jp.juggler.subwaytooter.table.daoSavedAccount +import jp.juggler.subwaytooter.table.sortedByNickname import jp.juggler.subwaytooter.util.matchHost import jp.juggler.subwaytooter.util.openCustomTab +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.notEmpty import jp.juggler.util.log.LogCategory @@ -244,122 +247,126 @@ fun ActMain.conversationOtherInstance( statusIdAccess: EntityId? = null, isReference: Boolean = false, ) { - val activity = this + launchAndShowError { + actionsDialog(getString(R.string.open_status_from)) { - val dialog = ActionsDialog() + val hostOriginal = Host.parse(urlArg.toUri().authority ?: "") - val hostOriginal = Host.parse(urlArg.toUri().authority ?: "") + // 選択肢:ブラウザで表示する + action(getString(R.string.open_web_on_host, hostOriginal.pretty)) { + openCustomTab(urlArg) + } - // 選択肢:ブラウザで表示する - dialog.addAction( - getString( - R.string.open_web_on_host, - hostOriginal.pretty - ) - ) { openCustomTab(urlArg) } + // トゥートの投稿元タンスにあるアカウント + val localAccountList = ArrayList() - // トゥートの投稿元タンスにあるアカウント - val localAccountList = ArrayList() + // TLを読んだタンスにあるアカウント + val accessAccountList = ArrayList() - // TLを読んだタンスにあるアカウント - val accessAccountList = ArrayList() + // その他のタンスにあるアカウント + val otherAccountList = ArrayList() - // その他のタンスにあるアカウント - val otherAccountList = ArrayList() + for (a in daoSavedAccount.loadAccountList()) { - for (a in SavedAccount.loadAccountList(applicationContext)) { + // 疑似アカウントは後でまとめて処理する + if (a.isPseudo) continue - // 疑似アカウントは後でまとめて処理する - if (a.isPseudo) continue + if (isReference && TootInstance.getCached(a)?.canUseReference != true) continue - if (isReference && TootInstance.getCached(a)?.canUseReference != true) continue + if (statusIdOriginal != null && a.matchHost(hostOriginal)) { + // アクセス情報+ステータスID でアクセスできるなら + // 同タンスのアカウントならステータスIDの変換なしに表示できる + localAccountList.add(a) + } else if (statusIdAccess != null && a.matchHost(hostAccess)) { + // 既に変換済みのステータスIDがあるなら、そのアカウントでもステータスIDの変換は必要ない + accessAccountList.add(a) + } else { + // 別タンスでも実アカウントなら検索APIでステータスIDを変換できる + otherAccountList.add(a) + } + } - if (statusIdOriginal != null && a.matchHost(hostOriginal)) { - // アクセス情報+ステータスID でアクセスできるなら - // 同タンスのアカウントならステータスIDの変換なしに表示できる - localAccountList.add(a) - } else if (statusIdAccess != null && a.matchHost(hostAccess)) { - // 既に変換済みのステータスIDがあるなら、そのアカウントでもステータスIDの変換は必要ない - accessAccountList.add(a) - } else { - // 別タンスでも実アカウントなら検索APIでステータスIDを変換できる - otherAccountList.add(a) - } - } + // 参照の場合、status URLから/references を除去しないとURLでの検索ができない + val url = when { + isReference -> """/references\z""".toRegex().replace(urlArg, "") + else -> urlArg + } - // 参照の場合、status URLから/references を除去しないとURLでの検索ができない - val url = when { - isReference -> """/references\z""".toRegex().replace(urlArg, "") - else -> urlArg - } - - // 同タンスのアカウントがないなら、疑似アカウントで開く選択肢 - if (localAccountList.isEmpty()) { - if (statusIdOriginal != null) { - dialog.addAction( - getString(R.string.open_in_pseudo_account, "?@${hostOriginal.pretty}") - ) { - launchMain { - addPseudoAccount(hostOriginal)?.let { sa -> - conversationLocal(pos, sa, statusIdOriginal, isReference = isReference) + // 同タンスのアカウントがないなら、疑似アカウントで開く選択肢 + if (localAccountList.isEmpty()) { + if (statusIdOriginal != null) { + action( + getString(R.string.open_in_pseudo_account, "?@${hostOriginal.pretty}") + ) { + launchMain { + addPseudoAccount(hostOriginal)?.let { sa -> + conversationLocal( + pos, + sa, + statusIdOriginal, + isReference = isReference + ) + } + } + } + } else { + action( + getString(R.string.open_in_pseudo_account, "?@${hostOriginal.pretty}") + ) { + launchMain { + addPseudoAccount(hostOriginal)?.let { sa -> + conversationRemote(pos, sa, url) + } + } } } } - } else { - dialog.addAction( - getString(R.string.open_in_pseudo_account, "?@${hostOriginal.pretty}") - ) { - launchMain { - addPseudoAccount(hostOriginal)?.let { sa -> - conversationRemote(pos, sa, url) + + // ローカルアカウント + if (statusIdOriginal != null) { + for (a in localAccountList.sortedByNickname()) { + action( + daoAcctColor.getStringWithNickname( + activity, + R.string.open_in_account, + a.acct + ) + ) { + conversationLocal(pos, a, statusIdOriginal, isReference = isReference) } } } + + // アクセスしたアカウント + if (statusIdAccess != null) { + for (a in accessAccountList.sortedByNickname()) { + action( + daoAcctColor.getStringWithNickname( + activity, + R.string.open_in_account, + a.acct + ) + ) { + conversationLocal(pos, a, statusIdAccess, isReference = isReference) + } + } + } + + // その他の実アカウント + for (a in otherAccountList.sortedByNickname()) { + action( + daoAcctColor.getStringWithNickname( + activity, + R.string.open_in_account, + a.acct + ) + ) { + conversationRemote(pos, a, url) + } + } } } - - // ローカルアカウント - if (statusIdOriginal != null) { - SavedAccount.sort(localAccountList) - for (a in localAccountList) { - dialog.addAction( - AcctColor.getStringWithNickname( - activity, - R.string.open_in_account, - a.acct - ) - ) { conversationLocal(pos, a, statusIdOriginal, isReference = isReference) } - } - } - - // アクセスしたアカウント - if (statusIdAccess != null) { - SavedAccount.sort(accessAccountList) - for (a in accessAccountList) { - dialog.addAction( - AcctColor.getStringWithNickname( - activity, - R.string.open_in_account, - a.acct - ) - ) { conversationLocal(pos, a, statusIdAccess, isReference = isReference) } - } - } - - // その他の実アカウント - SavedAccount.sort(otherAccountList) - for (a in otherAccountList) { - dialog.addAction( - AcctColor.getStringWithNickname( - activity, - R.string.open_in_account, - a.acct - ) - ) { conversationRemote(pos, a, url) } - } - - dialog.show(activity, activity.getString(R.string.open_status_from)) } // リモートかもしれない会話の流れを表示する @@ -466,65 +473,59 @@ fun ActMain.conversationFromTootsearch( ) { statusArg ?: return - // step2: 選択したアカウントで投稿を検索して返信元の投稿のIDを調べる - fun step2(a: SavedAccount) = launchMain { - var tmp: TootStatus? = null - runApiTask(a) { client -> - val (result, status) = client.syncStatus(a, statusArg) - tmp = status - result - }?.let { result -> - val status = tmp - val replyId = status?.in_reply_to_id - when { - status == null -> showToast(true, result.error ?: "?") - replyId == null -> showToast(true, "showReplyTootsearch: in_reply_to_id is null") - else -> conversationLocal(pos, a, replyId) - } - } - } - // step 1: choose account - val host = statusArg.account.apDomain val localAccountList = ArrayList() val otherAccountList = ArrayList() - - for (a in SavedAccount.loadAccountList(this)) { - - // 検索APIはログイン必須なので疑似アカウントは使えない - if (a.isPseudo) continue - - if (a.matchHost(host)) { - localAccountList.add(a) - } else { - otherAccountList.add(a) + for (a in daoSavedAccount.loadAccountList()) { + when { + // 検索APIはログイン必須なので疑似アカウントは使えない + a.isPseudo -> continue + a.matchHost(host) -> localAccountList.add(a) + else -> otherAccountList.add(a) } } - val dialog = ActionsDialog() + val activity = this + launchAndShowError { - SavedAccount.sort(localAccountList) - for (a in localAccountList) { - dialog.addAction( - AcctColor.getStringWithNickname( - this, - R.string.open_in_account, - a.acct - ) - ) { step2(a) } + // step2: 選択したアカウントで投稿を検索して返信元の投稿のIDを調べる + suspend fun step2(a: SavedAccount) { + var tmp: TootStatus? = null + runApiTask(a) { client -> + val (result, status) = client.syncStatus(a, statusArg) + tmp = status + result + }?.let { result -> + val status = tmp + val replyId = status?.in_reply_to_id + when { + status == null -> showToast(true, result.error ?: "?") + replyId == null -> showToast(true, "showReplyTootsearch: in_reply_to_id is null") + else -> conversationLocal(pos, a, replyId) + } + } + } + actionsDialog(getString(R.string.open_status_from)) { + for (a in localAccountList.sortedByNickname()) { + action( + daoAcctColor.getStringWithNickname( + activity, + R.string.open_in_account, + a.acct + ) + ) { step2(a) } + } + + for (a in otherAccountList.sortedByNickname()) { + action( + daoAcctColor.getStringWithNickname( + activity, + R.string.open_in_account, + a.acct + ) + ) { step2(a) } + } + } } - - SavedAccount.sort(otherAccountList) - for (a in otherAccountList) { - dialog.addAction( - AcctColor.getStringWithNickname( - this, - R.string.open_in_account, - a.acct - ) - ) { step2(a) } - } - - dialog.show(this, getString(R.string.open_status_from)) } diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Filter.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Filter.kt index 310c0b9a..c67f60e8 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Filter.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Filter.kt @@ -6,8 +6,8 @@ import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.api.entity.TootFilter import jp.juggler.subwaytooter.api.runApiTask import jp.juggler.subwaytooter.column.onFilterDeleted -import jp.juggler.subwaytooter.dialog.ActionsDialog import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm +import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.log.showToast @@ -17,15 +17,17 @@ import okhttp3.Request fun ActMain.openFilterMenu(accessInfo: SavedAccount, item: TootFilter?) { item ?: return - - val ad = ActionsDialog() - ad.addAction(getString(R.string.edit)) { - ActKeywordFilter.open(this, accessInfo, item.id) + val activity = this + launchAndShowError { + actionsDialog(getString(R.string.filter_of, item.displayString)) { + action(getString(R.string.edit)) { + ActKeywordFilter.open(activity, accessInfo, item.id) + } + action(getString(R.string.delete)) { + filterDelete(accessInfo, item) + } + } } - ad.addAction(getString(R.string.delete)) { - filterDelete(accessInfo, item) - } - ad.show(this, getString(R.string.filter_of, item.displayString)) } fun ActMain.filterDelete( diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Follow.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Follow.kt index fa257013..fc62e7e5 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Follow.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Follow.kt @@ -12,9 +12,7 @@ import jp.juggler.subwaytooter.column.fireRebindAdapterItems import jp.juggler.subwaytooter.column.removeUser import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.dialog.pickAccount -import jp.juggler.subwaytooter.table.AcctColor -import jp.juggler.subwaytooter.table.SavedAccount -import jp.juggler.subwaytooter.table.UserRelation +import jp.juggler.subwaytooter.table.* import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain import jp.juggler.util.log.showToast @@ -81,7 +79,7 @@ fun ActMain.clickFollowRequestAccept( accept -> R.string.follow_accept_confirm else -> R.string.follow_deny_confirm }, - AcctColor.getNickname(accessInfo, who) + daoAcctColor.getNickname(accessInfo, who) ) followRequestAuthorize(accessInfo, whoRef, accept) } @@ -152,12 +150,12 @@ fun ActMain.follow( getString( R.string.confirm_follow_request_who_from, whoRef.decoded_display_name, - AcctColor.getNickname(accessInfo) + daoAcctColor.getNickname(accessInfo) ), accessInfo.confirm_follow_locked, ) { newConfirmEnabled -> accessInfo.confirm_follow_locked = newConfirmEnabled - accessInfo.saveSetting() + daoSavedAccount.saveSetting(accessInfo) activity.reloadAccountSetting(accessInfo) } } else if (bFollow) { @@ -165,12 +163,12 @@ fun ActMain.follow( getString( R.string.confirm_follow_who_from, whoRef.decoded_display_name, - AcctColor.getNickname(accessInfo) + daoAcctColor.getNickname(accessInfo) ), accessInfo.confirm_follow ) { newConfirmEnabled -> accessInfo.confirm_follow = newConfirmEnabled - accessInfo.saveSetting() + daoSavedAccount.saveSetting(accessInfo) activity.reloadAccountSetting(accessInfo) } } else { @@ -178,12 +176,12 @@ fun ActMain.follow( getString( R.string.confirm_unfollow_who_from, whoRef.decoded_display_name, - AcctColor.getNickname(accessInfo) + daoAcctColor.getNickname(accessInfo) ), accessInfo.confirm_unfollow ) { newConfirmEnabled -> accessInfo.confirm_unfollow = newConfirmEnabled - accessInfo.saveSetting() + daoSavedAccount.saveSetting(accessInfo) activity.reloadAccountSetting(accessInfo) } } @@ -235,9 +233,9 @@ fun ActMain.follow( )?.also { result -> fun saveFollow(f: Boolean) { - val ur = UserRelation.load(accessInfo.db_id, userId) + val ur = daoUserRelation.load(accessInfo.db_id, userId) ur.following = f - UserRelation.save1Misskey( + daoUserRelation.save1Misskey( System.currentTimeMillis(), accessInfo.db_id, userId.toString(), @@ -264,7 +262,7 @@ fun ActMain.follow( "".toFormRequestBody().toPost() )?.also { result -> val newRelation = parseItem(::TootRelationShip, parser, result.jsonObject) - resultRelation = accessInfo.saveUserRelation(newRelation) + resultRelation = daoUserRelation.saveUserRelation(accessInfo, newRelation) } } }?.let { result -> @@ -313,26 +311,26 @@ private fun ActMain.followRemote( confirm( getString( R.string.confirm_follow_request_who_from, - AcctColor.getNickname(acct), - AcctColor.getNickname(accessInfo) + daoAcctColor.getNickname(acct), + daoAcctColor.getNickname(accessInfo) ), accessInfo.confirm_follow_locked, ) { newConfirmEnabled -> accessInfo.confirm_follow_locked = newConfirmEnabled - accessInfo.saveSetting() + daoSavedAccount.saveSetting(accessInfo) reloadAccountSetting(accessInfo) } } else { confirm( getString( R.string.confirm_follow_who_from, - AcctColor.getNickname(acct), - AcctColor.getNickname(accessInfo) + daoAcctColor.getNickname(acct), + daoAcctColor.getNickname(accessInfo) ), accessInfo.confirm_follow ) { newConfirmEnabled -> accessInfo.confirm_follow = newConfirmEnabled - accessInfo.saveSetting() + daoSavedAccount.saveSetting(accessInfo) reloadAccountSetting(accessInfo) } } @@ -357,12 +355,16 @@ private fun ActMain.followRemote( result?.error?.contains("already not following") == true ) { // DBから読み直して値を変更する - resultRelation = UserRelation.load(accessInfo.db_id, userId) + resultRelation = daoUserRelation.load(accessInfo.db_id, userId) .apply { following = true } } else { // parserに残ってるRelationをDBに保存する parser.account(result?.jsonObject)?.let { - resultRelation = accessInfo.saveUserRelationMisskey(it.id, parser) + resultRelation = daoUserRelation.saveUserRelationMisskey( + accessInfo, + it.id, + parser + ) } } } @@ -372,7 +374,7 @@ private fun ActMain.followRemote( "".toFormRequestBody().toPost() )?.also { result -> parseItem(::TootRelationShip, parser, result.jsonObject)?.let { - resultRelation = accessInfo.saveUserRelation(it) + resultRelation = daoUserRelation.saveUserRelation(accessInfo, it) } } } @@ -469,7 +471,11 @@ fun ActMain.followRequestAuthorize( val user = parser.account(result?.jsonObject) if (user != null) { // parserに残ってるRelationをDBに保存する - accessInfo.saveUserRelationMisskey(user.id, parser) + daoUserRelation.saveUserRelationMisskey( + accessInfo, + user.id, + parser + ) } // 読めなくてもエラー処理は行わない } @@ -481,7 +487,7 @@ fun ActMain.followRequestAuthorize( // Mastodon 3.0.0 から更新されたリレーションを返す // https//github.com/tootsuite/mastodon/pull/11800 val newRelation = parseItem(::TootRelationShip, parser, result.jsonObject) - accessInfo.saveUserRelation(newRelation) + daoUserRelation.saveUserRelation(accessInfo, newRelation) // 読めなくてもエラー処理は行わない } } @@ -543,7 +549,7 @@ fun ActMain.followRequestDelete( confirm( R.string.confirm_cancel_follow_request_who_from, whoRef.decoded_display_name, - AcctColor.getNickname(accessInfo) + daoAcctColor.getNickname(accessInfo) ) } @@ -572,7 +578,12 @@ fun ActMain.followRequestDelete( )?.also { result -> parser.account(result.jsonObject)?.let { // parserに残ってるRelationをDBに保存する - resultRelation = accessInfo.saveUserRelationMisskey(it.id, parser) + resultRelation = daoUserRelation.saveUserRelationMisskey( + accessInfo, + it.id, + parser + ) + } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_List.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_List.kt index 0b718523..74a60b5f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_List.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_List.kt @@ -13,9 +13,9 @@ import jp.juggler.subwaytooter.api.runApiTask import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.column.onListListUpdated import jp.juggler.subwaytooter.column.onListNameUpdated -import jp.juggler.subwaytooter.dialog.ActionsDialog import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.dialog.DlgTextInput +import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain @@ -36,26 +36,28 @@ fun ActMain.clickListTl(pos: Int, accessInfo: SavedAccount, item: TimelineItem?) fun ActMain.clickListMoreButton(pos: Int, accessInfo: SavedAccount, item: TimelineItem?) { when (item) { is TootList -> { - ActionsDialog() - .addAction(getString(R.string.list_timeline)) { - addColumn(pos, accessInfo, ColumnType.LIST_TL, item.id) + launchAndShowError { + actionsDialog(item.title) { + action(getString(R.string.list_timeline)) { + addColumn(pos, accessInfo, ColumnType.LIST_TL, item.id) + } + action(getString(R.string.list_member)) { + addColumn( + false, + pos, + accessInfo, + ColumnType.LIST_MEMBER, + item.id + ) + } + action(getString(R.string.rename)) { + listRename(accessInfo, item) + } + action(getString(R.string.delete)) { + listDelete(accessInfo, item) + } } - .addAction(getString(R.string.list_member)) { - addColumn( - false, - pos, - accessInfo, - ColumnType.LIST_MEMBER, - item.id - ) - } - .addAction(getString(R.string.rename)) { - listRename(accessInfo, item) - } - .addAction(getString(R.string.delete)) { - listDelete(accessInfo, item) - } - .show(this, item.title) + } } is MisskeyAntenna -> { diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_ListMember.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_ListMember.kt index 15143b58..62b0bd6f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_ListMember.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_ListMember.kt @@ -12,6 +12,7 @@ import jp.juggler.subwaytooter.api.syncAccountByAcct import jp.juggler.subwaytooter.column.onListMemberUpdated import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoUserRelation import jp.juggler.util.* import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain @@ -71,7 +72,8 @@ fun ActMain.listMemberAdd( "".toFormRequestBody().toPost() ) ?: return@runApiTask null - val relation = accessInfo.saveUserRelation( + val relation = daoUserRelation.saveUserRelation( + accessInfo, parseItem(::TootRelationShip, parser, result.jsonObject) ) ?: return@runApiTask TootApiResult("parse error.") diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_OpenPost.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_OpenPost.kt index 357e0699..41d0cb10 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_OpenPost.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_OpenPost.kt @@ -16,10 +16,13 @@ import jp.juggler.subwaytooter.api.runApiTask import jp.juggler.subwaytooter.api.syncStatus import jp.juggler.subwaytooter.dialog.pickAccount import jp.juggler.subwaytooter.pref.PrefB -import jp.juggler.subwaytooter.pref.PrefDevice import jp.juggler.subwaytooter.pref.PrefS +import jp.juggler.subwaytooter.pref.prefDevice import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.accountListCanQuote +import jp.juggler.subwaytooter.table.accountListNonPseudo import jp.juggler.subwaytooter.util.matchHost +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain import jp.juggler.util.log.LogCategory import jp.juggler.util.log.showToast @@ -37,7 +40,7 @@ fun ActPost.saveWindowSize() { // WindowMetrics#getBounds() the window size including all system bar areas windowManager?.currentWindowMetrics?.bounds?.let { bounds -> log.d("API=${Build.VERSION.SDK_INT}, WindowMetrics#getBounds() $bounds") - PrefDevice.savePostWindowBound(this, bounds.width(), bounds.height()) + prefDevice.savePostWindowBound(bounds.width(), bounds.height()) } } else { @Suppress("DEPRECATION") @@ -45,7 +48,7 @@ fun ActPost.saveWindowSize() { val dm = DisplayMetrics() display.getMetrics(dm) log.d("API=${Build.VERSION.SDK_INT}, displayMetrics=${dm.widthPixels},${dm.heightPixels}") - PrefDevice.savePostWindowBound(this, dm.widthPixels, dm.heightPixels) + prefDevice.savePostWindowBound(dm.widthPixels, dm.heightPixels) } } } @@ -75,8 +78,8 @@ fun ActMain.openActPostImpl( scheduledStatus: TootScheduled? = null, ) { - val useManyWindow = PrefB.bpManyWindowPost(pref) - val useMultiWindow = useManyWindow || PrefB.bpMultiWindowPost(pref) + val useManyWindow = PrefB.bpManyWindowPost.value + val useMultiWindow = useManyWindow || PrefB.bpMultiWindowPost.value val intent = ActPost.createIntent( context = this, @@ -99,7 +102,9 @@ fun ActMain.openActPostImpl( ActPost.refActPost?.get() ?.takeIf { it.isLiveActivity } ?.let { - it.updateText(intent) + launchAndShowError { + it.updateText(intent) + } return } } @@ -108,11 +113,10 @@ fun ActMain.openActPostImpl( intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) var options = ActivityOptionsCompat.makeBasic() - PrefDevice.loadPostWindowBound(this) - ?.let { - log.d("ActPost launchBounds $it") - options = options.setLaunchBounds(it) - } + prefDevice.loadPostWindowBound()?.let { + log.d("ActPost launchBounds $it") + options = options.setLaunchBounds(it) + } arActPost.launch(intent, options) } @@ -254,7 +258,7 @@ fun ActMain.quoteFromAnotherAccount( fun ActMain.quoteName(who: TootAccount) { var sv = who.display_name try { - val fmt = PrefS.spQuoteNameFormat(pref) + val fmt = PrefS.spQuoteNameFormat.value if (fmt.contains("%1\$s")) { sv = String.format(Locale.getDefault(), fmt, sv) } diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Reaction.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Reaction.kt index 1e9a9508..4a87f6ea 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Reaction.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Reaction.kt @@ -18,8 +18,10 @@ import jp.juggler.subwaytooter.dialog.launchEmojiPicker import jp.juggler.subwaytooter.dialog.pickAccount import jp.juggler.subwaytooter.emoji.CustomEmoji import jp.juggler.subwaytooter.emoji.UnicodeEmoji -import jp.juggler.subwaytooter.table.AcctColor import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.accountListCanReaction +import jp.juggler.subwaytooter.table.daoAcctColor +import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.util.DecodeOptions import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain @@ -122,11 +124,15 @@ fun ActMain.reactionAdd( ) val emojiSpan = TootReaction.toSpannableStringBuilder(options, code, urlArg) confirm( - getString(R.string.confirm_reaction, emojiSpan, AcctColor.getNickname(accessInfo)), + getString( + R.string.confirm_reaction, + emojiSpan, + daoAcctColor.getNickname(accessInfo) + ), accessInfo.confirm_reaction, ) { newConfirmEnabled -> accessInfo.confirm_reaction = newConfirmEnabled - accessInfo.saveSetting() + daoSavedAccount.saveSetting(accessInfo) } } @@ -336,16 +342,20 @@ private fun ActMain.reactionWithoutUi( isCustomEmoji && url?.likePleromaStatusUrl() == true -> confirm( R.string.confirm_reaction_to_pleroma, emojiSpan, - AcctColor.getNickname(accessInfo), + daoAcctColor.getNickname(accessInfo), resolvedStatus.account.acct.host?.pretty ?: "(null)" ) else -> confirm( - getString(R.string.confirm_reaction, emojiSpan, AcctColor.getNickname(accessInfo)), + getString( + R.string.confirm_reaction, + emojiSpan, + daoAcctColor.getNickname(accessInfo) + ), accessInfo.confirm_reaction, ) { newConfirmEnabled -> accessInfo.confirm_reaction = newConfirmEnabled - accessInfo.saveSetting() + daoSavedAccount.saveSetting(accessInfo) } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Status.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Status.kt index 9f181ed5..ad4be4f9 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Status.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Status.kt @@ -12,11 +12,13 @@ import jp.juggler.subwaytooter.api.entity.EntityId import jp.juggler.subwaytooter.api.entity.TootScheduled import jp.juggler.subwaytooter.api.entity.TootStatus import jp.juggler.subwaytooter.column.* -import jp.juggler.subwaytooter.dialog.ActionsDialog import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm +import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.dialog.pickAccount -import jp.juggler.subwaytooter.table.AcctColor import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.accountListNonPseudo +import jp.juggler.subwaytooter.table.daoAcctColor +import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.util.emptyCallback import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain @@ -85,18 +87,20 @@ fun ActMain.clickFavourite(accessInfo: SavedAccount, status: TootStatus, willToa } fun ActMain.clickScheduledToot(accessInfo: SavedAccount, item: TootScheduled, column: Column) { - ActionsDialog() - .addAction(getString(R.string.edit)) { - scheduledPostEdit(accessInfo, item) - } - .addAction(getString(R.string.delete)) { - launchAndShowError { - scheduledPostDelete(accessInfo, item) - column.onScheduleDeleted(item) - showToast(false, R.string.scheduled_post_deleted) + launchAndShowError { + actionsDialog { + action(getString(R.string.edit)) { + scheduledPostEdit(accessInfo, item) + } + action(getString(R.string.delete)) { + launchAndShowError { + scheduledPostDelete(accessInfo, item) + column.onScheduleDeleted(item) + showToast(false, R.string.scheduled_post_deleted) + } } } - .show(this) + } } fun ActMain.launchActText(intent: Intent) = arActText.launch(intent) @@ -125,7 +129,7 @@ fun ActMain.favourite( true -> R.string.confirm_favourite_from else -> R.string.confirm_unfavourite_from }, - AcctColor.getNickname(accessInfo) + daoAcctColor.getNickname(accessInfo) ), when (bSet) { true -> accessInfo.confirm_favourite @@ -136,7 +140,7 @@ fun ActMain.favourite( true -> accessInfo.confirm_favourite = newConfirmEnabled else -> accessInfo.confirm_unfavourite = newConfirmEnabled } - accessInfo.saveSetting() + daoSavedAccount.saveSetting(accessInfo) reloadAccountSetting(accessInfo) } } @@ -294,12 +298,12 @@ fun ActMain.bookmark( confirm( getString( R.string.confirm_unbookmark_from, - AcctColor.getNickname(accessInfo) + daoAcctColor.getNickname(accessInfo) ), accessInfo.confirm_unbookmark ) { newConfirmEnabled -> accessInfo.confirm_unbookmark = newConfirmEnabled - accessInfo.saveSetting() + daoSavedAccount.saveSetting(accessInfo) reloadAccountSetting(accessInfo) } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Tag.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Tag.kt index 2a77a1d6..718e1398 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Tag.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Tag.kt @@ -11,12 +11,15 @@ import jp.juggler.subwaytooter.api.entity.TootTag import jp.juggler.subwaytooter.api.runApiTask import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.column.onTagFollowChanged -import jp.juggler.subwaytooter.dialog.ActionsDialog -import jp.juggler.subwaytooter.table.AcctColor +import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoAcctColor +import jp.juggler.subwaytooter.table.daoSavedAccount +import jp.juggler.subwaytooter.table.sortedByNickname import jp.juggler.subwaytooter.util.matchHost import jp.juggler.subwaytooter.util.openCustomTab import jp.juggler.util.coroutine.AppDispatchers +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.encodePercent import jp.juggler.util.log.LogCategory @@ -57,23 +60,21 @@ fun ActMain.tagDialog( ) { val activity = this val tagWithSharp = "#$tagWithoutSharp" - launchMain { - try { - - val d = ActionsDialog() - .addAction(getString(R.string.open_hashtag_column)) { - tagTimelineFromAccount( - pos, - url, - host, - tagWithoutSharp - ) - } + launchAndShowError { + actionsDialog(tagWithSharp) { + action(getString(R.string.open_hashtag_column)) { + tagTimelineFromAccount( + pos, + url, + host, + tagWithoutSharp + ) + } // 投稿者別タグTL if (whoAcct != null) { - d.addAction( - AcctColor.getStringWithNickname( + action( + daoAcctColor.getStringWithNickname( activity, R.string.open_hashtag_from_account, whoAcct @@ -89,13 +90,13 @@ fun ActMain.tagDialog( } } - d.addAction(getString(R.string.open_in_browser)) { openCustomTab(url) } - .addAction( - getString( - R.string.quote_hashtag_of, - tagWithSharp - ) - ) { openPost("$tagWithSharp ") } + action(getString(R.string.open_in_browser)) { + openCustomTab(url) + } + + action(getString(R.string.quote_hashtag_of, tagWithSharp)) { + openPost("$tagWithSharp ") + } if (tagList != null && tagList.size > 1) { val sb = StringBuilder() @@ -104,11 +105,8 @@ fun ActMain.tagDialog( sb.append(s) } val tagAll = sb.toString() - d.addAction( - getString( - R.string.quote_all_hashtag_of, - tagAll - ) + action( + getString(R.string.quote_all_hashtag_of, tagAll) ) { openPost("$tagAll ") } } @@ -118,10 +116,12 @@ fun ActMain.tagDialog( if (tag == null) { val result = runApiTask(accessInfo) { client -> client.request("/api/v1/tags/${tagWithoutSharp.encodePercent()}") - } ?: return@launchMain //cancelled. - TootParser(activity, accessInfo) - .tag(result.jsonObject) - ?.let { tag = it } + } + if (result != null) { + TootParser(activity, accessInfo) + .tag(result.jsonObject) + ?.let { tag = it } + } } val toggle = !(tag?.following ?: false) @@ -129,14 +129,10 @@ fun ActMain.tagDialog( true -> R.string.follow_hashtag_of else -> R.string.unfollow_hashtag_of } - d.addAction(getString(toggleCaption, tagWithSharp)) { + action(getString(toggleCaption, tagWithSharp)) { followHashTag(accessInfo, tagWithoutSharp, toggle) } } - - d.show(activity, tagWithSharp) - } catch (ex: Throwable) { - log.e(ex, "tagDialog failed.") } } } @@ -175,80 +171,80 @@ fun ActMain.tagTimelineFromAccount( // 「投稿者別タグTL」を開くなら、投稿者のacctを指定する acct: Acct? = null, ) { + val activity = this + launchAndShowError { + actionsDialog("#$tagWithoutSharp") { - val dialog = ActionsDialog() + val accountList = daoSavedAccount.loadAccountList().sortedByNickname() - val accountList = SavedAccount.loadAccountList(this) - SavedAccount.sort(accountList) + // 分類する + val listOriginal = ArrayList() + val listOriginalPseudo = ArrayList() + val listOther = ArrayList() + for (a in accountList) { + if (acct == null) { + when { + !a.matchHost(host) -> listOther.add(a) + a.isPseudo -> listOriginalPseudo.add(a) + else -> listOriginal.add(a) + } + } else { + when { + // 疑似アカウントはacctからaccount idを取得できないので + // アカウント別タグTLを開けない + a.isPseudo -> Unit - // 分類する - val listOriginal = ArrayList() - val listOriginalPseudo = ArrayList() - val listOther = ArrayList() - for (a in accountList) { - if (acct == null) { - when { - !a.matchHost(host) -> listOther.add(a) - a.isPseudo -> listOriginalPseudo.add(a) - else -> listOriginal.add(a) + // ミスキーはアカウント別タグTLがないので + // アカウント別タグTLを開けない + a.isMisskey -> Unit + + !a.matchHost(host) -> listOther.add(a) + else -> listOriginal.add(a) + } + } } - } else { - when { - // 疑似アカウントはacctからaccount idを取得できないので - // アカウント別タグTLを開けない - a.isPseudo -> Unit - // ミスキーはアカウント別タグTLがないので - // アカウント別タグTLを開けない - a.isMisskey -> Unit + // ブラウザで表示する + if (!url.isNullOrBlank()) { + action(getString(R.string.open_web_on_host, host)) { + openCustomTab(url) + } + } - !a.matchHost(host) -> listOther.add(a) - else -> listOriginal.add(a) + // 同タンスのアカウントがない場合は疑似アカウントを作成して開く + // ただし疑似アカウントではアカウントの同期ができないため、特定ユーザのタグTLは読めない) + if (acct == null && listOriginal.isEmpty() && listOriginalPseudo.isEmpty()) { + action(getString(R.string.open_in_pseudo_account, "?@$host")) { + launchMain { + addPseudoAccount(host)?.let { tagTimeline(pos, it, tagWithoutSharp) } + } + } + } + + // 分類した順に選択肢を追加する + for (a in listOriginal) { + action( + daoAcctColor.getStringWithNickname(activity, R.string.open_in_account, a.acct) + ) { + tagTimeline(pos, a, tagWithoutSharp, acct?.ascii) + } + } + for (a in listOriginalPseudo) { + action( + daoAcctColor.getStringWithNickname(activity, R.string.open_in_account, a.acct) + ) { + tagTimeline(pos, a, tagWithoutSharp, acct?.ascii) + } + } + for (a in listOther) { + action( + daoAcctColor.getStringWithNickname(activity, R.string.open_in_account, a.acct) + ) { + tagTimeline(pos, a, tagWithoutSharp, acct?.ascii) + } } } } - - // ブラウザで表示する - if (!url.isNullOrBlank()) { - dialog.addAction(getString(R.string.open_web_on_host, host)) { - openCustomTab(url) - } - } - - // 同タンスのアカウントがない場合は疑似アカウントを作成して開く - // ただし疑似アカウントではアカウントの同期ができないため、特定ユーザのタグTLは読めない) - if (acct == null && listOriginal.isEmpty() && listOriginalPseudo.isEmpty()) { - dialog.addAction(getString(R.string.open_in_pseudo_account, "?@$host")) { - launchMain { - addPseudoAccount(host)?.let { tagTimeline(pos, it, tagWithoutSharp) } - } - } - } - - // 分類した順に選択肢を追加する - for (a in listOriginal) { - dialog.addAction( - AcctColor.getStringWithNickname( - this, - R.string.open_in_account, - a.acct - ) - ) { - tagTimeline(pos, a, tagWithoutSharp, acct?.ascii) - } - } - for (a in listOriginalPseudo) { - dialog.addAction(AcctColor.getStringWithNickname(this, R.string.open_in_account, a.acct)) { - tagTimeline(pos, a, tagWithoutSharp, acct?.ascii) - } - } - for (a in listOther) { - dialog.addAction(AcctColor.getStringWithNickname(this, R.string.open_in_account, a.acct)) { - tagTimeline(pos, a, tagWithoutSharp, acct?.ascii) - } - } - - dialog.show(this, "#$tagWithoutSharp") } fun ActMain.followHashTag( diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Timeline.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Timeline.kt index 76d5905c..b8bd6500 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_Timeline.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_Timeline.kt @@ -12,6 +12,9 @@ import jp.juggler.subwaytooter.api.syncStatus import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.dialog.pickAccount import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoSavedAccount +import jp.juggler.subwaytooter.table.sortInplaceByNickname +import jp.juggler.subwaytooter.table.sortedByNickname import jp.juggler.subwaytooter.util.matchHost import jp.juggler.util.coroutine.launchMain import jp.juggler.util.log.showToast @@ -102,7 +105,7 @@ fun ActMain.timelineLocal( launchMain { // 指定タンスのアカウントを持ってるか? val accountList = ArrayList() - for (a in SavedAccount.loadAccountList(applicationContext)) { + for (a in daoSavedAccount.loadAccountList()) { if (a.matchHost(host)) accountList.add(a) } @@ -113,12 +116,11 @@ fun ActMain.timelineLocal( } } else { // 持ってるならアカウントを選んで開く - SavedAccount.sort(accountList) pickAccount( bAllowPseudo = true, bAuto = false, message = getString(R.string.account_picker_add_timeline_of, host), - accountListArg = accountList + accountListArg = accountList.sortedByNickname() )?.let { addColumn(pos, it, ColumnType.LOCAL) } } } @@ -168,7 +170,7 @@ fun ActMain.timelineAroundByStatusAnotherAccount( // 利用可能なアカウントを列挙する val accountList1 = ArrayList() // 閲覧アカウントとホストが同じ val accountList2 = ArrayList() // その他実アカウント - label@ for (a in SavedAccount.loadAccountList(this)) { + label@ for (a in daoSavedAccount.loadAccountList()) { // Misskeyアカウントはステータスの同期が出来ないので選択させない if (a.isNA || a.isMisskey) continue when { @@ -179,8 +181,8 @@ fun ActMain.timelineAroundByStatusAnotherAccount( !a.isPseudo -> accountList2.add(a) } } - SavedAccount.sort(accountList1) - SavedAccount.sort(accountList2) + accountList1.sortInplaceByNickname() + accountList2.sortInplaceByNickname() accountList1.addAll(accountList2) if (accountList1.isEmpty()) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/action/Action_User.kt b/app/src/main/java/jp/juggler/subwaytooter/action/Action_User.kt index 264fe4e5..5b5144a5 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/action/Action_User.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/action/Action_User.kt @@ -14,10 +14,7 @@ import jp.juggler.subwaytooter.column.* import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.dialog.ReportForm import jp.juggler.subwaytooter.dialog.pickAccount -import jp.juggler.subwaytooter.table.AcctColor -import jp.juggler.subwaytooter.table.FavMute -import jp.juggler.subwaytooter.table.SavedAccount -import jp.juggler.subwaytooter.table.UserRelation +import jp.juggler.subwaytooter.table.* import jp.juggler.subwaytooter.util.matchHost import jp.juggler.subwaytooter.util.openCustomTab import jp.juggler.util.* @@ -72,9 +69,9 @@ fun ActMain.openAvatarImage(who: TootAccount) { fun ActMain.clickHideFavourite( accessInfo: SavedAccount, who: TootAccount, -) { +) = launchAndShowError { val acct = accessInfo.getFullAcct(who) - FavMute.save(acct) + daoFavMute.save(acct) showToast(false, R.string.changed) for (column in appState.columnList) { column.onHideFavouriteNotification(acct) @@ -84,8 +81,8 @@ fun ActMain.clickHideFavourite( fun ActMain.clickShowFavourite( accessInfo: SavedAccount, who: TootAccount, -) { - FavMute.delete(accessInfo.getFullAcct(who)) +) = launchAndShowError { + daoFavMute.delete(accessInfo.getFullAcct(who)) showToast(false, R.string.changed) } @@ -110,133 +107,134 @@ private fun ActMain.userMute( bMute: Boolean, bMuteNotification: Boolean, duration: Int?, -) { +) = launchAndShowError { val whoAcct = whoAccessInfo.getFullAcct(whoArg) if (accessInfo.isMe(whoAcct)) { showToast(false, R.string.it_is_you) - return + return@launchAndShowError } - launchMain { - var resultRelation: UserRelation? = null - var resultWhoId: EntityId? = null - runApiTask(accessInfo) { client -> - val parser = TootParser(this, accessInfo) - if (accessInfo.isPseudo) { - if (!whoAcct.isValidFull) { - TootApiResult("can't mute pseudo acct ${whoAcct.pretty}") - } else { - val relation = UserRelation.loadPseudo(whoAcct) - relation.muting = bMute - relation.savePseudo(whoAcct.ascii) - resultRelation = relation - resultWhoId = whoArg.id - TootApiResult() + var resultRelation: UserRelation? = null + var resultWhoId: EntityId? = null + runApiTask(accessInfo) { client -> + val parser = TootParser(this, accessInfo) + if (accessInfo.isPseudo) { + if (!whoAcct.isValidFull) { + TootApiResult("can't mute pseudo acct ${whoAcct.pretty}") + } else { + val relation = daoUserRelation.loadPseudo(whoAcct) + relation.muting = bMute + daoUserRelation.savePseudo(whoAcct.ascii, relation) + resultRelation = relation + resultWhoId = whoArg.id + TootApiResult() + } + } else { + val whoId = if (accessInfo.matchHost(whoAccessInfo)) { + whoArg.id + } else { + val (result, accountRef) = client.syncAccountByAcct(accessInfo, whoAcct) + accountRef?.get()?.id ?: return@runApiTask result + } + resultWhoId = whoId + + if (accessInfo.isMisskey) { + client.request( + when (bMute) { + true -> "/api/mute/create" + else -> "/api/mute/delete" + }, + accessInfo.putMisskeyApiToken().apply { + put("userId", whoId.toString()) + }.toPostRequestBuilder() + )?.apply { + if (jsonObject != null) { + // 204 no content + + // update user relation + val ur = daoUserRelation.load(accessInfo.db_id, whoId) + ur.muting = bMute + daoUserRelation.saveUserRelationMisskey( + accessInfo, + whoId, + parser + ) + + resultRelation = ur + } } } else { - val whoId = if (accessInfo.matchHost(whoAccessInfo)) { - whoArg.id - } else { - val (result, accountRef) = client.syncAccountByAcct(accessInfo, whoAcct) - accountRef?.get()?.id ?: return@runApiTask result - } - resultWhoId = whoId - - if (accessInfo.isMisskey) { - client.request( - when (bMute) { - true -> "/api/mute/create" - else -> "/api/mute/delete" - }, - accessInfo.putMisskeyApiToken().apply { - put("userId", whoId.toString()) - }.toPostRequestBuilder() - )?.apply { - if (jsonObject != null) { - // 204 no content - - // update user relation - val ur = UserRelation.load(accessInfo.db_id, whoId) - ur.muting = bMute - accessInfo.saveUserRelationMisskey( - whoId, - parser - ) - resultRelation = ur - } - } - } else { - client.request( - "/api/v1/accounts/$whoId/${if (bMute) "mute" else "unmute"}", - when { - !bMute -> "".toFormRequestBody() - else -> - buildJsonObject { - put("notifications", bMuteNotification) - if (duration != null) put("duration", duration) - }.toRequestBody() - }.toPost() - )?.apply { - val jsonObject = jsonObject - if (jsonObject != null) { - resultRelation = accessInfo.saveUserRelation( - parseItem(::TootRelationShip, parser, jsonObject) - ) - } + client.request( + "/api/v1/accounts/$whoId/${if (bMute) "mute" else "unmute"}", + when { + !bMute -> "".toFormRequestBody() + else -> + buildJsonObject { + put("notifications", bMuteNotification) + if (duration != null) put("duration", duration) + }.toRequestBody() + }.toPost() + )?.apply { + val jsonObject = jsonObject + if (jsonObject != null) { + resultRelation = daoUserRelation.saveUserRelation( + accessInfo, + parseItem(::TootRelationShip, parser, jsonObject) + ) } } } - }?.let { result -> - val relation = resultRelation - val whoId = resultWhoId - if (relation == null || whoId == null) { - showToast(false, result.error) - } else { - // 未確認だが、自分をミュートしようとするとリクエストは成功するがレスポンス中のmutingはfalseになるはず - if (bMute && !relation.muting) { - showToast(false, R.string.not_muted) - return@launchMain - } + } + }?.let { result -> + val relation = resultRelation + val whoId = resultWhoId + if (relation == null || whoId == null) { + showToast(false, result.error) + } else { + // 未確認だが、自分をミュートしようとするとリクエストは成功するがレスポンス中のmutingはfalseになるはず + if (bMute && !relation.muting) { + showToast(false, R.string.not_muted) + return@launchAndShowError + } - for (column in appState.columnList) { - if (column.accessInfo.isPseudo) { - if (relation.muting && column.type != ColumnType.PROFILE) { - // ミュートしたユーザの情報はTLから消える - column.removeAccountInTimelinePseudo(whoAcct) - } - // フォローアイコンの表示更新が走る - column.updateFollowIcons(accessInfo) - } else if (column.accessInfo == accessInfo) { - when { - !relation.muting -> { - if (column.type == ColumnType.MUTES) { - // ミュート解除したら「ミュートしたユーザ」カラムから消える - column.removeUser(accessInfo, ColumnType.MUTES, whoId) - } else { - // 他のカラムではフォローアイコンの表示更新が走る - column.updateFollowIcons(accessInfo) - } - } - - column.type == ColumnType.PROFILE && column.profileId == whoId -> { - // 該当ユーザのプロフページのトゥートはミュートしてても見れる - // しかしフォローアイコンの表示更新は必要 + for (column in appState.columnList) { + if (column.accessInfo.isPseudo) { + if (relation.muting && column.type != ColumnType.PROFILE) { + // ミュートしたユーザの情報はTLから消える + column.removeAccountInTimelinePseudo(whoAcct) + } + // フォローアイコンの表示更新が走る + column.updateFollowIcons(accessInfo) + } else if (column.accessInfo == accessInfo) { + when { + !relation.muting -> { + if (column.type == ColumnType.MUTES) { + // ミュート解除したら「ミュートしたユーザ」カラムから消える + column.removeUser(accessInfo, ColumnType.MUTES, whoId) + } else { + // 他のカラムではフォローアイコンの表示更新が走る column.updateFollowIcons(accessInfo) } + } - else -> { - // ミュートしたユーザの情報はTLから消える - column.removeAccountInTimeline(accessInfo, whoId) - } + column.type == ColumnType.PROFILE && column.profileId == whoId -> { + // 該当ユーザのプロフページのトゥートはミュートしてても見れる + // しかしフォローアイコンの表示更新は必要 + column.updateFollowIcons(accessInfo) + } + + else -> { + // ミュートしたユーザの情報はTLから消える + column.removeAccountInTimeline(accessInfo, whoId) } } } - - showToast( - false, - if (relation.muting) R.string.mute_succeeded else R.string.unmute_succeeded - ) } + + showToast( + false, + if (relation.muting) R.string.mute_succeeded else R.string.unmute_succeeded + ) } } } @@ -258,7 +256,7 @@ fun ActMain.userMuteConfirm( accessInfo: SavedAccount, who: TootAccount, whoAccessInfo: SavedAccount, -) { +) = launchAndShowError { val activity = this@userMuteConfirm // Mastodon 3.3から時限ミュート設定ができる @@ -288,59 +286,56 @@ fun ActMain.userMuteConfirm( cbMuteNotification.vg(hasMuteNotification) ?.setText(R.string.confirm_mute_notification_for_user) - launchMain { - val spMuteDuration: Spinner = view.findViewById(R.id.spMuteDuration) - val hasMuteDuration = try { - when { - accessInfo.isMisskey || accessInfo.isPseudo -> false - else -> { - var resultBoolean = false - runApiTask(accessInfo) { client -> - val (ti, ri) = TootInstance.get(client) - resultBoolean = ti?.versionGE(TootInstance.VERSION_3_3_0_rc1) == true - ri - } - resultBoolean - } - } - } catch (ignored: CancellationException) { - // not show error - return@launchMain - } catch (ex: RuntimeException) { - showToast(true, ex.message) - return@launchMain - } - - if (hasMuteDuration) { - view.findViewById(R.id.llMuteDuration).vg(true) - spMuteDuration.apply { - adapter = ArrayAdapter( - activity, - android.R.layout.simple_spinner_item, - choiceList.map { it.second }.toTypedArray(), - ).apply { - setDropDownViewResource(R.layout.lv_spinner_dropdown) + val spMuteDuration: Spinner = view.findViewById(R.id.spMuteDuration) + val hasMuteDuration = try { + when { + accessInfo.isMisskey || accessInfo.isPseudo -> false + else -> { + var resultBoolean = false + runApiTask(accessInfo) { client -> + val (ti, ri) = TootInstance.get(client) + resultBoolean = ti?.versionGE(TootInstance.VERSION_3_3_0_rc1) == true + ri } + resultBoolean } } - - AlertDialog.Builder(activity) - .setView(view) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok) { _, _ -> - userMute( - accessInfo, - who, - whoAccessInfo, - bMute = true, - bMuteNotification = cbMuteNotification.isChecked, - duration = spMuteDuration.selectedItemPosition - .takeIf { hasMuteDuration && it in choiceList.indices } - ?.let { choiceList[it].first } - ) - } - .show() + } catch (ignored: CancellationException) { + // not show error + return@launchAndShowError + } catch (ex: RuntimeException) { + showToast(true, ex.message) + return@launchAndShowError } + + if (hasMuteDuration) { + view.findViewById(R.id.llMuteDuration).vg(true) + spMuteDuration.apply { + adapter = ArrayAdapter( + activity, + android.R.layout.simple_spinner_item, + choiceList.map { it.second }.toTypedArray(), + ).apply { + setDropDownViewResource(R.layout.lv_spinner_dropdown) + } + } + } + + AlertDialog.Builder(activity) + .setView(view) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok) { _, _ -> + userMute( + accessInfo, + who, + whoAccessInfo, + bMute = true, + bMuteNotification = cbMuteNotification.isChecked, + duration = spMuteDuration.selectedItemPosition + .takeIf { hasMuteDuration && it in choiceList.indices } + ?.let { choiceList[it].first } + ) + }.show() } fun ActMain.userMuteFromAnotherAccount( @@ -381,9 +376,9 @@ fun ActMain.userBlock( if (whoAcct.ascii.contains('?')) { TootApiResult("can't block pseudo account ${whoAcct.pretty}") } else { - val relation = UserRelation.loadPseudo(whoAcct) + val relation = daoUserRelation.loadPseudo(whoAcct) relation.blocking = bBlock - relation.savePseudo(whoAcct.ascii) + daoUserRelation.savePseudo(whoAcct.ascii, relation) relationResult = relation TootApiResult() } @@ -397,11 +392,10 @@ fun ActMain.userBlock( whoIdResult = whoId if (accessInfo.isMisskey) { - fun saveBlock(v: Boolean) { - val ur = UserRelation.load(accessInfo.db_id, whoId) + val ur = daoUserRelation.load(accessInfo.db_id, whoId) ur.blocking = v - UserRelation.save1Misskey( + daoUserRelation.save1Misskey( System.currentTimeMillis(), accessInfo.db_id, whoId.toString(), @@ -434,7 +428,8 @@ fun ActMain.userBlock( "".toFormRequestBody().toPost() )?.also { result -> val parser = TootParser(this, accessInfo) - relationResult = accessInfo.saveUserRelation( + relationResult = daoUserRelation.saveUserRelation( + accessInfo, parseItem(::TootRelationShip, parser, result.jsonObject) ) } @@ -597,7 +592,7 @@ fun ActMain.userProfileFromAnotherAccount( bAuto = false, message = getString( R.string.account_picker_open_user_who, - AcctColor.getNickname(accessInfo, who) + daoAcctColor.getNickname(accessInfo, who) ), accountListArg = accountListNonPseudo(who.apDomain) )?.let { ai -> @@ -630,7 +625,7 @@ fun ActMain.userProfile( acct: Acct, userUrl: String, originalUrl: String = userUrl, -) { +) = launchAndShowError { if (accessInfo?.isPseudo == false) { // 文脈のアカウントがあり、疑似アカウントではない @@ -655,51 +650,49 @@ fun ActMain.userProfile( } } } - return + return@launchAndShowError } // 文脈がない、もしくは疑似アカウントだった // 疑似アカウントでは検索APIを使えないため、IDが分からない - if (!SavedAccount.hasRealAccount()) { + if (!daoSavedAccount.hasRealAccount()) { // 疑似アカウントしか登録されていない // chrome tab で開く openCustomTab(originalUrl) - return + return@launchAndShowError } - launchMain { - val activity = this@userProfile - pickAccount( - bAllowPseudo = false, - bAuto = false, - message = getString( - R.string.account_picker_open_user_who, - AcctColor.getNickname(acct) - ), - accountListArg = accountListNonPseudo(acct.host), - extraCallback = { ll, pad_se, pad_tb -> - // chrome tab で開くアクションを追加 - val lp = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - val b = AppCompatButton(activity) - b.setPaddingRelative(pad_se, pad_tb, pad_se, pad_tb) - b.gravity = Gravity.START or Gravity.CENTER_VERTICAL - b.isAllCaps = false - b.layoutParams = lp - b.minHeight = (0.5f + 32f * activity.density).toInt() - b.text = getString(R.string.open_in_browser) - b.setBackgroundResource(R.drawable.btn_bg_transparent_round6dp) + val activity = this@userProfile + pickAccount( + bAllowPseudo = false, + bAuto = false, + message = getString( + R.string.account_picker_open_user_who, + daoAcctColor.getNickname(acct) + ), + accountListArg = accountListNonPseudo(acct.host), + extraCallback = { ll, pad_se, pad_tb -> + // chrome tab で開くアクションを追加 + val lp = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + val b = AppCompatButton(activity) + b.setPaddingRelative(pad_se, pad_tb, pad_se, pad_tb) + b.gravity = Gravity.START or Gravity.CENTER_VERTICAL + b.isAllCaps = false + b.layoutParams = lp + b.minHeight = (0.5f + 32f * activity.density).toInt() + b.text = getString(R.string.open_in_browser) + b.setBackgroundResource(R.drawable.btn_bg_transparent_round6dp) - b.setOnClickListener { - openCustomTab(originalUrl) - } - ll.addView(b, 0) + b.setOnClickListener { + openCustomTab(originalUrl) } - )?.let { - userProfileFromUrlOrAcct(pos, it, acct, userUrl) + ll.addView(b, 0) } + )?.let { + userProfileFromUrlOrAcct(pos, it, acct, userUrl) } } @@ -798,12 +791,9 @@ fun ActMain.userSetShowBoosts( jsonObjectOf("reblogs" to bShow).toPostRequestBuilder() )?.also { result -> val parser = TootParser(this, accessInfo) - resultRelation = accessInfo.saveUserRelation( - parseItem( - ::TootRelationShip, - parser, - result.jsonObject - ) + resultRelation = daoUserRelation.saveUserRelation( + accessInfo, + parseItem(::TootRelationShip, parser, result.jsonObject) ) } }?.let { result -> @@ -860,7 +850,7 @@ fun ActMain.userSetStatusNotification( result.jsonObject ) if (relation != null) { - UserRelation.save1Mastodon( + daoUserRelation.save1Mastodon( System.currentTimeMillis(), accessInfo.db_id, relation @@ -901,7 +891,8 @@ fun ActMain.userEndorsement( ) ?.also { result -> val parser = TootParser(this, accessInfo) - resultRelation = accessInfo.saveUserRelation( + resultRelation = daoUserRelation.saveUserRelation( + accessInfo, parseItem(::TootRelationShip, parser, result.jsonObject) ) } diff --git a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainAccount.kt b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainAccount.kt index 3ff26ea9..125491a2 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainAccount.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainAccount.kt @@ -4,6 +4,7 @@ import jp.juggler.subwaytooter.ActMain import jp.juggler.subwaytooter.column.fireShowColumnHeader import jp.juggler.subwaytooter.pref.PrefL import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoSavedAccount // デフォルトの投稿先アカウントを探す。アカウント選択が必要な状況ならnull val ActMain.currentPostTarget: SavedAccount? @@ -17,9 +18,9 @@ val ActMain.currentPostTarget: SavedAccount? }, { env -> - val dbId = PrefL.lpTabletTootDefaultAccount() + val dbId = PrefL.lpTabletTootDefaultAccount.value if (dbId != -1L) { - val a = SavedAccount.loadAccount(this@currentPostTarget, dbId) + val a = daoSavedAccount.loadAccount(dbId) if (a != null && !a.isPseudo) return a } @@ -47,24 +48,23 @@ val ActMain.currentPostTarget: SavedAccount? }) fun ActMain.reloadAccountSetting( - newAccounts: ArrayList = SavedAccount.loadAccountList( - this - ), + newAccounts: List, ) { for (column in appState.columnList) { val a = column.accessInfo - if (!a.isNA) a.reloadSetting(this, newAccounts.find { it.acct == a.acct }) + val b = newAccounts.find { it.acct == a.acct } + if (!a.isNA && b != null) daoSavedAccount.reloadSetting(a, b) column.fireShowColumnHeader() } } fun ActMain.reloadAccountSetting(account: SavedAccount) { - val newData = SavedAccount.loadAccount(this, account.db_id) + val newData = daoSavedAccount.loadAccount(account.db_id) ?: return for (column in appState.columnList) { val a = column.accessInfo if (a.acct != newData.acct) continue - if (!a.isNA) a.reloadSetting(this, newData) + if (!a.isNA) daoSavedAccount.reloadSetting(a, newData) column.fireShowColumnHeader() } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainActions.kt b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainActions.kt index 3ff46df1..9f15bd1c 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainActions.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainActions.kt @@ -5,6 +5,7 @@ import android.text.Spannable import android.view.View import android.widget.TextView import androidx.core.view.GravityCompat +import androidx.work.WorkManager import jp.juggler.subwaytooter.ActMain import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.action.openColumnList @@ -17,86 +18,93 @@ import jp.juggler.subwaytooter.columnviewholder.ColumnViewHolder import jp.juggler.subwaytooter.columnviewholder.TabletColumnViewHolder import jp.juggler.subwaytooter.columnviewholder.ViewHolderHeaderBase import jp.juggler.subwaytooter.columnviewholder.ViewHolderItem -import jp.juggler.subwaytooter.dialog.ActionsDialog +import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.itemviewholder.ItemViewHolder import jp.juggler.subwaytooter.pref.* +import jp.juggler.subwaytooter.push.PushWorker +import jp.juggler.subwaytooter.push.pushRepo import jp.juggler.subwaytooter.span.MyClickableSpan +import jp.juggler.subwaytooter.util.checkPrivacyPolicy import jp.juggler.subwaytooter.util.openCustomTab +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.addTo import jp.juggler.util.data.cast import jp.juggler.util.data.notEmpty import jp.juggler.util.log.LogCategory import jp.juggler.util.log.showToast +import jp.juggler.util.ui.dismissSafe +import kotlinx.coroutines.suspendCancellableCoroutine import java.util.concurrent.TimeUnit private val log = LogCategory("ActMainActions") fun ActMain.onBackPressedImpl() { + launchAndShowError { - // メニューが開いていたら閉じる - if (drawer.isDrawerOpen(GravityCompat.START)) { - drawer.closeDrawer(GravityCompat.START) - return - } - - // カラムが0個ならアプリを終了する - if (appState.columnCount == 0) { - finish() - return - } - - // カラム設定が開いているならカラム設定を閉じる - if (closeColumnSetting()) { - return - } - - fun getClosableColumnList(): List { - val visibleColumnList = ArrayList() - phoneTab({ env -> - try { - appState.column(env.pager.currentItem)?.addTo(visibleColumnList) - } catch (ex: Throwable) { - log.e(ex, "getClosableColumnList failed.") - } - }, { env -> - visibleColumnList.addAll(env.visibleColumns) - }) - - return visibleColumnList.filter { !it.dontClose } - } - - // カラムが1個以上ある場合は設定に合わせて挙動を変える - when (PrefI.ipBackButtonAction.invoke(pref)) { - PrefI.BACK_EXIT_APP -> finish() - PrefI.BACK_OPEN_COLUMN_LIST -> openColumnList() - PrefI.BACK_CLOSE_COLUMN -> { - val closeableColumnList = getClosableColumnList() - when (closeableColumnList.size) { - 0 -> when { - PrefB.bpExitAppWhenCloseProtectedColumn(pref) && - PrefB.bpDontConfirmBeforeCloseColumn.invoke(pref) -> - finish() - else -> showToast(false, R.string.missing_closeable_column) - } - 1 -> closeColumn(closeableColumnList.first()) - else -> showToast( - false, - R.string.cant_close_column_by_back_button_when_multiple_column_shown - ) - } + // メニューが開いていたら閉じる + if (drawer.isDrawerOpen(GravityCompat.START)) { + drawer.closeDrawer(GravityCompat.START) + return@launchAndShowError } - else /* PrefI.BACK_ASK_ALWAYS */ -> { - val closeableColumnList = getClosableColumnList() - val dialog = ActionsDialog() - if (closeableColumnList.size == 1) { - val column = closeableColumnList.first() - dialog.addAction(getString(R.string.close_column)) { - closeColumn(column, bConfirmed = true) + + // カラムが0個ならアプリを終了する + if (appState.columnCount == 0) { + finish() + return@launchAndShowError + } + + // カラム設定が開いているならカラム設定を閉じる + if (closeColumnSetting()) { + return@launchAndShowError + } + + fun getClosableColumnList(): List { + val visibleColumnList = ArrayList() + phoneTab({ env -> + try { + appState.column(env.pager.currentItem)?.addTo(visibleColumnList) + } catch (ex: Throwable) { + log.e(ex, "getClosableColumnList failed.") + } + }, { env -> + visibleColumnList.addAll(env.visibleColumns) + }) + + return visibleColumnList.filter { !it.dontClose } + } + + // カラムが1個以上ある場合は設定に合わせて挙動を変える + when (PrefI.ipBackButtonAction.value) { + PrefI.BACK_EXIT_APP -> finish() + PrefI.BACK_OPEN_COLUMN_LIST -> openColumnList() + PrefI.BACK_CLOSE_COLUMN -> { + val closeableColumnList = getClosableColumnList() + when (closeableColumnList.size) { + 0 -> when { + PrefB.bpExitAppWhenCloseProtectedColumn.value && + PrefB.bpDontConfirmBeforeCloseColumn.value -> + finish() + else -> showToast(false, R.string.missing_closeable_column) + } + 1 -> closeColumn(closeableColumnList.first()) + else -> showToast( + false, + R.string.cant_close_column_by_back_button_when_multiple_column_shown + ) } } - dialog.addAction(getString(R.string.open_column_list)) { openColumnList() } - dialog.addAction(getString(R.string.app_exit)) { finish() } - dialog.show(this, null) + /* PrefI.BACK_ASK_ALWAYS */ + else -> actionsDialog { + val closeableColumnList = getClosableColumnList() + if (closeableColumnList.size == 1) { + val column = closeableColumnList.first() + action(getString(R.string.close_column)) { + closeColumn(column, bConfirmed = true) + } + } + action(getString(R.string.open_column_list)) { openColumnList() } + action(getString(R.string.app_exit)) { finish() } + } } } } @@ -178,23 +186,23 @@ fun ActMain.onMyClickableSpanClickedImpl(viewClicked: View, span: MyClickableSpa ) } -fun ActMain.themeDefaultChangedDialog() { +suspend fun ActMain.themeDefaultChangedDialog() { val lpThemeDefaultChangedWarnTime = PrefL.lpThemeDefaultChangedWarnTime val ipUiTheme = PrefI.ipUiTheme val now = System.currentTimeMillis() // テーマが未定義でなければ警告しない - if (pref.getInt(ipUiTheme.key, -1) != -1) { + if (lazyPref.getInt(ipUiTheme.key, -1) != -1) { log.i("themeDefaultChangedDialog: theme was set.") return } // 頻繁には警告しない - if (now - lpThemeDefaultChangedWarnTime.invoke(pref) < TimeUnit.DAYS.toMillis(60L)) { + if (now - lpThemeDefaultChangedWarnTime.value < TimeUnit.DAYS.toMillis(60L)) { log.i("themeDefaultChangedDialog: avoid frequently check.") return } - pref.edit().put(lpThemeDefaultChangedWarnTime, now).apply() + lpThemeDefaultChangedWarnTime.value = now // 色がすべてデフォルトなら警告不要 val customizedKeys = ArrayList() @@ -202,18 +210,50 @@ fun ActMain.themeDefaultChangedDialog() { item.pref?.let { p -> when { p == PrefS.spBoostAlpha -> Unit - p.hasNonDefaultValue(pref) -> customizedKeys.add(p.key) + p.hasNonDefaultValue() -> customizedKeys.add(p.key) } } } - log.w("themeDefaultChangedDialog: customizedKeys=${customizedKeys.joinToString(",")}") if (customizedKeys.isEmpty()) { - pref.edit().put(ipUiTheme, ipUiTheme.defVal).apply() + ipUiTheme.value = ipUiTheme.defVal return } - - AlertDialog.Builder(this) - .setMessage(R.string.color_theme_changed) - .setPositiveButton(android.R.string.ok, null) - .show() + log.w("themeDefaultChangedDialog: customizedKeys=${customizedKeys.joinToString(",")}") + suspendCancellableCoroutine { cont -> + val dialog = AlertDialog.Builder(this) + .setMessage(R.string.color_theme_changed) + .setPositiveButton(android.R.string.ok, null) + .setOnDismissListener { + if (cont.isActive) cont.resume(Unit) {} + } + .create() + cont.invokeOnCancellation { dialog.dismissSafe() } + dialog.show() + } +} + +fun ActMain.launchDialogs() { + launchAndShowError { + // プライバシーポリシー + val agreed = try { + checkPrivacyPolicy() + } catch (ex: Throwable) { + log.e(ex, "checkPrivacyPolicy failed.") + return@launchAndShowError + } + // 同意がないなら残りの何かは表示しない + if (!agreed) return@launchAndShowError + + // テーマ告知 + themeDefaultChangedDialog() + + // 通知権限の確認 + if(!prNotification.checkOrLaunch()) return@launchAndShowError + + // Workの掃除 + WorkManager.getInstance(applicationContext).pruneWork() + + // 定期的にendpointを再登録したい + PushWorker.enqueueRegisterEndpoint(applicationContext, keepAliveMode = true) + } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainAfterPost.kt b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainAfterPost.kt index ce4bed0c..0d097e17 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainAfterPost.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainAfterPost.kt @@ -65,7 +65,7 @@ fun ActMain.refreshAfterPost() { this.postedRedraftId = null } - val refreshAfterToot = PrefI.ipRefreshAfterToot(pref) + val refreshAfterToot = PrefI.ipRefreshAfterToot.value if (refreshAfterToot != PrefI.RAT_DONT_REFRESH) { appState.columnList .filter { it.accessInfo.acct == postedAcct } diff --git a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainAutoCW.kt b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainAutoCW.kt index c5e156f9..185040a4 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainAutoCW.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainAutoCW.kt @@ -12,7 +12,7 @@ import java.lang.ref.WeakReference // AutoCWの基準幅を計算する fun ActMain.resizeAutoCW(columnW: Int) { - val sv = PrefS.spAutoCWLines(pref) + val sv = PrefS.spAutoCWLines.value nAutoCwLines = sv.toIntOrNull() ?: -1 if (nAutoCwLines > 0) { val lvPad = (0.5f + 12 * density).toInt() diff --git a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainColumns.kt b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainColumns.kt index ca80f93f..be632897 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainColumns.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainColumns.kt @@ -16,8 +16,8 @@ import jp.juggler.subwaytooter.columnviewholder.showColumnSetting import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefI import jp.juggler.subwaytooter.pref.PrefS -import jp.juggler.subwaytooter.table.AcctColor import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoAcctColor import jp.juggler.util.* import jp.juggler.util.data.clip import jp.juggler.util.log.LogCategory @@ -103,7 +103,7 @@ fun ActMain.addColumn( vararg params: Any, ): Column { return addColumn( - PrefB.bpAllowColumnDuplication(pref), + PrefB.bpAllowColumnDuplication.value, indexArg, ai, type, @@ -175,7 +175,7 @@ fun ActMain.updateColumnStrip() { viewRoot.tag = index viewRoot.setOnClickListener { v -> val idx = v.tag as Int - if (PrefB.bpScrollTopFromColumnStrip(pref) && isVisibleColumn(idx)) { + if (PrefB.bpScrollTopFromColumnStrip.value && isVisibleColumn(idx)) { column.viewHolder?.scrollToTop2() return@setOnClickListener } @@ -193,9 +193,9 @@ fun ActMain.updateColumnStrip() { ivIcon.imageTintList = ColorStateList.valueOf(column.getHeaderNameColor()) // - val ac = AcctColor.load(column.accessInfo) - if (AcctColor.hasColorForeground(ac)) { - vAcctColor.setBackgroundColor(ac.color_fg) + val ac = daoAcctColor.load(column.accessInfo) + if (daoAcctColor.hasColorForeground(ac)) { + vAcctColor.setBackgroundColor(ac.colorFg) } else { vAcctColor.visibility = View.INVISIBLE } @@ -214,7 +214,7 @@ fun ActMain.closeColumn(column: Column, bConfirmed: Boolean = false) { return } - if (!bConfirmed && !PrefB.bpDontConfirmBeforeCloseColumn(pref)) { + if (!bConfirmed && !PrefB.bpDontConfirmBeforeCloseColumn.value) { AlertDialog.Builder(this) .setMessage(R.string.confirm_close_column) .setNegativeButton(R.string.cancel, null) @@ -366,7 +366,7 @@ fun ActMain.scrollToColumn(index: Int, smoothScroll: Boolean = true) { fun ActMain.scrollToLastColumn() { if (appState.columnCount <= 0) return - val columnPos = PrefI.ipLastColumnPos(pref) + val columnPos = PrefI.ipLastColumnPos.value log.d("ipLastColumnPos load $columnPos") // 前回最後に表示していたカラムの位置にスクロールする @@ -385,7 +385,7 @@ fun ActMain.scrollToLastColumn() { fun ActMain.resizeColumnWidth(views: ActMainTabletViews) { var columnWMinDp = ActMain.COLUMN_WIDTH_MIN_DP - val sv = PrefS.spColumnWidth(pref) + val sv = PrefS.spColumnWidth.value if (sv.isNotEmpty()) { try { val iv = Integer.parseInt(sv) diff --git a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainIntent.kt b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainIntent.kt index 93216feb..dc8e800e 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainIntent.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainIntent.kt @@ -4,6 +4,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity import androidx.core.net.toUri import jp.juggler.subwaytooter.ActMain import jp.juggler.subwaytooter.R @@ -18,20 +19,35 @@ import jp.juggler.subwaytooter.api.entity.TootStatus.Companion.findStatusIdFromU import jp.juggler.subwaytooter.api.entity.TootVisibility import jp.juggler.subwaytooter.api.runApiTask2 import jp.juggler.subwaytooter.api.showApiError +import jp.juggler.subwaytooter.auth.authRepo import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.column.startLoading +import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.dialog.pickAccount +import jp.juggler.subwaytooter.dialog.runInProgress import jp.juggler.subwaytooter.notification.PushSubscriptionHelper import jp.juggler.subwaytooter.notification.checkNotificationImmediate import jp.juggler.subwaytooter.notification.checkNotificationImmediateAll import jp.juggler.subwaytooter.notification.recycleClickedNotification +import jp.juggler.subwaytooter.pref.PrefDevice +import jp.juggler.subwaytooter.pref.prefDevice +import jp.juggler.subwaytooter.push.PushWorker +import jp.juggler.subwaytooter.push.fcmHandler +import jp.juggler.subwaytooter.push.pushRepo import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoSavedAccount +import jp.juggler.util.coroutine.AppDispatchers +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.decodePercent import jp.juggler.util.data.groupEx import jp.juggler.util.log.LogCategory import jp.juggler.util.log.showToast import jp.juggler.util.queryIntentActivitiesCompat +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withContext +import org.unifiedpush.android.connector.UnifiedPush +import java.util.ArrayList private val log = LogCategory("ActMainIntent") @@ -154,21 +170,22 @@ fun ActMain.handleOtherUri(uri: Uri): Boolean { return false } -private fun ActMain.handleCustomSchemaUri(uri: Uri) { +private fun ActMain.handleCustomSchemaUri(uri: Uri) = launchAndShowError { val dataIdString = uri.getQueryParameter("db_id") - if (dataIdString != null) { - // subwaytooter://notification_click/?db_id=(db_id) - handleNotificationClick(uri, dataIdString) - } else { + if (dataIdString == null) { // OAuth2 認証コールバック // subwaytooter://oauth(\d*)/?... handleOAuth2Callback(uri) + } else { + // subwaytooter://notification_click/?db_id=(db_id) + handleNotificationClick(uri, dataIdString) } } private fun ActMain.handleNotificationClick(uri: Uri, dataIdString: String) { try { - val account = dataIdString.toLongOrNull()?.let { SavedAccount.loadAccount(this, it) } + val account = dataIdString.toLongOrNull() + ?.let { daoSavedAccount.loadAccount(it) } if (account == null) { showToast(true, "handleNotificationClick: missing SavedAccount. id=$dataIdString") return @@ -227,7 +244,7 @@ fun ActMain.afterAccountVerify(auth2Result: Auth2Result): Boolean = auth2Result. // 「アカウント追加のハズが既存アカウントで認証していた」 // 「アクセストークン更新のハズが別アカウントで認証していた」 // などを防止するため、full acctでアプリ内DBを検索 - when (val sa = SavedAccount.loadAccountByAcct(this@afterAccountVerify, newAcct.ascii)) { + when (val sa = daoSavedAccount.loadAccountByAcct(newAcct)) { null -> afterAccountAdd(newAcct, auth2Result) else -> afterAccessTokenUpdate(auth2Result, sa) } @@ -238,10 +255,10 @@ private fun ActMain.afterAccessTokenUpdate( sa: SavedAccount, ): Boolean { // DBの情報を更新する - sa.updateTokenInfo(auth2Result) + authRepo.updateTokenInfo(sa, auth2Result) // 各カラムの持つアカウント情報をリロードする - reloadAccountSetting() + reloadAccountSetting(daoSavedAccount.loadAccountList()) // 自動でリロードする appState.columnList @@ -252,6 +269,7 @@ private fun ActMain.afterAccessTokenUpdate( PushSubscriptionHelper.clearLastCheck(sa) checkNotificationImmediateAll(this, onlySubscription = true) checkNotificationImmediate(this, sa.db_id) + updatePushDistributer() showToast(false, R.string.access_token_updated_for, sa.acct.pretty) return true @@ -263,7 +281,7 @@ private fun ActMain.afterAccountAdd( ): Boolean { val ta = auth2Result.tootAccount - val rowId = SavedAccount.insert( + val rowId = daoSavedAccount.saveNew( acct = newAcct.ascii, host = auth2Result.apiHost.ascii, domain = auth2Result.apDomain.ascii, @@ -271,7 +289,7 @@ private fun ActMain.afterAccountAdd( token = auth2Result.tokenJson, misskeyVersion = auth2Result.tootInstance.misskeyVersionMajor, ) - val account = SavedAccount.loadAccount(applicationContext, rowId) + val account = daoSavedAccount.loadAccount(rowId) if (account == null) { showToast(false, "loadAccount failed.") return false @@ -298,13 +316,13 @@ private fun ActMain.afterAccountAdd( } if (bModified) { - account.saveSetting() + daoSavedAccount.saveSetting(account) } } // 適当にカラムを追加する addColumn(false, defaultInsertPosition, account, ColumnType.HOME) - if (SavedAccount.count == 1) { + if (daoSavedAccount.isSingleAccount()) { addColumn(false, defaultInsertPosition, account, ColumnType.NOTIFICATIONS) addColumn(false, defaultInsertPosition, account, ColumnType.LOCAL) addColumn(false, defaultInsertPosition, account, ColumnType.FEDERATE) @@ -313,6 +331,7 @@ private fun ActMain.afterAccountAdd( // 通知の更新が必要かもしれない checkNotificationImmediateAll(this, onlySubscription = true) checkNotificationImmediate(this, account.db_id) + updatePushDistributer() showToast(false, R.string.account_confirmed) return true } @@ -329,3 +348,79 @@ fun ActMain.handleSharedIntent(intent: Intent) { ai?.let { openActPostImpl(it.db_id, sharedIntent = intent) } } } + +// アカウントを追加/更新したらappServerHashの取得をやりなおす +fun ActMain.updatePushDistributer(){ + when { + fcmHandler.noFcm && prefDevice.pushDistributor.isNullOrEmpty() -> { + try { + selectPushDistributor() + // 選択したら + } catch (_: CancellationException) { + // 選択しなかった場合は購読の更新を行わない + } + } + else -> PushWorker.enqueueRegisterEndpoint(this) + } +} + +fun AppCompatActivity.selectPushDistributor() { + val context = this + launchAndShowError { + val prefDevice = prefDevice + val lastDistributor = prefDevice.pushDistributor + + fun String.appendChecked(checked: Boolean) = when (checked) { + true -> "$this ✅" + else -> this + } + + actionsDialog(getString(R.string.select_push_delivery_service)) { + if (fcmHandler.hasFcm) { + action( + getString(R.string.firebase_cloud_messaging) + .appendChecked(lastDistributor == PrefDevice.PUSH_DISTRIBUTOR_FCM) + ) { + runInProgress(cancellable = false) { reporter -> + withContext(AppDispatchers.DEFAULT) { + pushRepo.switchDistributor( + PrefDevice.PUSH_DISTRIBUTOR_FCM, + reporter = reporter + ) + } + } + } + } + for (packageName in UnifiedPush.getDistributors( + context, + features = ArrayList(listOf(UnifiedPush.FEATURE_BYTES_MESSAGE)) + )) { + action( + packageName.appendChecked(lastDistributor == packageName) + ) { + runInProgress(cancellable = false) { reporter -> + withContext(AppDispatchers.DEFAULT) { + pushRepo.switchDistributor( + packageName, + reporter = reporter + ) + } + } + } + } + action( + getString(R.string.none) + .appendChecked(lastDistributor == PrefDevice.PUSH_DISTRIBUTOR_NONE) + ) { + runInProgress(cancellable = false) { reporter -> + withContext(AppDispatchers.DEFAULT) { + pushRepo.switchDistributor( + PrefDevice.PUSH_DISTRIBUTOR_NONE, + reporter = reporter + ) + } + } + } + } + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainQuickPost.kt b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainQuickPost.kt index cc04952f..a1bdbc2c 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainQuickPost.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainQuickPost.kt @@ -28,11 +28,11 @@ val ActMain.quickPostText: String fun ActMain.initUIQuickPost() { etQuickPost.typeface = ActMain.timelineFont - if (!PrefB.bpQuickPostBar.invoke(pref)) { + if (!PrefB.bpQuickPostBar.value) { llQuickPostBar.visibility = View.GONE } - if (PrefB.bpDontUseActionButtonWithQuickPostBar(pref)) { + if (PrefB.bpDontUseActionButtonWithQuickPostBar.value) { etQuickPost.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE etQuickPost.imeOptions = EditorInfo.IME_ACTION_NONE // 最後に指定する必要がある? @@ -70,7 +70,7 @@ fun ActMain.initUIQuickPost() { fun ActMain.showQuickPostVisibility() { btnQuickPostMenu.imageResource = - when (val resId = getVisibilityIconId(false, quickPostVisibility)) { + when (val resId = quickPostVisibility.getVisibilityIconId(false)) { R.drawable.ic_question -> R.drawable.ic_description else -> resId } @@ -82,7 +82,7 @@ fun ActMain.toggleQuickPostMenu() { fun ActMain.performQuickPost(account: SavedAccount?) { if (account == null) { - val a = if (tabletViews != null && !PrefB.bpQuickTootOmitAccountSelection(pref)) { + val a = if (tabletViews != null && !PrefB.bpQuickTootOmitAccountSelection.value) { // タブレットモードでオプションが無効なら // 簡易投稿は常にアカウント選択する null diff --git a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainStyle.kt b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainStyle.kt index b60abbaf..cb7e1de6 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainStyle.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainStyle.kt @@ -1,5 +1,6 @@ package jp.juggler.subwaytooter.actmain +import android.content.Context import android.content.res.ColorStateList import android.graphics.Typeface import android.view.View @@ -8,15 +9,15 @@ import android.widget.LinearLayout import jp.juggler.subwaytooter.ActMain import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.api.entity.TootStatus -import jp.juggler.subwaytooter.stylerBoostAlpha import jp.juggler.subwaytooter.itemviewholder.ItemViewHolder import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefF import jp.juggler.subwaytooter.pref.PrefI import jp.juggler.subwaytooter.pref.PrefS import jp.juggler.subwaytooter.pref.impl.StringPref -import jp.juggler.subwaytooter.stylerRoundRatio import jp.juggler.subwaytooter.span.MyClickableSpan +import jp.juggler.subwaytooter.stylerBoostAlpha +import jp.juggler.subwaytooter.stylerRoundRatio import jp.juggler.subwaytooter.util.CustomShare import jp.juggler.subwaytooter.view.ListDivider import jp.juggler.util.data.clip @@ -31,12 +32,12 @@ import kotlin.math.max private val log = LogCategory("ActMainStyle") -private fun ActMain.dpToPx(dp: Float) = - (dp * density + 0.5f).toInt() +private fun Float.dpToPx(context: Context) = + (this * context.resources.displayMetrics.density + 0.5f).toInt() // initUIから呼ばれる -fun ActMain.reloadFonts() { - ActMain.timelineFont = PrefS.spTimelineFont(pref).notEmpty()?.let { +fun reloadFonts() { + ActMain.timelineFont = PrefS.spTimelineFont.value.notEmpty()?.let { try { Typeface.createFromFile(it) } catch (ex: Throwable) { @@ -45,7 +46,7 @@ fun ActMain.reloadFonts() { } } ?: Typeface.DEFAULT - ActMain.timelineFontBold = PrefS.spTimelineFontBold(pref).notEmpty()?.let { + ActMain.timelineFontBold = PrefS.spTimelineFontBold.value.notEmpty()?.let { try { Typeface.createFromFile(it) } catch (ex: Throwable) { @@ -61,16 +62,14 @@ fun ActMain.reloadFonts() { } private fun ActMain.parseIconSize(stringPref: StringPref, minDp: Float = 1f) = - dpToPx( - try { - stringPref(pref) - .toFloatOrNull() - ?.takeIf { it.isFinite() && it >= minDp } - } catch (ex: Throwable) { - log.e(ex, "parseIconSize failed.") - null - } ?: stringPref.defVal.toFloat() - ) + (try { + stringPref.value + .toFloatOrNull() + ?.takeIf { it.isFinite() && it >= minDp } + } catch (ex: Throwable) { + log.e(ex, "parseIconSize failed.") + null + } ?: stringPref.defVal.toFloat()).dpToPx(this) // initUIから呼ばれる fun ActMain.reloadIconSize() { @@ -82,7 +81,7 @@ fun ActMain.reloadIconSize() { ActMain.stripIconSize = parseIconSize(PrefS.spStripIconSize) ActMain.screenBottomPadding = parseIconSize(PrefS.spScreenBottomPadding, minDp = 0f) - ActMain.eventFadeAlpha = PrefS.spEventTextAlpha() + ActMain.eventFadeAlpha = PrefS.spEventTextAlpha.value .toFloatOrNull() ?.takeIf { it.isFinite() } ?.clip(0f, 1f) @@ -90,10 +89,10 @@ fun ActMain.reloadIconSize() { } // initUIから呼ばれる -fun ActMain.reloadRoundRatio() { +fun reloadRoundRatio() { val sizeDp = when { - PrefB.bpDontRound(pref) -> 0f - else -> PrefS.spRoundRatio(pref) + PrefB.bpDontRound.value -> 0f + else -> PrefS.spRoundRatio.value .toFloatOrNull() ?.takeIf { it.isFinite() } ?: 33f @@ -102,8 +101,8 @@ fun ActMain.reloadRoundRatio() { } // initUI から呼ばれる -fun ActMain.reloadBoostAlpha() { - stylerBoostAlpha = PrefS.spBoostAlpha(pref) +fun reloadBoostAlpha() { + stylerBoostAlpha = PrefS.spBoostAlpha.value .toIntOrNull() ?.toFloat() ?.let { (it + 0.5f) / 100f } @@ -112,36 +111,35 @@ fun ActMain.reloadBoostAlpha() { } fun ActMain.reloadMediaHeight() { - appState.mediaThumbHeight = dpToPx( - PrefS.spMediaThumbHeight(pref) - .toFloatOrNull() - ?.takeIf { it >= 32f } - ?: 64f - ) + appState.mediaThumbHeight = ( + PrefS.spMediaThumbHeight.value + .toFloatOrNull() + ?.takeIf { it >= 32f } + ?: 64f + ).dpToPx(this) } private fun Float.clipFontSize(): Float = if (isNaN()) this else max(1f, this) fun ActMain.reloadTextSize() { - timelineFontSizeSp = PrefF.fpTimelineFontSize.invoke(pref).clipFontSize() - acctFontSizeSp = PrefF.fpAcctFontSize(pref).clipFontSize() - notificationTlFontSizeSp = PrefF.fpNotificationTlFontSize(pref).clipFontSize() - headerTextSizeSp = PrefF.fpHeaderTextSize(pref).clipFontSize() - val fv = PrefS.spTimelineSpacing(pref).toFloatOrNull() + timelineFontSizeSp = PrefF.fpTimelineFontSize.value.clipFontSize() + acctFontSizeSp = PrefF.fpAcctFontSize.value.clipFontSize() + notificationTlFontSizeSp = PrefF.fpNotificationTlFontSize.value.clipFontSize() + headerTextSizeSp = PrefF.fpHeaderTextSize.value.clipFontSize() + val fv = PrefS.spTimelineSpacing.value.toFloatOrNull() timelineSpacing = if (fv != null && fv.isFinite() && fv != 0f) fv else null } fun ActMain.loadColumnMin() = - dpToPx( - PrefS.spColumnWidth(pref) - .toFloatOrNull() - ?.takeIf { it.isFinite() && it >= 100f } - ?: ActMain.COLUMN_WIDTH_MIN_DP.toFloat() - ) + (PrefS.spColumnWidth.value + .toFloatOrNull() + ?.takeIf { it.isFinite() && it >= 100f } + ?: ActMain.COLUMN_WIDTH_MIN_DP.toFloat() + ).dpToPx(this) fun ActMain.justifyWindowContentPortrait() { - when (PrefI.ipJustifyWindowContentPortrait(pref)) { + when (PrefI.ipJustifyWindowContentPortrait.value) { PrefI.JWCP_START -> { val iconW = (ActMain.stripIconSize * 1.5f + 0.5f).toInt() val padding = resources.displayMetrics.widthPixels / 2 - iconW @@ -161,7 +159,7 @@ fun ActMain.justifyWindowContentPortrait() { PrefI.JWCP_END -> { val iconW = (ActMain.stripIconSize * 1.5f + 0.5f).toInt() - val borderWidth = dpToPx(1f) + val borderWidth = 1f.dpToPx(this) val padding = resources.displayMetrics.widthPixels / 2 - iconW - borderWidth fun ViewGroup.addViewAfterFirst(v: View) = addView(v, 1) @@ -182,10 +180,10 @@ fun ActMain.justifyWindowContentPortrait() { ////////////////////////////////////////////////////// // onStart時に呼ばれる -fun ActMain.reloadTimeZone() { +fun reloadTimeZone() { try { var tz = TimeZone.getDefault() - val tzId = PrefS.spTimeZone(pref) + val tzId = PrefS.spTimeZone.value if (tzId.isNotEmpty()) { tz = TimeZone.getTimeZone(tzId) } @@ -199,25 +197,25 @@ fun ActMain.reloadTimeZone() { // onStart時に呼ばれる // カラーカスタマイズを読み直す fun ActMain.reloadColors() { - ListDivider.color = PrefI.ipListDividerColor(pref) - TabletColumnDivider.color = PrefI.ipListDividerColor(pref) - ItemViewHolder.toot_color_unlisted = PrefI.ipTootColorUnlisted(pref) - ItemViewHolder.toot_color_follower = PrefI.ipTootColorFollower(pref) - ItemViewHolder.toot_color_direct_user = PrefI.ipTootColorDirectUser(pref) - ItemViewHolder.toot_color_direct_me = PrefI.ipTootColorDirectMe(pref) - MyClickableSpan.showLinkUnderline = PrefB.bpShowLinkUnderline(pref) - MyClickableSpan.defaultLinkColor = PrefI.ipLinkColor(pref).notZero() + ListDivider.color = PrefI.ipListDividerColor.value + TabletColumnDivider.color = PrefI.ipListDividerColor.value + ItemViewHolder.toot_color_unlisted = PrefI.ipTootColorUnlisted.value + ItemViewHolder.toot_color_follower = PrefI.ipTootColorFollower.value + ItemViewHolder.toot_color_direct_user = PrefI.ipTootColorDirectUser.value + ItemViewHolder.toot_color_direct_me = PrefI.ipTootColorDirectMe.value + MyClickableSpan.showLinkUnderline = PrefB.bpShowLinkUnderline.value + MyClickableSpan.defaultLinkColor = PrefI.ipLinkColor.value.notZero() ?: attrColor(R.attr.colorLink) CustomShare.reloadCache(this) } fun ActMain.showFooterColor() { - val footerButtonBgColor = PrefI.ipFooterButtonBgColor(pref) - val footerButtonFgColor = PrefI.ipFooterButtonFgColor(pref) - val footerTabBgColor = PrefI.ipFooterTabBgColor(pref) - val footerTabDividerColor = PrefI.ipFooterTabDividerColor(pref) - val footerTabIndicatorColor = PrefI.ipFooterTabIndicatorColor(pref) + val footerButtonBgColor = PrefI.ipFooterButtonBgColor.value + val footerButtonFgColor = PrefI.ipFooterButtonFgColor.value + val footerTabBgColor = PrefI.ipFooterTabBgColor.value + val footerTabDividerColor = PrefI.ipFooterTabDividerColor.value + val footerTabIndicatorColor = PrefI.ipFooterTabIndicatorColor.value val colorColumnStripBackground = footerTabBgColor.notZero() ?: attrColor(R.attr.colorColumnStripBackground) diff --git a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainSwitchUI.kt b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainSwitchUI.kt index 19a10052..84366f21 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainSwitchUI.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainSwitchUI.kt @@ -3,18 +3,23 @@ package jp.juggler.subwaytooter.actmain import android.view.View import androidx.recyclerview.widget.RecyclerView import jp.juggler.subwaytooter.ActMain -import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.R +import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.view.MyViewPager // スマホモードならラムダを実行する。タブレットモードならnullを返す -inline fun ActMain.phoneOnly(code: (ActMainPhoneViews) -> R): R? = phoneViews?.let { code(it) } +inline fun ActMain.phoneOnly(code: (ActMainPhoneViews) -> R): R? = + phoneViews?.let { code(it) } // タブレットモードならラムダを実行する。スマホモードならnullを返す -inline fun ActMain.tabOnly(code: (ActMainTabletViews) -> R): R? = tabletViews?.let { code(it) } +inline fun ActMain.tabOnly(code: (ActMainTabletViews) -> R): R? = + tabletViews?.let { code(it) } // スマホモードとタブレットモードでコードを切り替える -inline fun ActMain.phoneTab(codePhone: (ActMainPhoneViews) -> R, codeTablet: (ActMainTabletViews) -> R): R { +inline fun ActMain.phoneTab( + codePhone: (ActMainPhoneViews) -> R, + codeTablet: (ActMainTabletViews) -> R, +): R { phoneViews?.let { return codePhone(it) } tabletViews?.let { return codeTablet(it) } error("missing phoneViews/tabletViews") @@ -27,7 +32,7 @@ fun ActMain.initPhoneTablet() { val tmpTabletPager: RecyclerView = findViewById(R.id.rvPager) // スマホモードとタブレットモードの切り替え - if (PrefB.bpDisableTabletMode(pref) || sw < columnWMin * 2) { + if (PrefB.bpDisableTabletMode.value || sw < columnWMin * 2) { tmpTabletPager.visibility = View.GONE phoneViews = ActMainPhoneViews(this).apply { initUI(tmpPhonePager) diff --git a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainTabletViews.kt b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainTabletViews.kt index f17a51dd..aa327cae 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainTabletViews.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainTabletViews.kt @@ -92,7 +92,7 @@ class ActMainTabletViews(val actMain: ActMain) { // if( animator is DefaultItemAnimator){ // animator.supportsChangeAnimations = false // } - if (PrefB.bpTabletSnap()) { + if (PrefB.bpTabletSnap.value) { GravitySnapHelper(Gravity.START).attachToRecyclerView(this.tabletPager) } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/actmain/SideMenuAdapter.kt b/app/src/main/java/jp/juggler/subwaytooter/actmain/SideMenuAdapter.kt index 10f42e74..47f95e99 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actmain/SideMenuAdapter.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actmain/SideMenuAdapter.kt @@ -28,11 +28,12 @@ import jp.juggler.subwaytooter.dialog.pickAccount import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefS import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.accountListCanSeeMyReactions import jp.juggler.subwaytooter.util.VersionString import jp.juggler.subwaytooter.util.openBrowser import jp.juggler.util.coroutine.AppDispatchers +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchIO -import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.JsonObject import jp.juggler.util.data.decodeJsonObject import jp.juggler.util.data.decodeUTF8 @@ -120,7 +121,7 @@ class SideMenuAdapter( ) ) val newRelease = releaseInfo?.jsonObject( - if (PrefB.bpCheckBetaVersion()) "beta" else "stable" + if (PrefB.bpCheckBetaVersion.value) "beta" else "stable" ) // 使用中のアプリバージョンより新しいリリースがある? @@ -327,7 +328,7 @@ class SideMenuAdapter( timeline(defaultInsertPosition, ColumnType.BOOKMARKS) }, Item(icon = R.drawable.ic_face, title = R.string.reactioned_posts) { - launchMain { + launchAndShowError { accountListCanSeeMyReactions()?.let { list -> if (list.isEmpty()) { showToast(false, R.string.not_available_for_current_accounts) @@ -518,7 +519,7 @@ class SideMenuAdapter( private fun getTimeZoneString(context: Context): String { try { var tz = TimeZone.getDefault() - val tzId = PrefS.spTimeZone() + val tzId = PrefS.spTimeZone.value if (tzId.isBlank()) { return tz.displayName + "(" + context.getString(R.string.device_timezone) + ")" } diff --git a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAccount.kt b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAccount.kt index fd8a2b36..cfdb6dbe 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAccount.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAccount.kt @@ -5,8 +5,10 @@ import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.api.entity.TootVisibility import jp.juggler.subwaytooter.dialog.pickAccount -import jp.juggler.subwaytooter.table.AcctColor import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoAcctColor +import jp.juggler.subwaytooter.table.daoSavedAccount +import jp.juggler.subwaytooter.table.sortedByNickname import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.notZero import jp.juggler.util.log.LogCategory @@ -33,17 +35,17 @@ fun ActPost.selectAccount(a: SavedAccount?) { views.spLanguage.setSelection(max(0, languages.indexOfFirst { it.first == a.lang })) - val ac = AcctColor.load(a) + val ac = daoAcctColor.load(a) views.btnAccount.text = ac.nickname - if (AcctColor.hasColorBackground(ac)) { + if (daoAcctColor.hasColorBackground(ac)) { views.btnAccount.background = - getAdaptiveRippleDrawableRound(this, ac.color_bg, ac.color_fg) + getAdaptiveRippleDrawableRound(this, ac.colorBg, ac.colorFg) } else { views.btnAccount.setBackgroundResource(R.drawable.btn_bg_transparent_round6dp) } - views.btnAccount.textColor = ac.color_fg.notZero() + views.btnAccount.textColor = ac.colorFg.notZero() ?: attrColor(android.R.attr.textColorPrimary) } updateTextCount() @@ -75,8 +77,7 @@ fun ActPost.performAccountChooser() { if (!canSwitchAccount()) return if (isMultiWindowPost) { - accountList = SavedAccount.loadAccountList(this) - SavedAccount.sort(accountList) + accountList = daoSavedAccount.loadAccountList().sortedByNickname() } launchMain { diff --git a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAttachment.kt b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAttachment.kt index b0b477f0..30387d43 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAttachment.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAttachment.kt @@ -12,13 +12,14 @@ import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.runApiTask import jp.juggler.subwaytooter.calcIconRound import jp.juggler.subwaytooter.defaultColorIcon -import jp.juggler.subwaytooter.dialog.ActionsDialog import jp.juggler.subwaytooter.dialog.DlgFocusPoint import jp.juggler.subwaytooter.dialog.DlgTextInput +import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.util.AttachmentRequest import jp.juggler.subwaytooter.util.PostAttachment import jp.juggler.subwaytooter.view.MyNetworkImageView +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory @@ -169,7 +170,7 @@ fun ActPost.onPostAttachmentCompleteImpl(pa: PostAttachment) { log.i("onPostAttachmentComplete: upload complete.") // 投稿欄の末尾に追記する - if (PrefB.bpAppendAttachmentUrlToContent.invoke(pref)) { + if (PrefB.bpAppendAttachmentUrlToContent.value) { appendArrachmentUrl(a) } } @@ -215,35 +216,33 @@ fun ActPost.performAttachmentClick(idx: Int) { showToast(false, ex.withCaption("can't get attachment item[$idx].")) return } - - val a = ActionsDialog() - .addAction(getString(R.string.set_description)) { - editAttachmentDescription(pa) - } - - if (pa.attachment?.canFocus == true) { - a.addAction(getString(R.string.set_focus_point)) { - openFocusPoint(pa) - } - } - if (account?.isMastodon == true) { - when (pa.attachment?.type) { - TootAttachmentType.Audio, - TootAttachmentType.GIFV, - TootAttachmentType.Video, - -> a.addAction(getString(R.string.custom_thumbnail)) { - attachmentPicker.openCustomThumbnail(pa) + launchAndShowError { + actionsDialog(getString(R.string.media_attachment)) { + action(getString(R.string.set_description)) { + editAttachmentDescription(pa) } + if (pa.attachment?.canFocus == true) { + action(getString(R.string.set_focus_point)) { + openFocusPoint(pa) + } + } + if (account?.isMastodon == true) { + when (pa.attachment?.type) { + TootAttachmentType.Audio, + TootAttachmentType.GIFV, + TootAttachmentType.Video, + -> action(getString(R.string.custom_thumbnail)) { + attachmentPicker.openCustomThumbnail(pa) + } - else -> Unit + else -> Unit + } + } + action(getString(R.string.delete)) { + deleteAttachment(pa) + } } } - - a.addAction(getString(R.string.delete)) { - deleteAttachment(pa) - } - - a.show(this, title = getString(R.string.media_attachment)) } fun ActPost.deleteAttachment(pa: PostAttachment) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostDrafts.kt b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostDrafts.kt index 1d35deba..78d08a50 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostDrafts.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostDrafts.kt @@ -9,8 +9,9 @@ import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.dialog.DlgDraftPicker -import jp.juggler.subwaytooter.table.PostDraft import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoPostDraft +import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.util.DecodeOptions import jp.juggler.subwaytooter.util.PostAttachment import jp.juggler.util.coroutine.launchProgress @@ -129,7 +130,7 @@ fun ActPost.saveDraft() { states.visibility?.id?.toString()?.let { json.put(DRAFT_VISIBILITY, it) } states.inReplyToId?.putTo(json, DRAFT_REPLY_ID) - PostDraft.save(System.currentTimeMillis(), json) + daoPostDraft.save(System.currentTimeMillis(), json) } catch (ex: Throwable) { log.e(ex, "saveDraft failed.") } @@ -152,7 +153,7 @@ fun ActPost.restoreDraft(draft: JsonObject) { draft.jsonArray(DRAFT_ATTACHMENT_LIST)?.objectList()?.toMutableList() val accountDbId = draft.long(DRAFT_ACCOUNT_DB_ID) ?: -1L - val account = SavedAccount.loadAccount(this@restoreDraft, accountDbId) + val account = daoSavedAccount.loadAccount(accountDbId) if (account == null) { listWarning.add(getString(R.string.account_in_draft_is_lost)) try { diff --git a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostExtra.kt b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostExtra.kt index 94d54ba6..fbfb7974 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostExtra.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostExtra.kt @@ -1,7 +1,6 @@ package jp.juggler.subwaytooter.actpost import android.content.Intent -import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import jp.juggler.subwaytooter.ActMain import jp.juggler.subwaytooter.ActPost @@ -11,16 +10,20 @@ import jp.juggler.subwaytooter.actmain.onCompleteActPost import jp.juggler.subwaytooter.api.entity.TootPollsType import jp.juggler.subwaytooter.api.entity.TootVisibility import jp.juggler.subwaytooter.api.entity.unknownHostAndDomain -import jp.juggler.subwaytooter.dialog.ActionsDialog +import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm +import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.pref.PrefB -import jp.juggler.subwaytooter.table.PostDraft import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoPostDraft +import jp.juggler.subwaytooter.table.daoSavedAccount +import jp.juggler.subwaytooter.table.sortedByNickname import jp.juggler.subwaytooter.util.DecodeOptions import jp.juggler.subwaytooter.util.PostAttachment import jp.juggler.subwaytooter.util.PostImpl import jp.juggler.subwaytooter.util.PostResult import jp.juggler.util.* import jp.juggler.util.coroutine.launchAndShowError +import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.CharacterGroup import jp.juggler.util.log.LogCategory import jp.juggler.util.log.showToast @@ -104,27 +107,27 @@ fun ActPost.hasContent(): Boolean { } fun ActPost.resetText() { - isPostComplete = false + launchMain { + isPostComplete = false - resetReply() + resetReply() - resetMushroom() - states.redraftStatusId = null - states.editStatusId = null - states.timeSchedule = 0L - attachmentPicker.reset() - scheduledStatus = null - attachmentList.clear() - views.cbQuote.isChecked = false - views.etContent.setText("") - views.spPollType.setSelection(0, false) - etChoices.forEach { it.setText("") } - accountList = SavedAccount.loadAccountList(this) - SavedAccount.sort(accountList) - if (accountList.isEmpty()) { - showToast(true, R.string.please_add_account) - finish() - return + resetMushroom() + states.redraftStatusId = null + states.editStatusId = null + states.timeSchedule = 0L + attachmentPicker.reset() + scheduledStatus = null + attachmentList.clear() + views.cbQuote.isChecked = false + views.etContent.setText("") + views.spPollType.setSelection(0, false) + etChoices.forEach { it.setText("") } + accountList = daoSavedAccount.loadAccountList().sortedByNickname() + if (accountList.isEmpty()) { + showToast(true, R.string.please_add_account) + finish() + } } } @@ -148,28 +151,18 @@ fun ActPost.afterUpdateText() { } // 初期化時と投稿完了時とリセット確認後に呼ばれる -fun ActPost.updateText( +suspend fun ActPost.updateText( intent: Intent, - confirmed: Boolean = false, saveDraft: Boolean = true, resetAccount: Boolean = true, ) { if (!canSwitchAccount()) return - if (!confirmed && hasContent()) { - AlertDialog.Builder(this) - .setMessage("編集中のテキストや文脈を下書きに退避して、新しい投稿を編集しますか? ") - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok) { _, _ -> - updateText(intent, confirmed = true) - } - .setCancelable(true) - .show() - return + if (saveDraft && hasContent()) { + confirm(R.string.post_reset_confirm) + saveDraft() } - if (saveDraft) saveDraft() - resetText() // Android 9 から、明示的にフォーカスを当てる必要がある @@ -262,7 +255,7 @@ fun ActPost.initializeFromSharedIntent(sharedIntent: Intent) { else -> false } - if (!hasUri || !PrefB.bpIgnoreTextInSharedMedia(pref)) { + if (!hasUri || !PrefB.bpIgnoreTextInSharedMedia.value) { appendContentText(sharedIntent) } } catch (ex: Throwable) { @@ -271,33 +264,33 @@ fun ActPost.initializeFromSharedIntent(sharedIntent: Intent) { } fun ActPost.performMore() { - val dialog = ActionsDialog() + launchAndShowError { + actionsDialog { + action(getString(R.string.open_picker_emoji)) { + completionHelper.openEmojiPickerFromMore() + } - dialog.addAction(getString(R.string.open_picker_emoji)) { - completionHelper.openEmojiPickerFromMore() + action(getString(R.string.clear_text)) { + views.etContent.setText("") + views.etContentWarning.setText("") + } + + action(getString(R.string.clear_text_and_media)) { + views.etContent.setText("") + views.etContentWarning.setText("") + attachmentList.clear() + showMediaAttachment() + } + + if (daoPostDraft.hasDraft()) action(getString(R.string.restore_draft)) { + openDraftPicker() + } + + action(getString(R.string.recommended_plugin)) { + showRecommendedPlugin(null) + } + } } - - dialog.addAction(getString(R.string.clear_text)) { - views.etContent.setText("") - views.etContentWarning.setText("") - } - - dialog.addAction(getString(R.string.clear_text_and_media)) { - views.etContent.setText("") - views.etContentWarning.setText("") - attachmentList.clear() - showMediaAttachment() - } - - if (PostDraft.hasDraft()) dialog.addAction(getString(R.string.restore_draft)) { - openDraftPicker() - } - - dialog.addAction(getString(R.string.recommended_plugin)) { - showRecommendedPlugin(null) - } - - dialog.show(this, null) } fun ActPost.performPost() { @@ -366,7 +359,7 @@ fun ActPost.performPost() { if (isMultiWindowPost) { resetText() - updateText(Intent(), confirmed = true, saveDraft = false, resetAccount = false) + updateText(Intent(), saveDraft = false, resetAccount = false) afterUpdateText() } else { // ActMainの復元が必要な場合に備えてintentのdataでも渡す @@ -382,7 +375,7 @@ fun ActPost.performPost() { if (isMultiWindowPost) { resetText() - updateText(Intent(), confirmed = true, saveDraft = false, resetAccount = false) + updateText(Intent(), saveDraft = false, resetAccount = false) afterUpdateText() ActMain.refActMain?.get()?.onCompleteActPost(data) } else { diff --git a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostVisibility.kt b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostVisibility.kt index 6da66da2..46c6b7da 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostVisibility.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostVisibility.kt @@ -10,10 +10,8 @@ import jp.juggler.subwaytooter.getVisibilityCaption import jp.juggler.subwaytooter.getVisibilityIconId fun ActPost.showVisibility() { - val iconId = getVisibilityIconId( - account?.isMisskey == true, - states.visibility ?: TootVisibility.Public - ) + val iconId = (states.visibility ?: TootVisibility.Public) + .getVisibilityIconId(account?.isMisskey == true) views.btnVisibility.setImageResource(iconId) } diff --git a/app/src/main/java/jp/juggler/subwaytooter/actpost/CompletionHelper.kt b/app/src/main/java/jp/juggler/subwaytooter/actpost/CompletionHelper.kt index 130309fe..75eda033 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actpost/CompletionHelper.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actpost/CompletionHelper.kt @@ -1,6 +1,5 @@ package jp.juggler.subwaytooter.actpost -import android.content.SharedPreferences import android.os.Handler import android.text.* import android.text.style.ForegroundColorSpan @@ -9,21 +8,20 @@ import androidx.appcompat.app.AppCompatActivity import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.api.entity.TootTag -import jp.juggler.subwaytooter.dialog.ActionsDialog +import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.dialog.launchEmojiPicker import jp.juggler.subwaytooter.emoji.CustomEmoji import jp.juggler.subwaytooter.emoji.EmojiBase import jp.juggler.subwaytooter.emoji.UnicodeEmoji import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.span.NetworkEmojiSpan -import jp.juggler.subwaytooter.table.AcctSet -import jp.juggler.subwaytooter.table.SavedAccount -import jp.juggler.subwaytooter.table.TagSet +import jp.juggler.subwaytooter.table.* import jp.juggler.subwaytooter.util.DecodeOptions import jp.juggler.subwaytooter.util.EmojiDecoder import jp.juggler.subwaytooter.util.PopupAutoCompleteAcct import jp.juggler.subwaytooter.view.MyEditText import jp.juggler.util.* +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.asciiRegex import jp.juggler.util.log.LogCategory import jp.juggler.util.ui.attrColor @@ -33,7 +31,6 @@ import kotlin.math.min // 入力補完機能 class CompletionHelper( private val activity: AppCompatActivity, - private val pref: SharedPreferences, private val handler: Handler, ) { companion object { @@ -161,7 +158,7 @@ class CompletionHelper( val limit = 100 val s = src.substring(start, end) - val acctList = AcctSet.searchPrefix(s, limit) + val acctList = daoAcctSet.searchPrefix(s, limit) log.d("search for $s, result=${acctList.size}") if (acctList.isEmpty()) { closeAcctPopup() @@ -187,7 +184,7 @@ class CompletionHelper( val limit = 100 val s = src.substring(lastSharp + 1, end) - val tagList = TagSet.searchPrefix(s, limit) + val tagList = daoTagHistory.searchPrefix(s, limit) log.d("search for $s, result=${tagList.size}") if (tagList.isEmpty()) { closeAcctPopup() @@ -448,7 +445,7 @@ class CompletionHelper( launchEmojiPicker( activity, accessInfo, - closeOnSelected = PrefB.bpEmojiPickerCloseOnSelected(pref) + closeOnSelected = PrefB.bpEmojiPickerCloseOnSelected.value ) { emoji, bInstanceHasCustomEmoji -> val et = this@CompletionHelper.et ?: return@launchEmojiPicker @@ -482,7 +479,7 @@ class CompletionHelper( launchEmojiPicker( activity, accessInfo, - closeOnSelected = PrefB.bpEmojiPickerCloseOnSelected(pref) + closeOnSelected = PrefB.bpEmojiPickerCloseOnSelected.value ) { emoji, bInstanceHasCustomEmoji -> val et = this@CompletionHelper.et ?: return@launchEmojiPicker @@ -514,49 +511,50 @@ class CompletionHelper( } fun openFeaturedTagList(list: List?) { - val ad = ActionsDialog() - list?.forEach { tag -> - ad.addAction("#${tag.name}") { - val et = this.et ?: return@addAction + val et = this@CompletionHelper.et ?: return + activity.run { + launchAndShowError { + actionsDialog(getString(R.string.featured_hashtags)) { + list?.forEach { tag -> + action("#${tag.name}") { + val src = et.text ?: "" + val srcLength = src.length + val start = min(srcLength, et.selectionStart) + val end = min(srcLength, et.selectionEnd) - val src = et.text ?: "" - val srcLength = src.length - val start = min(srcLength, et.selectionStart) - val end = min(srcLength, et.selectionEnd) + val sb = SpannableStringBuilder() + .append(src.subSequence(0, start)) + .appendHashTag(tag.name) + val newSelection = sb.length + if (end < srcLength) sb.append(src.subSequence(end, srcLength)) - val sb = SpannableStringBuilder() - .append(src.subSequence(0, start)) - .appendHashTag(tag.name) - val newSelection = sb.length - if (end < srcLength) sb.append(src.subSequence(end, srcLength)) + et.text = sb + et.setSelection(newSelection) - et.text = sb - et.setSelection(newSelection) + procTextChanged.run() + } + } + action(activity.getString(R.string.input_sharp_itself)) { + val src = et.text ?: "" + val srcLength = src.length + val start = min(srcLength, et.selectionStart) + val end = min(srcLength, et.selectionEnd) - procTextChanged.run() + val sb = SpannableStringBuilder() + sb.append(src.subSequence(0, start)) + if (!EmojiDecoder.canStartHashtag(sb, sb.length)) sb.append(' ') + sb.append('#') + + val newSelection = sb.length + if (end < srcLength) sb.append(src.subSequence(end, srcLength)) + et.text = sb + et.setSelection(newSelection) + + procTextChanged.run() + } + } } } - ad.addAction(activity.getString(R.string.input_sharp_itself)) { - val et = this.et ?: return@addAction - - val src = et.text ?: "" - val srcLength = src.length - val start = min(srcLength, et.selectionStart) - val end = min(srcLength, et.selectionEnd) - - val sb = SpannableStringBuilder() - sb.append(src.subSequence(0, start)) - if (!EmojiDecoder.canStartHashtag(sb, sb.length)) sb.append(' ') - sb.append('#') - - val newSelection = sb.length - if (end < srcLength) sb.append(src.subSequence(end, srcLength)) - et.text = sb - et.setSelection(newSelection) - - procTextChanged.run() - } - ad.show(activity, activity.getString(R.string.featured_hashtags)) } // final ActionMode.Callback action_mode_callback = new ActionMode.Callback() { diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/ApiUtils2.kt b/app/src/main/java/jp/juggler/subwaytooter/api/ApiUtils2.kt new file mode 100644 index 00000000..2344e3e4 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/api/ApiUtils2.kt @@ -0,0 +1,206 @@ +package jp.juggler.subwaytooter.api + +import jp.juggler.util.coroutine.AppDispatchers +import jp.juggler.util.data.* +import jp.juggler.util.log.LogCategory +import jp.juggler.util.log.withCaption +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.internal.closeQuietly +import ru.gildor.coroutines.okhttp.await +import java.io.IOException + +private val log = LogCategory("ApiUtils2") + +const val JSON_SERVER_TYPE = "<>serverType" +const val SERVER_MISSKEY = "misskey" +const val SERVER_MASTODON = "mastodon" + +val DEFAULT_JSON_ERROR_PARSER = + { json: JsonObject -> json["error"]?.toString() } + +private val reWhiteSpace = """\s+""".toRegex() +private val reStartJsonArray = """\A\s*\[""".toRegex() +private val reStartJsonObject = """\A\s*\{""".toRegex() + +fun Request.Builder.authorizationBearer(token: String?) = + apply { token.notEmpty()?.let { header("Authorization", "Bearer $it") } } + +class ApiError( + message: String, + cause: Throwable? = null, + val response: Response? = null, +) : IOException(message, cause) + +private fun Response.formatError(caption: String? = null) = when { + caption.isNullOrBlank() -> "HTTP $code $message ${request.method} ${request.url}" + else -> "$caption: HTTP $code $message ${request.method} ${request.url}" +} + +private fun Request.formatError(ex: Throwable, caption: String? = null) = when { + caption.isNullOrBlank() -> "${ex.withCaption()} $method $url" + else -> "$caption: ${ex.withCaption()} $method $url" +} + +/** + * 応答ボディのHTMLやテキストを整形する + */ +private fun simplifyErrorHtml(body: String): String { +// // JsonObjectとして解釈できるならエラーメッセージを検出する +// try { +// val json = body.decodeJsonObject() +// jsonErrorParser(json)?.notEmpty()?.let { return it } +// } catch (_: Throwable) { +// } + +// // HTMLならタグの除去を試みる +// try { +// val ct = response.body?.contentType() +// if (ct?.subtype == "html") { +// // XXX HTMLデコードを省略 +// return reWhiteSpace.replace(body," ").trim() +// } +// } catch (_: Throwable) { +// } + + // XXX: Amazon S3 が403を返した場合にcontent-typeが?/xmlでserverがAmazonならXMLをパースしてエラーを整形することもできるが、多分必要ない + + // 通常テキストの空白や改行を整理した文字列を返す + try { + return reWhiteSpace.replace(body, " ").trim() + } catch (_: Throwable) { + } + + // 全部失敗したら入力そのまま + return body +} + +/** + * エラー応答のステータス部分や本文を文字列にする + */ +fun parseErrorResponse(response: Response, body: String? = null): String = + try { + val request = response.request + StringBuilder().apply { + // 応答ボディのテキストがあれば追加 + if (body.isNullOrBlank()) { + append("(missing response body)") + } else { + append(simplifyErrorHtml(body)) + } + if (isNotEmpty()) append(' ') + append("(HTTP ").append(response.code.toString()) + response.message.notBlank()?.let { message -> + append(' ') + append(message) + } + append(") ${request.method} ${request.url}") + }.toString().replace("""[\x0d\x0a]+""".toRegex(), "\n") + } catch (ex: Throwable) { + log.e(ex, "parseErrorResponse failed.") + "(can't parse response body)" + } + +suspend fun Request.await(okHttp: OkHttpClient) = + try { + okHttp.newCall(this).await() + } catch (ex: Throwable) { + throw ApiError(cause = ex, message = this.formatError(ex)) + } + +/** + * レスポンスボディを文字列として読む + * ボディがない場合はnullを返す + * その他はSendExceptionを返す + */ +private suspend fun Response.readString(): String? { + val response = this + return try { + // XXX: 進捗表示 + withContext(AppDispatchers.IO) { + val bodyString = response.body?.string() + if (bodyString.isNullOrEmpty()) { + if (response.code in 200 until 300) { + // Misskey の /api/notes/favorites/create は 204(no content)を返す。ボディはカラになる。 + return@withContext "" + } else if (!response.isSuccessful) { + throw ApiError( + response = response, + message = parseErrorResponse(response = response, body = ""), + ) + } + } + bodyString + } + } catch (ex: Throwable) { + when (ex) { + is CancellationException, is ApiError -> throw ex + else -> { + log.e(ex, "readString failed.") + throw ApiError( + response = response, + message = parseErrorResponse( + response = response, + ex.withCaption("readString failed.") + ) + ) + } + } + } finally { + response.body?.closeQuietly() + } +} + +/** + * ResponseWith をResponseWithに変換する + */ +suspend fun String?.stringToJsonObject(response: Response): JsonObject = + try { + val content = this + withContext(AppDispatchers.IO) { + when { + content == null -> throw ApiError( + response = response, + message = response.formatError("response body is null.") + ) + + // 204 no content は 空オブジェクトと解釈する + content == "" -> JsonObject() + + reStartJsonArray.containsMatchIn(content) -> + jsonObjectOf("root" to content.decodeJsonArray()) + + reStartJsonObject.containsMatchIn(content) -> { + val json = content.decodeJsonObject() + DEFAULT_JSON_ERROR_PARSER(json)?.let { error -> + throw ApiError( + response = response, + message = response.formatError(error) + ) + } + json + } + + else -> throw ApiError( + response = response, + message = response.formatError("not a JSON object.") + ) + } + } + } catch (ex: Throwable) { + when (ex) { + is CancellationException, is ApiError -> throw ex + else -> { + throw ApiError( + response = response, + message = response.formatError("readJsonObject failed."), + cause = ex, + ) + } + } + } + +suspend fun Response.readJsonObject() = readString().stringToJsonObject(this) diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClient.kt b/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClient.kt index 3ca5c7e9..325cf0ac 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClient.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClient.kt @@ -6,7 +6,6 @@ import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.api.auth.AuthBase import jp.juggler.subwaytooter.api.entity.* -import jp.juggler.subwaytooter.pref.pref import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.util.* import jp.juggler.util.data.* @@ -56,7 +55,6 @@ class TootApiClient( } // 認証に関する設定を保存する - internal val pref = context.pref() // インスタンスのホスト名 var apiHost: Host? = null @@ -407,8 +405,9 @@ class TootApiClient( requestBuilder.url(url) - (forceAccessToken ?: account?.getAccessToken()) - ?.notEmpty()?.let { requestBuilder.header("Authorization", "Bearer $it") } + (forceAccessToken ?: account?.bearerAccessToken)?.notEmpty()?.let { + requestBuilder.header("Authorization", "Bearer $it") + } requestBuilder.build() .also { log.d("request: ${it.method} $url") } @@ -503,10 +502,9 @@ class TootApiClient( url = "$url${delm}i=${accessToken.encodePercent()}" } } else { - val accessToken = account.getAccessToken() - if (accessToken?.isNotEmpty() == true) { - val delm = if (-1 != url.indexOf('?')) '&' else '?' - url = "$url${delm}access_token=${accessToken.encodePercent()}" + account.bearerAccessToken.notEmpty()?.let { + val delm = if (url.contains('?')) '&' else '?' + url = "$url${delm}access_token=${it.encodePercent()}" } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/auth/AuthBase.kt b/app/src/main/java/jp/juggler/subwaytooter/api/auth/AuthBase.kt index 7f5f7ae4..bc9fb70f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/auth/AuthBase.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/auth/AuthBase.kt @@ -34,7 +34,7 @@ abstract class AuthBase { val clientName get() = arrayOf( testClientName, - PrefS.spClientName.invoke(), + PrefS.spClientName.value, ).firstNotNullOfOrNull { it.notBlank() } ?: DEFAULT_CLIENT_NAME diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/auth/MastodonAuth.kt b/app/src/main/java/jp/juggler/subwaytooter/api/auth/MastodonAuth.kt index ed05d9ed..da21ca54 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/auth/MastodonAuth.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/auth/MastodonAuth.kt @@ -8,8 +8,8 @@ import jp.juggler.subwaytooter.api.entity.EntityId import jp.juggler.subwaytooter.api.entity.Host import jp.juggler.subwaytooter.api.entity.InstanceType import jp.juggler.subwaytooter.api.entity.TootInstance -import jp.juggler.subwaytooter.table.ClientInfo -import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoClientInfo +import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.util.LinkHelper import jp.juggler.util.data.JsonObject import jp.juggler.util.data.buildJsonObject @@ -84,7 +84,7 @@ class MastodonAuth(override val client: TootApiClient) : AuthBase() { } } clientInfo[KEY_CLIENT_CREDENTIAL] = clientCredential - ClientInfo.save(apiHost, clientName, clientInfo.toString()) + daoClientInfo.save(apiHost, clientName, clientInfo.toString()) return clientCredential } @@ -95,7 +95,7 @@ class MastodonAuth(override val client: TootApiClient) : AuthBase() { tootInstance: TootInstance?, forceUpdateClient: Boolean, ): JsonObject { - var clientInfo = ClientInfo.load(apiHost, clientName) + var clientInfo = daoClientInfo.load(apiHost, clientName) // スコープ一覧を取得する val scopeString = mastodonScope(tootInstance) @@ -126,7 +126,7 @@ class MastodonAuth(override val client: TootApiClient) : AuthBase() { else -> try { // マストドン2.4でスコープが追加された // 取得時のスコープ指定がマッチしない(もしくは記録されていない)ならクライアント情報を再利用してはいけない - ClientInfo.delete(apiHost, clientName) + daoClientInfo.delete(apiHost, clientName) // クライアントアプリ情報そのものはまだサーバに残っているが、明示的に消す方法は現状存在しない // client credential だけは消せる @@ -254,7 +254,7 @@ class MastodonAuth(override val client: TootApiClient) : AuthBase() { when { param.startsWith("db:") -> try { val dataId = param.substring(3).toLong(10) - val sa = SavedAccount.loadAccount(context, dataId) + val sa = daoSavedAccount.loadAccount(dataId) ?: error("missing account db_id=$dataId") client.account = sa } catch (ex: Throwable) { @@ -272,7 +272,7 @@ class MastodonAuth(override val client: TootApiClient) : AuthBase() { val apiHost = client.apiHost ?: error("can't get apiHost from callback parameter.") - val clientInfo = ClientInfo.load(apiHost, clientName) + val clientInfo = daoClientInfo.load(apiHost, clientName) ?: error("can't find client info for apiHost=$apiHost, clientName=$clientName") val tokenInfo = api.authStep2( diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/auth/MisskeyAuth10.kt b/app/src/main/java/jp/juggler/subwaytooter/api/auth/MisskeyAuth10.kt index 65aa7599..83b44041 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/auth/MisskeyAuth10.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/auth/MisskeyAuth10.kt @@ -8,9 +8,9 @@ import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.entity.EntityId import jp.juggler.subwaytooter.api.entity.Host import jp.juggler.subwaytooter.api.entity.TootInstance -import jp.juggler.subwaytooter.pref.PrefDevice -import jp.juggler.subwaytooter.table.ClientInfo -import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.pref.prefDevice +import jp.juggler.subwaytooter.table.daoClientInfo +import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.util.LinkHelper import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory @@ -96,14 +96,12 @@ class MisskeyAuth10(override val client: TootApiClient) : AuthBase() { * {"token":"0ba88e2d-4b7d-4599-8d90-dc341a005637","url":"https://misskey.xyz/auth/0ba88e2d-4b7d-4599-8d90-dc341a005637"} */ private suspend fun createAuthUri(apiHost: Host, appSecret: String): Uri { - PrefDevice.from(context).edit().apply { - putString(PrefDevice.LAST_AUTH_INSTANCE, apiHost.ascii) - putString(PrefDevice.LAST_AUTH_SECRET, appSecret) - when (val account = account) { - null -> remove(PrefDevice.LAST_AUTH_DB_ID) - else -> putLong(PrefDevice.LAST_AUTH_DB_ID, account.db_id) - } - }.apply() + context.prefDevice.saveLastAuth( + host = apiHost.ascii, + secret = appSecret, + dbId = account?.db_id, //nullable + ) + return api.authSessionGenerate(apiHost, appSecret) .string("url").notEmpty()?.toUri() ?: error("missing 'url' in session/generate.") @@ -122,7 +120,7 @@ class MisskeyAuth10(override val client: TootApiClient) : AuthBase() { ): Uri { val apiHost = apiHost ?: error("missing apiHost") - val clientInfo = ClientInfo.load(apiHost, clientName) + val clientInfo = daoClientInfo.load(apiHost, clientName) // スコープ一覧を取得する val scopeArray = getScopeArrayMisskey(ti) @@ -173,7 +171,7 @@ class MisskeyAuth10(override val client: TootApiClient) : AuthBase() { val appSecret = appJson.string(KEY_MISSKEY_APP_SECRET) .notBlank() ?: error(context.getString(R.string.cant_get_misskey_app_secret)) - ClientInfo.save(apiHost, clientName, appJson.toString()) + daoClientInfo.save(apiHost, clientName, appJson.toString()) return createAuthUri(apiHost, appSecret) } @@ -183,20 +181,21 @@ class MisskeyAuth10(override val client: TootApiClient) : AuthBase() { */ override suspend fun authStep2(uri: Uri): Auth2Result { - val prefDevice = PrefDevice.from(context) + val prefDevice = context.prefDevice val token = uri.getQueryParameter("token") ?.notBlank() ?: error("missing token in callback URL") - val hostStr = prefDevice.getString(PrefDevice.LAST_AUTH_INSTANCE, null) + val hostStr = prefDevice.lastAuthInstance ?.notBlank() ?: error("missing instance name.") + val apiHost = Host.parse(hostStr) - when (val dbId = prefDevice.getLong(PrefDevice.LAST_AUTH_DB_ID, -1L)) { + when (val dbId = prefDevice.lastAuthDbId) { // new registration - -1L -> client.apiHost = apiHost + null -> client.apiHost = apiHost // update access token - else -> SavedAccount.loadAccount(context, dbId)?.also { + else -> daoSavedAccount.loadAccount(dbId)?.also { client.account = it } ?: error("missing account db_id=$dbId") } @@ -209,7 +208,7 @@ class MisskeyAuth10(override val client: TootApiClient) : AuthBase() { ) @Suppress("UNUSED_VARIABLE") - val clientInfo = ClientInfo.load(apiHost, clientName) + val clientInfo = daoClientInfo.load(apiHost, clientName) ?.notEmpty() ?: error("missing client id") val appSecret = clientInfo.string(KEY_MISSKEY_APP_SECRET) diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/auth/MisskeyAuth13.kt b/app/src/main/java/jp/juggler/subwaytooter/api/auth/MisskeyAuth13.kt index 93cfa150..3a301290 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/auth/MisskeyAuth13.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/auth/MisskeyAuth13.kt @@ -7,8 +7,8 @@ import jp.juggler.subwaytooter.api.auth.MisskeyAuth10.Companion.encodeScopeArray import jp.juggler.subwaytooter.api.auth.MisskeyAuth10.Companion.getScopeArrayMisskey import jp.juggler.subwaytooter.api.entity.Host import jp.juggler.subwaytooter.api.entity.TootInstance -import jp.juggler.subwaytooter.pref.PrefDevice -import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.pref.prefDevice +import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.util.LinkHelper import jp.juggler.util.data.JsonObject import jp.juggler.util.data.notEmpty @@ -60,14 +60,11 @@ class MisskeyAuth13(override val client: TootApiClient) : AuthBase() { val sessionId = UUID.randomUUID().toString() - PrefDevice.from(client.context).edit().apply { - putString(PrefDevice.LAST_AUTH_INSTANCE, apiHost.ascii) - putString(PrefDevice.LAST_AUTH_SECRET, sessionId) - when (val account = account) { - null -> remove(PrefDevice.LAST_AUTH_DB_ID) - else -> putLong(PrefDevice.LAST_AUTH_DB_ID, account.db_id) - } - }.apply() + client.context.prefDevice.saveLastAuth( + host = apiHost.ascii, + secret = sessionId, + dbId = account?.db_id, + ) return api13.createAuthUrl( apiHost = apiHost, @@ -82,20 +79,20 @@ class MisskeyAuth13(override val client: TootApiClient) : AuthBase() { override suspend fun authStep2(uri: Uri): Auth2Result { // 認証開始時に保存した情報 - val prefDevice = PrefDevice.from(client.context) - val savedSessionId = prefDevice.getString(PrefDevice.LAST_AUTH_SECRET, null) + val prefDevice = client.context.prefDevice + val savedSessionId = prefDevice.lastAuthSecret - val apiHost = prefDevice.getString(PrefDevice.LAST_AUTH_INSTANCE, null) + val apiHost = prefDevice.lastAuthInstance ?.let { Host.parse(it) } ?: error("missing apiHost") - when (val dbId = prefDevice.getLong(PrefDevice.LAST_AUTH_DB_ID, -1L)) { + when (val dbId = prefDevice.lastAuthDbId) { // new registration - -1L -> client.apiHost = apiHost + null -> client.apiHost = apiHost // update access token else -> { - val sa = SavedAccount.loadAccount(context, dbId) + val sa = daoSavedAccount.loadAccount(dbId) ?: error("missing account db_id=$dbId") client.account = sa } @@ -131,7 +128,7 @@ class MisskeyAuth13(override val client: TootApiClient) : AuthBase() { .account(accountJson) ?: error("can't parse user json.") - prefDevice.edit().remove(PrefDevice.LAST_AUTH_SECRET).apply() + prefDevice.removeLastAuth() return Auth2Result( tootInstance = ti, diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/EntityId.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/EntityId.kt index 36ad5501..0adbe9de 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/EntityId.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/EntityId.kt @@ -29,7 +29,7 @@ class EntityId(val x: String) : Comparable { fun mayNull(x: String?) = if (x == null) null else EntityId(x) - fun String.decode(): EntityId? { + fun String.decodeEntityId(): EntityId? { if (this.isEmpty()) return null // first character is 'L' for EntityIdLong, 'S' for EntityIdString. // integer id is removed at https://source.joinmastodon.org/mastodon/docs/commit/e086d478afa140e7b0b9a60183655315966ad9ff @@ -37,26 +37,24 @@ class EntityId(val x: String) : Comparable { } fun from(intent: Intent?, key: String) = - intent?.string(key)?.decode() + intent?.string(key)?.decodeEntityId() fun from(bundle: Bundle?, key: String) = - bundle?.string(key)?.decode() + bundle?.string(key)?.decodeEntityId() // 内部保存データのデコード用。APIレスポンスのパースに使ってはいけない fun from(data: JsonObject?, key: String): EntityId? { val o = data?.get(key) if (o is Long) return EntityId(o.toString()) - return (o as? String)?.decode() + return (o as? String)?.decodeEntityId() } fun from(cursor: Cursor, key: String) = - cursor.getStringOrNull(key)?.decode() + cursor.getStringOrNull(key)?.decodeEntityId() } - private fun encode(): String { - val prefix = 'S' - return "$prefix$this" - } + // 昔は文字列とLong値を区別していたが、今はもうない + fun encode(): String = "S$this" fun putTo(data: Intent, key: String): Intent = data.putExtra(key, encode()) diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAccount.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAccount.kt index 0d5b152c..55a24d43 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAccount.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAccount.kt @@ -11,6 +11,7 @@ import jp.juggler.subwaytooter.emoji.CustomEmoji import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.UserRelation +import jp.juggler.subwaytooter.table.daoUserRelation import jp.juggler.subwaytooter.util.DecodeOptions import jp.juggler.subwaytooter.util.LinkHelper import jp.juggler.subwaytooter.util.NetworkEmojiInvalidator @@ -215,7 +216,7 @@ open class TootAccount(parser: TootParser, src: JsonObject) : HostAndDomain { this.fields = parseMisskeyFields(src) - UserRelation.fromAccount(parser, src, id) + daoUserRelation.fromAccount(parser, src, id) @Suppress("LeakingThis") MisskeyAccountDetailMap.fromAccount(parser, this, id) @@ -504,7 +505,7 @@ open class TootAccount(parser: TootParser, src: JsonObject) : HostAndDomain { .append(suggestionSource) } - if (PrefB.bpDirectoryLastActive() && last_status_at > 0L) { + if (PrefB.bpDirectoryLastActive.value && last_status_at > 0L) { prepareSb() .append(context.getString(R.string.last_active)) .append(delm) @@ -519,7 +520,7 @@ open class TootAccount(parser: TootParser, src: JsonObject) : HostAndDomain { } if (!fromProfileHeader) { - if (PrefB.bpDirectoryTootCount() && + if (PrefB.bpDirectoryTootCount.value && (statuses_count ?: 0L) > 0L ) { prepareSb() @@ -528,8 +529,8 @@ open class TootAccount(parser: TootParser, src: JsonObject) : HostAndDomain { .append(statuses_count.toString()) } - if (PrefB.bpDirectoryFollowers() && - !PrefB.bpHideFollowCount() && + if (PrefB.bpDirectoryFollowers.value && + !PrefB.bpHideFollowCount.value && (followers_count ?: 0L) > 0L ) { prepareSb() @@ -538,7 +539,7 @@ open class TootAccount(parser: TootParser, src: JsonObject) : HostAndDomain { .append(followers_count.toString()) } - if (PrefB.bpDirectoryNote() && note?.isNotEmpty() == true) { + if (PrefB.bpDirectoryNote.value && note?.isNotEmpty() == true) { val decodedNote = DecodeOptions( context, accessInfo, diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachment.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachment.kt index da252b9d..9bd9cd08 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachment.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachment.kt @@ -1,6 +1,5 @@ package jp.juggler.subwaytooter.api.entity -import android.content.SharedPreferences import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.util.* @@ -191,26 +190,26 @@ class TootAttachment : TootAttachmentLike { private fun parseType(src: String?) = TootAttachmentType.values().find { it.id == src } - override fun urlForThumbnail(pref: SharedPreferences) = - if (PrefB.bpPriorLocalURL(pref)) { + override fun urlForThumbnail() = + if (PrefB.bpPriorLocalURL.value) { preview_url.notEmpty() ?: preview_remote_url.notEmpty() } else { preview_remote_url.notEmpty() ?: preview_url.notEmpty() } ?: when (type) { - TootAttachmentType.Image -> getLargeUrl(pref) + TootAttachmentType.Image -> getLargeUrl() else -> null } - fun getLargeUrl(pref: SharedPreferences) = - if (PrefB.bpPriorLocalURL(pref)) { + fun getLargeUrl() = + if (PrefB.bpPriorLocalURL.value) { url.notEmpty() ?: remote_url } else { remote_url.notEmpty() ?: url } - fun getLargeUrlList(pref: SharedPreferences) = + fun getLargeUrlList() = ArrayList().apply { - if (PrefB.bpPriorLocalURL(pref)) { + if (PrefB.bpPriorLocalURL.value) { url.notEmpty()?.addTo(this) remote_url.notEmpty()?.addTo(this) } else { diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachmentLike.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachmentLike.kt index de27beed..eab3dee7 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachmentLike.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachmentLike.kt @@ -1,7 +1,5 @@ package jp.juggler.subwaytooter.api.entity -import android.content.SharedPreferences - enum class TootAttachmentType(val id: String) { Unknown("unknown"), Image("image"), @@ -16,7 +14,7 @@ interface TootAttachmentLike { val description: String? // url for thumbnail, or null or empty - fun urlForThumbnail(pref: SharedPreferences): String? + fun urlForThumbnail(): String? // url for description, or null or empty val urlForDescription: String? diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachmentMSP.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachmentMSP.kt index 93408b1a..0623d29b 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachmentMSP.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachmentMSP.kt @@ -14,7 +14,7 @@ class TootAttachmentMSP( override val description: String? get() = null - override fun urlForThumbnail(pref: SharedPreferences) = preview_url + override fun urlForThumbnail() = preview_url override val urlForDescription: String get() = preview_url diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootCard.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootCard.kt index 363ebd1f..2ec93988 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootCard.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootCard.kt @@ -1,7 +1,6 @@ package jp.juggler.subwaytooter.api.entity import jp.juggler.subwaytooter.api.TootParser -import jp.juggler.subwaytooter.pref.pref import jp.juggler.subwaytooter.util.DecodeOptions import jp.juggler.util.data.JsonObject import jp.juggler.util.data.filterNotEmpty @@ -61,7 +60,7 @@ class TootCard( }, image = src.media_attachments ?.firstOrNull() - ?.urlForThumbnail(parser.context.pref()) + ?.urlForThumbnail() ?: src.account.avatar_static, type = "photo" ) diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootInstance.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootInstance.kt index 4bc9e14c..b8f7a553 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootInstance.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootInstance.kt @@ -310,8 +310,9 @@ class TootInstance(parser: TootParser, src: JsonObject) { if (sendRequest(result) { val builder = Request.Builder().url("https://${apiHost?.ascii}/api/v1/instance") - (forceAccessToken ?: account?.getAccessToken()) - ?.notEmpty()?.let { builder.header("Authorization", "Bearer $it") } + (forceAccessToken ?: account?.bearerAccessToken)?.notEmpty()?.let { + builder.header("Authorization", "Bearer $it") + } builder.build() } ) { @@ -428,7 +429,7 @@ class TootInstance(parser: TootParser, src: JsonObject) { when { qrr.first?.instanceType == InstanceType.Pixelfed && - !PrefB.bpEnablePixelfed() && + !PrefB.bpEnablePixelfed.value && !req.allowPixelfed -> tiError("currently Pixelfed instance is not supported.") else -> qrr diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootReaction.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootReaction.kt index 24f2ffc4..dfda505c 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootReaction.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootReaction.kt @@ -9,7 +9,6 @@ import jp.juggler.subwaytooter.span.NetworkEmojiSpan import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.util.DecodeOptions import jp.juggler.subwaytooter.util.EmojiDecoder -import jp.juggler.util.* import jp.juggler.util.data.* import java.util.* @@ -162,7 +161,7 @@ class TootReaction( } private fun chooseUrl() = when { - PrefB.bpDisableEmojiAnimation() -> staticUrl + PrefB.bpDisableEmojiAnimation.value -> staticUrl else -> url } diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt index 36e54b3f..d63c6d4c 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt @@ -5,7 +5,6 @@ import android.content.Context import android.text.Spannable import android.text.SpannableString import androidx.annotation.StringRes -import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.api.TootAccountMap import jp.juggler.subwaytooter.api.TootParser @@ -365,7 +364,7 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() { this.mentions = mergeMentions(mentions1, mentions2) this.decoded_mentions = - HTMLDecoder.decodeMentions(parser.linkHelper, this) + HTMLDecoder.decodeMentions(parser, this) ?: EMPTY_SPANNABLE // contentを読んだ後にアンケートのデコード @@ -484,7 +483,7 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() { this.muted = false this.language = null this.decoded_mentions = - HTMLDecoder.decodeMentions(parser.linkHelper, this) + HTMLDecoder.decodeMentions(parser, this) ?: EMPTY_SPANNABLE val quote = when { @@ -696,7 +695,7 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() { this.muted = src.optBoolean("muted") this.language = src.string("language")?.notEmpty() this.decoded_mentions = - HTMLDecoder.decodeMentions(parser.linkHelper, this) + HTMLDecoder.decodeMentions(parser, this) ?: EMPTY_SPANNABLE val quote = when { @@ -1100,7 +1099,7 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() { fun markDeleted(context: Context, deletedAt: Long?): Boolean { - if (PrefB.bpDontRemoveDeletedToot(App1.getAppState(context).pref)) return false + if (PrefB.bpDontRemoveDeletedToot.value) return false var sv = if (deletedAt != null) { context.getString(R.string.status_deleted_at, formatTime(context, deletedAt, false)) @@ -1131,7 +1130,7 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() { internal val log = LogCategory("TootStatus") @Volatile - internal var muted_app: HashSet? = null + internal var muted_app: Set? = null @Volatile internal var muted_word: WordTrieTree? = null @@ -1419,7 +1418,7 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() { formatDate(t, date_format2, omitZeroSecond = false, omitYear = true) } - if (bAllowRelative && PrefB.bpRelativeTimestamp()) { + if (bAllowRelative && PrefB.bpRelativeTimestamp.value) { delta = abs(delta) diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/push/ApiPushAppServer.kt b/app/src/main/java/jp/juggler/subwaytooter/api/push/ApiPushAppServer.kt new file mode 100644 index 00000000..2157336a --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/api/push/ApiPushAppServer.kt @@ -0,0 +1,67 @@ +package jp.juggler.subwaytooter.api.push + +import jp.juggler.subwaytooter.api.await +import jp.juggler.subwaytooter.api.readJsonObject +import jp.juggler.subwaytooter.column.encodeQuery +import jp.juggler.util.coroutine.AppDispatchers +import jp.juggler.util.data.JsonObject +import jp.juggler.util.data.buildJsonObject +import jp.juggler.util.data.jsonArrayOf +import jp.juggler.util.network.toPostRequestBuilder +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * アプリサーバのAPI + */ +class ApiPushAppServer( + private val okHttp: OkHttpClient, + private val appServerPrefix: String = "https://mastodon-msg.juggler.jp/api/v2", +) { + /** + * 中継エンドポイントが無効になったら削除する + */ + suspend fun endpointRemove( + upUrl: String? = null, + fcmToken: String? = null, + hashId:String? = null, + ): JsonObject = buildJsonObject { + upUrl?.let { put("upUrl", it) } + fcmToken?.let { put("fcmToken", it) } + hashId?.let{ put("hashId", it) } + }.encodeQuery().let { + Request.Builder() + .url("${appServerPrefix}/endpoint/remove?$it") + }.delete().build() + .await(okHttp) + .readJsonObject() + + /** + * エンドポイントとアカウントハッシュをアプリサーバに登録する + */ + suspend fun endpointUpsert( + upUrl: String?, + fcmToken: String?, + acctHashList: List, + ): JsonObject = + buildJsonObject { + upUrl?.let { put("upUrl", it) } + fcmToken?.let { put("fcmToken", it) } + put("acctHashList", jsonArrayOf(*(acctHashList.toTypedArray()))) + }.toPostRequestBuilder() + .url("${appServerPrefix}/endpoint/upsert") + .build() + .await(okHttp) + .readJsonObject() + + suspend fun getLargeObject( + largeObjectId: String + ): ByteArray? = withContext(AppDispatchers.IO) { + Request.Builder() + .url("${appServerPrefix}/l/$largeObjectId") + .build() + .await(okHttp) + .body?.bytes() + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/push/ApiPushMastodon.kt b/app/src/main/java/jp/juggler/subwaytooter/api/push/ApiPushMastodon.kt new file mode 100644 index 00000000..464b366e --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/api/push/ApiPushMastodon.kt @@ -0,0 +1,118 @@ +package jp.juggler.subwaytooter.api.push + +import jp.juggler.subwaytooter.api.authorizationBearer +import jp.juggler.subwaytooter.api.await +import jp.juggler.subwaytooter.api.readJsonObject +import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.util.data.JsonObject +import jp.juggler.util.data.buildJsonObject +import jp.juggler.util.network.toPostRequestBuilder +import jp.juggler.util.network.toPutRequestBuilder +import okhttp3.OkHttpClient +import okhttp3.Request + +class ApiPushMastodon( + private val okHttp: OkHttpClient, +) { + companion object { + val alertTypes = arrayOf( + "mention", + "status", + "reblog", + "follow", + "follow_request", + "favourite", + "poll", + "update", + "admin.sign_up", + "admin.report", + ) + } + + /** + * アクセストークンに設定されたプッシュ購読を見る + */ + suspend fun getPushSubscription( + a: SavedAccount, + ): JsonObject = Request.Builder() + .url("https://${a.apiHost}/api/v1/push/subscription") + .authorizationBearer(a.bearerAccessToken) + .build() + .await(okHttp) + .readJsonObject() + + /** + * アクセストークンに設定されたプッシュ購読を削除する + */ + suspend fun deletePushSubscription( + a: SavedAccount, + ): JsonObject = Request.Builder() + .delete() + .url("https://${a.apiHost}/api/v1/push/subscription") + .authorizationBearer(a.bearerAccessToken) + .build() + .await(okHttp) + .readJsonObject() + + /** + * アクセストークンに対してプッシュ購読を登録する + */ + suspend fun createPushSubscription( + a: SavedAccount, + // REQUIRED String. The endpoint URL that is called when a notification event occurs. + endpointUrl: String, + // REQUIRED String. User agent public key. + // Base64 encoded string of a public key from a ECDH keypair using the prime256v1 curve. + p256dh: String, + // REQUIRED String. Auth secret. Base64 encoded string of 16 bytes of random data. + auth: String, + // map of alert type to boolean, true to receive for alert type. false? null? + alerts: Map, + // whether to receive push notifications from all, followed, follower, or none users. + policy: String, + ): JsonObject = buildJsonObject { + put("subscription", buildJsonObject { + put("endpoint", endpointUrl) + put("keys", buildJsonObject { + put("p256dh", p256dh) + put("auth", auth) + }) + }) + put("data", buildJsonObject { + put("alerts", buildJsonObject { + for (t in alertTypes) { + alerts[t]?.let { put(t, it) } + } + }) + }) + put("policy", policy) + }.toPostRequestBuilder() + .url("https://${a.apiHost}/api/v1/push/subscription") + .authorizationBearer(a.bearerAccessToken) + .build() + .await(okHttp) + .readJsonObject() + + /** + * 購読のdata部分を更新する + */ + suspend fun updatePushSubscriptionData( + a: SavedAccount, + alerts: Map, + policy: String, + ): JsonObject = buildJsonObject { + put("data", buildJsonObject { + put("alerts", buildJsonObject { + for (t in alertTypes) { + alerts[t]?.let { put(t, it) } + } + }) + }) + put("policy", policy) + }.toPutRequestBuilder() + .url("https://${a.apiHost}/api/v1/push/subscription") + .authorizationBearer(a.bearerAccessToken) + .build() + .await(okHttp) + .readJsonObject() +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/push/ApiPushMisskey.kt b/app/src/main/java/jp/juggler/subwaytooter/api/push/ApiPushMisskey.kt new file mode 100644 index 00000000..ceed8f4e --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/api/push/ApiPushMisskey.kt @@ -0,0 +1,77 @@ +package jp.juggler.subwaytooter.api.push + +import jp.juggler.subwaytooter.api.await +import jp.juggler.subwaytooter.api.readJsonObject +import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.util.data.JsonObject +import jp.juggler.util.data.buildJsonObject +import jp.juggler.util.network.toPostRequestBuilder +import okhttp3.OkHttpClient + +class ApiPushMisskey( + private val okHttp: OkHttpClient, +) { + + /** + * エンドポイントURLを指定してプッシュ購読の情報を取得する + */ + suspend fun getPushSubscription( + a: SavedAccount, + endpoint: String, + ): JsonObject = buildJsonObject { + a.misskeyApiToken?.let { put("i", it) } + put("endpoint", endpoint) + }.toPostRequestBuilder() + .url("https://${a.apiHost}/api/sw/show-registration") + .build() + .await(okHttp) + .readJsonObject() + + suspend fun deletePushSubscription( + a: SavedAccount, + endpoint: String, + ): JsonObject = buildJsonObject { + a.misskeyApiToken?.let { put("i", it) } + put("endpoint", endpoint) + }.toPostRequestBuilder() + .url("https://${a.apiHost}/api/sw/unregister") + .build() + .await(okHttp) + .readJsonObject() + + /** + * プッシュ購読を更新する。 + * endpointのURLはクエリに使われる。変更できるのはsendReadMessageだけ。 + */ + suspend fun updatePushSubscription( + a: SavedAccount, + endpoint: String, + sendReadMessage: Boolean, + ): JsonObject = buildJsonObject { + a.misskeyApiToken?.let { put("i", it) } + put("endpoint", endpoint) + put("sendReadMessage", sendReadMessage) + }.toPostRequestBuilder() + .url("https://${a.apiHost}/api/sw/update-registration") + .build() + .await(okHttp) + .readJsonObject() + + suspend fun createPushSubscription( + a: SavedAccount, + endpoint: String, + auth: String, + publicKey: String, + sendReadMessage: Boolean, + ): JsonObject = buildJsonObject { + a.misskeyApiToken?.let { put("i", it) } + put("endpoint", endpoint) + put("auth", auth) + put("publickey", publicKey) + put("sendReadMessage", sendReadMessage) + }.toPostRequestBuilder() + .url("https://${a.apiHost}/api/sw/register") + .build() + .await(okHttp) + .readJsonObject() +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/appsetting/AppDataExporter.kt b/app/src/main/java/jp/juggler/subwaytooter/appsetting/AppDataExporter.kt index eb576301..285b877a 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/appsetting/AppDataExporter.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/appsetting/AppDataExporter.kt @@ -5,7 +5,6 @@ import android.content.ContentValues import android.content.Context import android.content.SharedPreferences import android.database.Cursor -import android.database.sqlite.SQLiteDatabase import android.net.Uri import android.provider.BaseColumns import android.util.JsonReader @@ -17,10 +16,9 @@ import jp.juggler.subwaytooter.api.entity.EntityId import jp.juggler.subwaytooter.column.Column import jp.juggler.subwaytooter.column.ColumnEncoder import jp.juggler.subwaytooter.column.getBackgroundImageDir -import jp.juggler.subwaytooter.global.appDatabase import jp.juggler.subwaytooter.pref.PrefL import jp.juggler.subwaytooter.pref.impl.* -import jp.juggler.subwaytooter.pref.put +import jp.juggler.subwaytooter.pref.lazyPref import jp.juggler.subwaytooter.table.* import jp.juggler.util.* import jp.juggler.util.data.* @@ -117,50 +115,49 @@ object AppDataExporter { writer.name(jsonKey) writer.beginArray() - appDatabase.query(table, null, null, null, null, null, null) - ?.use { cursor -> - val names = ArrayList() - val column_count = cursor.columnCount - for (i in 0 until column_count) { - names.add(cursor.getColumnName(i)) - } - while (cursor.moveToNext()) { - writer.beginObject() - - for (i in 0 until column_count) { - when (cursor.getType(i)) { - Cursor.FIELD_TYPE_NULL -> { - writer.name(names[i]) - writer.nullValue() - } - - Cursor.FIELD_TYPE_INTEGER -> { - writer.name(names[i]) - writer.value(cursor.getLong(i)) - } - - Cursor.FIELD_TYPE_STRING -> { - writer.name(names[i]) - writer.value(cursor.getString(i)) - } - - Cursor.FIELD_TYPE_FLOAT -> { - val d = cursor.getDouble(i) - if (d.isNaN() || d.isInfinite()) { - log.w("column ${names[i]} is nan or infinite value.") - } else { - writer.name(names[i]) - writer.value(d) - } - } - - Cursor.FIELD_TYPE_BLOB -> log.w("column ${names[i]} is blob.") - } - } - - writer.endObject() - } + appDatabase.rawQuery("select from $table", emptyArray()).use { cursor -> + val names = ArrayList() + val column_count = cursor.columnCount + for (i in 0 until column_count) { + names.add(cursor.getColumnName(i)) } + while (cursor.moveToNext()) { + writer.beginObject() + + for (i in 0 until column_count) { + when (cursor.getType(i)) { + Cursor.FIELD_TYPE_NULL -> { + writer.name(names[i]) + writer.nullValue() + } + + Cursor.FIELD_TYPE_INTEGER -> { + writer.name(names[i]) + writer.value(cursor.getLong(i)) + } + + Cursor.FIELD_TYPE_STRING -> { + writer.name(names[i]) + writer.value(cursor.getString(i)) + } + + Cursor.FIELD_TYPE_FLOAT -> { + val d = cursor.getDouble(i) + if (d.isNaN() || d.isInfinite()) { + log.w("column ${names[i]} is nan or infinite value.") + } else { + writer.name(names[i]) + writer.value(d) + } + } + + Cursor.FIELD_TYPE_BLOB -> log.w("column ${names[i]} is blob.") + } + } + + writer.endObject() + } + } writer.endArray() } @@ -198,19 +195,21 @@ object AppDataExporter { } if (SavedAccount.table == table) { - // 一時的に存在したが現在のDBスキーマにはない項目は読み飛ばす - if ("nickname" == name || "color" == name) { - reader.skipValue() - continue - } - - // リアルタイム通知に関連する項目は読み飛ばす - if (SavedAccount.COL_NOTIFICATION_TAG.name == name || - SavedAccount.COL_REGISTER_KEY.name == name || - SavedAccount.COL_REGISTER_TIME.name == name - ) { - reader.skipValue() - continue + when (name) { + // 一時的に存在したが現在のDBスキーマにはない項目は読み飛ばす + "nickname", + "color", + "notification_server", + "register_key", + "register_time", + "last_notification_error", + "last_subscription_error", + "last_push_endpoint", + -> { + reader.skipValue() + continue + } + else -> Unit } } @@ -230,8 +229,7 @@ object AppDataExporter { } } reader.endObject() - val new_id = - db.insertWithOnConflict(table, null, cv, SQLiteDatabase.CONFLICT_REPLACE) + val new_id = db.replace(table, null, cv) if (new_id == -1L) error("importTable: invalid row_id") idMap?.put(old_id, new_id) } @@ -359,7 +357,7 @@ object AppDataExporter { val app_state = App1.getAppState(context) - writePref(writer, app_state.pref) + writePref(writer, lazyPref) writeFromTable(writer, KEY_ACCOUNT, SavedAccount.table) writeFromTable(writer, KEY_ACCT_COLOR, AcctColor.table) @@ -386,12 +384,12 @@ object AppDataExporter { while (reader.hasNext()) { when (reader.nextName()) { - KEY_PREF -> importPref(reader, app_state.pref) + KEY_PREF -> importPref(reader, lazyPref) KEY_ACCOUNT -> importTable(reader, SavedAccount.table, account_id_map) KEY_ACCT_COLOR -> { importTable(reader, AcctColor.table, null) - AcctColor.clearMemoryCache() + daoAcctColor.clearMemoryCache() } KEY_MUTED_APP -> importTable(reader, MutedApp.table, null) @@ -408,10 +406,10 @@ object AppDataExporter { } run { - val old_id = PrefL.lpTabletTootDefaultAccount(app_state.pref) + val old_id = PrefL.lpTabletTootDefaultAccount.value if (old_id != -1L) { val new_id = account_id_map[old_id] - app_state.pref.edit().put(PrefL.lpTabletTootDefaultAccount, new_id ?: -1L).apply() + PrefL.lpTabletTootDefaultAccount.value = new_id ?: -1L } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/appsetting/AppSettingItem.kt b/app/src/main/java/jp/juggler/subwaytooter/appsetting/AppSettingItem.kt index fe309134..c4b35420 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/appsetting/AppSettingItem.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/appsetting/AppSettingItem.kt @@ -10,12 +10,17 @@ import android.widget.TextView import androidx.annotation.StringRes import androidx.appcompat.widget.AppCompatImageView import jp.juggler.subwaytooter.* +import jp.juggler.subwaytooter.actmain.selectPushDistributor +import jp.juggler.subwaytooter.dialog.runInProgress import jp.juggler.subwaytooter.drawable.MediaBackgroundDrawable import jp.juggler.subwaytooter.itemviewholder.AdditionalButtonsPosition import jp.juggler.subwaytooter.pref.* import jp.juggler.subwaytooter.pref.impl.* +import jp.juggler.subwaytooter.table.daoSavedAccount +import jp.juggler.subwaytooter.table.sortedByNickname import jp.juggler.subwaytooter.util.CustomShareTarget import jp.juggler.subwaytooter.util.openBrowser +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.cast import jp.juggler.util.data.intentOpenDocument import jp.juggler.util.data.notZero @@ -24,6 +29,7 @@ import jp.juggler.util.ui.InputTypeEx import jp.juggler.util.ui.attrColor import jp.juggler.util.ui.getAdaptiveRippleDrawable import jp.juggler.util.ui.getAdaptiveRippleDrawableRound +import kotlinx.coroutines.delay import org.jetbrains.anko.backgroundDrawable import java.util.concurrent.atomic.AtomicInteger @@ -227,6 +233,10 @@ class AppSettingItem( val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_setting).apply { section(R.string.notifications) { + action(R.string.push_distributor) { + action = { selectPushDistributor() } + desc = R.string.push_distributor_desc + } text( PrefS.spPullNotificationCheckInterval, @@ -447,14 +457,18 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett ) { val lp = pref.cast()!! spinnerInitializer = { spinner -> - val adapter = AccountAdapter() - spinner.adapter = adapter - spinner.setSelection(adapter.getIndexFromId(lp(pref))) + launchAndShowError { + val list = daoSavedAccount.loadAccountList() + .sortedByNickname() + val adapter = AccountAdapter(list) + spinner.adapter = adapter + spinner.setSelection(adapter.getIndexFromId(lp.value)) + } } spinnerOnSelected = { spinner, index -> - val adapter = spinner.adapter.cast() - ?: error("spinnerOnSelected: missing AccountAdapter") - pref.edit().put(lp, adapter.getIdFromIndex(index)).apply() + spinner.adapter.cast() + ?.getIdFromIndex(index) + ?.let { lp.value = it } } } @@ -522,12 +536,12 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett spinnerInitializer = { spinner -> val adapter = TimeZoneAdapter() spinner.adapter = adapter - spinner.setSelection(adapter.getIndexFromId(sp(pref))) + spinner.setSelection(adapter.getIndexFromId(sp.value)) } spinnerOnSelected = { spinner, index -> val adapter = spinner.adapter.cast() ?: error("spinnerOnSelected: missing TimeZoneAdapter") - pref.edit().put(sp, adapter.getIdFromIndex(index)).apply() + sp.value = adapter.getIdFromIndex(index) } } @@ -581,7 +595,7 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett } } onClickReset = { - pref.edit().remove(item.pref?.key).apply() + item.pref?.removeValue() showTimelineFont(item) } showTextView = { showTimelineFont(item, it) } @@ -602,7 +616,7 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett } } onClickReset = { - pref.edit().remove(item.pref?.key).apply() + item.pref?.removeValue() showTimelineFont(AppSettingItem.TIMELINE_FONT_BOLD) } showTextView = { showTimelineFont(item, it) } @@ -621,7 +635,7 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett fromFloat = { formatFontSize(it) } captionFontSize = { - val fv = fp(pref) + val fv = fp.value when { !fv.isFinite() -> PrefF.default_timeline_font_size fv < 1f -> 1f @@ -629,7 +643,7 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett } } captionSpacing = { - PrefS.spTimelineSpacing(pref).toFloatOrNull() + PrefS.spTimelineSpacing.value.toFloatOrNull() } changed = { findItemViewHolder(item)?.updateCaption() @@ -644,7 +658,7 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett fromFloat = { formatFontSize(it) } captionFontSize = { - val fv = fp(pref) + val fv = fp.value when { !fv.isFinite() -> PrefF.default_acct_font_size fv < 1f -> 1f @@ -667,7 +681,7 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett fromFloat = { formatFontSize(it) } captionFontSize = { - val fv = fp(pref) + val fv = fp.value when { !fv.isFinite() -> PrefF.default_notification_tl_font_size fv < 1f -> 1f @@ -675,7 +689,7 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett } } captionSpacing = { - PrefS.spTimelineSpacing(pref).toFloatOrNull() + PrefS.spTimelineSpacing.value.toFloatOrNull() } changed = { findItemViewHolder(item)?.updateCaption() @@ -718,7 +732,7 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett fromFloat = { formatFontSize(it) } captionFontSize = { - val fv = fp(pref) + val fv = fp.value when { !fv.isFinite() -> PrefF.default_header_font_size fv < 1f -> 1f @@ -843,8 +857,8 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett val ivColumnHeader: ImageView = viewRoot.findViewById(R.id.ivColumnHeader) val tvColumnName: TextView = viewRoot.findViewById(R.id.tvColumnName) - val colorColumnHeaderBg = PrefI.ipCcdHeaderBg(activity.pref) - val colorColumnHeaderFg = PrefI.ipCcdHeaderFg(activity.pref) + val colorColumnHeaderBg = PrefI.ipCcdHeaderBg.value + val colorColumnHeaderFg = PrefI.ipCcdHeaderFg.value val headerBg = when { colorColumnHeaderBg != 0 -> colorColumnHeaderBg @@ -876,9 +890,9 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett val tvSampleAcct: TextView = viewRoot.findViewById(R.id.tvSampleAcct) val tvSampleContent: TextView = viewRoot.findViewById(R.id.tvSampleContent) - val colorColumnBg = PrefI.ipCcdContentBg(activity.pref) - val colorColumnAcct = PrefI.ipCcdContentAcct(activity.pref) - val colorColumnText = PrefI.ipCcdContentText(activity.pref) + val colorColumnBg = PrefI.ipCcdContentBg.value + val colorColumnAcct = PrefI.ipCcdContentAcct.value + val colorColumnText = PrefI.ipCcdContentText.value flColumnBackground.setBackgroundColor(colorColumnBg) // may 0 @@ -909,7 +923,6 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett group(R.string.footer_color) { AppSettingItem.SAMPLE_FOOTER = sample(R.layout.setting_sample_footer) { activity, viewRoot -> - val pref = activity.pref val ivFooterToot: AppCompatImageView = viewRoot.findViewById(R.id.ivFooterToot) val ivFooterMenu: AppCompatImageView = viewRoot.findViewById(R.id.ivFooterMenu) val llFooterBG: View = viewRoot.findViewById(R.id.llFooterBG) @@ -917,11 +930,11 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett val vFooterDivider2: View = viewRoot.findViewById(R.id.vFooterDivider2) val vIndicator: View = viewRoot.findViewById(R.id.vIndicator) - val footerButtonBgColor = PrefI.ipFooterButtonBgColor(pref) - val footerButtonFgColor = PrefI.ipFooterButtonFgColor(pref) - val footerTabBgColor = PrefI.ipFooterTabBgColor(pref) - val footerTabDividerColor = PrefI.ipFooterTabDividerColor(pref) - val footerTabIndicatorColor = PrefI.ipFooterTabIndicatorColor(pref) + val footerButtonBgColor = PrefI.ipFooterButtonBgColor.value + val footerButtonFgColor = PrefI.ipFooterButtonFgColor.value + val footerTabBgColor = PrefI.ipFooterTabBgColor.value + val footerTabDividerColor = PrefI.ipFooterTabDividerColor.value + val footerTabIndicatorColor = PrefI.ipFooterTabIndicatorColor.value val colorColumnStripBackground = footerTabBgColor.notZero() ?: activity.attrColor(R.attr.colorColumnStripBackground) @@ -1023,6 +1036,21 @@ val appSettingRoot = AppSettingItem(null, SettingType.Section, R.string.app_sett } } } + + action(R.string.test_progress_dialog){ + action={ + launchAndShowError { + runInProgress(cancellable=true) { + it.setMessage("message") + it.setTitle("title") + delay(2000L) + it.setMessage("message ".repeat(30)) + it.setTitle("title ".repeat(30)) + delay(2000L) + } + } + } + } } action(R.string.app_data_export) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/auth/AuthRepo.kt b/app/src/main/java/jp/juggler/subwaytooter/auth/AuthRepo.kt new file mode 100644 index 00000000..3aab4184 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/auth/AuthRepo.kt @@ -0,0 +1,118 @@ +package jp.juggler.subwaytooter.auth + +import android.content.Context +import jp.juggler.subwaytooter.api.TootApiClient +import jp.juggler.subwaytooter.api.TootParser +import jp.juggler.subwaytooter.api.auth.Auth2Result +import jp.juggler.subwaytooter.api.entity.EntityId +import jp.juggler.subwaytooter.notification.checkNotificationImmediate +import jp.juggler.subwaytooter.notification.checkNotificationImmediateAll +import jp.juggler.subwaytooter.pref.PrefL +import jp.juggler.subwaytooter.pref.lazyContext +import jp.juggler.subwaytooter.table.AcctColor +import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.appDatabase +import jp.juggler.util.log.LogCategory + +val Context.authRepo + get() = AuthRepo( + context = this, + daoAcctColor = AcctColor.Access(appDatabase), + daoSavedAccount = SavedAccount.Access(appDatabase, lazyContext), + ) + +class AuthRepo( + private val context: Context = lazyContext, + private val daoAcctColor: AcctColor.Access = + AcctColor.Access(appDatabase), + private val daoSavedAccount: SavedAccount.Access = + SavedAccount.Access(appDatabase, lazyContext), +) { + companion object { + private val log = LogCategory("AuthRepo") + } + + /** + * ユーザ登録の確認手順が完了しているかどうか + * + * - マストドン以外だと何もしないはず + */ + suspend fun checkConfirmed(item: SavedAccount, client: TootApiClient) { + // 承認待ち状態ではないならチェックしない + if (item.loginAccount?.id != EntityId.CONFIRMING) return + + // DBに保存されていないならチェックしない + if (item.db_id == SavedAccount.INVALID_DB_ID) return + + // アクセストークンがないならチェックしない + val accessToken = item.bearerAccessToken ?: return + + // ユーザ情報を取得してみる。承認済みなら読めるはず + // 読めなければ例外が出る + val userJson = client.verifyAccount( + accessToken = accessToken, + outTokenInfo = null, + misskeyVersion = 0, // Mastodon only + ) + // 読めたらアプリ内の記録を更新する + TootParser(context, item).account(userJson)?.let { ta -> + item.loginAccount = ta + daoSavedAccount.saveSetting(item) + checkNotificationImmediateAll(context, onlySubscription = true) + checkNotificationImmediate(context, item.db_id) + } + } + + fun accountRemove(account: SavedAccount) { + // if account is default account of tablet mode, + // reset default. + if (account.db_id == PrefL.lpTabletTootDefaultAccount.value) { + PrefL.lpTabletTootDefaultAccount.value = -1L + } + daoSavedAccount.delete(account.db_id) + // appServerUnregister(context.applicationContextSafe, account) + } + + fun updateTokenInfo(item: SavedAccount, auth2Result: Auth2Result) { + item.token_info = auth2Result.tokenJson + item.loginAccount = auth2Result.tootAccount + item.misskeyVersion = auth2Result.tootInstance.misskeyVersionMajor + daoSavedAccount.saveSetting(item) + } + + // notification_tagがもう使われてない +// private fun appServerUnregister(context: Context, account: SavedAccount) { +// launchIO { +// try { +// val installId = PrefDevice.from(context).getString(PrefDevice.KEY_INSTALL_ID, null) +// if (installId?.isEmpty() != false) { +// error("missing install_id") +// } +// +// val tag = "" // notification_tagはもう使われてない +// if (tag.isNullO) { +// error("missing notification_tag") +// } +// +// val call = App1.ok_http_client.newCall( +// "instance_url=${ +// "https://${account.apiHost.ascii}".encodePercent() +// }&app_id=${ +// context.packageName.encodePercent() +// }&tag=$tag" +// .toFormRequestBody() +// .toPost() +// .url("$APP_SERVER/unregister") +// .build() +// ) +// +// val response = call.await() +// if (!response.isSuccessful) { +// log.e("appServerUnregister: $response") +// } +// } catch (ex: Throwable) { +// log.e(ex, "appServerUnregister failed.") +// } +// } +// } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/Column.kt b/app/src/main/java/jp/juggler/subwaytooter/column/Column.kt index 99d3161e..46537ba3 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/Column.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/Column.kt @@ -1,7 +1,6 @@ package jp.juggler.subwaytooter.column import android.content.Context -import android.content.SharedPreferences import android.util.SparseArray import androidx.appcompat.app.AppCompatActivity import jp.juggler.subwaytooter.AppState @@ -13,6 +12,7 @@ import jp.juggler.subwaytooter.pref.PrefI import jp.juggler.subwaytooter.streaming.StreamCallback import jp.juggler.subwaytooter.streaming.StreamStatus import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.util.BucketList import jp.juggler.subwaytooter.util.ScrollPosition import jp.juggler.util.data.* @@ -64,10 +64,10 @@ class Column( internal const val QUICK_FILTER_VOTE = 6 internal const val QUICK_FILTER_POST = 7 - fun loadAccount(context: Context, src: JsonObject): SavedAccount { + fun loadAccount(src: JsonObject): SavedAccount { val account_db_id = src.long(ColumnEncoder.KEY_ACCOUNT_ROW_ID) ?: -1L - return if (account_db_id >= 0) { - SavedAccount.loadAccount(context, account_db_id) + return if (account_db_id > 0) { + daoSavedAccount.loadAccount(account_db_id) ?: error("missing account") } else { SavedAccount.na @@ -91,24 +91,24 @@ class Column( var defaultColorContentAcct = 0 var defaultColorContentText = 0 - fun reloadDefaultColor(activity: AppCompatActivity, pref: SharedPreferences) { + fun reloadDefaultColor(activity: AppCompatActivity) { - defaultColorHeaderBg = PrefI.ipCcdHeaderBg(pref).notZero() + defaultColorHeaderBg = PrefI.ipCcdHeaderBg.value.notZero() ?: activity.attrColor(R.attr.color_column_header) - defaultColorHeaderName = PrefI.ipCcdHeaderFg(pref).notZero() + defaultColorHeaderName = PrefI.ipCcdHeaderFg.value.notZero() ?: activity.attrColor(R.attr.colorColumnHeaderName) - defaultColorHeaderPageNumber = PrefI.ipCcdHeaderFg(pref).notZero() + defaultColorHeaderPageNumber = PrefI.ipCcdHeaderFg.value.notZero() ?: activity.attrColor(R.attr.colorColumnHeaderPageNumber) - defaultColorContentBg = PrefI.ipCcdContentBg(pref) + defaultColorContentBg = PrefI.ipCcdContentBg.value // may zero - defaultColorContentAcct = PrefI.ipCcdContentAcct(pref).notZero() + defaultColorContentAcct = PrefI.ipCcdContentAcct.value.notZero() ?: activity.attrColor(R.attr.colorTimeSmall) - defaultColorContentText = PrefI.ipCcdContentText(pref).notZero() + defaultColorContentText = PrefI.ipCcdContentText.value.notZero() ?: activity.attrColor(R.attr.colorTextContent) } @@ -257,7 +257,7 @@ class Column( var keywordFilterTrees: FilterTrees? = null @Volatile - var favMuteSet: HashSet? = null + var favMuteSet: Set? = null @Volatile var highlightTrie: WordTrieTree? = null @@ -341,7 +341,7 @@ class Column( internal constructor(appState: AppState, src: JsonObject) : this( appState, appState.context, - loadAccount(appState.context, src), + loadAccount(src), src.optInt(ColumnEncoder.KEY_TYPE), ColumnEncoder.decodeColumnId(src) ) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnActions.kt b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnActions.kt index dcb62dd2..e96eec3c 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnActions.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnActions.kt @@ -192,7 +192,7 @@ fun Column.removeNotifications() { listData.clear() duplicateMap.clear() fireShowContent(reason = "removeNotifications", reset = true) - onNotificationCleared(context, accessInfo.db_id) + onNotificationCleared(accessInfo.db_id) } // 通知を削除した後に呼ばれる diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnEncoder.kt b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnEncoder.kt index 8eca3e37..03c320f5 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnEncoder.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnEncoder.kt @@ -1,7 +1,7 @@ package jp.juggler.subwaytooter.column import jp.juggler.subwaytooter.api.entity.EntityId -import jp.juggler.subwaytooter.table.AcctColor +import jp.juggler.subwaytooter.table.daoAcctColor import jp.juggler.util.data.JsonException import jp.juggler.util.data.JsonObject import jp.juggler.util.data.encodeBase64Url @@ -248,11 +248,11 @@ object ColumnEncoder { } // 以下は保存には必要ないが、カラムリスト画面で使う - val ac = AcctColor.load(accessInfo) + val ac = daoAcctColor.load(accessInfo) dst[KEY_COLUMN_ACCESS_ACCT] = accessInfo.acct.ascii dst[KEY_COLUMN_ACCESS_STR] = ac.nickname - dst[KEY_COLUMN_ACCESS_COLOR] = ac.color_fg - dst[KEY_COLUMN_ACCESS_COLOR_BG] = ac.color_bg + dst[KEY_COLUMN_ACCESS_COLOR] = ac.colorFg + dst[KEY_COLUMN_ACCESS_COLOR_BG] = ac.colorBg dst[KEY_COLUMN_NAME] = getColumnName(true) dst[KEY_OLD_INDEX] = oldIndex } diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnExtra1.kt b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnExtra1.kt index 967fb04b..48c7c6a2 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnExtra1.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnExtra1.kt @@ -197,7 +197,7 @@ fun Column.onActivityStart() { if (!bRefreshLoading && canAutoRefresh() && - !PrefB.bpDontRefreshOnResume(appState.pref) && + !PrefB.bpDontRefreshOnResume.value && !dontAutoRefresh ) { // リフレッシュしてからストリーミング開始 @@ -237,7 +237,7 @@ fun Column.startLoading() { initFilter() - Column.showOpenSticker = PrefB.bpOpenSticker(appState.pref) + Column.showOpenSticker = PrefB.bpOpenSticker.value mRefreshLoadingErrorPopupState = 0 mRefreshLoadingError = "" diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnFilters.kt b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnFilters.kt index 28989982..203d2efb 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnFilters.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnFilters.kt @@ -7,10 +7,7 @@ import jp.juggler.subwaytooter.api.ApiTask import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.runApiTask -import jp.juggler.subwaytooter.table.FavMute -import jp.juggler.subwaytooter.table.HighlightWord -import jp.juggler.subwaytooter.table.SavedAccount -import jp.juggler.subwaytooter.table.UserRelation +import jp.juggler.subwaytooter.table.* import jp.juggler.util.* import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.WordTrieTree @@ -171,8 +168,8 @@ fun Column.initFilter() { } } - favMuteSet = FavMute.acctSet - highlightTrie = HighlightWord.nameSet + favMuteSet = daoFavMute.acctSet() + highlightTrie = daoHighlightWord.nameSet() } private fun Column.isFilteredByAttachment(status: TootStatus): Boolean { @@ -252,10 +249,10 @@ fun Column.isFiltered(status: TootStatus): Boolean { if (checkLanguageFilter(status)) return true if (accessInfo.isPseudo) { - var r = UserRelation.loadPseudo(accessInfo.getFullAcct(status.account)) + var r = daoUserRelation.loadPseudo(accessInfo.getFullAcct(status.account)) if (r.muting || r.blocking) return true if (reblog != null) { - r = UserRelation.loadPseudo(accessInfo.getFullAcct(reblog.account)) + r = daoUserRelation.loadPseudo(accessInfo.getFullAcct(reblog.account)) if (r.muting || r.blocking) return true } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnStreaming.kt b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnStreaming.kt index 85c8ba5c..d1ded02c 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnStreaming.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnStreaming.kt @@ -167,8 +167,8 @@ fun Column.mergeStreamingMessage() { App1.sound(it) } } - o.highlightSpeech?.let { - appState.addSpeech(it.name, dedupMode = DedupMode.RecentExpire) + o.highlightSpeech?.name?.notEmpty()?.let { + appState.addSpeech(it, dedupMode = DedupMode.RecentExpire) } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask.kt b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask.kt index 84f8d600..e853815a 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask.kt @@ -1,7 +1,6 @@ package jp.juggler.subwaytooter.column import android.content.Context -import android.content.SharedPreferences import android.os.SystemClock import jp.juggler.subwaytooter.api.ApiPath import jp.juggler.subwaytooter.api.TootApiClient @@ -72,9 +71,6 @@ abstract class ColumnTask( val misskeyVersion: Int get() = accessInfo.misskeyVersion - val pref: SharedPreferences - get() = column.appState.pref - internal fun JsonObject.addMisskeyNotificationFilter() = addMisskeyNotificationFilter(column) internal fun JsonObject.addRangeMisskey(bBottom: Boolean) = addRangeMisskey(column, bBottom) diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Gap.kt b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Gap.kt index 9259ecd9..592c1bf0 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Gap.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Gap.kt @@ -128,7 +128,7 @@ class ColumnTask_Gap( val iv = when { isHead -> PrefI.ipGapHeadScrollPosition else -> PrefI.ipGapTailScrollPosition - }.invoke(pref) + }.value val scrollHead = iv == PrefI.GSP_HEAD if (scrollHead) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Loading.kt b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Loading.kt index 33efb5ef..e7a4c548 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Loading.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Loading.kt @@ -5,6 +5,7 @@ import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.finder.* +import jp.juggler.subwaytooter.auth.authRepo import jp.juggler.subwaytooter.columnviewholder.scrollToTop import jp.juggler.subwaytooter.notification.injectData import jp.juggler.subwaytooter.pref.PrefB @@ -33,7 +34,7 @@ class ColumnTask_Loading( override suspend fun background(): TootApiResult? { ctStarted.set(true) - if (PrefB.bpOpenSticker(pref)) { + if (PrefB.bpOpenSticker.value) { OpenSticker.loadAndWait() } @@ -52,7 +53,7 @@ class ColumnTask_Loading( client.account = accessInfo try { - accessInfo.checkConfirmed(context, client) + context.authRepo.checkConfirmed(accessInfo, client) column.keywordFilterTrees = column.encodeFilterTree(column.loadFilter2(client)) diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Refresh.kt b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Refresh.kt index 9a597ff6..385ccc48 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Refresh.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnTask_Refresh.kt @@ -16,6 +16,7 @@ import jp.juggler.util.coroutine.runOnMainLooper import jp.juggler.util.coroutine.runOnMainLooperDelayed import jp.juggler.util.data.JsonArray import jp.juggler.util.data.JsonObject +import jp.juggler.util.data.notEmpty import jp.juggler.util.log.LogCategory import jp.juggler.util.log.withCaption import jp.juggler.util.network.toPostRequestBuilder @@ -157,8 +158,8 @@ class ColumnTask_Refresh( App1.sound(it) } } - o.highlightSpeech?.let { - column.appState.addSpeech(it.name, dedupMode = DedupMode.RecentExpire) + o.highlightSpeech?.name.notEmpty()?.let { + column.appState.addSpeech(it, dedupMode = DedupMode.RecentExpire) } } } @@ -306,7 +307,7 @@ class ColumnTask_Refresh( isCancelled -> false listTmp?.isNotEmpty() != true -> false willAddGap -> true - else -> PrefB.bpForceGap() + else -> PrefB.bpForceGap.value } if (doesAddGap()) { @@ -495,7 +496,7 @@ class ColumnTask_Refresh( if (!isCancelled && listTmp?.isNotEmpty() == true && - (willAddGap || PrefB.bpForceGap(context)) + (willAddGap || PrefB.bpForceGap.value) ) { addOne(listTmp, TootGap.mayNull(maxId, lastSinceId), head = addToHead) } @@ -582,7 +583,7 @@ class ColumnTask_Refresh( if (!isCancelled && listTmp?.isNotEmpty() == true && - (willAddGap || PrefB.bpForceGap(context)) + (willAddGap || PrefB.bpForceGap.value) ) { addOne(listTmp, TootGap.mayNull(maxId, lastSinceId), head = addToHead) } @@ -690,16 +691,14 @@ class ColumnTask_Refresh( params.apply { if (!bBottom) { if (first) { - - addRangeMisskey(bBottom) + addRangeMisskey(bBottom = false) } else { putMisskeySince(column.idRecent) } } else { if (first) { - when (column.pagingType) { - ColumnPagingType.Default -> addRangeMisskey(bBottom) + ColumnPagingType.Default -> addRangeMisskey(bBottom = true) ColumnPagingType.Offset -> put("offset", column.offsetNext) ColumnPagingType.Cursor -> put("cursor", column.idOld) diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnType.kt b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnType.kt index ec617c2e..d329b50b 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/ColumnType.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/ColumnType.kt @@ -15,7 +15,7 @@ import jp.juggler.subwaytooter.search.NotestockHelper.refreshNotestock import jp.juggler.subwaytooter.search.TootsearchHelper.loadingTootsearch import jp.juggler.subwaytooter.search.TootsearchHelper.refreshTootsearch import jp.juggler.subwaytooter.streaming.StreamSpec -import jp.juggler.subwaytooter.table.AcctColor +import jp.juggler.subwaytooter.table.daoAcctColor import jp.juggler.util.* import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory @@ -685,7 +685,7 @@ enum class ColumnType( R.string.profile_of, when (who) { null -> profileId.toString() - else -> AcctColor.getNickname(accessInfo, who) + else -> daoAcctColor.getNickname(accessInfo, who) } ) }, diff --git a/app/src/main/java/jp/juggler/subwaytooter/column/UserRelationLoader.kt b/app/src/main/java/jp/juggler/subwaytooter/column/UserRelationLoader.kt index dc8df598..fc04b99e 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/column/UserRelationLoader.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/column/UserRelationLoader.kt @@ -3,12 +3,13 @@ package jp.juggler.subwaytooter.column import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.entity.* -import jp.juggler.subwaytooter.table.AcctSet -import jp.juggler.subwaytooter.table.TagSet -import jp.juggler.subwaytooter.table.UserRelation +import jp.juggler.subwaytooter.table.daoAcctSet +import jp.juggler.subwaytooter.table.daoTagHistory +import jp.juggler.subwaytooter.table.daoUserRelation import jp.juggler.util.data.toJsonArray import jp.juggler.util.log.LogCategory import jp.juggler.util.network.toPostRequestBuilder +import kotlin.math.min class UserRelationLoader(val column: Column) { companion object { @@ -63,7 +64,13 @@ class UserRelationLoader(val column: Column) { while (start < end) { var step = end - start if (step > Column.RELATIONSHIP_LOAD_STEP) step = Column.RELATIONSHIP_LOAD_STEP - UserRelation.saveListMisskey(now, column.accessInfo.db_id, whoList, start, step) + daoUserRelation.saveListMisskey( + now, + column.accessInfo.db_id, + whoList, + start, + step + ) start += step } log.d("updateRelation: update $end relations.") @@ -108,7 +115,11 @@ class UserRelationLoader(val column: Column) { for (i in 0 until list.size) { list[i].id = userIdList[i] } - UserRelation.saveListMisskeyRelationApi(now, column.accessInfo.db_id, list) + daoUserRelation.saveListMisskeyRelationApi( + now, + column.accessInfo.db_id, + list + ) } } log.d("updateRelation: update $n relations.") @@ -134,7 +145,7 @@ class UserRelationLoader(val column: Column) { } val result = client.request(sb.toString()) ?: break // cancelled. val list = parseList(::TootRelationShip, parser, result.jsonArray) - if (list.size > 0) UserRelation.saveListMastodon( + if (list.size > 0) daoUserRelation.saveListMastodon( now, column.accessInfo.db_id, list @@ -156,7 +167,7 @@ class UserRelationLoader(val column: Column) { while (n < acctList.size) { var length = size - n if (length > Column.ACCT_DB_STEP) length = Column.ACCT_DB_STEP - AcctSet.saveList(now, acctList, n, length) + daoAcctSet.saveList(now, acctList, n, length) n += length } log.d("updateRelation: update $n acct.") @@ -171,11 +182,10 @@ class UserRelationLoader(val column: Column) { val now = System.currentTimeMillis() n = 0 - while (n < tagList.size) { - var length = size - n - if (length > Column.ACCT_DB_STEP) length = Column.ACCT_DB_STEP - TagSet.saveList(now, tagList, n, length) - n += length + while (n < size) { + val step = min(Column.ACCT_DB_STEP, size - n) + daoTagHistory.saveList(now, tagList, n, step) + n += step } log.d("updateRelation: update $n tag.") } diff --git a/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolder.kt b/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolder.kt index fa1b1673..f277015e 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolder.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolder.kt @@ -24,7 +24,7 @@ import jp.juggler.subwaytooter.* import jp.juggler.subwaytooter.column.* import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.streaming.* -import jp.juggler.subwaytooter.table.AcctColor +import jp.juggler.subwaytooter.table.daoAcctColor import jp.juggler.subwaytooter.util.* import jp.juggler.subwaytooter.view.MyLinkMovementMethod import jp.juggler.subwaytooter.view.MyTextView @@ -219,15 +219,15 @@ class ColumnViewHolder( val column = this.column if (column == null || column.isDispose.get()) return@Runnable - val ac = AcctColor.load(column.accessInfo) + val ac = daoAcctColor.load(column.accessInfo) tvColumnContext.text = ac.nickname tvColumnContext.setTextColor( - ac.color_fg.notZero() + ac.colorFg.notZero() ?: activity.attrColor(R.attr.colorTimeSmall) ) - tvColumnContext.setBackgroundColor(ac.color_bg) + tvColumnContext.setBackgroundColor(ac.colorBg) tvColumnContext.setPaddingRelative(activity.acctPadLr, 0, activity.acctPadLr, 0) tvColumnName.text = column.getColumnName(false) @@ -358,7 +358,7 @@ class ColumnViewHolder( } } - if (PrefB.bpShareViewPool(activity.pref)) { + if (PrefB.bpShareViewPool.value) { listView.setRecycledViewPool(activity.viewPool) } listView.itemAnimator = null @@ -432,7 +432,7 @@ class ColumnViewHolder( cbWithHighlight, ).forEach { it.setOnCheckedChangeListener(this) } - if (PrefB.bpMoveNotificationsQuickFilter(activity.pref)) { + if (PrefB.bpMoveNotificationsQuickFilter.value) { (svQuickFilter.parent as? ViewGroup)?.removeView(svQuickFilter) llColumnSettingInside.addView(svQuickFilter, 0) diff --git a/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderAnnouncements.kt b/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderAnnouncements.kt index 800070c6..e556fafc 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderAnnouncements.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderAnnouncements.kt @@ -337,7 +337,7 @@ private fun ColumnViewHolder.showReactions( ) val actMain = activity - val disableEmojiAnimation = PrefB.bpDisableEmojiAnimation(actMain.pref) + val disableEmojiAnimation = PrefB.bpDisableEmojiAnimation.value for (reaction in reactions) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderLifecycle.kt b/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderLifecycle.kt index d7032fa3..e6f8f460 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderLifecycle.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderLifecycle.kt @@ -48,7 +48,7 @@ fun ColumnViewHolder.closeBitmaps() { fun ColumnViewHolder.loadBackgroundImage(iv: ImageView, url: String?) { try { - if (url == null || url.isEmpty() || PrefB.bpDontShowColumnBackgroundImage(activity.pref)) { + if (url == null || url.isEmpty() || PrefB.bpDontShowColumnBackgroundImage.value) { // 指定がないなら閉じる closeBitmaps() return @@ -128,7 +128,7 @@ fun ColumnViewHolder.onPageCreate(column: Column, pageIdx: Int, pageCount: Int) ColumnViewHolder.log.d("onPageCreate [$pageIdx] ${column.getColumnName(true)}") - val bSimpleList = !column.isConversation && PrefB.bpSimpleList(activity.pref) + val bSimpleList = !column.isConversation && PrefB.bpSimpleList.value tvColumnIndex.text = activity.getString(R.string.column_index, pageIdx + 1, pageCount) tvColumnStatus.text = "?" @@ -221,7 +221,7 @@ fun ColumnViewHolder.onPageCreate(column: Column, pageIdx: Int, pageCount: Int) btnEmojiAdd.vg(false) etSearch.vg(true) - btnSearchClear.vg(PrefB.bpShowSearchClear(activity.pref)) + btnSearchClear.vg(PrefB.bpShowSearchClear.value) cbResolve.vg(column.type == ColumnType.SEARCH) } @@ -281,7 +281,7 @@ fun ColumnViewHolder.onPageCreate(column: Column, pageIdx: Int, pageCount: Int) fun dip(dp: Int): Int = (activity.density * dp + 0.5f).toInt() val context = activity - val announcementsBgColor = PrefI.ipAnnouncementsBgColor().notZero() + val announcementsBgColor = PrefI.ipAnnouncementsBgColor.value.notZero() ?: context.attrColor(R.attr.colorSearchFormBackground) btnAnnouncementsCutout.apply { @@ -294,7 +294,7 @@ fun ColumnViewHolder.onPageCreate(column: Column, pageIdx: Int, pageCount: Int) setPadding(0, padV, 0, padV) } - val searchBgColor = PrefI.ipSearchBgColor().notZero() + val searchBgColor = PrefI.ipSearchBgColor.value.notZero() ?: context.attrColor(R.attr.colorSearchFormBackground) llSearch.apply { diff --git a/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderQuickFilter.kt b/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderQuickFilter.kt index a99c2284..c0b2929c 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderQuickFilter.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ColumnViewHolderQuickFilter.kt @@ -30,7 +30,7 @@ fun ColumnViewHolder.showQuickFilter() { btnQuickFilterReaction.vg(column.isMisskey) btnQuickFilterFavourite.vg(!column.isMisskey) - val insideColumnSetting = PrefB.bpMoveNotificationsQuickFilter(activity.pref) + val insideColumnSetting = PrefB.bpMoveNotificationsQuickFilter.value val showQuickFilterButton: (btn: View, iconId: Int, selected: Boolean) -> Unit diff --git a/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ViewHolderHeaderProfile.kt b/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ViewHolderHeaderProfile.kt index c95a32c0..8d3509f6 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ViewHolderHeaderProfile.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/columnviewholder/ViewHolderHeaderProfile.kt @@ -28,9 +28,7 @@ import jp.juggler.subwaytooter.span.EmojiImageSpan import jp.juggler.subwaytooter.span.LinkInfo import jp.juggler.subwaytooter.span.MyClickableSpan import jp.juggler.subwaytooter.span.createSpan -import jp.juggler.subwaytooter.table.AcctColor -import jp.juggler.subwaytooter.table.SavedAccount -import jp.juggler.subwaytooter.table.UserRelation +import jp.juggler.subwaytooter.table.* import jp.juggler.subwaytooter.util.DecodeOptions import jp.juggler.subwaytooter.util.NetworkEmojiInvalidator import jp.juggler.subwaytooter.util.openCustomTab @@ -356,7 +354,7 @@ internal class ViewHolderHeaderProfile( whoDetail?.statuses_count ?: who.statuses_count }" - val hideFollowCount = PrefB.bpHideFollowCount(activity.pref) + val hideFollowCount = PrefB.bpHideFollowCount.value var caption = activity.getString(R.string.following) btnFollowing.text = when { @@ -370,7 +368,7 @@ internal class ViewHolderHeaderProfile( else -> "${caption}\n${whoDetail?.followers_count ?: who.followers_count}" } - val relation = UserRelation.load(accessInfo.db_id, who.id) + val relation = daoUserRelation.load(accessInfo.db_id, who.id) this.relation = relation setFollowIcon( activity, @@ -414,7 +412,7 @@ internal class ViewHolderHeaderProfile( setAcct(tvMovedAcct, accessInfo, moved) - val relation = UserRelation.load(accessInfo.db_id, moved.id) + val relation = daoUserRelation.load(accessInfo.db_id, moved.id) setFollowIcon( activity, btnMoved, @@ -482,7 +480,11 @@ internal class ViewHolderHeaderProfile( val lastColumn = column DlgTextInput.show( activity, - AcctColor.getStringWithNickname(activity, R.string.personal_notes_of, who.acct), + daoAcctColor.getStringWithNickname( + activity, + R.string.personal_notes_of, + who.acct + ), relation?.note ?: "", allowEmpty = true, callback = object : DlgTextInput.Callback { @@ -551,16 +553,16 @@ internal class ViewHolderHeaderProfile( } private fun setAcct(tv: TextView, accessInfo: SavedAccount, who: TootAccount) { - val ac = AcctColor.load(accessInfo, who) + val ac = daoAcctColor.load(accessInfo, who) tv.text = when { - AcctColor.hasNickname(ac) -> ac.nickname - PrefB.bpShortAcctLocalUser() -> "@${who.acct.pretty}" + daoAcctColor.hasNickname(ac) -> ac.nickname + PrefB.bpShortAcctLocalUser.value -> "@${who.acct.pretty}" else -> "@${ac.nickname}" } - tv.textColor = ac.color_fg.notZero() ?: column.getAcctColor() + tv.textColor = ac.colorFg.notZero() ?: column.getAcctColor() - tv.setBackgroundColor(ac.color_bg) // may 0 + tv.setBackgroundColor(ac.colorBg) // may 0 tv.setPaddingRelative(activity.acctPadLr, 0, activity.acctPadLr, 0) } @@ -594,7 +596,7 @@ internal class ViewHolderHeaderProfile( when { emoji == null -> append("locked") - PrefB.bpUseTwemoji() -> + PrefB.bpUseTwemoji.value -> appendSpan("locked", emoji.createSpan(activity)) else -> append(emoji.unifiedCode) @@ -607,7 +609,7 @@ internal class ViewHolderHeaderProfile( when { emoji == null -> append("bot") - PrefB.bpUseTwemoji() -> + PrefB.bpUseTwemoji.value -> appendSpan("bot", emoji.createSpan(activity)) else -> append(emoji.unifiedCode) @@ -620,7 +622,7 @@ internal class ViewHolderHeaderProfile( when { emoji == null -> append("suspended") - PrefB.bpUseTwemoji() -> + PrefB.bpUseTwemoji.value -> appendSpan("suspended", emoji.createSpan(activity)) else -> append(emoji.unifiedCode) @@ -715,7 +717,7 @@ internal class ViewHolderHeaderProfile( valueText.append(TootStatus.formatTime(activity, item.verified_at, false)) val end = valueText.length - val linkFgColor = PrefI.ipVerifiedLinkFgColor(activity.pref).notZero() + val linkFgColor = PrefI.ipVerifiedLinkFgColor.value.notZero() ?: (Color.BLACK or 0x7fbc99) valueText.setSpan( @@ -737,7 +739,7 @@ internal class ViewHolderHeaderProfile( valueView.movementMethod = MyLinkMovementMethod if (item.verified_at > 0L) { - val linkBgColor = PrefI.ipVerifiedLinkBgColor(activity.pref).notZero() + val linkBgColor = PrefI.ipVerifiedLinkBgColor.value.notZero() ?: (0x337fbc99) valueView.setBackgroundColor(linkBgColor) diff --git a/app/src/main/java/jp/juggler/subwaytooter/dialog/AccountPicker.kt b/app/src/main/java/jp/juggler/subwaytooter/dialog/AccountPicker.kt index 671e9024..84c7f554 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/dialog/AccountPicker.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/dialog/AccountPicker.kt @@ -3,9 +3,7 @@ package jp.juggler.subwaytooter.dialog import android.annotation.SuppressLint import android.app.Dialog import android.content.DialogInterface -import android.text.Spannable import android.text.SpannableStringBuilder -import android.text.style.RelativeSizeSpan import android.view.Gravity import android.view.View import android.widget.LinearLayout @@ -13,8 +11,7 @@ import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.AppCompatButton import jp.juggler.subwaytooter.R -import jp.juggler.subwaytooter.table.AcctColor -import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.* import jp.juggler.util.log.showToast import jp.juggler.util.ui.dismissSafe import jp.juggler.util.ui.getAdaptiveRippleDrawableRound @@ -30,7 +27,7 @@ suspend fun AppCompatActivity.pickAccount( bAllowMastodon: Boolean = true, bAuto: Boolean = false, message: String? = null, - accountListArg: MutableList? = null, + accountListArg: List? = null, dismissCallback: (dialog: DialogInterface) -> Unit = {}, extraCallback: (LinearLayout, Int, Int) -> Unit = { _, _, _ -> }, ): SavedAccount? { @@ -54,11 +51,10 @@ suspend fun AppCompatActivity.pickAccount( else -> 0 } - val accountList: MutableList = accountListArg - ?: SavedAccount.loadAccountList(activity) + val accountList = accountListArg + ?: daoSavedAccount.loadAccountList() .filter { 0 == it.checkMastodon() + it.checkMisskey() + it.checkPseudo() } - .toMutableList() - .also { SavedAccount.sort(it) } + .sortedByNickname() if (accountList.isEmpty()) { @@ -127,17 +123,21 @@ suspend fun AppCompatActivity.pickAccount( LinearLayout.LayoutParams.WRAP_CONTENT ) - val ac = AcctColor.load(a) + val ac = daoAcctColor.load(a) val b = AppCompatButton(activity) - if (AcctColor.hasColorBackground(ac)) { - b.background = getAdaptiveRippleDrawableRound(activity, ac.color_bg, ac.color_fg) + if (daoAcctColor.hasColorBackground(ac)) { + b.background = getAdaptiveRippleDrawableRound( + activity, + ac.colorBg, + ac.colorFg + ) } else { b.setBackgroundResource(R.drawable.btn_bg_transparent_round6dp) } - if (AcctColor.hasColorForeground(ac)) { - b.textColor = ac.color_fg + if (daoAcctColor.hasColorForeground(ac)) { + b.textColor = ac.colorFg } b.setPaddingRelative(padX, padY, padX, padY) @@ -147,19 +147,20 @@ suspend fun AppCompatActivity.pickAccount( b.minHeight = (0.5f + 32f * density).toInt() val sb = SpannableStringBuilder(ac.nickname) - if (a.lastNotificationError?.isNotEmpty() == true) { - sb.append("\n") - val start = sb.length - sb.append(a.lastNotificationError) - val end = sb.length - sb.setSpan(RelativeSizeSpan(0.7f), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - } else if (a.last_subscription_error?.isNotEmpty() == true) { - sb.append("\n") - val start = sb.length - sb.append(a.last_subscription_error) - val end = sb.length - sb.setSpan(RelativeSizeSpan(0.7f), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - } +// TODO エラー状態を表示する +// if (a.lastNotificationError?.isNotEmpty() == true) { +// sb.append("\n") +// val start = sb.length +// sb.append(a.lastNotificationError) +// val end = sb.length +// sb.setSpan(RelativeSizeSpan(0.7f), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) +// } else if (a.last_subscription_error?.isNotEmpty() == true) { +// sb.append("\n") +// val start = sb.length +// sb.append(a.last_subscription_error) +// val end = sb.length +// sb.setSpan(RelativeSizeSpan(0.7f), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) +// } b.text = sb b.setOnClickListener { diff --git a/app/src/main/java/jp/juggler/subwaytooter/dialog/ActionsDialog.kt b/app/src/main/java/jp/juggler/subwaytooter/dialog/ActionsDialog.kt index b2653aa7..8eec7cef 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/dialog/ActionsDialog.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/dialog/ActionsDialog.kt @@ -1,35 +1,50 @@ package jp.juggler.subwaytooter.dialog import android.content.Context -import androidx.appcompat.app.AlertDialog -import jp.juggler.subwaytooter.R import jp.juggler.util.data.notEmpty +import jp.juggler.util.ui.dismissSafe +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine import java.util.* +import kotlin.coroutines.resumeWithException -class ActionsDialog { +class ActionsDialogInitializer( + val title: CharSequence? = null, +) { + class Action(val caption: CharSequence, val action: suspend () -> Unit) - private val actionList = ArrayList() + val list = ArrayList() - private class Action(val caption: CharSequence, val action: () -> Unit) - - fun addAction(caption: CharSequence, action: () -> Unit): ActionsDialog { - - actionList.add(Action(caption, action)) - - return this + fun action(caption: CharSequence, action: suspend () -> Unit) { + list.add(Action(caption, action)) } - fun show(context: Context, title: CharSequence? = null): ActionsDialog { - AlertDialog.Builder(context).apply { - setNegativeButton(R.string.cancel, null) - setItems(actionList.map { it.caption }.toTypedArray()) { _, which -> - if (which >= 0 && which < actionList.size) { - actionList[which].action() + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun showSuspend(context: Context): Action = + suspendCancellableCoroutine { cont -> + val dialog = android.app.AlertDialog.Builder(context).apply { + title?.notEmpty()?.let { setTitle(it) } + setNegativeButton(android.R.string.cancel, null) + setItems(list.map { it.caption }.toTypedArray()) { d, i -> + if (cont.isActive) cont.resume(list[i]) {} + d.dismissSafe() } - } - title?.notEmpty()?.let { setTitle(it) } - }.show() - - return this - } + setOnDismissListener { + if (cont.isActive) cont.resumeWithException(CancellationException()) + } + }.create() + cont.invokeOnCancellation { dialog.dismissSafe() } + dialog.show() + } +} + +suspend fun Context.actionsDialog( + title: String? = null, + init: suspend ActionsDialogInitializer.() -> Unit, +) { + ActionsDialogInitializer(title) + .apply { init() } + .showSuspend(this) + .action.invoke() } diff --git a/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgConfirm.kt b/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgConfirm.kt index c7473c75..07e132fb 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgConfirm.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgConfirm.kt @@ -64,13 +64,13 @@ object DlgConfirm { // } @SuppressLint("InflateParams") - suspend fun AppCompatActivity.confirm( + suspend inline fun AppCompatActivity.confirm( message: String, - getConfirmEnabled: Boolean, + isConfirmEnabled: Boolean, setConfirmEnabled: (newConfirmEnabled: Boolean) -> Unit, ) { - if (!getConfirmEnabled) return - suspendCancellableCoroutine { cont -> + if (!isConfirmEnabled) return + val skipNext = suspendCancellableCoroutine { cont -> try { val views = DlgConfirmBinding.inflate(layoutInflater) views.tvMessage.text = message @@ -79,10 +79,7 @@ object DlgConfirm { .setCancelable(true) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok) { _, _ -> - if (views.cbSkipNext.isChecked) { - setConfirmEnabled(false) - } - if (cont.isActive) cont.resume(Unit) + if (cont.isActive) cont.resume(views.cbSkipNext.isChecked) } dialog.setOnDismissListener { if (cont.isActive) cont.resumeWithException(CancellationException("dialog cancelled.")) @@ -92,6 +89,7 @@ object DlgConfirm { cont.resumeWithException(ex) } } + if (skipNext) setConfirmEnabled(false) } suspend fun AppCompatActivity.confirm(@StringRes messageId: Int, vararg args: Any?) = diff --git a/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgDraftPicker.kt b/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgDraftPicker.kt index 0f9597e9..8512ba4d 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgDraftPicker.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgDraftPicker.kt @@ -8,18 +8,20 @@ import android.view.ViewGroup import android.widget.AdapterView import android.widget.BaseAdapter import android.widget.ListView -import android.widget.TextView import androidx.appcompat.app.AlertDialog import jp.juggler.subwaytooter.ActPost import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.actpost.DRAFT_CONTENT import jp.juggler.subwaytooter.actpost.DRAFT_CONTENT_WARNING import jp.juggler.subwaytooter.api.entity.TootStatus +import jp.juggler.subwaytooter.databinding.LvDraftPickerBinding import jp.juggler.subwaytooter.table.PostDraft +import jp.juggler.subwaytooter.table.daoPostDraft import jp.juggler.util.* import jp.juggler.util.coroutine.AppDispatchers -import jp.juggler.util.coroutine.launchMain +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.JsonObject +import jp.juggler.util.data.cast import jp.juggler.util.log.LogCategory import jp.juggler.util.log.showToast import jp.juggler.util.ui.dismissSafe @@ -41,7 +43,7 @@ class DlgDraftPicker : AdapterView.OnItemClickListener, AdapterView.OnItemLongCl private lateinit var adapter: MyAdapter private lateinit var dialog: AlertDialog - private var cursor: Cursor? = null + private var listCursor: Cursor? = null private var colIdx: PostDraft.ColIdx? = null private var task: Job? = null @@ -60,25 +62,22 @@ class DlgDraftPicker : AdapterView.OnItemClickListener, AdapterView.OnItemLongCl position: Int, id: Long, ): Boolean { - - val draft = getPostDraft(position) - if (draft != null) { - activity.showToast(false, R.string.draft_deleted) - draft.delete() - reload() - return true + activity.launchAndShowError { + getPostDraft(position)?.let { + daoPostDraft.delete(it) + reload() + activity.showToast(false, R.string.draft_deleted) + } } - - return false + return true } override fun onDismiss(dialog: DialogInterface) { task?.cancel() task = null - lvDraft.adapter = null - - cursor?.close() + listCursor?.close() + listCursor = null } @SuppressLint("InflateParams") @@ -107,59 +106,53 @@ class DlgDraftPicker : AdapterView.OnItemClickListener, AdapterView.OnItemLongCl reload() } - private fun updateCursor(newCursor: Cursor?) { - if (!dialog.isShowing) { - // dialog is already closed. - newCursor?.close() - } else if (newCursor != null) { - val old = this.cursor - this.cursor = newCursor - colIdx = PostDraft.ColIdx(newCursor) - adapter.notifyDataSetChanged() - old?.close() - } - } - private fun reload() { // cancel old task task?.cancel() - task = launchMain { - val cursor = try { + task = activity.launchAndShowError { + val newCursor = try { withContext(AppDispatchers.IO) { - PostDraft.createCursor() - } ?: error("cursor is null") + daoPostDraft.createCursor() + } } catch (ignored: CancellationException) { - return@launchMain + return@launchAndShowError } catch (ex: Throwable) { log.e(ex, "failed to loading drafts.") activity.showToast(ex, "failed to loading drafts.") - return@launchMain + return@launchAndShowError + } + + if (!dialog.isShowing) { + // dialog is already closed. + newCursor.close() + } else { + val old = listCursor + listCursor = newCursor + colIdx = PostDraft.ColIdx(newCursor) + adapter.notifyDataSetChanged() + old?.close() } - updateCursor(cursor) } } - private fun getPostDraft(position: Int): PostDraft? { - val cursor = this.cursor - return if (cursor == null) null else PostDraft.loadFromCursor(cursor, colIdx, position) - } - - private inner class MyViewHolder(view: View) { - - val tvTime: TextView - val tvText: TextView - - init { - tvTime = view.findViewById(R.id.tvTime) - tvText = view.findViewById(R.id.tvText) + private fun getPostDraft(position: Int): PostDraft? = + listCursor?.let { + daoPostDraft.loadFromCursor(it, colIdx, position) } - fun bind(position: Int) { - val draft = getPostDraft(position) ?: return + private inner class MyViewHolder( + parent: ViewGroup?, + ) { + val views = LvDraftPickerBinding.inflate(activity.layoutInflater, parent, false) + .also { it.root.tag = this } - tvTime.text = TootStatus.formatTime(tvTime.context, draft.time_save, false) + fun bind(draft: PostDraft?) { + draft ?: return + val context = views.root.context + views.tvTime.text = + TootStatus.formatTime(context, draft.time_save, false) val json = draft.json if (json != null) { @@ -173,38 +166,18 @@ class DlgDraftPicker : AdapterView.OnItemClickListener, AdapterView.OnItemLongCl if (sb.isNotEmpty()) sb.append("\n") sb.append(c) } - tvText.text = sb + views.tvText.text = sb } } } private inner class MyAdapter : BaseAdapter() { - - override fun getCount(): Int { - return cursor?.count ?: 0 - } - - override fun getItem(position: Int): Any? { - return getPostDraft(position) - } - - override fun getItemId(position: Int): Long { - return 0 - } - - override fun getView(position: Int, viewOld: View?, parent: ViewGroup): View { - val view: View - val holder: MyViewHolder - if (viewOld == null) { - view = activity.layoutInflater.inflate(R.layout.lv_draft_picker, parent, false) - holder = MyViewHolder(view) - view.tag = holder - } else { - view = viewOld - holder = view.tag as MyViewHolder - } - holder.bind(position) - return view - } + override fun getCount() = listCursor?.count ?: 0 + override fun getItemId(position: Int) = 0L + override fun getItem(position: Int) = getPostDraft(position) + override fun getView(position: Int, convertView: View?, parent: ViewGroup) = + (convertView?.tag?.cast() ?: MyViewHolder(parent)) + .also { it.bind(getItem(position)) } + .views.root } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgListMember.kt b/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgListMember.kt index 30acc8e2..a132f3bb 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgListMember.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgListMember.kt @@ -13,8 +13,9 @@ import jp.juggler.subwaytooter.action.* import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.calcIconRound -import jp.juggler.subwaytooter.table.AcctColor import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.accountListNonPseudo +import jp.juggler.subwaytooter.table.daoAcctColor import jp.juggler.subwaytooter.util.NetworkEmojiInvalidator import jp.juggler.subwaytooter.view.MyListView import jp.juggler.subwaytooter.view.MyNetworkImageView @@ -48,7 +49,7 @@ class DlgListMember( private val adapter: MyListAdapter init { - this.accountList = activity.accountListNonPseudo(null) + this.accountList = accountListNonPseudo(null) this.targetUserFullAcct = listOwnerArg.getFullAcct(who) this.listOwner = if (listOwnerArg.isPseudo) { @@ -135,15 +136,15 @@ class DlgListMember( btnListOwner.setBackgroundResource(R.drawable.btn_bg_transparent_round6dp) // } else { - val ac = AcctColor.load(a) + val ac = daoAcctColor.load(a) btnListOwner.text = ac.nickname - if (AcctColor.hasColorBackground(ac)) { - btnListOwner.setBackgroundColor(ac.color_bg) + if (daoAcctColor.hasColorBackground(ac)) { + btnListOwner.setBackgroundColor(ac.colorBg) } else { btnListOwner.setBackgroundResource(R.drawable.btn_bg_transparent_round6dp) } - btnListOwner.textColor = ac.color_fg.notZero() + btnListOwner.textColor = ac.colorFg.notZero() ?: activity.attrColor(android.R.attr.textColorPrimary) } diff --git a/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgQuickTootMenu.kt b/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgQuickTootMenu.kt index 3eb70b0d..c1e35371 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgQuickTootMenu.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/dialog/DlgQuickTootMenu.kt @@ -11,7 +11,6 @@ import android.widget.EditText import jp.juggler.subwaytooter.* import jp.juggler.subwaytooter.api.entity.TootVisibility import jp.juggler.subwaytooter.pref.PrefS -import jp.juggler.subwaytooter.pref.put import jp.juggler.util.ui.dismissSafe import java.lang.ref.WeakReference @@ -125,17 +124,14 @@ class DlgQuickTootMenu( } private fun loadStrings() = - PrefS.spQuickTootMacro(activity.pref).split("\n") + PrefS.spQuickTootMacro.value.split("\n") - private fun saveStrings() = activity.pref - .edit() - .put( - PrefS.spQuickTootMacro, + private fun saveStrings() { + PrefS.spQuickTootMacro.value = etText.joinToString("\n") { (it?.text?.toString() ?: "").replace("\n", " ") } - ) - .apply() + } override fun onClick(v: View?) { // TODO when (v?.id) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/dialog/EmojiPicker.kt b/app/src/main/java/jp/juggler/subwaytooter/dialog/EmojiPicker.kt index f8677035..fd0df936 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/dialog/EmojiPicker.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/dialog/EmojiPicker.kt @@ -24,10 +24,8 @@ import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.databinding.EmojiPickerDialogBinding import jp.juggler.subwaytooter.emoji.* -import jp.juggler.subwaytooter.global.appPref import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefS -import jp.juggler.subwaytooter.pref.put import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.util.minHeightCompat import jp.juggler.subwaytooter.util.minWidthCompat @@ -103,7 +101,7 @@ private class EmojiPicker( private val recentsJsonList: List? get() = try { - PrefS.spEmojiPickerRecent().decodeJsonArray().objectList() + PrefS.spEmojiPickerRecent.value.decodeJsonArray().objectList() } catch (ex: Throwable) { log.w(ex, "can't load spEmojiPickerRecent") null @@ -176,8 +174,7 @@ private class EmojiPicker( // 保存する try { - val sv = list.toJsonArray().toString() - appPref.edit().put(PrefS.spEmojiPickerRecent, sv).apply() + PrefS.spEmojiPickerRecent.value = list.toJsonArray().toString() } catch (ex: Throwable) { log.e(ex, "can't save spEmojiPickerRecent") } @@ -396,8 +393,8 @@ private class EmojiPicker( private val gridSize = (0.5f + 48f * activity.resources.displayMetrics.density).toInt() private val matchParent = RecyclerView.LayoutParams.MATCH_PARENT - private val useTwemoji = PrefB.bpUseTwemoji() - private val disableAnimation = PrefB.bpDisableEmojiAnimation() + private val useTwemoji = PrefB.bpUseTwemoji.value + private val disableAnimation = PrefB.bpDisableEmojiAnimation.value private var selectedTone: SkinTone = (ibSkinTone[0].tag as SkinTone) @@ -524,7 +521,7 @@ private class EmojiPicker( EmojiCategory.Symbols, EmojiCategory.Flags, ) - if (PrefB.bpEmojiPickerCategoryOther(activity)) { + if (PrefB.bpEmojiPickerCategoryOther.value) { categories.add(EmojiCategory.Others) } for (category in categories) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/dialog/SuspendProgress.kt b/app/src/main/java/jp/juggler/subwaytooter/dialog/SuspendProgress.kt new file mode 100644 index 00000000..5716da26 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/dialog/SuspendProgress.kt @@ -0,0 +1,96 @@ +package jp.juggler.subwaytooter.dialog + +import android.app.Dialog +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import jp.juggler.subwaytooter.databinding.DlgSuspendProgressBinding +import jp.juggler.util.coroutine.AppDispatchers +import jp.juggler.util.log.LogCategory +import jp.juggler.util.ui.dismissSafe +import jp.juggler.util.ui.vg +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +private val log = LogCategory("SuspendProgress") + +class SuspendProgress(val activity: AppCompatActivity) { + + private val views = DlgSuspendProgressBinding.inflate(activity.layoutInflater) + private val dialog = Dialog(activity) + + suspend fun run( + message: String, + title: String, + cancellable: Boolean, + block: suspend (Reporter) -> T?, + ): T? = Reporter().use { reporter -> + try { + dialog.setContentView(views.root) + reporter.setMessage(message) + reporter.setTitle(title) + dialog.setCancelable(cancellable) + dialog.setCanceledOnTouchOutside(cancellable) + dialog.show() + block(reporter) + } finally { + dialog.dismissSafe() + } + } + + inner class Reporter : AutoCloseable { + private val flowMessage = MutableStateFlow("") + private val flowTitle = MutableStateFlow("") + + private val jobMessage = activity.lifecycleScope.launch(AppDispatchers.MainImmediate) { + try { + flowMessage.collect { + views.tvMessage.vg(it.isNotEmpty())?.text = it + } + } catch (ex: Throwable) { + when (ex) { + is CancellationException, is ClosedReceiveChannelException -> Unit + else -> log.w(ex, "error.") + } + } + } + private val jobTitle = activity.lifecycleScope.launch(AppDispatchers.MainImmediate) { + try { + flowTitle.collect { + views.tvTitle.vg(it.isNotEmpty())?.text = it + } + } catch (ex: Throwable) { + when (ex) { + is CancellationException, is ClosedReceiveChannelException -> Unit + else -> log.w(ex, "error.") + } + } + } + + override fun close() { + jobMessage.cancel() + jobTitle.cancel() + } + + fun setMessage(msg: CharSequence) { + flowMessage.value = msg + } + + fun setTitle(title: CharSequence) { + flowTitle.value = title + } + } +} + +suspend fun AppCompatActivity.runInProgress( + message: String = "please wait…", + title: String = "", + cancellable: Boolean = true, + block: suspend (SuspendProgress.Reporter) -> T?, +): T? = SuspendProgress(this).run( + message = message, + title = title, + cancellable = cancellable, + block = block +) diff --git a/app/src/main/java/jp/juggler/subwaytooter/emoji/EmojiBase.kt b/app/src/main/java/jp/juggler/subwaytooter/emoji/EmojiBase.kt index 8f334613..26f2b264 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/emoji/EmojiBase.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/emoji/EmojiBase.kt @@ -74,7 +74,7 @@ class CustomEmoji( get() = shortcode fun chooseUrl() = when { - PrefB.bpDisableEmojiAnimation() -> staticUrl + PrefB.bpDisableEmojiAnimation.value -> staticUrl else -> url } diff --git a/app/src/main/java/jp/juggler/subwaytooter/global/AppPrefHolder.kt b/app/src/main/java/jp/juggler/subwaytooter/global/AppPrefHolder.kt deleted file mode 100644 index 7e552724..00000000 --- a/app/src/main/java/jp/juggler/subwaytooter/global/AppPrefHolder.kt +++ /dev/null @@ -1,13 +0,0 @@ -package jp.juggler.subwaytooter.global - -import android.content.Context -import android.content.SharedPreferences -import jp.juggler.subwaytooter.pref.pref - -interface AppPrefHolder { - val pref: SharedPreferences -} - -class AppPrefHolderImpl(context: Context) : AppPrefHolder { - override val pref = context.applicationContext.pref() -} diff --git a/app/src/main/java/jp/juggler/subwaytooter/global/Global.kt b/app/src/main/java/jp/juggler/subwaytooter/global/Global.kt deleted file mode 100644 index 57106912..00000000 --- a/app/src/main/java/jp/juggler/subwaytooter/global/Global.kt +++ /dev/null @@ -1,61 +0,0 @@ -package jp.juggler.subwaytooter.global - -import android.content.Context -import androidx.startup.Initializer -import jp.juggler.util.log.LogCategory -import org.koin.android.ext.koin.androidContext -import org.koin.core.Koin -import org.koin.core.context.startKoin -import org.koin.dsl.module -import org.koin.mp.KoinPlatformTools - -val appDatabase by lazy { getKoin().get().database } - -val appPref by lazy { getKoin().get().pref } - -fun getKoin(): Koin = KoinPlatformTools.defaultContext().get() - -object Global { - private val log = LogCategory("Global") - - private var isPrepared = false - - fun prepare(contextArg: Context, caller: String): Global { - // double check befort/after lock - if (!isPrepared) { - synchronized(this) { - if (!isPrepared) { - isPrepared = true - log.i("prepare. caller=$caller") - startKoin { - androidContext(contextArg) - modules(module { - single { - val context: Context = get() - log.i("AppPrefHolderImpl: context=$context") - AppPrefHolderImpl(context) - } - single { - val context: Context = get() - log.i("AppDatabaseHolderImpl: context=$context") - AppDatabaseHolderImpl(context) - } - }) - } - getKoin().get().afterGlobalPrepare() - } - } - } - return this - } -} - -class GlobalInitializer : Initializer { - override fun dependencies(): MutableList>> { - return mutableListOf() - } - - override fun create(context: Context): Global { - return Global.prepare(context, "GlobalInitializer") - } -} diff --git a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/DlgContextMenu.kt b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/DlgContextMenu.kt index 565afe00..d4e3403a 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/DlgContextMenu.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/DlgContextMenu.kt @@ -27,9 +27,7 @@ import jp.juggler.subwaytooter.dialog.DlgQRCode import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefI import jp.juggler.subwaytooter.span.MyClickableSpan -import jp.juggler.subwaytooter.table.FavMute -import jp.juggler.subwaytooter.table.SavedAccount -import jp.juggler.subwaytooter.table.UserRelation +import jp.juggler.subwaytooter.table.* import jp.juggler.subwaytooter.util.* import jp.juggler.util.* import jp.juggler.util.data.* @@ -83,8 +81,8 @@ internal class DlgContextMenu( this.relation = when { who == null -> UserRelation() - accessInfo.isPseudo -> UserRelation.loadPseudo(accessInfo.getFullAcct(who)) - else -> UserRelation.load(accessInfo.db_id, who.id) + accessInfo.isPseudo -> daoUserRelation.loadPseudo(accessInfo.getFullAcct(who)) + else -> daoUserRelation.load(accessInfo.db_id, who.id) } this.dialog = Dialog(activity) @@ -109,7 +107,7 @@ internal class DlgContextMenu( views.btnSendMessage, ).forEach { it.setOnLongClickListener(this) } - val accountList = SavedAccount.loadAccountList(activity) + val accountList = daoSavedAccount.loadAccountList() val accountListNonPseudo = ArrayList() for (a in accountList) { @@ -127,7 +125,7 @@ internal class DlgContextMenu( } else { val statusByMe = accessInfo.isMe(status.account) - if (PrefB.bpLinksInContextMenu(activity.pref) && contentTextView != null) { + if (PrefB.bpLinksInContextMenu.value && contentTextView != null) { var insPos = 0 @@ -229,11 +227,11 @@ internal class DlgContextMenu( views.llNotification.vg(notification != null) val colorButtonAccent = - PrefI.ipButtonFollowingColor(activity.pref).notZero() + PrefI.ipButtonFollowingColor.value.notZero() ?: activity.attrColor(R.attr.colorButtonAccentFollow) val colorButtonFollowRequest = - PrefI.ipButtonFollowRequestColor.invoke(activity.pref).notZero() + PrefI.ipButtonFollowRequestColor.value.notZero() ?: activity.attrColor(R.attr.colorButtonAccentFollowRequest) val colorButtonNormal = @@ -320,7 +318,7 @@ internal class DlgContextMenu( ) views.btnDomainTimeline.vg( - PrefB.bpEnableDomainTimeline.invoke(activity.pref) && + PrefB.bpEnableDomainTimeline.value && !accessInfo.isPseudo && !accessInfo.isMisskey ) @@ -396,7 +394,7 @@ internal class DlgContextMenu( views.btnShowFavourite.visibility = View.GONE } - FavMute.contains(accessInfo.getFullAcct(who)) -> { + daoFavMute.contains(accessInfo.getFullAcct(who)) -> { views.btnHideFavourite.visibility = View.GONE views.btnShowFavourite.visibility = View.VISIBLE } @@ -450,7 +448,7 @@ internal class DlgContextMenu( } when { - PrefB.bpAlwaysExpandContextMenuItems(activity.pref) -> { + PrefB.bpAlwaysExpandContextMenuItems.value -> { group.vg(true) btn.background = null } diff --git a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolder.kt b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolder.kt index 3745d67f..7defe836 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolder.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolder.kt @@ -575,7 +575,7 @@ class ItemViewHolder( ivCardImage = myNetworkImageView { contentDescription = context.getString(R.string.thumbnail) scaleType = when { - PrefB.bpDontCropMediaThumb() -> ImageView.ScaleType.FIT_CENTER + PrefB.bpDontCropMediaThumb.value -> ImageView.ScaleType.FIT_CENTER else -> ImageView.ScaleType.CENTER_CROP } }.lparams(0, matchParent) { @@ -682,7 +682,7 @@ class ItemViewHolder( } val thumbnailHeight = actMain.appState.mediaThumbHeight - flMedia = when (PrefB.bpVerticalArrangeThumbnails(actMain.pref)) { + flMedia = when (PrefB.bpVerticalArrangeThumbnails.value) { true -> inflateVerticalMedia(thumbnailHeight) else -> inflateHorizontalMedia(thumbnailHeight) } @@ -719,7 +719,7 @@ class ItemViewHolder( actMain, matchParent, 3f, - justifyContent = when (PrefI.ipBoostButtonJustify()) { + justifyContent = when (PrefI.ipBoostButtonJustify.value) { 0 -> JustifyContent.FLEX_START 1 -> JustifyContent.CENTER else -> JustifyContent.FLEX_END diff --git a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderActions.kt b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderActions.kt index b7d2c917..2287f707 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderActions.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderActions.kt @@ -11,16 +11,19 @@ import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.column.Column import jp.juggler.subwaytooter.column.startGap import jp.juggler.subwaytooter.pref.PrefB -import jp.juggler.subwaytooter.table.ContentWarning -import jp.juggler.subwaytooter.table.MediaShown +import jp.juggler.subwaytooter.table.daoContentWarning +import jp.juggler.subwaytooter.table.daoMediaShown import jp.juggler.subwaytooter.util.copyToClipboard import jp.juggler.subwaytooter.util.openCustomTab +import jp.juggler.util.coroutine.AppDispatchers +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.cast import jp.juggler.util.data.ellipsizeDot3 import jp.juggler.util.data.notEmpty import jp.juggler.util.log.LogCategory import jp.juggler.util.log.showToast import jp.juggler.util.ui.vg +import kotlinx.coroutines.withContext private val log = LogCategory("ItemViewHolderActions") @@ -221,7 +224,7 @@ private fun ItemViewHolder.clickMedia(i: Int) { } // 内蔵メディアビューアを使う - PrefB.bpUseInternalMediaViewer() -> + PrefB.bpUseInternalMediaViewer.value -> ActMediaViewer.open( activity, column.showMediaDescription, @@ -260,16 +263,36 @@ private fun ItemViewHolder.showHideMediaViews(show: Boolean) { llCardImage.vg(show) btnShowMedia.vg(!show) btnCardImageShow.vg(!show) - statusShowing?.let { MediaShown.save(it, show) } - item.cast()?.let { MediaShown.save(it.uri, show) } + val statusShowing = this.statusShowing + val item = this.item + activity.launchAndShowError { + withContext(AppDispatchers.IO) { + statusShowing?.let { + daoMediaShown.save(it, show) + } + item.cast()?.let { + daoMediaShown.save(it.uri, show) + } + } + } } private fun ItemViewHolder.toggleContentWarning() { // トグル動作 val show = llContents.visibility == View.GONE - statusShowing?.let { ContentWarning.save(it, show) } - item.cast()?.let { ContentWarning.save(it.uri, show) } + val statusShowing = statusShowing + val item = item + activity.launchAndShowError { + withContext(AppDispatchers.IO) { + statusShowing?.let { + daoContentWarning.save(it, show) + } + item.cast()?.let { + daoContentWarning.save(it.uri, show) + } + } + } // 1個だけ開閉するのではなく、例えば通知TLにある複数の要素をまとめて開閉するなどある listAdapter.notifyChange(reason = "ContentWarning onClick", reset = true) diff --git a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderPreviewCard.kt b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderPreviewCard.kt index bbf5dc36..5c6579ee 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderPreviewCard.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderPreviewCard.kt @@ -6,7 +6,7 @@ import jp.juggler.subwaytooter.api.entity.TootStatus import jp.juggler.subwaytooter.column.isConversation import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefS -import jp.juggler.subwaytooter.table.MediaShown +import jp.juggler.subwaytooter.table.daoMediaShown import jp.juggler.subwaytooter.util.DecodeOptions import jp.juggler.subwaytooter.util.HTMLDecoder import jp.juggler.util.data.ellipsize @@ -48,7 +48,7 @@ private fun addLinkAndCaption( fun ItemViewHolder.showPreviewCard(status: TootStatus) { - if (PrefB.bpDontShowPreviewCard(activity.pref)) return + if (PrefB.bpDontShowPreviewCard.value) return val card = status.card ?: return @@ -112,7 +112,7 @@ fun ItemViewHolder.showPreviewCard(status: TootStatus) { if (description != null && description.isNotEmpty()) { if (sb.isNotEmpty()) sb.append("
") - val limit = PrefS.spCardDescriptionLength.toInt(activity.pref) + val limit = PrefS.spCardDescriptionLength.toInt() sb.append( HTMLDecoder.encodeEntity( @@ -150,7 +150,7 @@ fun ItemViewHolder.showPreviewCard(status: TootStatus) { accessInfo.dont_hide_nsfw -> true else -> !status.sensitive } - val isShown = MediaShown.isShown(status, defaultShown) + val isShown = daoMediaShown.isShown(status, defaultShown) llCardImage.vg(isShown) btnCardImageShow.vg(!isShown) } diff --git a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderReaction.kt b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderReaction.kt index aa1c3741..68dd3b35 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderReaction.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderReaction.kt @@ -31,7 +31,7 @@ import org.jetbrains.anko.dip fun ItemViewHolder.makeReactionsView(status: TootStatus) { val reactionSet = status.reactionSet if (reactionSet?.hasReaction() != true) { - if (!TootReaction.canReaction(accessInfo) || !PrefB.bpKeepReactionSpace(activity.pref)) return + if (!TootReaction.canReaction(accessInfo) || !PrefB.bpKeepReactionSpace.value) return } val density = activity.density @@ -95,7 +95,7 @@ fun ItemViewHolder.makeReactionsView(status: TootStatus) { // 自分がリアクションしたやつは背景を変える getAdaptiveRippleDrawableRound( act, - PrefI.ipButtonReactionedColor.invoke(act.pref).notZero() + PrefI.ipButtonReactionedColor.value.notZero() ?: act.attrColor(R.attr.colorButtonAccentReaction), act.attrColor(R.attr.colorRippleEffect), roundNormal = true diff --git a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShow.kt b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShow.kt index 67e9a126..1f9d1eb9 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShow.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShow.kt @@ -204,14 +204,14 @@ fun ItemViewHolder.bind( item.isQuoteToot -> { // 引用Renote - val colorBg = PrefI.ipEventBgColorBoost.invoke(activity.pref) + val colorBg = PrefI.ipEventBgColorBoost.value showReply(item.account, reblog, R.drawable.ic_quote, R.string.quote_to) showStatus(item, colorBg) } else -> { // 引用なしブースト - val colorBg = PrefI.ipEventBgColorBoost.invoke(activity.pref) + val colorBg = PrefI.ipEventBgColorBoost.value showBoost( item.accountRef, item.time_created_at, @@ -284,7 +284,7 @@ fun ItemViewHolder.showAccount(whoRef: TootAccountRef) { } ) - val relation = UserRelation.load(accessInfo.db_id, who.id) + val relation = daoUserRelation.load(accessInfo.db_id, who.id) setFollowIcon( activity, btnFollow, @@ -457,7 +457,7 @@ fun ItemViewHolder.showGap() { btnGapTail.vg(column.type.gapDirection(column, false)) ?.imageTintList = contentColorCsl - val c = PrefI.ipEventBgColorGap() + val c = PrefI.ipEventBgColorGap.value if (c != 0) this.viewRoot.backgroundColor = c } @@ -515,11 +515,11 @@ fun ItemViewHolder.showReply(replyer: TootAccount?, reply: TootStatus, iconId: I fun ItemViewHolder.showReply(replyer: TootAccount?, reply: TootStatus, accountId: EntityId) { val name = if (accountId == reply.account.id) { // 自己レスなら - AcctColor.getNicknameWithColor(accessInfo, reply.account) + daoAcctColor.getNicknameWithColor(accessInfo, reply.account) } else { val m = reply.mentions?.find { it.id == accountId } if (m != null) { - AcctColor.getNicknameWithColor(accessInfo.getFullAcct(m.acct)) + daoAcctColor.getNicknameWithColor(accessInfo.getFullAcct(m.acct)) } else { SpannableString("ID($accountId)") } @@ -589,17 +589,13 @@ fun ItemViewHolder.showStatusTime( } // visibility - val visIconId = getVisibilityIconId(accessInfo.isMisskey, status.visibility) + val visIconId = status.visibility.getVisibilityIconId(accessInfo.isMisskey) if (R.drawable.ic_public != visIconId) { if (sb.isNotEmpty()) sb.append('\u200B') sb.appendColorShadeIcon( activity, visIconId, - getVisibilityString( - activity, - accessInfo.isMisskey, - status.visibility - ) + status.visibility.getVisibilityString(accessInfo.isMisskey) ) } @@ -651,17 +647,13 @@ fun ItemViewHolder.showStatusTime( } } else { reblogVisibility?.takeIf { it != TootVisibility.Unknown }?.let { visibility -> - val visIconId = getVisibilityIconId(accessInfo.isMisskey, visibility) + val visIconId = visibility.getVisibilityIconId(accessInfo.isMisskey) if (R.drawable.ic_public != visIconId) { if (sb.isNotEmpty()) sb.append('\u200B') sb.appendColorShadeIcon( activity, visIconId, - getVisibilityString( - activity, - accessInfo.isMisskey, - visibility - ) + visibility.getVisibilityString(accessInfo.isMisskey) ) } } @@ -692,17 +684,13 @@ fun ItemViewHolder.showStatusTimeScheduled( } // visibility - val visIconId = getVisibilityIconId(accessInfo.isMisskey, item.visibility) + val visIconId = item.visibility.getVisibilityIconId(accessInfo.isMisskey) if (R.drawable.ic_public != visIconId) { if (sb.isNotEmpty()) sb.append('\u200B') sb.appendColorShadeIcon( activity, visIconId, - getVisibilityString( - activity, - accessInfo.isMisskey, - item.visibility - ) + item.visibility.getVisibilityString(accessInfo.isMisskey) ) } @@ -771,7 +759,7 @@ fun ItemViewHolder.showScheduled(item: TootScheduled) { llContentWarning.visibility = View.VISIBLE tvContentWarning.text = decodedSpoilerText spoilerInvalidator.register(decodedSpoilerText) - val cwShown = ContentWarning.isShown(item.uri, accessInfo.expand_cw) + val cwShown = daoContentWarning.isShown(item.uri, accessInfo.expand_cw) setContentVisibility(cwShown) } @@ -796,7 +784,7 @@ fun ItemViewHolder.showScheduled(item: TootScheduled) { accessInfo.dont_hide_nsfw -> true else -> !item.sensitive } - val isShown = MediaShown.isShown(item.uri, defaultShown) + val isShown = daoMediaShown.isShown(item.uri, defaultShown) btnShowMedia.visibility = if (!isShown) View.VISIBLE else View.GONE llMedia.visibility = if (!isShown) View.GONE else View.VISIBLE @@ -870,14 +858,14 @@ fun ItemViewHolder.showConversationIcons(cs: TootConversationSummary) { } fun ItemViewHolder.setAcct(tv: TextView, accessInfo: SavedAccount, who: TootAccount) { - val ac = AcctColor.load(accessInfo, who) + val ac = daoAcctColor.load(accessInfo, who) tv.text = when { - AcctColor.hasNickname(ac) -> ac.nickname - PrefB.bpShortAcctLocalUser() -> "@${who.acct.pretty}" + daoAcctColor.hasNickname(ac) -> ac.nickname + PrefB.bpShortAcctLocalUser.value -> "@${who.acct.pretty}" else -> "@${ac.nickname}" } - tv.textColor = ac.color_fg.notZero() ?: this.acctColor + tv.textColor = ac.colorFg.notZero() ?: this.acctColor - tv.setBackgroundColor(ac.color_bg) // may 0 + tv.setBackgroundColor(ac.colorBg) // may 0 tv.setPaddingRelative(activity.acctPadLr, 0, activity.acctPadLr, 0) } diff --git a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShowNotification.kt b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShowNotification.kt index f485b048..8bb9454f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShowNotification.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShowNotification.kt @@ -81,7 +81,7 @@ private fun ItemViewHolder.showNotificationFollow( n: TootNotification, nAccountRef: TootAccountRef?, ) { - val colorBg = PrefI.ipEventBgColorFollow(activity.pref) + val colorBg = PrefI.ipEventBgColorFollow.value colorBg.notZero()?.let { viewRoot.backgroundColor = it } nAccountRef?.let { showBoost( @@ -98,7 +98,7 @@ private fun ItemViewHolder.showNotificationUnfollow( n: TootNotification, nAccountRef: TootAccountRef?, ) { - val colorBg = PrefI.ipEventBgColorUnfollow(activity.pref) + val colorBg = PrefI.ipEventBgColorUnfollow.value colorBg.notZero()?.let { viewRoot.backgroundColor = it } nAccountRef?.let { showBoost( @@ -115,7 +115,7 @@ private fun ItemViewHolder.showNotificationSignup( n: TootNotification, nAccountRef: TootAccountRef?, ) { - val colorBg = PrefI.ipEventBgColorSignUp(activity.pref) + val colorBg = PrefI.ipEventBgColorSignUp.value colorBg.notZero()?.let { viewRoot.backgroundColor = it } nAccountRef?.let { showBoost( @@ -132,7 +132,7 @@ private fun ItemViewHolder.showNotificationFollowRequest( n: TootNotification, nAccountRef: TootAccountRef?, ) { - val colorBg = PrefI.ipEventBgColorFollowRequest(activity.pref) + val colorBg = PrefI.ipEventBgColorFollowRequest.value colorBg.notZero()?.let { viewRoot.backgroundColor = it } nAccountRef?.let { showBoost( @@ -152,7 +152,7 @@ private fun ItemViewHolder.showNotificationFollowRequestAccepted( n: TootNotification, nAccountRef: TootAccountRef?, ) { - val colorBg = PrefI.ipEventBgColorFollow(activity.pref) + val colorBg = PrefI.ipEventBgColorFollow.value colorBg.notZero()?.let { viewRoot.backgroundColor = it } nAccountRef?.let { showBoost( @@ -170,10 +170,10 @@ private fun ItemViewHolder.showNotificationPost( nAccountRef: TootAccountRef?, nStatus: TootStatus?, ) { - val colorBg = PrefI.ipEventBgColorStatus(activity.pref) + val colorBg = PrefI.ipEventBgColorStatus.value val iconId = when (nStatus) { null -> R.drawable.ic_question - else -> getVisibilityIconId(accessInfo.isMisskey, nStatus.visibility) + else -> nStatus.visibility.getVisibilityIconId(accessInfo.isMisskey) } nAccountRef?.let { showBoost(it, n.time_created_at, iconId, R.string.display_name_posted_by) } nStatus?.let { showNotificationStatus(it, colorBg) } @@ -184,7 +184,7 @@ private fun ItemViewHolder.showNotificationUpdate( nAccountRef: TootAccountRef?, nStatus: TootStatus?, ) { - val colorBg = PrefI.ipEventBgColorUpdate(activity.pref) + val colorBg = PrefI.ipEventBgColorUpdate.value val iconId = R.drawable.ic_history nAccountRef?.let { showBoost( @@ -202,7 +202,7 @@ private fun ItemViewHolder.showNotificationStatusReference( nAccountRef: TootAccountRef?, nStatus: TootStatus?, ) { - val colorBg = PrefI.ipEventBgColorStatusReference(activity.pref) + val colorBg = PrefI.ipEventBgColorStatusReference.value val iconId = R.drawable.ic_link_variant nAccountRef?.let { showBoost( @@ -220,7 +220,7 @@ private fun ItemViewHolder.showNotificationReaction( nAccountRef: TootAccountRef?, nStatus: TootStatus?, ) { - val colorBg = PrefI.ipEventBgColorReaction(activity.pref) + val colorBg = PrefI.ipEventBgColorReaction.value nAccountRef?.let { showBoost( it, n.time_created_at, @@ -242,7 +242,7 @@ private fun ItemViewHolder.showNotificationFavourite( val iconId = R.drawable.ic_star_outline showBoost(it, n.time_created_at, iconId, R.string.display_name_favourited_by) } - val colorBg = PrefI.ipEventBgColorFavourite(activity.pref) + val colorBg = PrefI.ipEventBgColorFavourite.value nStatus?.let { showNotificationStatus(it, colorBg, fadeText = true) } } @@ -261,7 +261,7 @@ private fun ItemViewHolder.showNotificationReblog( reblogVisibility = n.reblog_visibility ) } - val colorBg = PrefI.ipEventBgColorBoost(activity.pref) + val colorBg = PrefI.ipEventBgColorBoost.value nStatus?.let { showNotificationStatus(it, colorBg, fadeText = true) } } @@ -280,7 +280,7 @@ private fun ItemViewHolder.showNotificationRenote( boostStatus = nStatus ) } - val colorBg = PrefI.ipEventBgColorBoost(activity.pref) + val colorBg = PrefI.ipEventBgColorBoost.value nStatus?.let { showNotificationStatus(it, colorBg) } } @@ -310,7 +310,7 @@ private fun ItemViewHolder.showNotificationMention( } } - val colorBg = PrefI.ipEventBgColorMention(activity.pref) + val colorBg = PrefI.ipEventBgColorMention.value nStatus?.let { showNotificationStatus(it, colorBg) } } @@ -328,7 +328,7 @@ private fun ItemViewHolder.showNotificationQuote( ) } - val colorBg = PrefI.ipEventBgColorQuote(activity.pref) + val colorBg = PrefI.ipEventBgColorQuote.value nStatus?.let { showNotificationStatus(it, colorBg) } } @@ -345,7 +345,7 @@ private fun ItemViewHolder.showNotificationVote( R.string.display_name_voted_by ) } - val colorBg = PrefI.ipEventBgColorVote(activity.pref) + val colorBg = PrefI.ipEventBgColorVote.value nStatus?.let { showNotificationStatus(it, colorBg) } } @@ -399,13 +399,13 @@ private fun ItemViewHolder.showNotificationStatus( item.isQuoteToot -> { // 引用Renote showReply(item.account, reblog, R.drawable.ic_quote, R.string.quote_to) - showStatus(item, PrefI.ipEventBgColorQuote(activity.pref), fadeText = fadeText) + showStatus(item, PrefI.ipEventBgColorQuote.value, fadeText = fadeText) } else -> { // 通常のブースト。引用なしブースト。 // ブースト表示は通知イベントと被るのでしない - showStatusOrReply(reblog, PrefI.ipEventBgColorBoost(activity.pref), fadeText = fadeText) + showStatusOrReply(reblog, PrefI.ipEventBgColorBoost.value, fadeText = fadeText) } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShowStatus.kt b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShowStatus.kt index 548b8cbf..86ca2086 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShowStatus.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/ItemViewHolderShowStatus.kt @@ -12,8 +12,8 @@ import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.column.isConversation import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefI -import jp.juggler.subwaytooter.table.ContentWarning -import jp.juggler.subwaytooter.table.MediaShown +import jp.juggler.subwaytooter.table.daoContentWarning +import jp.juggler.subwaytooter.table.daoMediaShown import jp.juggler.subwaytooter.util.OpenSticker import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory @@ -38,12 +38,12 @@ fun ItemViewHolder.showStatusOrReply( when { reply != null -> { showReply(item.account, reply, R.drawable.ic_reply, R.string.reply_to) - if (colorBgArg == 0) colorBg = PrefI.ipEventBgColorMention(activity.pref) + if (colorBgArg == 0) colorBg = PrefI.ipEventBgColorMention.value } inReplyToId != null && inReplyToAccountId != null -> { showReply(null, item, inReplyToAccountId) - if (colorBgArg == 0) colorBg = PrefI.ipEventBgColorMention(activity.pref) + if (colorBgArg == 0) colorBg = PrefI.ipEventBgColorMention.value } } showStatus(item, colorBg, fadeText = fadeText) @@ -59,7 +59,7 @@ fun ItemViewHolder.showStatus( if (filteredWord != null) { showMessageHolder( TootMessageHolder( - if (PrefB.bpShowFilteredWord(activity.pref)) { + if (PrefB.bpShowFilteredWord.value) { "${activity.getString(R.string.filtered)} / $filteredWord" } else { activity.getString(R.string.filtered) @@ -73,11 +73,11 @@ fun ItemViewHolder.showStatus( llStatus.visibility = View.VISIBLE if (status.conversation_main) { - PrefI.ipConversationMainTootBgColor(activity.pref).notZero() + PrefI.ipConversationMainTootBgColor.value.notZero() ?: activity.attrColor(R.attr.colorConversationMainTootBg) } else { colorBg.notZero() ?: when (status.bookmarked) { - true -> PrefI.ipEventBgColorBookmark() + true -> PrefI.ipEventBgColorBookmark.value false -> 0 }.notZero() ?: when (status.getBackgroundColorType(accessInfo)) { TootVisibility.UnlistedHome -> ItemViewHolder.toot_color_unlisted @@ -183,7 +183,7 @@ private fun ItemViewHolder.showSpoilerTextAndContent(status: TootStatus) { llContentWarning.visibility = View.VISIBLE tvContentWarning.text = status.decoded_spoiler_text spoilerInvalidator.register(status.decoded_spoiler_text) - val cwShown = ContentWarning.isShown(status, accessInfo.expand_cw) + val cwShown = daoContentWarning.isShown(status, accessInfo.expand_cw) setContentVisibility(cwShown) } @@ -192,7 +192,7 @@ private fun ItemViewHolder.showSpoilerTextAndContent(status: TootStatus) { llContentWarning.visibility = View.VISIBLE tvContentWarning.text = r.decodedSpoilerText spoilerInvalidator.register(r.decodedSpoilerText) - val cwShown = ContentWarning.isShown(status, accessInfo.expand_cw) + val cwShown = daoContentWarning.isShown(status, accessInfo.expand_cw) setContentVisibility(cwShown) } @@ -227,14 +227,14 @@ private fun ItemViewHolder.showApplicationAndLanguage(status: TootStatus) { val application = status.application if (application != null && - (column.isConversation || PrefB.bpShowAppName(activity.pref)) + (column.isConversation || PrefB.bpShowAppName.value) ) { prepareSb().append(activity.getString(R.string.application_is, application.name ?: "")) } val language = status.language if (language != null && - (column.isConversation || PrefB.bpShowLanguage(activity.pref)) + (column.isConversation || PrefB.bpShowLanguage.value) ) { prepareSb().append(activity.getString(R.string.language_is, language)) } @@ -304,7 +304,7 @@ private fun ItemViewHolder.showAttachments(status: TootStatus) { accessInfo.dont_hide_nsfw -> true else -> !status.sensitive } - val isShown = MediaShown.isShown(status, defaultShown) + val isShown = daoMediaShown.isShown(status, defaultShown) btnShowMedia.visibility = if (!isShown) View.VISIBLE else View.GONE llMedia.visibility = if (!isShown) View.GONE else View.VISIBLE @@ -341,7 +341,7 @@ fun ItemViewHolder.setMedia( iv.setFocusPoint(ta.focusX, ta.focusY) - if (PrefB.bpDontCropMediaThumb()) { + if (PrefB.bpDontCropMediaThumb.value) { iv.scaleType = ImageView.ScaleType.FIT_CENTER } else { iv.setScaleTypeForMedia() @@ -353,7 +353,7 @@ fun ItemViewHolder.setMedia( TootAttachmentType.Audio -> { iv.setMediaType(0) iv.setDefaultImage(defaultColorIcon(activity, R.drawable.wide_music)) - iv.setImageUrl(0f, ta.urlForThumbnail(activity.pref)) + iv.setImageUrl(0f, ta.urlForThumbnail()) showUrl = true } @@ -364,7 +364,7 @@ fun ItemViewHolder.setMedia( showUrl = true } - else -> when (val urlThumbnail = ta.urlForThumbnail(activity.pref)) { + else -> when (val urlThumbnail = ta.urlForThumbnail()) { null, "" -> { iv.setMediaType(0) iv.setDefaultImage(defaultColorIcon(activity, R.drawable.wide_question)) diff --git a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/StatusButtons.kt b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/StatusButtons.kt index 0bb85d39..ac172a61 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/StatusButtons.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/StatusButtons.kt @@ -16,14 +16,15 @@ import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.action.* import jp.juggler.subwaytooter.actmain.nextPosition import jp.juggler.subwaytooter.api.entity.* -import jp.juggler.subwaytooter.stylerBoostAlpha import jp.juggler.subwaytooter.column.Column import jp.juggler.subwaytooter.column.getContentColor import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefI import jp.juggler.subwaytooter.setFollowIcon +import jp.juggler.subwaytooter.stylerBoostAlpha import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.UserRelation +import jp.juggler.subwaytooter.table.daoUserRelation import jp.juggler.subwaytooter.util.CustomShare import jp.juggler.subwaytooter.util.CustomShareTarget import jp.juggler.subwaytooter.util.startMargin @@ -170,7 +171,7 @@ class StatusButtons( R.drawable.ic_reply, when (val repliesCount = status.replies_count) { null -> "" - else -> when (PrefI.ipRepliesCount(activity.pref)) { + else -> when (PrefI.ipRepliesCount.value) { PrefI.RC_SIMPLE -> when { repliesCount >= 2L -> "1+" repliesCount == 1L -> "1" @@ -191,7 +192,7 @@ class StatusButtons( setButton( btnBoost, false, - PrefI.ipButtonBoostedColor.invoke(activity.pref).notZero() + PrefI.ipButtonBoostedColor.value.notZero() ?: activity.attrColor(R.attr.colorButtonAccentBoost), R.drawable.ic_mail, "", @@ -213,7 +214,7 @@ class StatusButtons( true, when { status.reblogged -> - PrefI.ipButtonBoostedColor(activity.pref).notZero() + PrefI.ipButtonBoostedColor.value.notZero() ?: activity.attrColor(R.attr.colorButtonAccentBoost) else -> colorTextContent @@ -221,7 +222,7 @@ class StatusButtons( R.drawable.ic_repeat, when (val boostsCount = status.reblogs_count) { null -> "" - else -> when (PrefI.ipBoostsCount(activity.pref)) { + else -> when (PrefI.ipBoostsCount.value) { PrefI.RC_SIMPLE -> when { boostsCount >= 2L -> "1+" boostsCount == 1L -> "1" @@ -283,7 +284,7 @@ class StatusButtons( true, when { status.favourited -> - PrefI.ipButtonFavoritedColor(activity.pref).notZero() + PrefI.ipButtonFavoritedColor.value.notZero() ?: activity.attrColor(R.attr.colorButtonAccentFavourite) else -> colorTextContent }, @@ -293,7 +294,7 @@ class StatusButtons( }, when (val favouritesCount = status.favourites_count) { null -> "" - else -> when (PrefI.ipFavouritesCount(activity.pref)) { + else -> when (PrefI.ipFavouritesCount.value) { PrefI.RC_SIMPLE -> when { favouritesCount >= 2L -> "1+" favouritesCount == 1L -> "1" @@ -309,7 +310,7 @@ class StatusButtons( } private fun bindBookmarkButton(status: TootStatus) { - btnBookmark.vg(PrefB.bpShowBookmarkButton()) + btnBookmark.vg(PrefB.bpShowBookmarkButton.value) ?.let { btn -> when { activity.appState.isBusyBookmark(accessInfo, status) -> @@ -327,7 +328,7 @@ class StatusButtons( true, when { status.bookmarked -> - PrefI.ipButtonBookmarkedColor(activity.pref).notZero() + PrefI.ipButtonBookmarkedColor.value.notZero() ?: activity.attrColor(R.attr.colorButtonAccentBookmark) else -> colorTextContent @@ -344,12 +345,12 @@ class StatusButtons( private fun bindFollowButton(status: TootStatus) { val account = status.account - this.relation = if (!PrefB.bpShowFollowButtonInButtonBar(activity.pref)) { + this.relation = if (!PrefB.bpShowFollowButtonInButtonBar.value) { llFollow2.visibility = View.GONE null } else { llFollow2.visibility = View.VISIBLE - val relation = UserRelation.load(accessInfo.db_id, account.id) + val relation = daoUserRelation.load(accessInfo.db_id, account.id) setFollowIcon( activity, btnFollow2, @@ -367,7 +368,7 @@ class StatusButtons( optionalButtonFirst = null optionalButtonCount = 0 - btnTranslate.vg(PrefB.bpShowTranslateButton(activity.pref)) + btnTranslate.vg(PrefB.bpShowTranslateButton.value) ?.showCustomShare(CustomShareTarget.Translate) btnCustomShare1.showCustomShare(CustomShareTarget.CustomShare1) btnCustomShare2.showCustomShare(CustomShareTarget.CustomShare2) @@ -409,7 +410,7 @@ class StatusButtons( optionalButtonFirst: View?, ): (btn: ImageButton) -> Unit { val lpConversation = btnConversation.layoutParams as? FlexboxLayout.LayoutParams - return when (AdditionalButtonsPosition.fromIndex(PrefI.ipAdditionalButtonsPosition(activity.pref))) { + return when (AdditionalButtonsPosition.fromIndex(PrefI.ipAdditionalButtonsPosition.value)) { AdditionalButtonsPosition.Top -> { // 1行目に追加ボタンが並ぶ // 2行目は通常ボタンが並ぶ @@ -828,7 +829,9 @@ class StatusButtonsViewHolder( } flexWrap = FlexWrap.WRAP this.justifyContent = justifyContent - when (AdditionalButtonsPosition.fromIndex(PrefI.ipAdditionalButtonsPosition(activity.pref))) { + when (AdditionalButtonsPosition.fromIndex( + PrefI.ipAdditionalButtonsPosition.value + )) { AdditionalButtonsPosition.Top, AdditionalButtonsPosition.Start -> { additionalButtons() normalButtons() diff --git a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/StatusButtonsPopup.kt b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/StatusButtonsPopup.kt index 13c80673..2760e374 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/StatusButtonsPopup.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/itemviewholder/StatusButtonsPopup.kt @@ -93,7 +93,7 @@ internal class StatusButtonsPopup( buttonsForStatus.bind(status, notification) buttonsForStatus.closeWindow = window - val bgColor = PrefI.ipPopupBgColor.invoke(activity.pref) + val bgColor = PrefI.ipPopupBgColor.value .notZero() ?: activity.attrColor(R.attr.colorStatusButtonsPopupBg) val bgColorState = ColorStateList.valueOf(bgColor) views.ivTriangleTop.backgroundTintList = bgColorState diff --git a/app/src/main/java/jp/juggler/subwaytooter/mfm/NodeType.kt b/app/src/main/java/jp/juggler/subwaytooter/mfm/NodeType.kt index ac2ec502..af4eefce 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/mfm/NodeType.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/mfm/NodeType.kt @@ -1,5 +1,6 @@ package jp.juggler.subwaytooter.mfm +import jp.juggler.subwaytooter.table.daoAcctColor import jp.juggler.util.data.encodePercent import jp.juggler.util.data.notEmpty import jp.juggler.util.ui.fontSpan @@ -224,7 +225,7 @@ enum class NodeType(val render: SpanOutputEnv.(Node) -> Unit) { url = url, tag = options.linkTag, ac = jp.juggler.subwaytooter.api.entity.TootAccount.getAcctFromUrl(url) - ?.let { acct -> jp.juggler.subwaytooter.table.AcctColor.load(acct) }, + ?.let { acct -> daoAcctColor.load(acct) }, caption = sb.substring(start, sb.length) ) spanList.addFirst( diff --git a/app/src/main/java/jp/juggler/subwaytooter/mfm/SpanOutputEnv.kt b/app/src/main/java/jp/juggler/subwaytooter/mfm/SpanOutputEnv.kt index 18f0616b..282d3420 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/mfm/SpanOutputEnv.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/mfm/SpanOutputEnv.kt @@ -12,8 +12,9 @@ import jp.juggler.subwaytooter.span.HighlightSpan import jp.juggler.subwaytooter.span.LinkInfo import jp.juggler.subwaytooter.span.MyClickableSpan import jp.juggler.subwaytooter.span.SvgEmojiSpan -import jp.juggler.subwaytooter.table.AcctColor import jp.juggler.subwaytooter.table.HighlightWord +import jp.juggler.subwaytooter.table.daoAcctColor +import jp.juggler.subwaytooter.table.daoHighlightWord import jp.juggler.subwaytooter.util.DecodeOptions import jp.juggler.subwaytooter.util.HTMLDecoder import jp.juggler.subwaytooter.util.LinkHelper @@ -26,8 +27,8 @@ class SpanOutputEnv( ) { val context: Context = options.context ?: error("missing context") - val decorationEnabled = PrefB.bpMfmDecorationEnabled(context) - val showUnsupportedMarkup = PrefB.bpMfmDecorationShowUnsupportedMarkup(context) + val decorationEnabled = PrefB.bpMfmDecorationEnabled.value + val showUnsupportedMarkup = PrefB.bpMfmDecorationShowUnsupportedMarkup.value val fontBold = ActMain.timelineFontBold val linkHelper: LinkHelper? = options.linkHelper @@ -74,7 +75,7 @@ class SpanOutputEnv( val list = options.highlightTrie?.matchList(sb, start, end) if (list != null) { for (range in list) { - val word = HighlightWord.load(range.word) ?: continue + val word = daoHighlightWord.load(range.word) ?: continue spanList.addLast( range.start, range.end, @@ -167,11 +168,7 @@ class SpanOutputEnv( val linkInfo = LinkInfo( caption = text, url = url, - ac = fullAcct?.let { - AcctColor.load( - fullAcct - ) - }, + ac = fullAcct?.let { daoAcctColor.load(fullAcct) }, tag = options.linkTag, mention = mention ) @@ -221,7 +218,7 @@ class SpanOutputEnv( // リンク表記はユーザの記述やアプリ設定の影響を受ける val caption = "@${ when { - PrefB.bpMentionFullAcct() -> fullAcct + PrefB.bpMentionFullAcct.value -> fullAcct else -> rawAcct }.pretty }" diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/AlertNotification.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/AlertNotification.kt new file mode 100644 index 00000000..d2ad423e --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/notification/AlertNotification.kt @@ -0,0 +1,104 @@ +package jp.juggler.subwaytooter.notification + +import android.Manifest +import android.app.AlertDialog +import android.app.PendingIntent +import android.content.Context +import android.content.pm.PackageManager +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope +import jp.juggler.subwaytooter.ActAlert.Companion.intentActAlert +import jp.juggler.subwaytooter.R +import jp.juggler.subwaytooter.notification.NotificationDeleteReceiver.Companion.intentNotificationDelete +import jp.juggler.util.log.LogCategory +import jp.juggler.util.log.withCaption +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +private val log = LogCategory("AlertNotification") + +/** + * トーストの代わりに使えるような、単純なメッセージを表示する通知 + */ + +fun Context.showAlertNotification( + message: String, + title: String = getString(R.string.alert), +) { + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + log.w("missing POST_NOTIFICATIONS. alert=$message") + return + } + + val nc = NotificationChannels.Alert + + val now = System.currentTimeMillis() + val tag = "${System.currentTimeMillis()}/${message.hashCode()}" + val uri = "${nc.uriPrefixDelete}/$tag" + + // Create an explicit intent for an Activity in your app + val iTap = intentActAlert(tag = tag, message = message, title = title) + val iDelete = intentNotificationDelete(uri.toUri()) + val piTap = PendingIntent.getActivity(this, nc.pircTap, iTap, PendingIntent.FLAG_IMMUTABLE) + val piDelete = + PendingIntent.getBroadcast(this, nc.pircDelete, iDelete, PendingIntent.FLAG_IMMUTABLE) + + val builder = NotificationCompat.Builder(this, nc.id).apply { + priority = nc.priority + setSmallIcon(R.drawable.ic_error) + setContentTitle(title) + setContentText(message) + setWhen(now) + setContentIntent(piTap) + setDeleteIntent(piDelete) + setAutoCancel(true) + } + + NotificationManagerCompat.from(this).notify(tag, nc.notificationId, builder.build()) +} + +fun Context.dialogOrAlert(message: String) { + try { + AlertDialog.Builder(this) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } catch (_: Throwable) { + showAlertNotification(message) + } +} + +fun Context.showError(ex: Throwable, message: String) { + when (ex) { + is CancellationException -> Unit + is IllegalStateException -> { + log.e(ex, message) + dialogOrAlert(ex.message ?: ex.cause?.message ?: "?") + } + else -> { + log.e(ex, message) + dialogOrAlert(ex.withCaption(message)) + } + } +} + +//fun AppCompatActivity.launchAndShowError( +// context: CoroutineContext = EmptyCoroutineContext, +// block: suspend () -> Unit, +//) = lifecycleScope.launch(context) { +// try { +// block() +// } catch (ex: Throwable) { +// showError(ex, "") +// } +//} diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/CheckerNotification.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/CheckerNotification.kt index 6497110e..a1948e31 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/notification/CheckerNotification.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/notification/CheckerNotification.kt @@ -5,7 +5,6 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import jp.juggler.subwaytooter.ActMain import jp.juggler.subwaytooter.R @@ -15,12 +14,14 @@ object CheckerNotification { private val log = LogCategory("CheckerNotification") + private val nc = NotificationChannels.Checker + private var lastMessage: String? = null suspend fun showMessage( context: Context, text: String, - shower: suspend (Notification) -> Unit, + shower: suspend (Notification, NotificationChannels) -> Unit, ) { // テキストが変化していないなら更新しない if (text.isEmpty() || text == lastMessage) return @@ -28,51 +29,36 @@ object CheckerNotification { lastMessage = text log.i(text) -// // This PendingIntent can be used to cancel the worker -// val cancel = context.getString(R.string.cancel) -// val cancelIntent = WorkManager.getInstance(context) -// .createCancelPendingIntent(id) - - // Android 8 から、通知のスタイルはユーザが管理することになった - // NotificationChannel を端末に登録しておけば、チャネルごとに管理画面が作られる - // The user-visible description of the channel. - val channel = NotificationHelper.createNotificationChannel( - context, - "PollingForegrounder", - "real-time message notifier", - null, - NotificationManagerCompat.IMPORTANCE_LOW - ) - val builder = NotificationCompat.Builder(context, channel.id) - // 通知タップ時のPendingIntent - val clickIntent = Intent(context, ActMain::class.java).apply { + val iTap = Intent(context, ActMain::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } - val clickPi = PendingIntent.getActivity( + val piTap = PendingIntent.getActivity( context, - 2, - clickIntent, + nc.pircTap, + iTap, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) // ここは常に白テーマのアイコンと色を使う - builder - .setContentIntent(clickPi) - .setAutoCancel(false) - .setOngoing(true) - .setSmallIcon(R.drawable.ic_notification) - .setColor(ContextCompat.getColor(context, R.color.colorOsNotificationAccent)) - .setWhen(System.currentTimeMillis()) - .setContentTitle(context.getString(R.string.loading_notification_title)) - .setContentText(text) - // .addAction(android.R.drawable.ic_delete, cancel, cancelIntent) + val builder = NotificationCompat.Builder(context, nc.id).apply { + priority = nc.priority + setContentIntent(piTap) + setAutoCancel(false) + setOngoing(true) + setSmallIcon(R.drawable.ic_notification) + color = ContextCompat.getColor(context, R.color.colorOsNotificationAccent) + setWhen(System.currentTimeMillis()) + setContentTitle(context.getString(R.string.loading_notification_title)) + setContentText(text) + // .addAction(android.R.drawable.ic_delete, cancel, cancelIntent) - // Android 7.0 ではグループを指定しないと勝手に通知が束ねられてしまう。 - // 束ねられた通知をタップしても pi_click が実行されないので困るため、 - // アカウント別にグループキーを設定する - builder.setGroup(context.packageName + ":PollingForegrounder") + // Android 7.0 ではグループを指定しないと勝手に通知が束ねられてしまう。 + // 束ねられた通知をタップしても pi_click が実行されないので困る。 + // グループキーを設定する + setGroup(context.packageName + ":PollingForegrounder") + } - shower(builder.build()) + shower(builder.build(), nc) } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/MessageNotification.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/MessageNotification.kt deleted file mode 100644 index bd2ece38..00000000 --- a/app/src/main/java/jp/juggler/subwaytooter/notification/MessageNotification.kt +++ /dev/null @@ -1,176 +0,0 @@ -package jp.juggler.subwaytooter.notification - -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.provider.Settings -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat -import androidx.core.net.toUri -import jp.juggler.subwaytooter.ActCallback -import jp.juggler.subwaytooter.EventReceiver -import jp.juggler.subwaytooter.R -import jp.juggler.subwaytooter.pref.PrefB -import jp.juggler.subwaytooter.table.SavedAccount -import jp.juggler.util.data.encodePercent -import jp.juggler.util.data.toMutableMap -import jp.juggler.util.log.LogCategory - -object MessageNotification { - private val log = LogCategory("MessageNotification") - - private const val NOTIFICATION_ID_MESSAGE = 1 - - const val TRACKING_NAME_DEFAULT = "" - const val TRACKING_NAME_REPLY = "reply" - - /** - * メッセージ通知を消す - */ - fun NotificationManager.removeMessageNotification(id: String?, tag: String) { - when (id) { - null -> cancel(tag, NOTIFICATION_ID_MESSAGE) - else -> cancel("$tag/$id", NOTIFICATION_ID_MESSAGE) - } - } - - /** メッセージ通知をたくさん消す - * - */ - fun NotificationManager.removeMessageNotification(account: SavedAccount, tag: String) { - if (PrefB.bpDivideNotification()) { - activeNotifications?.filterNotNull()?.filter { - it.id == NOTIFICATION_ID_MESSAGE && it.tag.startsWith("$tag/") - }?.forEach { - log.d("cancel: ${it.tag} context=${account.acct.pretty} $tag") - cancel(it.tag, NOTIFICATION_ID_MESSAGE) - } - } else { - cancel(tag, NOTIFICATION_ID_MESSAGE) - } - } - - /** - * 表示中のメッセージ通知の一覧 - */ - fun NotificationManager.getMessageNotifications(tag: String) = - activeNotifications?.filterNotNull()?.filter { - it.id == NOTIFICATION_ID_MESSAGE && it.tag.startsWith("$tag/") - }?.map { Pair(it.tag, it) }?.toMutableMap() ?: mutableMapOf() - - fun NotificationManager.showMessageNotification( - context: Context, - account: SavedAccount, - trackingName: String, - trackingType: TrackingType, - notificationTag: String, - notificationId: String? = null, - setContent: (builder: NotificationCompat.Builder) -> Unit, - ) { - log.d("showNotification[${account.acct.pretty}] creating notification(1)") - - // Android 8 から、通知のスタイルはユーザが管理することになった - // NotificationChannel を端末に登録しておけば、チャネルごとに管理画面が作られる - val channel = createMessageNotificationChannel( - context, - account, - trackingName - ) - val builder = NotificationCompat.Builder(context, channel.id) - - builder.apply { - - val params = listOf( - "db_id" to account.db_id.toString(), - "type" to trackingType.str, - "notificationId" to notificationId - ).mapNotNull { - when (val second = it.second) { - null -> null - else -> "${it.first.encodePercent()}=${second.encodePercent()}" - } - }.joinToString("&") - - val flag = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - - PendingIntent.getActivity( - context, - 257, - Intent(context, ActCallback::class.java).apply { - data = "subwaytooter://notification_click/?$params".toUri() - // FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY を付与してはいけない - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - }, - flag - )?.let { setContentIntent(it) } - - PendingIntent.getBroadcast( - context, - 257, - Intent(context, EventReceiver::class.java).apply { - action = EventReceiver.ACTION_NOTIFICATION_DELETE - data = "subwaytooter://notification_delete/?$params".toUri() - }, - flag - )?.let { setDeleteIntent(it) } - - setAutoCancel(true) - - // 常に白テーマのアイコンを使う - setSmallIcon(R.drawable.ic_notification) - - // 常に白テーマの色を使う - color = ContextCompat.getColor(context, R.color.colorOsNotificationAccent) - - // Android 7.0 ではグループを指定しないと勝手に通知が束ねられてしまう。 - // 束ねられた通知をタップしても pi_click が実行されないので困るため、 - // アカウント別にグループキーを設定する - setGroup(context.packageName + ":" + account.acct.ascii) - } - - log.d("showNotification[${account.acct.pretty}] creating notification(3)") - setContent(builder) - - log.d("showNotification[${account.acct.pretty}] set notification...") - notify( - notificationTag, - NOTIFICATION_ID_MESSAGE, - builder.build() - ) - } - - private fun createMessageNotificationChannel( - context: Context, - account: SavedAccount, - trackingName: String, - ) = when (trackingName) { - "" -> NotificationHelper.createNotificationChannel( - context, - account.acct.ascii, // id - account.acct.pretty, // name - context.getString(R.string.notification_channel_description, account.acct.pretty), - NotificationManager.IMPORTANCE_DEFAULT // : NotificationManager.IMPORTANCE_LOW; - ) - - else -> NotificationHelper.createNotificationChannel( - context, - "${account.acct.ascii}/$trackingName", // id - "${account.acct.pretty}/$trackingName", // name - context.getString(R.string.notification_channel_description, account.acct.pretty), - NotificationManager.IMPORTANCE_DEFAULT // : NotificationManager.IMPORTANCE_LOW; - ) - } - - fun openNotificationChannelSetting( - context: Context, - account: SavedAccount, - trackingName: String, - ) { - val channel = createMessageNotificationChannel(context, account, trackingName) - val intent = Intent("android.settings.CHANNEL_NOTIFICATION_SETTINGS") - intent.putExtra(Settings.EXTRA_CHANNEL_ID, channel.id) - intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - context.startActivity(intent) - } -} diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/NotificationChannels.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/NotificationChannels.kt new file mode 100644 index 00000000..3c22fff5 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/notification/NotificationChannels.kt @@ -0,0 +1,141 @@ +package jp.juggler.subwaytooter.notification + +import android.app.NotificationChannel +import android.content.Context +import androidx.annotation.StringRes +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.startup.Initializer +import jp.juggler.subwaytooter.R +import jp.juggler.util.* +import jp.juggler.util.log.LogCategory + +private val log = LogCategory("NotificationChannels") + +enum class NotificationChannels( + val id: String, + @StringRes val titleId: Int, + @StringRes val descId: Int, + val importance: Int, + val priority: Int, + // 通知ID。(ID+tagでユニーク) + val notificationId: Int, + // PendingIntentのrequestCode。(ID+intentのdata Uriでユニーク) + // pending intent request code for tap + val pircTap: Int, + // pending intent request code for delete + val pircDelete: Int, + // 通知削除のUri prefix + val uriPrefixDelete: String, +) { + PullNotification( + id = "SnsNotification", + titleId = R.string.pull_notification, + descId = R.string.pull_notification_desc, + importance = NotificationManagerCompat.IMPORTANCE_DEFAULT, + priority = NotificationCompat.PRIORITY_DEFAULT, + notificationId = 1, + pircTap = 1, + pircDelete = 1, // uriでtapとdeleteを区別している + uriPrefixDelete = "subwaytooter://sns-notification", + ), + Checker( + id = "PollingForegrounder", + titleId = R.string.polling_foregrounder, + descId = R.string.polling_foregrounder_desc, + importance = NotificationManagerCompat.IMPORTANCE_LOW, + priority = NotificationCompat.PRIORITY_MIN, + notificationId = 2, + pircTap = 2, + pircDelete = -1, + uriPrefixDelete = "subwaytooter://checker", + ), + ServerTimeout( + id = "ErrorNotification", + titleId = R.string.server_timeout, + descId = R.string.server_timeout_desc, + importance = NotificationManagerCompat.IMPORTANCE_LOW, + priority = NotificationCompat.PRIORITY_LOW, + notificationId = 3, + pircTap = 3, + pircDelete = -1, + uriPrefixDelete = "subwaytooter://server-timeout", + ), + PushMessage( + id = "PushMessage", + titleId = R.string.push_message, + descId = R.string.push_message_desc, + importance = NotificationManagerCompat.IMPORTANCE_HIGH, + priority = NotificationCompat.PRIORITY_HIGH, + notificationId = 4, + pircTap = 4, + pircDelete = 5, + uriPrefixDelete = "pushreceiverapp://pushMessage", + ), + Alert( + id = "Alert", + titleId = R.string.alert, + descId = R.string.alert_notification_desc, + importance = NotificationManagerCompat.IMPORTANCE_HIGH, + priority = NotificationCompat.PRIORITY_HIGH, + notificationId = 6, + pircTap = 6, + pircDelete = -1, + uriPrefixDelete = "pushreceiverapp://alert", + ), + PushMessageWorker( + id = "PushMessageWorker", + titleId = R.string.push_worker, + descId = R.string.push_worker_desc, + importance = NotificationManagerCompat.IMPORTANCE_LOW, + priority = NotificationCompat.PRIORITY_LOW, + notificationId = 7, + pircTap = 7, + pircDelete = 8, + uriPrefixDelete = "pushreceiverapp://PushMessageWorker", + ), + ///////////////////////////// + // 以下、通知IDやpirc を吟味していない + + // SubscriptionUpdate( +// id = "SubscriptionUpdate", +// titleId = R.string.push_subscription_update, +// descId = R.string.push_subscription_update_desc, +// importance = NotificationManagerCompat.IMPORTANCE_LOW, +// priority = NotificationCompat.PRIORITY_LOW, +// notificationId = 3, +// pircTap = 4, +// pircDelete = 5, +// uriPrefixDelete = "pushreceiverapp://subscriptionUpdate", +// ), +} + +/** + * 通知チャネルの初期化を + * androidx app startupのイニシャライザとして実装したもの + */ +@Suppress("unused") +class NotificationChannelsInitializer : Initializer { + override fun dependencies(): List>> = + emptyList() + + override fun create(context: Context): Boolean { + context.run { + val list = NotificationChannels.values() + log.i("createNotificationChannel(s) size=${list.size}") + val notificationManager = NotificationManagerCompat.from(this) + list.map { + NotificationChannel( + it.id, + getString(it.titleId), + it.importance, + ).apply { + description = getString(it.descId) + } + }.forEach { + notificationManager.createNotificationChannel(it) + } + } + return true + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/NotificationDeleteReceiver.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/NotificationDeleteReceiver.kt new file mode 100644 index 00000000..bf9503fc --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/notification/NotificationDeleteReceiver.kt @@ -0,0 +1,36 @@ +package jp.juggler.subwaytooter.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.net.Uri +import jp.juggler.subwaytooter.push.pushRepo +import jp.juggler.util.coroutine.EmptyScope +import jp.juggler.util.log.LogCategory +import kotlinx.coroutines.launch + +class NotificationDeleteReceiver : BroadcastReceiver() { + companion object { + private val log = LogCategory("NotificationDeleteReceiver") + fun Context.intentNotificationDelete(dataUri: Uri) = + Intent(this, NotificationDeleteReceiver::class.java).apply { + data = dataUri + } + } + + override fun onReceive(context: Context, intent: Intent?) { + EmptyScope.launch { + try { + val uri = intent?.data?.toString() + log.i("onReceive uri=$uri") + when { + uri == null -> Unit + uri.startsWith(NotificationChannels.PushMessage.uriPrefixDelete) -> + context.pushRepo.onDeleteNotification(uri) + } + } catch (ex: Throwable) { + log.e(ex, "onReceive failed.") + } + } + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/NotificationHelper.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/NotificationHelper.kt index 6df68034..cdf7ead1 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/notification/NotificationHelper.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/notification/NotificationHelper.kt @@ -1,36 +1,36 @@ package jp.juggler.subwaytooter.notification - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import jp.juggler.util.log.LogCategory - -object NotificationHelper { - - private val log = LogCategory("NotificationHelper") - - fun createNotificationChannel( - context: Context, - channelId: String, // id - name: String, // The user-visible name of the channel. - description: String?, // The user-visible description of the channel. - importance: Int, - ): NotificationChannel { - val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? - ?: throw NotImplementedError("missing NotificationManager system service") - - val channel = try { - notificationManager.getNotificationChannel(channelId)!! - } catch (ex: Throwable) { - log.e(ex, "getNotificationChannel failed.") - null - } ?: NotificationChannel(channelId, name, importance) - - channel.name = name - channel.importance = importance - description?.let { channel.description = it } - notificationManager.createNotificationChannel(channel) - return channel - } -} +// +//import android.app.NotificationChannel +//import android.app.NotificationManager +//import android.content.Context +//import jp.juggler.util.log.LogCategory +// +//object NotificationHelper { +// +// private val log = LogCategory("NotificationHelper") +// +// fun createNotificationChannel( +// context: Context, +// channelId: String, // id +// name: String, // The user-visible name of the channel. +// description: String?, // The user-visible description of the channel. +// importance: Int, +// ): NotificationChannel { +// val notificationManager = +// context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? +// ?: throw NotImplementedError("missing NotificationManager system service") +// +// val channel = try { +// notificationManager.getNotificationChannel(channelId)!! +// } catch (ex: Throwable) { +// log.e(ex, "getNotificationChannel failed.") +// null +// } ?: NotificationChannel(channelId, name, importance) +// +// channel.name = name +// channel.importance = importance +// description?.let { channel.description = it } +// notificationManager.createNotificationChannel(channel) +// return channel +// } +//} diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/PollingChecker.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/PollingChecker.kt index 50f366a9..07f41195 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/notification/PollingChecker.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/notification/PollingChecker.kt @@ -8,9 +8,9 @@ import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.notification.CheckerWakeLocks.Companion.checkerWakeLocks -import jp.juggler.subwaytooter.notification.MessageNotification.getMessageNotifications -import jp.juggler.subwaytooter.notification.MessageNotification.removeMessageNotification -import jp.juggler.subwaytooter.notification.MessageNotification.showMessageNotification +import jp.juggler.subwaytooter.notification.PullNotification.getMessageNotifications +import jp.juggler.subwaytooter.notification.PullNotification.removeMessageNotification +import jp.juggler.subwaytooter.notification.PullNotification.showMessageNotification import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.table.* import jp.juggler.util.coroutine.AppDispatchers @@ -96,7 +96,7 @@ class PollingChecker( private fun NotificationData.getNotificationLine(): String { - val name = when (PrefB.bpShowAcctInSystemNotification()) { + val name = when (PrefB.bpShowAcctInSystemNotification.value) { false -> notification.accountRef?.decoded_display_name true -> { @@ -175,7 +175,7 @@ class PollingChecker( who == null -> true account.isMe(who) -> true - else -> UserRelation.load(account.db_id, who.id).following + else -> daoUserRelation.load(account.db_id, who.id).following } } @@ -188,7 +188,7 @@ class PollingChecker( who == null -> true account.isMe(who) -> true - else -> UserRelation.load(account.db_id, who.id).followed_by + else -> daoUserRelation.load(account.db_id, who.id).followed_by } } @@ -199,7 +199,7 @@ class PollingChecker( suspend fun check( checkNetwork: Boolean = true, - onlySubscription: Boolean = false, + onlyEnqueue: Boolean = false, progress: suspend (SavedAccount, PollingState) -> Unit, ) { try { @@ -217,7 +217,7 @@ class PollingChecker( return@withContext } - val account = SavedAccount.loadAccount(context, accountDbId) + val account = daoSavedAccount.loadAccount(accountDbId) if (account == null || account.isPseudo || !account.isConfirmed) { // 疑似アカウントはチェック対象外 // 未確認アカウントはチェック対象外 @@ -234,19 +234,19 @@ class PollingChecker( commonMutex.withLock { // グローバル変数の暖気 if (TootStatus.muted_app == null) { - TootStatus.muted_app = MutedApp.nameSet + TootStatus.muted_app = daoMutedApp.nameSet() } if (TootStatus.muted_word == null) { - TootStatus.muted_word = MutedWord.nameSet + TootStatus.muted_word = daoMutedWord.nameSet() } } - // installIdとデバイストークンの取得 - val deviceToken = loadFirebaseMessagingToken(context) - loadInstallId(context, account, deviceToken, progress) +// // installIdとデバイストークンの取得 +// val deviceToken = loadFirebaseMessagingToken(context) +// loadInstallId(context, account, deviceToken, progress) val favMuteSet = commonMutex.withLock { - FavMute.acctSet + daoFavMute.acctSet() } accountMutex(accountDbId).withLock { @@ -255,49 +255,58 @@ class PollingChecker( progress(account, PollingState.CheckServerInformation) val (instance, instanceResult) = TootInstance.get(client) if (instance == null) { - account.updateNotificationError("${instanceResult?.error} ${instanceResult?.requestInfo}".trim()) + daoAccountNotificationStatus.updateNotificationError( + account.acct, + "${instanceResult?.error} ${instanceResult?.requestInfo}".trim() + ) error("can't get server information. ${instanceResult?.error} ${instanceResult?.requestInfo}".trim()) } } - wps.updateSubscription(client, progress = progress) - ?: throw CancellationException() - - val wpsLog = wps.logString - if (wpsLog.isNotEmpty()) { - log.w("subsctiption warning: ${account.acct.pretty} $wpsLog") - } +// wps.updateSubscription(client, progress = progress) +// ?: throw CancellationException() +// +// val wpsLog = wps.logString +// if (wpsLog.isNotEmpty()) { +// log.w("subsctiption warning: ${account.acct.pretty} $wpsLog") +// } if (wps.flags == 0) { - if (account.lastNotificationError != null) { - account.updateNotificationError(null) - } + // 通知表示のエラーをクリアする + daoAccountNotificationStatus.updateNotificationError( + account.acct, + null + ) log.i("notification check not required.") return@withLock } progress(account, PollingState.CheckNotifications) PollingWorker2.enqueuePolling(context) - if (onlySubscription) { - log.i("exit due to onlySubscription") + if (onlyEnqueue) { + log.i("exit due to onlyEnqueue") return@withLock } injectData.notEmpty()?.let { list -> log.d("processInjectedData ${account.acct} size=${list.size}") - NotificationCache(accountDbId).apply { - load() - inject(account, list) - } + val nc = NotificationCache(accountDbId) + daoNotificationCache.loadInto(nc) + daoNotificationCache.inject(nc, account, list) } cache = NotificationCache(account.db_id).apply { - load() + daoNotificationCache.loadInto(this) requestAsync( + daoNotificationCache, client, account, wps.flags, ) { result -> - account.updateNotificationError("${result.error} ${result.requestInfo}".trim()) + // 通知取得のエラーを保存する + daoAccountNotificationStatus.updateNotificationError( + account.acct, + "${result.error} ${result.requestInfo}".trim() + ) if (result.error?.contains("Timeout") == true && !account.dont_show_timeout ) { @@ -306,12 +315,12 @@ class PollingChecker( } } - if (PrefB.bpSeparateReplyNotificationGroup()) { + if (PrefB.bpSeparateReplyNotificationGroup.value) { var tr = TrackingRunner( account = account, favMuteSet = favMuteSet, trackingType = TrackingType.NotReply, - trackingName = MessageNotification.TRACKING_NAME_DEFAULT + trackingName = PullNotification.TRACKING_NAME_DEFAULT ) tr.checkAccount() yield() @@ -321,7 +330,7 @@ class PollingChecker( account = account, favMuteSet = favMuteSet, trackingType = TrackingType.Reply, - trackingName = MessageNotification.TRACKING_NAME_REPLY + trackingName = PullNotification.TRACKING_NAME_REPLY ) tr.checkAccount() yield() @@ -331,7 +340,7 @@ class PollingChecker( account = account, favMuteSet = favMuteSet, trackingType = TrackingType.All, - trackingName = MessageNotification.TRACKING_NAME_DEFAULT + trackingName = PullNotification.TRACKING_NAME_DEFAULT ) tr.checkAccount() yield() @@ -346,7 +355,7 @@ class PollingChecker( inner class TrackingRunner( val account: SavedAccount, - val favMuteSet: HashSet, + val favMuteSet: Set, var trackingType: TrackingType = TrackingType.All, var trackingName: String = "", ) { @@ -361,9 +370,6 @@ class PollingChecker( suspend fun checkAccount() { - this.nr = - NotificationTracking.load(account.acct.pretty, account.db_id, trackingName) - fun JsonObject.isMention() = when (NotificationCache.parseNotificationType(account, this)) { TootNotification.TYPE_REPLY, TootNotification.TYPE_MENTION -> true @@ -376,6 +382,9 @@ class PollingChecker( TrackingType.NotReply -> cache.data.filter { !it.isMention() } } + this.nr = daoNotificationTracking + .load(account.acct, account.db_id, trackingName) + // 新しい順に並んでいる。先頭から10件までを処理する。ただし処理順序は古い方から val size = min(10, jsonList.size) for (i in (0 until size).reversed()) { @@ -395,7 +404,7 @@ class PollingChecker( } } if (latestId != null) nr.nid_show = latestId - nr.save(account.acct.pretty) + daoNotificationTracking.save(account.acct, nr) } private fun updateSub(src: JsonObject) { @@ -447,7 +456,8 @@ class PollingChecker( else -> "${account.db_id}/$trackingName" } - val nt = NotificationTracking.load(account.acct.pretty, account.db_id, trackingName) + val nt = daoNotificationTracking + .load(account.acct, account.db_id, trackingName) when (val first = dstListData.firstOrNull()) { null -> { log.d("showNotification[${account.acct.pretty}/$notificationTag] cancel notification.") @@ -460,19 +470,21 @@ class PollingChecker( first.notification.time_created_at == nt.post_time && first.notification.id == nt.post_id -> log.d("showNotification[${account.acct.pretty}] id=${first.notification.id} is already shown.") - PrefB.bpDivideNotification() -> { + PrefB.bpDivideNotification.value -> { updateNotificationDivided(notificationTag, nt) - nt.updatePost( + daoNotificationTracking.updatePost( first.notification.id, - first.notification.time_created_at + first.notification.time_created_at, + nt, ) } else -> { updateNotificationMerged(notificationTag, first) - nt.updatePost( + daoNotificationTracking.updatePost( first.notification.id, - first.notification.time_created_at + first.notification.time_created_at, + nt, ) } } @@ -512,7 +524,6 @@ class PollingChecker( notificationManager.showMessageNotification( context, account, - trackingName, trackingType, itemTag, notificationId = item.notification.id.toString() @@ -543,7 +554,6 @@ class PollingChecker( notificationManager.showMessageNotification( context, account, - trackingName, trackingType, notificationTag ) { builder -> diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/PollingUtils.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/PollingUtils.kt index a0c67f76..d3a4c3ef 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/notification/PollingUtils.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/notification/PollingUtils.kt @@ -7,33 +7,26 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkQuery import androidx.work.await -import com.google.firebase.messaging.FirebaseMessaging import jp.juggler.subwaytooter.App1 -import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.entity.TootNotification -import jp.juggler.subwaytooter.notification.MessageNotification.removeMessageNotification +import jp.juggler.subwaytooter.notification.PullNotification.removeMessageNotification import jp.juggler.subwaytooter.notification.ServerTimeoutNotification.createServerTimeoutNotification -import jp.juggler.subwaytooter.pref.PrefDevice -import jp.juggler.subwaytooter.table.NotificationCache -import jp.juggler.subwaytooter.table.NotificationTracking import jp.juggler.subwaytooter.table.SavedAccount -import jp.juggler.subwaytooter.util.PrivacyPolicyChecker -import jp.juggler.util.* +import jp.juggler.subwaytooter.table.daoNotificationCache +import jp.juggler.subwaytooter.table.daoNotificationTracking +import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.util.coroutine.AppDispatchers import jp.juggler.util.coroutine.EmptyScope import jp.juggler.util.coroutine.launchDefault -import jp.juggler.util.data.* -import jp.juggler.util.log.* -import jp.juggler.util.ui.* +import jp.juggler.util.data.ellipsizeDot3 +import jp.juggler.util.data.notEmpty +import jp.juggler.util.log.LogCategory +import jp.juggler.util.systemService import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.tasks.await -import okhttp3.Request -import ru.gildor.coroutines.okhttp.await -import java.util.* import java.util.concurrent.atomic.AtomicBoolean private val log = LogCategory("PollingUtils") @@ -63,75 +56,6 @@ suspend fun setImportProtector(context: Context, newProtect: Boolean) { } } -suspend fun loadFirebaseMessagingToken(context: Context): String = - PollingChecker.commonMutex.withLock { - val prefDevice = PrefDevice.from(context) - - // 設定ファイルに保持されていたらそれを使う - prefDevice.getString(PrefDevice.KEY_DEVICE_TOKEN, null) - ?.notEmpty()?.let { return it } - - // 古い形式 - // return FirebaseInstanceId.getInstance().getToken(FCM_SENDER_ID, FCM_SCOPE) - - // com.google.firebase:firebase-messaging.20.3.0 以降 - // implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$kotlinx_coroutines_version" - val sv = FirebaseMessaging.getInstance().token.await() - if (sv.isNullOrBlank()) { - error("loadFirebaseMessagingToken: device token is null or empty.") - } - return sv.also { - prefDevice.edit() - .putString(PrefDevice.KEY_DEVICE_TOKEN, it) - .apply() - } - } - -// インストールIDを生成する前に、各データの通知登録キャッシュをクリアする -// トークンがまだ生成されていない場合、このメソッドは null を返します。 -@Suppress("BlockingMethodInNonBlockingContext") -suspend fun loadInstallId( - context: Context, - account: SavedAccount, - deviceToken: String, - progress: suspend (SavedAccount, PollingState) -> Unit, -): String = PollingChecker.commonMutex.withLock { - // インストールIDを生成する - // インストールID生成時にSavedAccountテーブルを操作することがあるので - // アカウントリストの取得より先に行う - if (!PrivacyPolicyChecker(context).agreed) { - cancelAllWorkAndJoin(context) - throw InstallIdException( - null, - "the user not agreed to privacy policy." - ) - } - - val prefDevice = PrefDevice.from(context) - - prefDevice.getString(PrefDevice.KEY_INSTALL_ID, null) - ?.notEmpty()?.let { return it } - - progress(account, PollingState.PrepareInstallId) - - SavedAccount.clearRegistrationCache() - - val request = Request.Builder() - .url("$APP_SERVER/counter") - .build() - - val response = App1.ok_http_client.newCall(request).await() - val body = response.body?.string() - if (!response.isSuccessful || body?.isEmpty() != false) { - TootApiClient.formatResponse( - response, - "loadInstallId: get/counter failed." - ).let { throw InstallIdException(null, it) } - } - (deviceToken + UUID.randomUUID() + body).digestSHA256Base64Url() - .also { prefDevice.edit().putString(PrefDevice.KEY_INSTALL_ID, it).apply() } -} - fun resetNotificationTracking(account: SavedAccount) { if (importProtector.get()) { log.w("resetNotificationTracking: abort by importProtector.") @@ -139,7 +63,7 @@ fun resetNotificationTracking(account: SavedAccount) { } launchDefault { PollingChecker.accountMutex(account.db_id).withLock { - NotificationTracking.resetTrackingState(account.db_id) + daoNotificationTracking.resetTrackingState(account.db_id) } } } @@ -181,7 +105,7 @@ fun restartAllWorker(context: Context) { log.w("restartAllWorker: abort by importProtector.") return@launch } - NotificationTracking.resetPostAll() + daoNotificationTracking.resetPostAll() App1.prepare(context, "restartAllWorker") PollingWorker2.enqueuePolling(context) } catch (ex: Throwable) { @@ -190,7 +114,7 @@ fun restartAllWorker(context: Context) { } } -fun onNotificationCleared(context: Context, accountDbId: Long) { +fun onNotificationCleared(accountDbId: Long) { EmptyScope.launch { try { if (importProtector.get()) { @@ -199,8 +123,8 @@ fun onNotificationCleared(context: Context, accountDbId: Long) { } PollingChecker.accountMutex(accountDbId).withLock { log.d("deleteCacheData: db_id=$accountDbId") - SavedAccount.loadAccount(context, accountDbId) ?: return@withLock - NotificationCache.deleteCache(accountDbId) + daoSavedAccount.loadAccount(accountDbId) ?: return@withLock + daoNotificationCache.deleteCache(accountDbId) } } catch (ex: Throwable) { log.e(ex, "onNotificationCleared failed.") @@ -214,7 +138,9 @@ suspend fun onNotificationDeleted(dbId: Long, typeName: String) { return } PollingChecker.accountMutex(dbId).withLock { - NotificationTracking.updateRead(dbId, typeName) + daoSavedAccount.loadAccount(dbId)?.let { + daoNotificationTracking.updateRead(dbId, typeName) + } } } @@ -307,7 +233,7 @@ suspend fun checkNoticifationAll( } } - SavedAccount.loadAccountList(context).mapNotNull { sa -> + daoSavedAccount.loadAccountList().mapNotNull { sa -> when { sa.isPseudo || !sa.isConfirmed -> null else -> EmptyScope.launch(AppDispatchers.DEFAULT) { @@ -317,7 +243,7 @@ suspend fun checkNoticifationAll( accountDbId = sa.db_id, ).check( checkNetwork = false, - onlySubscription = onlySubscription, + onlyEnqueue = onlySubscription, ) { a, s -> updateStatus(a, s) } updateStatus(sa, PollingState.Complete) } catch (ex: Throwable) { @@ -407,8 +333,13 @@ fun recycleClickedNotification(context: Context, uri: Uri) { log.w("recycleClickedNotification: abort by importProtector.") return@launchDefault } + + // アカウントの存在確認 + daoSavedAccount.loadAccount(dbId)?.acct + ?: error("missing account. dbId=$dbId") + PollingChecker.accountMutex(dbId).withLock { - NotificationTracking.updateRead(dbId, typeName) + daoNotificationTracking.updateRead(dbId, typeName) } } catch (ex: Throwable) { log.e(ex, "recycleClickedNotification failed.") diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/PollingWorker2.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/PollingWorker2.kt index 1b33865a..9385f32a 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/notification/PollingWorker2.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/notification/PollingWorker2.kt @@ -1,12 +1,15 @@ package jp.juggler.subwaytooter.notification +import android.Manifest import android.app.ActivityManager import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat import androidx.work.* import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.R -import jp.juggler.subwaytooter.pref.PrefDevice import jp.juggler.subwaytooter.pref.PrefS +import jp.juggler.subwaytooter.pref.prefDevice import jp.juggler.util.log.LogCategory import kotlinx.coroutines.CancellationException import kotlinx.coroutines.coroutineScope @@ -27,8 +30,6 @@ class PollingWorker2( private val log = LogCategory("PollingWorker") private const val KEY_ACCOUNT_DB_ID = "accountDbId" - private const val NOTIFICATION_ID_POLLING_WORKER = 2 - private const val WORK_NAME = "PollingWorker2" suspend fun cancelPolling(context: Context) { @@ -42,19 +43,20 @@ class PollingWorker2( ) { val workManager = WorkManager.getInstance(context) - val prefDevice = PrefDevice.from(context) + val prefDevice = context.prefDevice val newInterval = max( PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, - (PrefS.spPullNotificationCheckInterval().toLongOrNull() ?: 0L) * 60000L, + (PrefS.spPullNotificationCheckInterval.value.toLongOrNull() ?: 0L) * 60000L, ) - // すでに同じインターバルのが存在するなら何もしない - if (workManager.getWorkInfosForUniqueWork(WORK_NAME).await().any { - val oldInterval = - prefDevice.getLong(PrefDevice.KEY_POLLING_WORKER2_INTERVAL, 0L) - oldInterval == newInterval && !it.state.isFinished - }) { + // 未完了のジョブがあり、インターバルが同じなら何もしない + if (workManager.getWorkInfosForUniqueWork(WORK_NAME).await() + .any { + val oldInterval = prefDevice.pollingWorker2Interval ?: 0L + oldInterval == newInterval && !it.state.isFinished + } + ) { return } @@ -88,7 +90,7 @@ class PollingWorker2( workRequest ).await() - prefDevice.edit().putLong(PrefDevice.KEY_POLLING_WORKER2_INTERVAL, newInterval).apply() + prefDevice.pollingWorker2Interval = newInterval } } @@ -99,10 +101,17 @@ class PollingWorker2( } private suspend fun showMessage(text: String) = - CheckerNotification.showMessage(applicationContext, text) { + CheckerNotification.showMessage(applicationContext, text) { n, nc -> try { - setForegroundAsync(ForegroundInfo(NOTIFICATION_ID_POLLING_WORKER, it)) - .await() + if (ActivityCompat.checkSelfPermission( + applicationContext, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + log.w("missing POST_NOTIFICATIONS. checker=$text") + } else { + setForegroundAsync(ForegroundInfo(nc.notificationId, n)).await() + } } catch (ex: Throwable) { log.e(ex, "showMessage failed.") } diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/PullNotification.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/PullNotification.kt new file mode 100644 index 00000000..58fb937f --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/notification/PullNotification.kt @@ -0,0 +1,143 @@ +package jp.juggler.subwaytooter.notification + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import jp.juggler.subwaytooter.ActCallback +import jp.juggler.subwaytooter.EventReceiver +import jp.juggler.subwaytooter.R +import jp.juggler.subwaytooter.pref.PrefB +import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.util.data.encodePercent +import jp.juggler.util.data.toMutableMap +import jp.juggler.util.log.LogCategory + +object PullNotification { + private val log = LogCategory("PullNotification") + + const val TRACKING_NAME_DEFAULT = "" + const val TRACKING_NAME_REPLY = "reply" + + private val nc = NotificationChannels.PullNotification + + /** + * メッセージ通知を消す + */ + fun NotificationManager.removeMessageNotification(id: String?, tag: String) { + when (id) { + null -> cancel(tag, nc.notificationId) + else -> cancel("$tag/$id", nc.notificationId) + } + } + + /** + * メッセージ通知をたくさん消す + */ + fun NotificationManager.removeMessageNotification(account: SavedAccount, tag: String) { + if (PrefB.bpDivideNotification.value) { + activeNotifications?.filterNotNull()?.filter { + it.id == nc.notificationId && it.tag.startsWith("$tag/") + }?.forEach { + log.d("cancel: ${it.tag} context=${account.acct.pretty} $tag") + cancel(it.tag, nc.notificationId) + } + } else { + cancel(tag, nc.notificationId) + } + } + + /** + * 表示中のメッセージ通知の一覧 + */ + fun NotificationManager.getMessageNotifications(tag: String) = + activeNotifications?.filterNotNull()?.filter { + it.id == nc.notificationId && it.tag.startsWith("$tag/") + }?.map { Pair(it.tag, it) }?.toMutableMap() ?: mutableMapOf() + + fun NotificationManager.showMessageNotification( + context: Context, + account: SavedAccount, + trackingType: TrackingType, + notificationTag: String, + notificationId: String? = null, + setContent: (builder: NotificationCompat.Builder) -> Unit, + ) { + log.d("showNotification[${account.acct.pretty}] creating notification(1)") + + val params = listOf( + "db_id" to account.db_id.toString(), + "type" to trackingType.str, + "notificationId" to notificationId + ).mapNotNull { + when (val second = it.second) { + null -> null + else -> "${it.first.encodePercent()}=${second.encodePercent()}" + } + }.joinToString("&") + + val iTap = Intent(context, ActCallback::class.java).apply { + data = "subwaytooter://notification_click/?$params".toUri() + // FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY を付与してはいけない + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + val piTap = PendingIntent.getActivity( + context, + nc.pircTap, + iTap, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val iDelete = Intent(context, EventReceiver::class.java).apply { + action = EventReceiver.ACTION_NOTIFICATION_DELETE + data = "subwaytooter://notification_delete/?$params".toUri() + } + val piDelete = PendingIntent.getBroadcast( + context, + nc.pircDelete, + iDelete, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(context, nc.id).apply { + priority = nc.priority + + setContentIntent(piTap) + setDeleteIntent(piDelete) + setAutoCancel(true) + + // 常に白テーマのアイコンを使う + setSmallIcon(R.drawable.ic_notification) + + // 常に白テーマの色を使う + color = ContextCompat.getColor(context, R.color.colorOsNotificationAccent) + + // Android 7.0 ではグループを指定しないと勝手に通知が束ねられてしまう。 + // 束ねられた通知をタップしても pi_click が実行されないので困るため、 + // アカウント別にグループキーを設定する + setGroup(context.packageName + ":" + account.acct.ascii) + } + + log.d("showNotification[${account.acct.pretty}] creating notification(3)") + setContent(builder) + + log.d("showNotification[${account.acct.pretty}] set notification...") + notify( + notificationTag, + nc.notificationId, + builder.build() + ) + } + + fun openNotificationChannelSetting(context: Context) { + val nc = NotificationChannels.PullNotification + val intent = Intent("android.settings.CHANNEL_NOTIFICATION_SETTINGS") + intent.putExtra(Settings.EXTRA_CHANNEL_ID, nc.id) + intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + context.startActivity(intent) + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/PushNotification.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/PushNotification.kt new file mode 100644 index 00000000..1b73bacc --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/notification/PushNotification.kt @@ -0,0 +1,91 @@ +package jp.juggler.subwaytooter.notification + +import android.Manifest +import android.app.PendingIntent +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.core.net.toUri +import jp.juggler.subwaytooter.notification.NotificationDeleteReceiver.Companion.intentNotificationDelete +import jp.juggler.subwaytooter.table.PushMessage +import jp.juggler.subwaytooter.util.loadIcon +import jp.juggler.util.data.notEmpty +import jp.juggler.util.log.LogCategory + +private val log = LogCategory("PushNotification") + +/** + * SNSからの通知を表示する + */ +suspend fun Context.showPushNotification( + pm: PushMessage, +) { + if (Build.VERSION.SDK_INT >= 33) { + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + log.w("missing POST_NOTIFICATIONS.") + return + } + } + + val nc = NotificationChannels.PushMessage + val density = resources.displayMetrics.density + + val iconAndColor = pm.notificationIconAndColor() + + suspend fun PushMessage.loadSmallIcon(context: Context): IconCompat { + iconSmall?.notEmpty() + ?.let { loadIcon(pm.iconSmall, (24f * density + 0.5f).toInt()) } + ?.let { return IconCompat.createWithBitmap(it) } + val iconId = iconAndColor.iconId + return IconCompat.createWithResource(context, iconId) + } + + val iconSmall = pm.loadSmallIcon(this) + val iconBitmapLarge = loadIcon(pm.iconLarge, (48f * density + 0.5f).toInt()) + + val url = "${nc.uriPrefixDelete}/${pm.id}" + val iDelete = intentNotificationDelete(url.toUri()) + val piDelete = + PendingIntent.getBroadcast(this, nc.pircDelete, iDelete, PendingIntent.FLAG_IMMUTABLE) + + // val iTap = intentActMessage(pm.messageDbId) + // val piTap = PendingIntent.getActivity(this, nc.pircTap, iTap, PendingIntent.FLAG_IMMUTABLE) + + val builder = NotificationCompat.Builder(this, NotificationChannels.PushMessage.id).apply { + priority = nc.priority + color = iconAndColor.color + setSmallIcon(iconSmall) + iconBitmapLarge?.let { setLargeIcon(it) } + setContentTitle(pm.loginAcct) + setContentText(pm.text) + setWhen(pm.timestamp) + // setContentIntent(piTap) + setDeleteIntent(piDelete) + setAutoCancel(true) + } + + NotificationManagerCompat.from(this).notify(url, nc.notificationId, builder.build()) +} + +/** + * 通知を消す + * + * - 試験アプリなのであまり積極的に消さない… + */ +fun Context.deleteSnsNotification(messageDbId: Long) { + try { + val nc = NotificationChannels.PushMessage + val url = "${nc.uriPrefixDelete}/${messageDbId}" + NotificationManagerCompat.from(this).cancel(url, nc.notificationId) + } catch (ex: Throwable) { + log.e(ex, "deleteSnsNotification failed. messageDbId=$messageDbId") + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/PushSubscriptionHelper.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/PushSubscriptionHelper.kt index b61fa591..8e260ff0 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/notification/PushSubscriptionHelper.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/notification/PushSubscriptionHelper.kt @@ -1,19 +1,18 @@ package jp.juggler.subwaytooter.notification import android.content.Context -import androidx.core.net.toUri import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.TootApiResult import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SubscriptionServerKey +import jp.juggler.subwaytooter.table.appDatabase import jp.juggler.util.* import jp.juggler.util.data.* import jp.juggler.util.log.* import jp.juggler.util.network.toPostRequestBuilder import jp.juggler.util.ui.* -import kotlinx.coroutines.CancellationException import okhttp3.Request import okhttp3.Response @@ -21,6 +20,8 @@ class PushSubscriptionHelper( val context: Context, val account: SavedAccount, val verbose: Boolean = false, + private val daoSubscriptionServerKey: SubscriptionServerKey.Access = + SubscriptionServerKey.Access(appDatabase), ) { companion object { @@ -96,7 +97,7 @@ class PushSubscriptionHelper( } // 既に登録済みの値と同じなら何もしない - val oldKey = SubscriptionServerKey.find(clientIdentifier) + val oldKey = daoSubscriptionServerKey.find(clientIdentifier) if (oldKey != serverKey) { // サーバキーをアプリサーバに登録 @@ -115,7 +116,7 @@ class PushSubscriptionHelper( 200 -> { // 登録できたサーバーキーをアプリ内DBに保存 - SubscriptionServerKey.save(clientIdentifier, serverKey) + daoSubscriptionServerKey.save(clientIdentifier, serverKey) addLog("(server public key is registered.)") } @@ -137,7 +138,8 @@ class PushSubscriptionHelper( endpoint: String, ): TootApiResult { - if (account.last_push_endpoint == endpoint) return TootApiResult() + // deprecated + // if (account.last_push_endpoint == endpoint) return TootApiResult() return client.http( buildJsonObject { @@ -152,7 +154,8 @@ class PushSubscriptionHelper( result.response?.let { res -> when (res.code.also { res.close() }) { in 200 until 300 -> { - account.updateLastPushEndpoint(endpoint) + // deprecated + // account.updateLastPushEndpoint(endpoint) } else -> { result.caption = "(SubwayTooter App server)" @@ -163,248 +166,248 @@ class PushSubscriptionHelper( } } - suspend fun updateSubscription( - client: TootApiClient, - force: Boolean = false, - progress: suspend (SavedAccount, PollingState) -> Unit = { _, _ -> }, - ): TootApiResult? = try { - when { - isRecentlyChecked() -> - TootApiResult(ERROR_PREVENT_FREQUENTLY_CHECK) +// suspend fun updateSubscription( +// client: TootApiClient, +// force: Boolean = false, +// progress: suspend (SavedAccount, PollingState) -> Unit = { _, _ -> }, +// ): TootApiResult? = try { +// when { +// isRecentlyChecked() -> +// TootApiResult(ERROR_PREVENT_FREQUENTLY_CHECK) +// +// account.isPseudo -> +// TootApiResult(context.getString(R.string.pseudo_account_not_supported)) +// +// else -> { +// progress(account, PollingState.CheckPushSubscription) +// when { +// account.isMisskey -> updateSubscriptionMisskey(client) +// else -> updateSubscriptionMastodon(client, force) +// } +// } +// } +// } catch (ex: Throwable) { +// TootApiResult(ex.withCaption("error.")) +// }?.apply { +// +// if (error != null) addLog("$error $requestInfo".trimEnd()) +// +// // update error text on account table +// val log = logString +// when { +// log.contains(ERROR_PREVENT_FREQUENTLY_CHECK) -> { +// // don't update if check was skipped. +// } +// +// subscribed || log.isEmpty() -> Unit +// // clear error text if succeeded or no error log +//// if (account.last_subscription_error != null) { +//// account.updateSubscriptionError(null) +//// } +// +// else -> Unit +// // record error text +//// account.updateSubscriptionError(log) +// } +// } - account.isPseudo -> - TootApiResult(context.getString(R.string.pseudo_account_not_supported)) +// private suspend fun updateSubscriptionMisskey(client: TootApiClient): TootApiResult? { +// +// // 現在の購読状態を取得できないので、毎回購読の更新を行う +// // FCMのデバイスIDを取得 +// val deviceId = try { +// loadFirebaseMessagingToken(context) +// } catch (ex: Throwable) { +// log.e(ex, "loadFirebaseMessagingToken failed.") +// return when (ex) { +// is CancellationException -> null +// else -> TootApiResult(error = context.getString(R.string.missing_fcm_device_id)) +// } +// } +// +// // アクセストークン +// val accessToken = account.misskeyApiToken +// ?: return TootApiResult(error = "missing misskeyApiToken.") +// +// // インストールIDを取得 +// val installId = try { +// loadInstallId( +// context, +// account, +// deviceId +// ) { a, s -> log.i("[${a.acct.pretty}]${s.desc}") } +// } catch (ex: Throwable) { +// log.e(ex, "loadInstallId failed.") +// return when (ex) { +// is CancellationException -> null +// else -> TootApiResult(error = context.getString(R.string.missing_install_id)) +// } +// } +// +// // クライアント識別子 +// val clientIdentifier = "$accessToken$installId".digestSHA256Base64Url() +// +// // 購読が不要な場合 +// // アプリサーバが410を返せるように状態を通知する +// if (flags == 0) return registerEndpoint(client, deviceId, "none").also { +// if (it.error == null && verbose) addLog(context.getString(R.string.push_subscription_updated)) +// } +// +// /* +// https://github.com/syuilo/misskey/blob/master/src/services/create-notification.ts#L46 +// Misskeyは通知に既読の概念があり、イベント発生後2秒たっても未読の時だけプッシュ通知が発生する。 +// STでプッシュ通知を試すにはSTの画面を非表示にする必要があるのでWebUIを使って投稿していたが、 +// WebUIを開いていると通知はすぐ既読になるのでプッシュ通知は発生しない。 +// プッシュ通知のテスト時はST2台を使い、片方をプッシュ通知の受信チェック、もう片方を投稿などの作業に使うことになる。 +// */ +// +// // https://github.com/syuilo/misskey/issues/2541 +// // https://github.com/syuilo/misskey/commit/4c6fb60dd25d7e2865fc7c4d97728593ffc3c902 +// // 2018/9/1 の上記コミット以降、Misskeyでもサーバ公開鍵を得られるようになった +// +// val endpoint = +// "$APP_SERVER/webpushcallback/${deviceId.encodePercent()}/${account.acct.ascii.encodePercent()}/$flags/$clientIdentifier/misskey" +// +// // アプリサーバが過去のendpoint urlに410を返せるよう、状態を通知する +// val r = registerEndpoint(client, deviceId, endpoint.toUri().encodedPath!!) +// if (r.error != null) return r +// +// // 購読 +// @Suppress("SpellCheckingInspection") +// return client.request( +// "/api/sw/register", +// account.putMisskeyApiToken().apply { +// put("endpoint", endpoint) +// put("auth", "iRdmDrOS6eK6xvG1H6KshQ") +// put( +// "publickey", +// "BBEUVi7Ehdzzpe_ZvlzzkQnhujNJuBKH1R0xYg7XdAKNFKQG9Gpm0TSGRGSuaU7LUFKX-uz8YW0hAshifDCkPuE" +// ) +// } +// .toPostRequestBuilder() +// )?.also { result -> +// val jsonObject = result.jsonObject +// if (jsonObject == null) { +// addLog("API error.") +// } else { +// if (verbose) addLog(context.getString(R.string.push_subscription_updated)) +// subscribed = true +// return updateServerKey( +// client, +// clientIdentifier, +// jsonObject.string("key") ?: "3q2+rw" +// ) +// } +// } +// } - else -> { - progress(account, PollingState.CheckPushSubscription) - when { - account.isMisskey -> updateSubscriptionMisskey(client) - else -> updateSubscriptionMastodon(client, force) - } - } - } - } catch (ex: Throwable) { - TootApiResult(ex.withCaption("error.")) - }?.apply { - - if (error != null) addLog("$error $requestInfo".trimEnd()) - - // update error text on account table - val log = logString - when { - log.contains(ERROR_PREVENT_FREQUENTLY_CHECK) -> { - // don't update if check was skipped. - } - - subscribed || log.isEmpty() -> - // clear error text if succeeded or no error log - if (account.last_subscription_error != null) { - account.updateSubscriptionError(null) - } - - else -> - // record error text - account.updateSubscriptionError(log) - } - } - - private suspend fun updateSubscriptionMisskey(client: TootApiClient): TootApiResult? { - - // 現在の購読状態を取得できないので、毎回購読の更新を行う - // FCMのデバイスIDを取得 - val deviceId = try { - loadFirebaseMessagingToken(context) - } catch (ex: Throwable) { - log.e(ex, "loadFirebaseMessagingToken failed.") - return when (ex) { - is CancellationException -> null - else -> TootApiResult(error = context.getString(R.string.missing_fcm_device_id)) - } - } - - // アクセストークン - val accessToken = account.misskeyApiToken - ?: return TootApiResult(error = "missing misskeyApiToken.") - - // インストールIDを取得 - val installId = try { - loadInstallId( - context, - account, - deviceId - ) { a, s -> log.i("[${a.acct.pretty}]${s.desc}") } - } catch (ex: Throwable) { - log.e(ex, "loadInstallId failed.") - return when (ex) { - is CancellationException -> null - else -> TootApiResult(error = context.getString(R.string.missing_install_id)) - } - } - - // クライアント識別子 - val clientIdentifier = "$accessToken$installId".digestSHA256Base64Url() - - // 購読が不要な場合 - // アプリサーバが410を返せるように状態を通知する - if (flags == 0) return registerEndpoint(client, deviceId, "none").also { - if (it.error == null && verbose) addLog(context.getString(R.string.push_subscription_updated)) - } - - /* - https://github.com/syuilo/misskey/blob/master/src/services/create-notification.ts#L46 - Misskeyは通知に既読の概念があり、イベント発生後2秒たっても未読の時だけプッシュ通知が発生する。 - STでプッシュ通知を試すにはSTの画面を非表示にする必要があるのでWebUIを使って投稿していたが、 - WebUIを開いていると通知はすぐ既読になるのでプッシュ通知は発生しない。 - プッシュ通知のテスト時はST2台を使い、片方をプッシュ通知の受信チェック、もう片方を投稿などの作業に使うことになる。 - */ - - // https://github.com/syuilo/misskey/issues/2541 - // https://github.com/syuilo/misskey/commit/4c6fb60dd25d7e2865fc7c4d97728593ffc3c902 - // 2018/9/1 の上記コミット以降、Misskeyでもサーバ公開鍵を得られるようになった - - val endpoint = - "$APP_SERVER/webpushcallback/${deviceId.encodePercent()}/${account.acct.ascii.encodePercent()}/$flags/$clientIdentifier/misskey" - - // アプリサーバが過去のendpoint urlに410を返せるよう、状態を通知する - val r = registerEndpoint(client, deviceId, endpoint.toUri().encodedPath!!) - if (r.error != null) return r - - // 購読 - @Suppress("SpellCheckingInspection") - return client.request( - "/api/sw/register", - account.putMisskeyApiToken().apply { - put("endpoint", endpoint) - put("auth", "iRdmDrOS6eK6xvG1H6KshQ") - put( - "publickey", - "BBEUVi7Ehdzzpe_ZvlzzkQnhujNJuBKH1R0xYg7XdAKNFKQG9Gpm0TSGRGSuaU7LUFKX-uz8YW0hAshifDCkPuE" - ) - } - .toPostRequestBuilder() - )?.also { result -> - val jsonObject = result.jsonObject - if (jsonObject == null) { - addLog("API error.") - } else { - if (verbose) addLog(context.getString(R.string.push_subscription_updated)) - subscribed = true - return updateServerKey( - client, - clientIdentifier, - jsonObject.string("key") ?: "3q2+rw" - ) - } - } - } - - private suspend fun updateSubscriptionMastodon( - client: TootApiClient, - force: Boolean, - ): TootApiResult? { - - // 現在の購読状態を取得 - // https://github.com/tootsuite/mastodon/pull/7471 - // https://github.com/tootsuite/mastodon/pull/7472 - - val subscription404: Boolean - val oldSubscription: TootPushSubscription? - checkCurrentSubscription(client).let { - if (it.failed) return it.result - subscription404 = it.is404 - oldSubscription = parseItem(::TootPushSubscription, it.result?.jsonObject) - } - - if (oldSubscription == null) { - log.i("${account.acct}: oldSubscription is null") - val (ti, result) = TootInstance.get(client) - ti ?: return result - checkInstanceVersionMastodon(ti, subscription404)?.let { return it } - } - - // FCMのデバイスIDを取得 - val deviceId = try { - loadFirebaseMessagingToken(context) - } catch (ex: Throwable) { - log.e(ex, "loadFirebaseMessagingToken failed.") - return when (ex) { - is CancellationException -> null - else -> TootApiResult(error = context.getString(R.string.missing_fcm_device_id)) - } - } - - // インストールIDを取得 - val installId = try { - loadInstallId( - context, - account, - deviceId - ) { a, s -> log.i("[${a.acct.pretty}]${s.desc}") } - } catch (ex: Throwable) { - log.e(ex, "loadInstallId failed.") - return when (ex) { - is CancellationException -> null - else -> TootApiResult(error = context.getString(R.string.missing_install_id)) - } - } - // アクセストークン - val accessToken = account.getAccessToken() - ?: return TootApiResult(error = "missing access token.") - - // アクセストークンのダイジェスト - val tokenDigest = accessToken.digestSHA256Base64Url() - - // クライアント識別子 - val clientIdentifier = "$accessToken$installId".digestSHA256Base64Url() - - val endpoint = - "$APP_SERVER/webpushcallback/${deviceId.encodePercent()}/${account.acct.ascii.encodePercent()}/$flags/$clientIdentifier" - - val newAlerts = JsonObject().apply { - put("follow", account.notification_follow) - put(TootNotification.TYPE_ADMIN_SIGNUP, account.notification_follow) - put("favourite", account.notification_favourite) - put("reblog", account.notification_boost) - put("mention", account.notification_mention) - put("poll", account.notification_vote) - put("follow_request", account.notification_follow_request) - put("status", account.notification_post) - put("update", account.notification_update) - put("emoji_reaction", account.notification_reaction) // fedibird拡張 - } - - if (!force) { - canSkipSubscriptionMastodon( - client = client, - clientIdentifier = clientIdentifier, - endpoint = endpoint, - oldSubscription = oldSubscription, - newAlerts = newAlerts, - )?.let { return it } - } - - // アクセストークンの優先権を取得 - checkDeviceHasPriority( - client, - tokenDigest = tokenDigest, - installId = installId, - ).let { - if (it.failed) return it.result - } - - return when (flags) { - // 通知設定が全てカラなので、購読を取り消したい - 0 -> unsubscribeMastodon(client) - - // 通知設定が空ではないので購読を行いたい - else -> subscribeMastodon( - client = client, - clientIdentifier = clientIdentifier, - endpoint = endpoint, - newAlerts = newAlerts - ) - } - } +// private suspend fun updateSubscriptionMastodon( +// client: TootApiClient, +// force: Boolean, +// ): TootApiResult? { +// +// // 現在の購読状態を取得 +// // https://github.com/tootsuite/mastodon/pull/7471 +// // https://github.com/tootsuite/mastodon/pull/7472 +// +// val subscription404: Boolean +// val oldSubscription: TootPushSubscription? +// checkCurrentSubscription(client).let { +// if (it.failed) return it.result +// subscription404 = it.is404 +// oldSubscription = parseItem(::TootPushSubscription, it.result?.jsonObject) +// } +// +// if (oldSubscription == null) { +// log.i("${account.acct}: oldSubscription is null") +// val (ti, result) = TootInstance.get(client) +// ti ?: return result +// checkInstanceVersionMastodon(ti, subscription404)?.let { return it } +// } +// +// // FCMのデバイスIDを取得 +// val deviceId = try { +// loadFirebaseMessagingToken(context) +// } catch (ex: Throwable) { +// log.e(ex, "loadFirebaseMessagingToken failed.") +// return when (ex) { +// is CancellationException -> null +// else -> TootApiResult(error = context.getString(R.string.missing_fcm_device_id)) +// } +// } +// +// // インストールIDを取得 +// val installId = try { +// loadInstallId( +// context, +// account, +// deviceId +// ) { a, s -> log.i("[${a.acct.pretty}]${s.desc}") } +// } catch (ex: Throwable) { +// log.e(ex, "loadInstallId failed.") +// return when (ex) { +// is CancellationException -> null +// else -> TootApiResult(error = context.getString(R.string.missing_install_id)) +// } +// } +// // アクセストークン +// val accessToken = account.bearerAccessToken +// ?: return TootApiResult(error = "missing access token.") +// +// // アクセストークンのダイジェスト +// val tokenDigest = accessToken.digestSHA256Base64Url() +// +// // クライアント識別子 +// val clientIdentifier = "$accessToken$installId".digestSHA256Base64Url() +// +// val endpoint = +// "$APP_SERVER/webpushcallback/${deviceId.encodePercent()}/${account.acct.ascii.encodePercent()}/$flags/$clientIdentifier" +// +// val newAlerts = JsonObject().apply { +// put("follow", account.notification_follow) +// put(TootNotification.TYPE_ADMIN_SIGNUP, account.notification_follow) +// put("favourite", account.notification_favourite) +// put("reblog", account.notification_boost) +// put("mention", account.notification_mention) +// put("poll", account.notification_vote) +// put("follow_request", account.notification_follow_request) +// put("status", account.notification_post) +// put("update", account.notification_update) +// put("emoji_reaction", account.notification_reaction) // fedibird拡張 +// } +// +// if (!force) { +// canSkipSubscriptionMastodon( +// client = client, +// clientIdentifier = clientIdentifier, +// endpoint = endpoint, +// oldSubscription = oldSubscription, +// newAlerts = newAlerts, +// )?.let { return it } +// } +// +// // アクセストークンの優先権を取得 +// checkDeviceHasPriority( +// client, +// tokenDigest = tokenDigest, +// installId = installId, +// ).let { +// if (it.failed) return it.result +// } +// +// return when (flags) { +// // 通知設定が全てカラなので、購読を取り消したい +// 0 -> unsubscribeMastodon(client) +// +// // 通知設定が空ではないので購読を行いたい +// else -> subscribeMastodon( +// client = client, +// clientIdentifier = clientIdentifier, +// endpoint = endpoint, +// newAlerts = newAlerts +// ) +// } +// } private class CheckDeviceHasPriorityResult( val result: TootApiResult?, diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/ServerTimeoutNotification.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/ServerTimeoutNotification.kt index 005dc1ba..32976e56 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/notification/ServerTimeoutNotification.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/notification/ServerTimeoutNotification.kt @@ -15,35 +15,27 @@ object ServerTimeoutNotification { private val log = LogCategory("ServerTimeoutNotification") - private const val NOTIFICATION_ID_TIMEOUT = 3 - fun createServerTimeoutNotification( context: Context, accounts: String, ) { val notificationManager: NotificationManager = systemService(context)!! + val nc = NotificationChannels.ServerTimeout + // 通知タップ時のPendingIntent - val clickIntent = Intent(context, ActCallback::class.java) - // FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY を付与してはいけない - clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val iTap = Intent(context, ActCallback::class.java).apply { + // FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY を付与してはいけない + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } val clickPi = PendingIntent.getActivity( context, - 3, - clickIntent, + nc.pircTap, + iTap, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) - // Android 8 から、通知のスタイルはユーザが管理することになった - // NotificationChannel を端末に登録しておけば、チャネルごとに管理画面が作られる - val channel = NotificationHelper.createNotificationChannel( - context, - "ErrorNotification", - "Error", - null, - 2 /* NotificationManager.IMPORTANCE_LOW */ - ) - val builder = NotificationCompat.Builder(context, channel.id) + val builder = NotificationCompat.Builder(context, nc.id) val header = context.getString(R.string.error_notification_title) val summary = context.getString(R.string.error_notification_summary) @@ -51,6 +43,7 @@ object ServerTimeoutNotification { // ここは常に白テーマのアイコンを使う // ここは常に白テーマの色を使う builder.apply { + priority = nc.priority setContentIntent(clickPi) setAutoCancel(true) setSmallIcon(R.drawable.ic_notification) @@ -60,6 +53,6 @@ object ServerTimeoutNotification { setContentTitle(header) setContentText("$summary: $accounts") } - notificationManager.notify(NOTIFICATION_ID_TIMEOUT, builder.build()) + notificationManager.notify(nc.notificationId, builder.build()) } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/SnsNotificationIcon.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/SnsNotificationIcon.kt new file mode 100644 index 00000000..7bf4b27d --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/notification/SnsNotificationIcon.kt @@ -0,0 +1,119 @@ +package jp.juggler.subwaytooter.notification + +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import jp.juggler.subwaytooter.R +import jp.juggler.subwaytooter.table.PushMessage +import jp.juggler.util.log.LogCategory + +private val log = LogCategory("NotificationIconAndColor") + +enum class NotificationIconAndColor( + @ColorInt colorArg: Int, + @DrawableRes val iconId: Int, + val keys: Array, +) { + Favourite( + 0xe5e825, + R.drawable.ic_star_outline, + arrayOf("favourite"), + ), + Mention( + 0x60f516, + R.drawable.outline_alternate_email_24, + arrayOf("mention"), + ), + Reply( + 0xff3dbb, + R.drawable.ic_reply, + arrayOf("reply") + ), + Reblog( + 0x39e3d5, + R.drawable.ic_repeat, + arrayOf("reblog", "renote"), + ), + Quote( + 0x40a9ff, + R.drawable.ic_quote, + arrayOf("quote"), + ), + Follow( + 0xf57a33, + R.drawable.ic_person_add, + arrayOf("follow", "followRequestAccepted") + ), + Unfollow( + 0x9433f5, + R.drawable.ic_follow_cross, + arrayOf("unfollow") + ), + Reaction( + 0xf5f233, + R.drawable.outline_add_reaction_24, + arrayOf("reaction", "emoji_reaction", "pleroma:emoji_reaction") + ), + FollowRequest( + 0xf53333, + R.drawable.ic_follow_wait, + arrayOf("follow_request", "receiveFollowRequest"), + ), + Poll( + 0x33f59b, + R.drawable.outline_poll_24, + arrayOf("pollVote", "poll_vote", "poll"), + ), + Status( + 0x33f597, + R.drawable.ic_edit, + arrayOf("status", "update", "status_reference") + ), + SignUp( + 0xf56a33, + R.drawable.outline_group_add_24, + arrayOf("admin.sign_up"), + ), + + Unknown( + 0xae1aed, + R.drawable.ic_question, + arrayOf("unknown"), + ) + ; + + val color = Color.BLACK or colorArg + + companion object { + val map = buildMap { + values().forEach { + for (k in it.keys) { + val old: NotificationIconAndColor? = get(k) + if (old != null) { + error("NotificationIconAndColor: $k is duplicate: ${it.name} and ${old.name}") + } else { + put(k, it) + } + } + } + } + } +} + +fun String.findNotificationIconAndColor() = + NotificationIconAndColor.map[this] + +fun PushMessage.notificationIconAndColor(): NotificationIconAndColor { + // mastodon + messageJson?.string("notification_type") + ?.findNotificationIconAndColor()?.let { return it } + + // misskey + when (messageJson?.string("type")) { + "notification" -> + messageJson?.jsonObject("body")?.string("type") + ?.findNotificationIconAndColor()?.let { return it } + } + + return NotificationIconAndColor.Unknown +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/notification/TrackingType.kt b/app/src/main/java/jp/juggler/subwaytooter/notification/TrackingType.kt index a0db5ddd..ca4c1aed 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/notification/TrackingType.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/notification/TrackingType.kt @@ -4,9 +4,9 @@ enum class TrackingType( val str: String, val typeName: String, ) { - All("all", MessageNotification.TRACKING_NAME_DEFAULT), - Reply("reply", MessageNotification.TRACKING_NAME_REPLY), - NotReply("notReply", MessageNotification.TRACKING_NAME_DEFAULT); + All("all", PullNotification.TRACKING_NAME_DEFAULT), + Reply("reply", PullNotification.TRACKING_NAME_REPLY), + NotReply("notReply", PullNotification.TRACKING_NAME_DEFAULT); companion object { private val valuesCache = values() diff --git a/app/src/main/java/jp/juggler/subwaytooter/pref/LazyContextHolder.kt b/app/src/main/java/jp/juggler/subwaytooter/pref/LazyContextHolder.kt new file mode 100644 index 00000000..0386e115 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/pref/LazyContextHolder.kt @@ -0,0 +1,46 @@ +package jp.juggler.subwaytooter.pref + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import androidx.startup.Initializer +import jp.juggler.util.os.applicationContextSafe +import java.util.concurrent.atomic.AtomicReference + +var lazyContextOverride = AtomicReference() +var lazyPrefOverride = AtomicReference() + +val lazyContext + get() = lazyContextOverride.get() + ?: LazyContextHolder.contextNullable + ?: error("LazyContextHolder not initialized") + +val lazyPref + get() = lazyPrefOverride.get() + ?: LazyContextHolder.prefNullable + ?: error("LazyContextHolder not initialized") + +@SuppressLint("StaticFieldLeak") +object LazyContextHolder { + var contextNullable: Context? = null + var prefNullable: SharedPreferences? = null + + fun init(context: Context) { + contextNullable = context + prefNullable = context.getSharedPreferences( + "${context.packageName}_preferences", + Context.MODE_PRIVATE + ) + } +} + +class LazyContextInitializer : Initializer { + override fun dependencies(): List>> = + emptyList() + + override fun create(context: Context): LazyContextHolder { + LazyContextHolder.init(context.applicationContextSafe) + return LazyContextHolder + } +} + diff --git a/app/src/main/java/jp/juggler/subwaytooter/pref/PrefDevice.kt b/app/src/main/java/jp/juggler/subwaytooter/pref/PrefDevice.kt index ce82920d..9a5f3abf 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/pref/PrefDevice.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/pref/PrefDevice.kt @@ -3,35 +3,205 @@ package jp.juggler.subwaytooter.pref import android.content.Context import android.content.SharedPreferences import android.graphics.Rect +import androidx.startup.AppInitializer +import androidx.startup.Initializer +import jp.juggler.util.os.applicationContextSafe +import java.util.* -object PrefDevice { +class PrefDevice(context: Context) { - private const val file_name = "device" + companion object { + // この設定ファイルはバックアップ対象から除外するべき + const val SHARED_PREFERENCE_NAME = "device" - internal const val KEY_DEVICE_TOKEN = "device_token" - internal const val KEY_INSTALL_ID = "install_id" - private const val KEY_POST_WINDOW_W = "postWindowW" - private const val KEY_POST_WINDOW_H = "postWindowH" + // 認証開始時の状況を覚える + private const val PREF_AUTH_SERVER_TYPE = "authServerType" + private const val PREF_AUTH_API_HOST = "authApiHost" + private const val PREF_AUTH_SESSION_ID = "authSessionId" - const val KEY_POLLING_WORKER2_INTERVAL = "pollingworker2Interval" + private const val PREF_FCM_TOKEN = "fcmToken" + private const val PREF_FCM_TOKEN_EXPIRED = "fcmTokenExpired" + private const val PREF_INSTALL_ID_V2 = "installIdV2" + private const val PREF_UP_ENDPOINT = "upEndpoint" + private const val PREF_UP_ENDPOINT_EXPIRED = "upEndpointExpired" + private const val PREF_PUSH_DISTRIBUTOR = "pushDistributor" + private const val PREF_TIME_LAST_ENDPOINT_REGISTER = "timeLastEndpointRegister" - const val LAST_AUTH_INSTANCE = "lastAuthInstance" - const val LAST_AUTH_SECRET = "lastAuthSecret" - const val LAST_AUTH_DB_ID = "lastAuthDbId" + const val PUSH_DISTRIBUTOR_FCM = "fcm" + const val PUSH_DISTRIBUTOR_NONE = "none" - fun from(context: Context): SharedPreferences { - return context.getSharedPreferences(file_name, Context.MODE_PRIVATE) + // 以下は古いキー + // private const val KEY_DEVICE_TOKEN = "device_token" + private const val KEY_INSTALL_ID = "install_id" + private const val KEY_POST_WINDOW_W = "postWindowW" + private const val KEY_POST_WINDOW_H = "postWindowH" + + private const val KEY_POLLING_WORKER2_INTERVAL = "pollingworker2Interval" + private const val LAST_AUTH_INSTANCE = "lastAuthInstance" + private const val LAST_AUTH_SECRET = "lastAuthSecret" + private const val LAST_AUTH_DB_ID = "lastAuthDbId" + + fun SharedPreferences.Editor.putLongNullable(key: String, value: Long?) = apply { + if (value == null) remove(key) else putLong(key, value) + } + + fun SharedPreferences.Editor.putIntNullable(key: String, value: Int?) = apply { + if (value == null) remove(key) else putInt(key, value) + } + + fun SharedPreferences.Editor.putBooleanNullable(key: String, value: Boolean?) = apply { + if (value == null) remove(key) else putBoolean(key, value) + } } - fun savePostWindowBound(context: Context, w: Int, h: Int) { + private val sp = context.getSharedPreferences(SHARED_PREFERENCE_NAME, Context.MODE_PRIVATE) + + private fun edit(block: (SharedPreferences.Editor) -> Unit) { + val e = sp.edit() + block(e) + e.apply() + } + + @Suppress("SameParameterValue") + private fun string(key: String) = sp.getString(key, null) + + @Suppress("SameParameterValue") + private fun long(key: String) = if (sp.contains(key)) sp.getLong(key, 0L) else null + + @Suppress("SameParameterValue") + private fun int(key: String) = if (sp.contains(key)) sp.getInt(key, 0) else null + + @Suppress("SameParameterValue") + private fun String?.saveTo(key: String) = + edit { it.putString(key, this) } + + @Suppress("SameParameterValue") + private fun Long?.saveTo(key: String) = + edit { it.putLongNullable(key, this) } + + @Suppress("SameParameterValue") + private fun Int?.saveTo(key: String) = + edit { it.putIntNullable(key, this) } + + // 認証開始時の状態を覚えておく + val authServerType: String? get() = string(PREF_AUTH_SERVER_TYPE) + val authApiHost: String? get() = string(PREF_AUTH_API_HOST) + val authSessionId: String? get() = string(PREF_AUTH_SESSION_ID) + fun saveAuthStart(apiHost: String, sessionId: String) { + edit { + it.putString(PREF_AUTH_API_HOST, apiHost) + it.putString(PREF_AUTH_SESSION_ID, sessionId) + } + } + + // アプリサーバV2用のインストールID + val installIdv2: String + get() = synchronized(this) { + string(PREF_INSTALL_ID_V2) + ?: UUID.randomUUID().toString() + .apply { saveTo(PREF_INSTALL_ID_V2) } + } + + var fcmToken: String? + get() = string(PREF_FCM_TOKEN) + set(value) { + value.saveTo(PREF_FCM_TOKEN) + } + + var fcmTokenExpired: String? + get() = string(PREF_FCM_TOKEN_EXPIRED) + set(value) { + value.saveTo(PREF_FCM_TOKEN_EXPIRED) + } + + var upEndpoint: String? + get() = string(PREF_UP_ENDPOINT) + set(value) { + value.saveTo(PREF_UP_ENDPOINT) + } + + var upEndpointExpired: String? + get() = string(PREF_UP_ENDPOINT_EXPIRED) + set(value) { + value.saveTo(PREF_UP_ENDPOINT_EXPIRED) + } + + var pushDistributor: String? + get() = string(PREF_PUSH_DISTRIBUTOR) + set(value) { + value.saveTo(PREF_PUSH_DISTRIBUTOR) + } + + var timeLastEndpointRegister: Long + get() = long(PREF_TIME_LAST_ENDPOINT_REGISTER) ?: 0L + set(value) { + value.saveTo(PREF_TIME_LAST_ENDPOINT_REGISTER) + } + + ////////////////////////////////// + // 以下は古い + + fun savePostWindowBound(w: Int, h: Int) { if (w < 64 || h < 64) return - from(context).edit().putInt(KEY_POST_WINDOW_W, w).putInt(KEY_POST_WINDOW_H, h).apply() + edit { + it.putInt(KEY_POST_WINDOW_W, w) + it.putInt(KEY_POST_WINDOW_H, h) + } } - fun loadPostWindowBound(context: Context): Rect? { - val pref = from(context) - val w = pref.getInt(KEY_POST_WINDOW_W, 0) - val h = pref.getInt(KEY_POST_WINDOW_H, 0) + fun loadPostWindowBound(): Rect? { + val w = int(KEY_POST_WINDOW_W) ?: 0 + val h = int(KEY_POST_WINDOW_H) ?: 0 return if (w <= 0 || h <= 0) null else Rect(0, 0, w, h) } + + var pollingWorker2Interval: Long? + get() = long(KEY_POLLING_WORKER2_INTERVAL) + set(value) { + value.saveTo(KEY_POLLING_WORKER2_INTERVAL) + } + + /** + * Misskey 10 の認証開始時に状態を覚える + */ + fun saveLastAuth(host: String, secret: String, dbId: Long?) = + edit { + it.putString(LAST_AUTH_INSTANCE, host) + it.putString(LAST_AUTH_SECRET, secret) + it.putLongNullable(LAST_AUTH_DB_ID, dbId) + } + + fun removeLastAuth() { + edit { + it.remove(LAST_AUTH_INSTANCE) + it.remove(LAST_AUTH_SECRET) + it.remove(LAST_AUTH_DB_ID) + } + } + + val lastAuthInstance: String? + get() = string(LAST_AUTH_INSTANCE) + + val lastAuthSecret: String? + get() = string(LAST_AUTH_SECRET) + + val lastAuthDbId: Long? + get() = long(LAST_AUTH_DB_ID) + + /** + * アプリサーバV1で使っていたインストールID + */ + val installIdV1 get() = string(KEY_INSTALL_ID) } + +class PrefDeviceInitializer : Initializer { + override fun dependencies(): List>> = + emptyList() + + override fun create(context: Context) = + PrefDevice(context.applicationContextSafe) +} + +val Context.prefDevice: PrefDevice + get() = AppInitializer.getInstance(this) + .initializeComponent(PrefDeviceInitializer::class.java) diff --git a/app/src/main/java/jp/juggler/subwaytooter/pref/PrefExt.kt b/app/src/main/java/jp/juggler/subwaytooter/pref/PrefExt.kt index baca4332..78ed5303 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/pref/PrefExt.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/pref/PrefExt.kt @@ -4,9 +4,6 @@ import android.content.Context import android.content.SharedPreferences import jp.juggler.subwaytooter.pref.impl.* -fun Context.pref(): SharedPreferences = - this.getSharedPreferences(this.packageName + "_preferences", Context.MODE_PRIVATE) - fun SharedPreferences.Editor.remove(item: BasePref<*>): SharedPreferences.Editor { item.remove(this) return this diff --git a/app/src/main/java/jp/juggler/subwaytooter/pref/impl/BasePref.kt b/app/src/main/java/jp/juggler/subwaytooter/pref/impl/BasePref.kt index 589d6e4e..2d5d0b49 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/pref/impl/BasePref.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/pref/impl/BasePref.kt @@ -1,9 +1,7 @@ package jp.juggler.subwaytooter.pref.impl -import android.content.Context import android.content.SharedPreferences -import jp.juggler.subwaytooter.global.appPref -import jp.juggler.subwaytooter.pref.pref +import jp.juggler.subwaytooter.pref.lazyPref @Suppress("EqualsOrHashCode") abstract class BasePref(val key: String, val defVal: T) { @@ -24,28 +22,35 @@ abstract class BasePref(val key: String, val defVal: T) { } abstract fun put(editor: SharedPreferences.Editor, v: T) - abstract operator fun invoke(pref: SharedPreferences): T + abstract fun readFrom(pref: SharedPreferences): T + + var value : T + get()= readFrom(lazyPref) + set(value){ + val e = lazyPref.edit() + put(e,value) + e.apply() + } + + fun removeValue(pref:SharedPreferences = lazyPref){ + pref.edit().remove(key).apply() + } override fun equals(other: Any?) = this === other override fun hashCode(): Int = key.hashCode() - open operator fun invoke(context: Context): T = - invoke(context.pref()) - - operator fun invoke(): T = invoke(appPref) - fun remove(e: SharedPreferences.Editor): SharedPreferences.Editor = e.remove(key) fun removeDefault(pref: SharedPreferences, e: SharedPreferences.Editor) = - if (pref.contains(key) && this.invoke(pref) == defVal) { + if (pref.contains(key) && this.value == defVal) { e.remove(key) true } else { false } - abstract fun hasNonDefaultValue(pref: SharedPreferences): Boolean + abstract fun hasNonDefaultValue(pref: SharedPreferences= lazyPref): Boolean } \ No newline at end of file diff --git a/app/src/main/java/jp/juggler/subwaytooter/pref/impl/BooleanPref.kt b/app/src/main/java/jp/juggler/subwaytooter/pref/impl/BooleanPref.kt index 8d86433c..8b8087a8 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/pref/impl/BooleanPref.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/pref/impl/BooleanPref.kt @@ -4,7 +4,7 @@ import android.content.SharedPreferences class BooleanPref(key: String, defVal: Boolean) : BasePref(key, defVal) { - override operator fun invoke(pref: SharedPreferences): Boolean = + override fun readFrom(pref: SharedPreferences): Boolean = pref.getBoolean(key, defVal) // put if value is not default, remove if value is same to default diff --git a/app/src/main/java/jp/juggler/subwaytooter/pref/impl/FloatPref.kt b/app/src/main/java/jp/juggler/subwaytooter/pref/impl/FloatPref.kt index 7e5fd599..a5eaa9e8 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/pref/impl/FloatPref.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/pref/impl/FloatPref.kt @@ -4,7 +4,7 @@ import android.content.SharedPreferences class FloatPref(key: String, defVal: Float) : BasePref(key, defVal) { - override operator fun invoke(pref: SharedPreferences): Float = + override fun readFrom(pref: SharedPreferences): Float = pref.getFloat(key, defVal) override fun put(editor: SharedPreferences.Editor, v: Float) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/pref/impl/IntPref.kt b/app/src/main/java/jp/juggler/subwaytooter/pref/impl/IntPref.kt index b9662cd3..c66c4bcf 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/pref/impl/IntPref.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/pref/impl/IntPref.kt @@ -4,7 +4,7 @@ import android.content.SharedPreferences class IntPref(key: String, defVal: Int, val noRemove:Boolean = false) : BasePref(key, defVal) { - override operator fun invoke(pref: SharedPreferences): Int = + override fun readFrom(pref: SharedPreferences): Int = pref.getInt(key, defVal) override fun put(editor: SharedPreferences.Editor, v: Int) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/pref/impl/LongPref.kt b/app/src/main/java/jp/juggler/subwaytooter/pref/impl/LongPref.kt index 3edd6d2e..3ba4a588 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/pref/impl/LongPref.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/pref/impl/LongPref.kt @@ -4,7 +4,7 @@ import android.content.SharedPreferences class LongPref(key: String, defVal: Long) : BasePref(key, defVal) { - override operator fun invoke(pref: SharedPreferences): Long = + override fun readFrom(pref: SharedPreferences): Long = pref.getLong(key, defVal) override fun put(editor: SharedPreferences.Editor, v: Long) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/pref/impl/StringPref.kt b/app/src/main/java/jp/juggler/subwaytooter/pref/impl/StringPref.kt index 315b56a7..25d98446 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/pref/impl/StringPref.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/pref/impl/StringPref.kt @@ -8,7 +8,7 @@ class StringPref( val skipImport: Boolean = false, ) : BasePref(key, defVal) { - override operator fun invoke(pref: SharedPreferences): String = + override fun readFrom(pref: SharedPreferences): String = pref.getString(key, defVal) ?: defVal override fun put(editor: SharedPreferences.Editor, v: String) { @@ -18,5 +18,5 @@ class StringPref( override fun hasNonDefaultValue(pref: SharedPreferences) = defVal != pref.getString(key, defVal) - fun toInt(pref: SharedPreferences) = invoke(pref).toIntOrNull() ?: defVal.toInt() -} \ No newline at end of file + fun toInt() = value.toIntOrNull() ?: defVal.toInt() +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/push/FcmHandler.kt b/app/src/main/java/jp/juggler/subwaytooter/push/FcmHandler.kt new file mode 100644 index 00000000..a2fd0a92 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/push/FcmHandler.kt @@ -0,0 +1,100 @@ +package jp.juggler.subwaytooter.push + +import android.content.Context +import androidx.startup.AppInitializer +import androidx.startup.Initializer +import jp.juggler.util.coroutine.AppDispatchers +import jp.juggler.util.coroutine.EmptyScope +import jp.juggler.util.log.LogCategory +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import jp.juggler.subwaytooter.BuildConfig +import jp.juggler.subwaytooter.pref.prefDevice + +private val log = LogCategory("FcmHandler") + +class FcmHandler( + private val context: Context, +) { + companion object { + val reNoFcm = """noFcm""".toRegex(RegexOption.IGNORE_CASE) + } + + val fcmToken = MutableStateFlow(context.prefDevice.fcmToken) + + val noFcm :Boolean + get()= reNoFcm.containsMatchIn(BuildConfig.FLAVOR) + + val hasFcm get() = !noFcm + + fun onTokenChanged(token: String?) { + context.prefDevice.fcmToken = token + EmptyScope.launch(AppDispatchers.IO) { fcmToken.emit(token) } + } + + suspend fun onMessageReceived(data: Map) { + try { + context.pushRepo.handleFcmMessage(data) + } catch (ex: Throwable) { + log.e(ex, "onMessage failed.") + } + } + + suspend fun deleteFcmToken() = + withContext(AppDispatchers.IO) { + // 古いトークンを覚えておく + context.prefDevice.fcmToken + ?.takeIf { it.isNotEmpty() } + ?.let { context.prefDevice.fcmTokenExpired = it } + // FCMから削除する + log.i("deleteFcmToken: start") + FcmTokenLoader().deleteToken() + log.i("deleteFcmToken: end") + onTokenChanged(null) + log.i("deleteFcmToken complete") + } + + suspend fun loadFcmToken(): String? = try { + withContext(AppDispatchers.IO) { + log.i("loadFcmToken start") + val token = FcmTokenLoader().getToken() + log.i("loadFcmToken onTokenChanged") + onTokenChanged(token) + log.i("loadFcmToken end") + token + } + } catch (ex: Throwable) { + // https://github.com/firebase/firebase-android-sdk/issues/4053 + // java.io.IOException: java.util.concurrent.ExecutionException: java.io.IOException: SERVICE_NOT_AVAILABLE + + // + // java.lang.IllegalStateException: Default FirebaseApp is not initialized in this process jp.juggler.pushreceiverapp. Make sure to call FirebaseApp.initializeApp(Context) first. + // at com.google.firebase.FirebaseApp.getInstance(FirebaseApp.java:186) + + log.w(ex, "loadFcmToken failed") + null + } +} + +/** + * AndroidManifest.xml で androidx.startup.InitializationProvider から参照される + */ +@Suppress("unused") +class FcmHandlerInitializer : Initializer { + override fun dependencies(): List>> = + emptyList() + + override fun create(context: Context): FcmHandler { + val newHandler = FcmHandler(context.applicationContext) + log.i("FcmHandlerInitializer hasFcm=${newHandler.hasFcm}, BuildConfig.FLAVOR=${BuildConfig.FLAVOR}") + EmptyScope.launch{ + newHandler.loadFcmToken() + } + return newHandler + } +} + +val Context.fcmHandler: FcmHandler + get() = AppInitializer.getInstance(this) + .initializeComponent(FcmHandlerInitializer::class.java) diff --git a/app/src/main/java/jp/juggler/subwaytooter/push/PushBase.kt b/app/src/main/java/jp/juggler/subwaytooter/push/PushBase.kt new file mode 100644 index 00000000..508713b6 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/push/PushBase.kt @@ -0,0 +1,55 @@ +package jp.juggler.subwaytooter.push + +import android.content.Context +import androidx.annotation.StringRes +import jp.juggler.subwaytooter.pref.PrefDevice +import jp.juggler.subwaytooter.table.AccountNotificationStatus +import jp.juggler.subwaytooter.table.PushMessage +import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.util.data.digestSHA256 +import jp.juggler.util.data.encodeBase64Url +import jp.juggler.util.data.encodeUTF8 +import jp.juggler.util.data.notEmpty + +/** + * PushMastodon, PushMisskey13 のベースクラス + */ +abstract class PushBase { + companion object { + const val appServerUrlPrefix = "https://mastodon-msg.juggler.jp/api/v2/m" + } + + interface SubscriptionLogger { + val context: Context + fun i(msg: String) + fun i(@StringRes stringId: Int) = i(context.getString(stringId)) + + fun e(msg: String) + fun e(@StringRes stringId: Int) = i(context.getString(stringId)) + fun e(ex: Throwable, msg: String) + } + + protected abstract val prefDevice: PrefDevice + protected abstract val daoStatus: AccountNotificationStatus.Access + + // 購読の確認と更新 + abstract suspend fun updateSubscription( + subLog: SubscriptionLogger, + a: SavedAccount, + willRemoveSubscription: Boolean, + ) + + // プッシュメッセージのJSONデータを通知用に整形 + abstract suspend fun formatPushMessage( + a: SavedAccount, + pm: PushMessage, + ) + + fun deviceHash(a: SavedAccount) = + "${prefDevice.installIdv2},${a.acct}".encodeUTF8().digestSHA256().encodeBase64Url() + + fun snsCallbackUrl(a: SavedAccount): String? = + daoStatus.appServerHash(a.acct)?.notEmpty()?.let { + "$appServerUrlPrefix/a_${it}/dh_${deviceHash(a)}" + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/push/PushMastodon.kt b/app/src/main/java/jp/juggler/subwaytooter/push/PushMastodon.kt new file mode 100644 index 00000000..35419cc2 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/push/PushMastodon.kt @@ -0,0 +1,215 @@ +package jp.juggler.subwaytooter.push + +import jp.juggler.crypt.defaultSecurityProvider +import jp.juggler.crypt.encodeP256Dh +import jp.juggler.crypt.generateKeyPair +import jp.juggler.subwaytooter.R +import jp.juggler.subwaytooter.api.ApiError +import jp.juggler.subwaytooter.api.push.ApiPushMastodon +import jp.juggler.subwaytooter.pref.PrefDevice +import jp.juggler.subwaytooter.pref.lazyContext +import jp.juggler.subwaytooter.pref.prefDevice +import jp.juggler.subwaytooter.push.PushRepo.Companion.followDomain +import jp.juggler.subwaytooter.table.* +import jp.juggler.util.data.decodeBase64 +import jp.juggler.util.data.encodeBase64Url +import jp.juggler.util.data.notBlank +import jp.juggler.util.data.notEmpty +import jp.juggler.util.log.LogCategory +import jp.juggler.util.time.parseTimeIso8601 +import java.security.Provider +import java.security.SecureRandom +import java.security.interfaces.ECPublicKey + +private val log = LogCategory("PushMastodon") + +class PushMastodon( + private val api: ApiPushMastodon, + private val provider: Provider = + defaultSecurityProvider, + override val prefDevice: PrefDevice = + lazyContext.prefDevice, + override val daoStatus: AccountNotificationStatus.Access = + AccountNotificationStatus.Access(appDatabase), +) : PushBase() { + override suspend fun updateSubscription( + subLog: SubscriptionLogger, + a: SavedAccount, + willRemoveSubscription: Boolean, + ) { + val deviceHash = deviceHash(a) + val newUrl = snsCallbackUrl(a) // appServerHashを参照する + if (newUrl.isNullOrEmpty()) { + if (willRemoveSubscription) { + val msg = + lazyContext.getString(R.string.push_subscription_app_server_hash_missing_but_ok) + subLog.i(msg) + } else { + val msg = + lazyContext.getString(R.string.push_subscription_app_server_hash_missing_error) + subLog.e(msg) + daoAccountNotificationStatus.updateSubscriptionError( + a.acct, + msg + ) + } + return + } + + val oldSubscription = try { + api.getPushSubscription(a) + } catch (ex: Throwable) { + if ((ex as? ApiError)?.response?.code == 404) { + null + } else { + throw ex + } + } + log.i("${a.acct} oldSubscription=${oldSubscription}") + + val oldEndpointUrl = oldSubscription?.string("endpoint") + when (oldEndpointUrl) { + // 購読がない。作ってもよい + null -> Unit + else -> { + val params = buildMap { + if (oldEndpointUrl.startsWith(appServerUrlPrefix)) { + oldEndpointUrl.substring(appServerUrlPrefix.length) + .split("/") + .forEach { pair -> + val cols = pair.split("_", limit = 2) + cols.elementAtOrNull(0)?.notEmpty()?.let { k -> + put(k, cols.elementAtOrNull(1) ?: "") + } + } + } + } + if (params["dh"] != deviceHash) { + // この端末で作成した購読ではない。 + subLog.e("subscription deviceHash not match. keep it for other devices. ${a.acct} $oldEndpointUrl") + return + } + } + } + + if (willRemoveSubscription) { + when (oldSubscription) { + null -> { + subLog.i("subscription is not exist, not required. nothing to do.") + } + else -> { + subLog.i("removing unnecessary subscription.") + api.deletePushSubscription(a) + } + } + return + } + + val alerts = ApiPushMastodon.alertTypes.associateWith { true } + if (newUrl != oldEndpointUrl) { + val keyPair = provider.generateKeyPair() + val auth = ByteArray(16).also { SecureRandom().nextBytes(it) } + val p256dh = encodeP256Dh(keyPair.public as ECPublicKey) + + subLog.i("api.createPushSubscription") + val response = api.createPushSubscription( + a = a, + endpointUrl = newUrl, + p256dh = p256dh.encodeBase64Url(), + auth = auth.encodeBase64Url(), + alerts = alerts, + policy = "all", + ) + val serverKeyStr = response.string("server_key") + ?: error("missing server_key.") + + val serverKey = serverKeyStr.decodeBase64() + + // p256dhは65バイトのはず + // authは16バイトのはず + // serverKeyは65バイトのはず + + // 登録できたらアカウントに覚える + daoStatus.savePushKey( + acct = a.acct, + pushKeyPrivate = keyPair.private.encoded, + pushKeyPublic = p256dh, + pushAuthSecret = auth, + pushServerKey = serverKey, + lastPushEndpoint = newUrl, + ) + subLog.i("Push subscription has been updated.") + } else { + // エンドポイントURLに変化なし + // Alertの更新はしたいかもしれない + // XXX + subLog.i("Push subscription endpoint URL is not changed. keep..") + } + } + + override suspend fun formatPushMessage( + a: SavedAccount, + pm: PushMessage, + ) { + val json = pm.messageJson ?: return + val apiHost = a.apiHost + + pm.notificationType = json.string("notification_type") + pm.iconLarge = json.string("icon").followDomain(apiHost) + pm.text = arrayOf( + // あなたのトゥートが tateisu 🤹 さんにお気に入り登録されました + json.string("title"), + // 対象の投稿の本文? + json.string("body"), + // 対象の投稿の本文? (古い + json.jsonObject("data")?.string("content"), + ).mapNotNull { it?.trim()?.notBlank() }.joinToString("\n") + when { + pm.notificationType.isNullOrEmpty() -> { + // old mastodon + // { + // "title": "あなたのトゥートが tateisu 🤹 さんにお気に入り登録されました", + // "image": null, + // "badge": "https://mastodon2.juggler.jp/badge.png", + // "tag": 84, + // "timestamp": "2018-05-11T17:06:42.887Z", + // "icon": "/system/accounts/avatars/000/000/003/original/72f1da33539be11e.jpg", + // "data": { + // "content": ":enemy_bullet:", + // "nsfw": null, + // "url": "https://mastodon2.juggler.jp/web/statuses/98793123081777841", + // "actions": [], + // "access_token": null, + // "message": "%{count} 件の通知", + // "dir": "ltr" + // } + // } + + json.string("timestamp")?.parseTimeIso8601() + ?.let { pm.timestamp = it } + + // 重複排除は完全に諦める + pm.notificationId = pm.timestamp.toString() + + pm.iconSmall = json.string("badge").followDomain(apiHost) + } + else -> { + // Mastodon 4.0 + // { + // "access_token": "***", + // "preferred_locale": "ja", + // "notification_id": 341897, + // "notification_type": "favourite", + // "icon": "https://m1j.zzz.ac/aed1...e5343f2e7b.png", + // "title": "tateisu⛏️@テスト鯖 :ct080:さんにお気に入りに登録されました", + // "body": "テスト" + // } + + pm.notificationId = json.string("notification_id") + + // - iconSmall は通知タイプに合わせてアプリが用意するらしい + // - タイムスタンプ情報はない。 + } + } + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/push/PushMisskey.kt b/app/src/main/java/jp/juggler/subwaytooter/push/PushMisskey.kt new file mode 100644 index 00000000..4686817e --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/push/PushMisskey.kt @@ -0,0 +1,201 @@ +package jp.juggler.subwaytooter.push + +import jp.juggler.crypt.defaultSecurityProvider +import jp.juggler.crypt.encodeP256Dh +import jp.juggler.crypt.generateKeyPair +import jp.juggler.subwaytooter.R +import jp.juggler.subwaytooter.api.ApiError +import jp.juggler.subwaytooter.api.push.ApiPushMisskey +import jp.juggler.subwaytooter.pref.PrefDevice +import jp.juggler.subwaytooter.pref.lazyContext +import jp.juggler.subwaytooter.pref.prefDevice +import jp.juggler.subwaytooter.push.PushRepo.Companion.followDomain +import jp.juggler.subwaytooter.table.* +import jp.juggler.util.data.decodeBase64 +import jp.juggler.util.data.encodeBase64Url +import jp.juggler.util.data.notBlank +import jp.juggler.util.data.notEmpty + +import java.security.Provider +import java.security.SecureRandom +import java.security.interfaces.ECPublicKey + +class PushMisskey( + private val api: ApiPushMisskey, + private val provider: Provider = + defaultSecurityProvider, + override val prefDevice: PrefDevice = + lazyContext.prefDevice, + override val daoStatus: AccountNotificationStatus.Access = + AccountNotificationStatus.Access(appDatabase), +) : PushBase() { + + override suspend fun updateSubscription( + subLog: SubscriptionLogger, + a: SavedAccount, + willRemoveSubscription: Boolean, + ) { + val newUrl = snsCallbackUrl(a) + + val lastEndpointUrl = daoStatus.lastEndpointUrl(a.acct) + ?: newUrl + + var status = daoStatus.load(a.acct) + + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") + var hasEmptySubscription = false + + if (!lastEndpointUrl.isNullOrEmpty()) { + val lastSubscription = when (lastEndpointUrl) { + null, "" -> null + else -> try { + // Misskeyは2022/12/18に現在の購読を確認するAPIができた + api.getPushSubscription(a, lastEndpointUrl) + // 購読がない => 空オブジェクト (v13 drdr.club でそんな感じ) + } catch (ex: Throwable) { + // APIがない => 404 (v10 めいすきーのソースと動作で確認) + when ((ex as? ApiError)?.response?.code) { + in 400 until 500 -> null + else -> throw ex + } + } + } + + if (lastSubscription != null) { + if (lastSubscription.size == 0) { + // 購読がないと空レスポンスになり、アプリ側で空オブジェクトに変換される + @Suppress("UNUSED_VALUE") + hasEmptySubscription = true + } else if (lastEndpointUrl == newUrl && !willRemoveSubscription) { + when (lastSubscription.boolean("sendReadMessage")) { + false -> subLog.i(R.string.push_subscription_keep_using) + else -> { + // 未読クリア通知はオフにしたい + api.updatePushSubscription(a, newUrl, sendReadMessage = false) + subLog.i(R.string.push_subscription_off_unread_notification) + } + } + return + } else { + // 古い購読はあったが、削除したい + api.deletePushSubscription(a, lastEndpointUrl) + daoStatus.deleteLastEndpointUrl(a.acct) + if (willRemoveSubscription) { + subLog.i(R.string.push_subscription_delete_current) + return + } + } + } + } + if (newUrl == null) { + if (willRemoveSubscription) { + subLog.i(R.string.push_subscription_app_server_hash_missing_but_ok) + } else { + subLog.e(R.string.push_subscription_app_server_hash_missing_error) + } + return + } else if (willRemoveSubscription) { + // 購読を解除したい。 + // hasEmptySubscription が真なら購読はないが、 + // とりあえず何か届いても確実に読めないようにする + when (status?.pushKeyPrivate) { + null -> subLog.i("購読は不要な状態です") + else -> { + daoStatus.deletePushKey(a.acct) + subLog.i("購読が不要なので解読用キーを削除しました") + } + } + return + } + + // 鍵がなければ作る + if (status?.pushKeyPrivate == null || + status.pushKeyPublic == null || + status.pushAuthSecret == null + ) { + subLog.i("秘密鍵を生成します…") + val keyPair = provider.generateKeyPair() + val auth = ByteArray(16).also { SecureRandom().nextBytes(it) } + val p256dh = encodeP256Dh(keyPair.public as ECPublicKey) + daoStatus.savePushKey( + a.acct, + pushKeyPrivate = keyPair.private.encoded, + pushKeyPublic = p256dh, + pushAuthSecret = auth, + ) + status = daoStatus.load(a.acct) + } + + // 購読する + status!! + val json = api.createPushSubscription( + a = a, + endpoint = newUrl, + auth = status.pushAuthSecret!!.encodeBase64Url(), + publicKey = status.pushKeyPublic!!.encodeBase64Url(), + sendReadMessage = false, + ) + // https://github.com/syuilo/misskey/issues/2541 + // https://github.com/syuilo/misskey/commit/4c6fb60dd25d7e2865fc7c4d97728593ffc3c902 + // 2018/9/1 の上記コミット以降、Misskeyでもサーバ公開鍵を得られるようになった + val serverKey = json.string("key") + ?.notEmpty()?.decodeBase64() + ?: error("missing server key in response of sw/register API.") + if (!serverKey.contentEquals(status.pushServerKey)) { + daoStatus.saveServerKey( + acct = a.acct, + lastPushEndpoint = newUrl, + pushServerKey = serverKey, + ) + subLog.i("server key has been changed.") + } + subLog.i("subscription complete.") + } + + /* + https://github.com/syuilo/misskey/blob/master/src/services/create-notification.ts#L46 + Misskeyは通知に既読の概念があり、イベント発生後2秒たっても未読の時だけプッシュ通知が発生する。 + WebUIを開いていると通知はすぐ既読になるのでプッシュ通知は発生しない。 + プッシュ通知のテスト時はST2台を使い、片方をプッシュ通知の受信チェック、もう片方を投稿などの作業に使うことになる。 + */ + override suspend fun formatPushMessage( + a: SavedAccount, + pm: PushMessage, + ) { + val json = pm.messageJson ?: return + val apiHost = a.apiHost + + pm.iconSmall = null // バッジ画像のURLはない。通知種別により決まる + + json.long("dateTime")?.let { + pm.timestamp = it + } + + val body = json.jsonObject("body") + + val user = body?.jsonObject("user") + + pm.iconLarge = user?.string("avatarUrl").followDomain(apiHost) + + when (val eventType = json.string("type")) { + "notification" -> { + val notificationType = body?.string("type") + + pm.notificationType = notificationType + + pm.text = arrayOf( + user?.string("username"), + notificationType, + body?.string("text")?.takeIf { + when (notificationType) { + "mention", "quote" -> true + else -> false + } + } + ).mapNotNull { it?.trim()?.notBlank() }.joinToString("\n") + } + // 通知以外のイベントは全部無視したい + else -> error("謎のイベント $eventType user=${user?.string("username")}") + } + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/push/PushRepo.kt b/app/src/main/java/jp/juggler/subwaytooter/push/PushRepo.kt new file mode 100644 index 00000000..aa6f3e9d --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/push/PushRepo.kt @@ -0,0 +1,588 @@ +package jp.juggler.subwaytooter.push + +import android.content.Context +import androidx.work.WorkManager +import androidx.work.await +import jp.juggler.crypt.* +import jp.juggler.subwaytooter.R +import jp.juggler.subwaytooter.api.entity.Acct +import jp.juggler.subwaytooter.api.entity.Host +import jp.juggler.subwaytooter.api.push.ApiPushAppServer +import jp.juggler.subwaytooter.api.push.ApiPushMastodon +import jp.juggler.subwaytooter.api.push.ApiPushMisskey +import jp.juggler.subwaytooter.dialog.SuspendProgress +import jp.juggler.subwaytooter.notification.showPushNotification +import jp.juggler.subwaytooter.pref.PrefDevice +import jp.juggler.subwaytooter.pref.prefDevice +import jp.juggler.subwaytooter.push.* +import jp.juggler.subwaytooter.push.PushWorker.Companion.enqueuePushMessage +import jp.juggler.subwaytooter.push.PushWorker.Companion.enqueueRegisterEndpoint +import jp.juggler.subwaytooter.table.* +import jp.juggler.util.coroutine.AppDispatchers +import jp.juggler.util.data.* +import jp.juggler.util.data.Base128.decodeBase128 +import jp.juggler.util.log.LogCategory +import jp.juggler.util.log.withCaption +import jp.juggler.util.os.applicationContextSafe +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import org.unifiedpush.android.connector.UnifiedPush +import java.lang.ref.WeakReference +import java.security.Provider +import java.util.concurrent.TimeUnit + +private val log = LogCategory("PushRepo") + +val Context.pushRepo: PushRepo + get() { + val okHttp = OkHttpClient.Builder().apply { + connectTimeout(60, TimeUnit.SECONDS) + writeTimeout(60, TimeUnit.SECONDS) + readTimeout(60, TimeUnit.SECONDS) + }.build() + val appDatabase = appDatabase + return PushRepo( + context = applicationContextSafe, + apiPushAppServer = ApiPushAppServer(okHttp), + apiPushMastodon = ApiPushMastodon(okHttp), + apiPushMisskey = ApiPushMisskey(okHttp), + daoSavedAccount = SavedAccount.Access(appDatabase, this), + daoPushMessage = PushMessage.Access(appDatabase), + daoStatus = AccountNotificationStatus.Access(appDatabase), + provider = defaultSecurityProvider, + prefDevice = prefDevice, + fcmHandler = fcmHandler, + ) + } + +class PushRepo( + private val context: Context, + private val apiPushMastodon: ApiPushMastodon, + private val apiPushMisskey: ApiPushMisskey, + private val apiPushAppServer: ApiPushAppServer, + private val daoSavedAccount: SavedAccount.Access, + private val daoPushMessage: PushMessage.Access, + private val daoStatus: AccountNotificationStatus.Access, + private val provider: Provider, + private val prefDevice: PrefDevice, + private val fcmHandler: FcmHandler, +) { + companion object { + private val reHttp = """https?://""".toRegex() + + @Suppress("RegExpSimplifiable") + private val reTailDigits = """([0-9]+)\z""".toRegex() + + const val JSON_CAME_FROM = "<>cameFrom" + const val CAME_FROM_UNIFIED_PUSH = "unifiedPush" + const val CAME_FROM_FCM = "fcm" + + var refReporter: WeakReference? = null + + fun String?.followDomain(apiHost: Host) = when { + isNullOrEmpty() -> null + reHttp.containsMatchIn(this) -> this + this[0] == '/' -> "https://$apiHost$this" + else -> "https://$apiHost/$this" + } + } + + private val pushMisskey by lazy { + PushMisskey( + api = apiPushMisskey, + provider = provider, + prefDevice = prefDevice, + daoStatus = daoStatus, + ) + } + private val pushMastodon by lazy { + PushMastodon( + api = apiPushMastodon, + provider = provider, + prefDevice = prefDevice, + daoStatus = daoStatus, + ) + } + + /** + * UPでプッシュサービスを選ぶと呼ばれる + */ + suspend fun switchDistributor( + pushDistributor: String, + reporter: SuspendProgress.Reporter, + ) { + val timeSwitchStart = System.currentTimeMillis() + refReporter = WeakReference(reporter) + + log.i("switchDistributor: pushDistributor=$pushDistributor") + prefDevice.pushDistributor = pushDistributor + + withContext(Dispatchers.IO) { + reporter.setMessage(context.getString(R.string.removing_old_distributer)) + + // WorkManagerの完了済みのジョブを捨てる + WorkManager.getInstance(context).pruneWork().await() + + // Unified購読の削除 + // 後でブロードキャストを受け取るかもしれない + UnifiedPush.unregisterApp(context) + + // FCMトークンの削除。これでこの端末のこのアプリへの古いエンドポイント登録はgoneになり消えるはず + fcmHandler.deleteFcmToken() + + when (pushDistributor) { + PrefDevice.PUSH_DISTRIBUTOR_NONE -> { + // 購読解除 + reporter.setMessage("SubscriptionUpdateService.launch") + enqueueRegisterEndpoint(context) + } + PrefDevice.PUSH_DISTRIBUTOR_FCM -> { + // 特にイベントは来ないので、プッシュ購読をやりなおす + reporter.setMessage("SubscriptionUpdateService.launch") + enqueueRegisterEndpoint(context) + } + else -> { + reporter.setMessage("UnifiedPush.saveDistributor") + UnifiedPush.saveDistributor(context, pushDistributor) + // 何らかの理由で登録は壊れることがあるため、登録し直す + reporter.setMessage("UnifiedPush.registerApp") + UnifiedPush.registerApp(context) + // 少し後にonNewEndpointが発生するので、続きはそこで + } + } + } + val timeout = timeSwitchStart + TimeUnit.SECONDS.toMillis(20) + while (true) { + val now = System.currentTimeMillis() + if (now >= timeout) { + reporter.setMessage("timeout") + delay(888L) + break + } + if (PushWorker.timeEndRegisterEndpoint.get() >= timeSwitchStart || + PushWorker.timeEndUpEndpoint.get() >= timeSwitchStart + ) { + reporter.setMessage("complete") + delay(888L) + break + } + delay(1000L) + } + } + + /** + * switchDistributor が UnifiedPush.registerAppする + * ↓ + * UpMessageReceiver の onNewEndpoint が呼ばれる + * ↓ + * PushWorker の ACTION_UP_ENDPOINT が登録される + * ↓ + * ワーカーからnewUpEndpoint()が呼ばれる + */ + suspend fun newUpEndpoint(upEndpoint: String) { + refReporter?.get()?.setMessage("新しい UnifiedPush endpoint URL を取得しました") + + val upPackageName = UnifiedPush.getDistributor(context).notEmpty() + ?: error("missing upPackageName") + + if (upPackageName != prefDevice.pushDistributor) { + log.w("newEndpoint: race condition detected!") + } + + // 古いエンドポイントを別プロパティに覚えておく + prefDevice.upEndpoint + ?.takeIf { it.isNotEmpty() && it != upEndpoint } + ?.let { prefDevice.upEndpointExpired = it } + + prefDevice.upEndpoint = upEndpoint + + // 購読の更新 + registerEndpoint(keepAliveMode = false) + } + + /** + * - PushWorkerのACTION_UP_ENDPOINTの実行中に呼ばれる + * - PushWorkerのACTION_REGISTER_ENDPOINTの実行中に呼ばれる + */ + suspend fun registerEndpoint( + keepAliveMode: Boolean, + ) { + log.i("registerEndpoint: keepAliveMode=$keepAliveMode") + + // 古いFCMトークンの情報はアプリサーバ側で勝手に消えるはず + try { + // 期限切れのUPエンドポイントがあればそれ経由の中継を解除する + prefDevice.fcmTokenExpired.notEmpty()?.let { + refReporter?.get()?.setMessage("期限切れのFCMデバイストークンをアプリサーバから削除しています") + log.i("remove fcmTokenExpired") + apiPushAppServer.endpointRemove(fcmToken = it) + prefDevice.fcmTokenExpired = null + } + } catch (ex: Throwable) { + log.w(ex, "can't forgot fcmTokenExpired") + } + + try { + // 期限切れのUPエンドポイントがあればそれ経由の中継を解除する + prefDevice.upEndpointExpired.notEmpty()?.let { + refReporter?.get()?.setMessage("期限切れのUnifiedPushエンドポイントをアプリサーバから削除しています") + log.i("remove upEndpointExpired") + apiPushAppServer.endpointRemove(upUrl = it) + prefDevice.upEndpointExpired = null + } + } catch (ex: Throwable) { + log.w(ex, "can't forgot upEndpointExpired") + } + + val realAccounts = daoSavedAccount.loadAccountList() + .filter { !it.isNA } + + val accts = realAccounts.map { it.acct } + + // map of acctHash to account + val acctHashMap = daoStatus.updateAcctHash(accts) + if (acctHashMap.isEmpty()) { + log.w("acctHashMap is empty. no need to update register endpoint") + return + } + + if (keepAliveMode) { + val lastUpdated = prefDevice.timeLastEndpointRegister + val now = System.currentTimeMillis() + if (now - lastUpdated < TimeUnit.DAYS.toMillis(3)) { + log.i("lazeMode: skip re-registration.") + } + } + + var willRemoveSubscription = false + + // アプリサーバにendpointを登録する + refReporter?.get()?.setMessage("アプリサーバにプッシュサービスの情報を送信しています") + log.i("pushDistributor=${prefDevice.pushDistributor}") + val acctHashList = acctHashMap.keys.toList() + val json = when (prefDevice.pushDistributor) { + null, "" -> when { + fcmHandler.hasFcm -> registerEndpointFcm(acctHashList) + else -> { + log.w("pushDistributor not selected. but can't select default distributor from background service.") + null + } + } + PrefDevice.PUSH_DISTRIBUTOR_NONE -> { + willRemoveSubscription = true + null + } + PrefDevice.PUSH_DISTRIBUTOR_FCM -> registerEndpointFcm(acctHashList) + else -> registerEndpointUnifiedPush(acctHashList) + } + when { + json.isNullOrEmpty() -> + log.i("no information of appServerHash.") + + else -> { + // acctHash => appServerHash のマップが返ってくる + // ステータスに覚える + var saveCount = 0 + for (acctHash in json.keys) { + val acct = acctHashMap[acctHash] ?: continue + val appServerHash = json.string(acctHash) ?: continue + ++saveCount + val status = daoStatus.loadOrCreate(acct) + if (status.appServerHash == appServerHash) continue + daoStatus.saveAppServerHash(status.id, appServerHash) + } + log.i("appServerHash updated. saveCount=$saveCount") + } + } + + realAccounts.forEach { a -> + val subLog = object : PushBase.SubscriptionLogger { + override val context = this@PushRepo.context + override fun i(msg: String) { + log.i("[${a.acct}]$msg") + } + + override fun e(msg: String) { + log.e("[${a.acct}]$msg") + daoAccountNotificationStatus.updateSubscriptionError( + a.acct, + msg + ) + } + + override fun e(ex: Throwable, msg: String) { + log.e(ex, "[${a.acct}]$msg") + daoAccountNotificationStatus.updateSubscriptionError( + a.acct, + ex.withCaption(msg) + ) + } + } + try { + refReporter?.get()?.setMessage("${a.acct.pretty} のWebPush購読を更新しています") + daoAccountNotificationStatus.updateSubscriptionError( + a.acct, + null + ) + pushBase(a).updateSubscription( + subLog = subLog, + a = a, + willRemoveSubscription = willRemoveSubscription + ) + } catch (ex: Throwable) { + subLog.e(ex, "updateSubscription failed.") + } + } + prefDevice.timeLastEndpointRegister = System.currentTimeMillis() + } + + private suspend fun registerEndpointUnifiedPush(acctHashList: List) = + when (val upEndpoint = prefDevice.upEndpoint) { + null, "" -> { + log.w("missing upEndpoint. can't register endpoint.") + null + } + else -> { + log.i("endpointUpsert up ") + apiPushAppServer.endpointUpsert( + upUrl = upEndpoint, + fcmToken = null, + acctHashList = acctHashList + ) + } + } + + private suspend fun registerEndpointFcm(acctHashList: List) = + when (val fcmToken = fcmHandler.loadFcmToken()) { + null, "" -> { + log.w("missing fcmToken. can't register endpoint.") + null + } + else -> { + log.i("endpointUpsert fcm ") + apiPushAppServer.endpointUpsert( + upUrl = null, + fcmToken = fcmToken, + acctHashList = acctHashList + ) + } + } + + /** + * アカウント設定から、SNSサーバに購読を行う + * + * willRemoveSubscription=trueの場合、購読を削除する。 + * アクセストークン更新やアカウント削除の際に古い購読を捨てたい場合に使う。 + */ + suspend fun updateSubscription( + subLog: PushBase.SubscriptionLogger, + a: SavedAccount, + willRemoveSubscription: Boolean, + ) { + pushBase(a).updateSubscription( + subLog = subLog, + a = a, + willRemoveSubscription = willRemoveSubscription + ) + } + + private fun pushBase(a: SavedAccount) = when { + a.isMisskey -> pushMisskey + else -> pushMastodon + } + + ////////////////////////////////////////////////////////////////////////////// + // メッセージの処理 + + /** + * FcmHandlerから呼ばれる。 + */ + fun handleFcmMessage(data: Map) { + data["d"]?.decodeBase128()?.let { saveRawMessage(it) } + } + + /** + * UpMessageReceiverから呼ばれる。 + */ + fun saveUpMessage(message: ByteArray) { + saveRawMessage(message) + } + + /** + * 受信した生データを保存して、後はワーカーに任せる + */ + private fun saveRawMessage(bytes: ByteArray) { + val pm = PushMessage(rawBody = bytes) + daoPushMessage.save(pm) + enqueuePushMessage(context, pm.id) + } + + /** + * UIで再解読を選択した + * + * - 実際のアプリでは解読できたものだけを保存したいが、これは試験アプリなので… + */ + suspend fun reDecode(pm: PushMessage) { + withContext(AppDispatchers.IO) { + updateMessage(pm.id) + } + } + + /** + * UpWorkerから呼ばれる。 + * 保存データを解釈して通知を出す。 + */ + suspend fun updateMessage(messageId: Long) { + // DBからロード + val pm = daoPushMessage.find(messageId) + ?: error("missing pushMessage") + + // rawBodyをBinPackMapにデコード + var map = pm.rawBody?.decodeBinPackMap() + ?: error("binPack decode failed.") + + // ペイロードがなくてURLが付与されたメッセージは + // アプリサーバから読み直す + if (map["b"] == null) { + map.string("l")?.let { largeObjectId -> + apiPushAppServer.getLargeObject(largeObjectId) + ?.let { + map = it.decodeBinPack() as? BinPackMap + ?: error("binPack decode failed.") + pm.rawBody = it + daoPushMessage.save(pm) + } + } + } + + // acctHashがある + val acctHash = map.string("a") ?: error("missing a.") + + val status = daoStatus.findByAcctHash(acctHash) + ?: error("missing status for acctHash $acctHash") + + val account = daoSavedAccount.loadAccountByAcct(Acct.parse(status.acct)) + ?: error("missing account for acct ${status.acct}") + + pm.loginAcct = status.acct + + decodeMessageContent(status, pm, map) + + if (pm.messageJson == null) { + // デコード失敗 + // 古い鍵で行った購読だろう。 + // メッセージに含まれるappServerHashを指定してendpoint登録を削除する + // するとアプリサーバはSNSサーバに対してgoneを返すようになり掃除が適切に行われるはず + map.string("c").notEmpty()?.let{ + val count = apiPushAppServer.endpointRemove(hashId = it) + .int("count") + log.w("endpointRemove $count hashId=$it") + } + error("can't decode WebPush message to JSON.") + } + log.i("${status.acct} ${pm.messageJson}") + + // messageJsonを解釈して通知に出す内容を決める + try { + pushBase(account).formatPushMessage(account, pm) + }catch(ex:Throwable){ + log.e(ex,"formatPushMessage failed.") + return + } + + daoPushMessage.save(pm) + + // 解読できた(例外が出なかった)なら通知を出す + context.showPushNotification(pm) + } + + /** + * プッシュされたデータを解読してDB上の項目を更新する + * + * - 実際のアプリでは解読できたものだけを保存したいが、これは試験アプリなので… + */ + private fun decodeMessageContent( + status: AccountNotificationStatus, + pm: PushMessage, + map: BinPackMap, + ) { + val encryptedBody = map.bytes("b") ?: error("missing encryptedBody") + val headers = map.map("h") ?: error("missing headers") + + pm.headerJson = buildJsonObject { + headers.entries.forEach { e -> + put(e.key.toString(), e.value.toString()) + } + } + + // ヘッダを探すときは小文字化 + fun header(name: String): String? = headers.string(name.lowercase()) + + // log.i("headerJson.keys=${headerJson.keys.joinToString(",")}") + // headerJson={ + // "Digest":"SHA-256=nnn", + // "Content-Encoding":"aesgcm", + // "Encryption":"salt=75n4Si2vAVv2xZFXnIh5Ww", + // "Crypto-Key":"dh=XXX;p256ecdsa=XXX", + // "Authorization":"WebPush XXX.XXX.XXX" + // } + + try { + if (header("Content-Encoding")?.trim() == "aes128gcm") { + Aes128GcmDecoder(encryptedBody.byteRangeReader(), provider).run { + deriveKeyWebPush( + // receiver private key in X509 format + receiverPrivateBytes = status.pushKeyPrivate + ?: error("missing pushKeyPrivate"), + // receiver public key in 65bytes X9.62 uncompressed format + receiverPublicBytes = status.pushKeyPublic + ?: error("missing pushKeyPublic"), + // auth secrets created at subscription + authSecret = status.pushAuthSecret ?: error("missing pushAuthSecret"), + ) + decode() + } + } else { + // Crypt-Key から dh と p256ecdsa を見る + val cryptKeys = header("Crypto-Key") + ?.parseSemicolon() ?: error("missing Crypto-Key") + + AesGcmDecoder( + receiverPrivateBytes = status.pushKeyPrivate ?: error("missing pushKeyPrivate"), + receiverPublicBytes = status.pushKeyPublic ?: error("missing pushKeyPublic"), + senderPublicBytes = cryptKeys["dh"]?.decodeBase64() + ?: status.pushServerKey ?: error("missing pushServerKey"), + authSecret = status.pushAuthSecret ?: error("missing pushAuthSecret"), + saltBytes = header("Encryption")?.parseSemicolon() + ?.get("salt")?.decodeBase64() + ?: error("missing Encryption.salt"), + provider = provider + ).run { + deriveKey() + decode(encryptedBody.toByteRange()) + } + } + } catch (ex: Throwable) { + // クライアント側の鍵が異なる等でデコードできない場合がある + log.e(ex.withCaption("message decipher failed.")) + null + }?.decodeUTF8()?.decodeJsonObject()?.let { + pm.messageJson = it + daoPushMessage.save(pm) + } + } + + /** + * 通知をスワイプして削除した。 + * - URLからDB上の項目のIDを取得 + * - timeDismissを更新する + */ + fun onDeleteNotification(uri: String) { + val messageDbId = reTailDigits.find(uri)?.groupValues?.elementAtOrNull(0) + ?.toLongOrNull() + ?: error("missing messageDbId in $uri") + daoPushMessage.dismiss(messageDbId) + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/push/PushWorker.kt b/app/src/main/java/jp/juggler/subwaytooter/push/PushWorker.kt new file mode 100644 index 00000000..def5675f --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/push/PushWorker.kt @@ -0,0 +1,124 @@ +package jp.juggler.subwaytooter.push + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.work.* +import jp.juggler.subwaytooter.R +import jp.juggler.subwaytooter.notification.NotificationChannels +import jp.juggler.util.coroutine.AppDispatchers +import jp.juggler.util.data.notEmpty +import jp.juggler.util.log.LogCategory +import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicLong + +class PushWorker(appContext: Context, workerParams: WorkerParameters) : + CoroutineWorker(appContext, workerParams) { + + companion object { + private val log = LogCategory("PushWorker") + + const val KEY_ACTION = "action" + const val KEY_ENDPOINT = "endpoint" + const val KEY_MESSAGE_ID = "messageId" + const val KEY_KEEP_ALIVE_MODE = "keepAliveMode" + + const val ACTION_UP_ENDPOINT = "upEndpoint" + const val ACTION_MESSAGE = "message" + const val ACTION_REGISTER_ENDPOINT = "endpointRegister" + + val timeStartUpEndpoint = AtomicLong(0L) + val timeEndUpEndpoint = AtomicLong(0L) + val timeStartRegisterEndpoint = AtomicLong(0L) + val timeEndRegisterEndpoint = AtomicLong(0L) + + fun enqueueUpEndpoint(context: Context, endpoint: String) { + workDataOf( + PushWorker.KEY_ACTION to PushWorker.ACTION_UP_ENDPOINT, + PushWorker.KEY_ENDPOINT to endpoint, + ).launchPushWorker(context) + } + + fun enqueueRegisterEndpoint(context: Context, keepAliveMode: Boolean = false) { + workDataOf( + KEY_ACTION to ACTION_REGISTER_ENDPOINT, + KEY_KEEP_ALIVE_MODE to keepAliveMode, + ).launchPushWorker(context) + } + + fun enqueuePushMessage(context: Context, messageId: Long) { + workDataOf( + PushWorker.KEY_ACTION to PushWorker.ACTION_MESSAGE, + PushWorker.KEY_MESSAGE_ID to messageId, + ).launchPushWorker(context) + } + + fun Data.launchPushWorker(context: Context) { + // EXPEDITED だと制約の種類が限られる + // すぐ起動してほしいので制約は少なめにする + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .build() + + val request = OneTimeWorkRequestBuilder().apply { + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + setConstraints(constraints) + setInputData(this@launchPushWorker) + } + WorkManager.getInstance(context).enqueue(request.build()) + log.i("enqueued!") + } + } + + override suspend fun doWork(): Result = try { + setForegroundAsync(createForegroundInfo()) + withContext(AppDispatchers.IO) { + val pushRepo = applicationContext.pushRepo + when (val action = inputData.getString(KEY_ACTION)) { + ACTION_UP_ENDPOINT -> { + timeStartUpEndpoint.set(System.currentTimeMillis()) + try { + val endpoint = inputData.getString(KEY_ENDPOINT) + ?.notEmpty() ?: error("missing endpoint.") + pushRepo.newUpEndpoint(endpoint) + }finally{ + timeEndUpEndpoint.set(System.currentTimeMillis()) + } + } + ACTION_REGISTER_ENDPOINT -> { + timeStartRegisterEndpoint.set(System.currentTimeMillis()) + try { + val keepAliveMode = inputData.getBoolean(KEY_KEEP_ALIVE_MODE, false) + pushRepo.registerEndpoint(keepAliveMode) + }finally{ + timeEndRegisterEndpoint.set(System.currentTimeMillis()) + } + } + ACTION_MESSAGE -> { + val messageId = inputData.getLong(KEY_MESSAGE_ID, 0L) + .takeIf { it != 0L } ?: error("missing message id.") + pushRepo.updateMessage(messageId) + } + else -> error("invalid action $action") + } + } + Result.success() + } catch (ex: Throwable) { + log.e(ex, "doWork failed.") + Result.failure() + } + + // Creates an instance of ForegroundInfo which can be used to update the + // ongoing notification. + private fun createForegroundInfo() = applicationContext.run { + val nc = NotificationChannels.PushMessageWorker + val builder = NotificationCompat.Builder(this, nc.id).apply { + priority = nc.priority + setSmallIcon(R.drawable.ic_refresh) + setContentTitle(getString(nc.titleId)) + setContentText(getString(nc.descId)) + setWhen(System.currentTimeMillis()) + setOngoing(true) + } + ForegroundInfo(nc.notificationId, builder.build()) + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/push/UpMessageReceiver.kt b/app/src/main/java/jp/juggler/subwaytooter/push/UpMessageReceiver.kt new file mode 100644 index 00000000..3d91ec80 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/push/UpMessageReceiver.kt @@ -0,0 +1,72 @@ +package jp.juggler.subwaytooter.push + +import android.content.Context +import androidx.work.workDataOf +import jp.juggler.subwaytooter.notification.showAlertNotification +import jp.juggler.subwaytooter.push.PushWorker.Companion.enqueueUpEndpoint +import jp.juggler.subwaytooter.push.PushWorker.Companion.launchPushWorker +import jp.juggler.util.log.LogCategory +import jp.juggler.util.os.checkAppForeground +import kotlinx.coroutines.runBlocking +import org.unifiedpush.android.connector.MessagingReceiver + +/** + * UnifiedPush のイベントを処理するレシーバー。 + * - メインスレッドで呼ばれてコルーチン的に辛い。 + * - データ保存だけして残りはUpWorkでバックグラウンド処理する。 + */ +class UpMessageReceiver : MessagingReceiver() { + companion object { + private val log = LogCategory("UpMessageReceiver") + } + + /** + * registerApp が完了すると呼ばれる + * - メインスレッドで呼ばれる + * - UIから操作した直後なので、だいたいフォアグラウンド状態だからrunBlockingしなくてもいいかな。 + */ + override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { + try { + enqueueUpEndpoint(context,endpoint) + } catch (ex: Throwable) { + log.e(ex, "onNewEndpoint failed.") + } + } + + /** + * registerAppに失敗した + * - 呼ばれるのを見たことがない… + */ + override fun onRegistrationFailed(context: Context, instance: String) { + checkAppForeground("UpMessageReceiver.onRegistrationFailed") + context.showAlertNotification("onRegistrationFailed: instance=$instance, thread=${Thread.currentThread().name}") + } + + /** + * 登録解除が完了したら呼ばれる + * - メインスレッドで呼ばれる + * - ntfyアプリ上から購読を削除した場合に呼ばれた。 + * - 特に何もしなくていいかな… + */ + override fun onUnregistered(context: Context, instance: String) { + checkAppForeground("UpMessageReceiver.onUnregistered") + } + + /** + * メッセージを受信した + * - メインスレッドで呼ばれる + * - runBlocking するべきかしないべきか迷う… + * - これ契機でのサービス起動とかないはず。 + * - アイコンのロードが失敗するのかもしれない + */ + override fun onMessage(context: Context, message: ByteArray, instance: String) { + checkAppForeground("UpMessageReceiver.onMessage") + runBlocking { + try { + context.pushRepo.saveUpMessage(message) + } catch (ex: Throwable) { + log.e(ex, "onMessage failed.") + } + } + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/span/MyClickableSpan.kt b/app/src/main/java/jp/juggler/subwaytooter/span/MyClickableSpan.kt index ffbe0c70..27ea260c 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/span/MyClickableSpan.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/span/MyClickableSpan.kt @@ -37,8 +37,8 @@ class MyClickableSpan(val linkInfo: LinkInfo) : ClickableSpan() { init { val ac = linkInfo.ac if (ac != null) { - this.colorFg = ac.color_fg - this.colorBg = ac.color_bg + this.colorFg = ac.colorFg + this.colorBg = ac.colorBg } else { this.colorFg = 0 this.colorBg = 0 diff --git a/app/src/main/java/jp/juggler/subwaytooter/span/NetworkEmojiSpan.kt b/app/src/main/java/jp/juggler/subwaytooter/span/NetworkEmojiSpan.kt index cd81fec1..05670bdf 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/span/NetworkEmojiSpan.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/span/NetworkEmojiSpan.kt @@ -89,7 +89,7 @@ class NetworkEmojiSpan internal constructor( } ?: return val t = when { - PrefB.bpDisableEmojiAnimation() -> 0L + PrefB.bpDisableEmojiAnimation.value -> 0L else -> invalidateCallback.timeFromStart } @@ -152,7 +152,7 @@ class NetworkEmojiSpan internal constructor( // 少し後に描画しなおす val delay = mFrameFindResult.delay - if (delay != Long.MAX_VALUE && !PrefB.bpDisableEmojiAnimation()) { + if (delay != Long.MAX_VALUE && !PrefB.bpDisableEmojiAnimation.value) { invalidateCallback.delayInvalidate(delay) } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/streaming/StreamConnection.kt b/app/src/main/java/jp/juggler/subwaytooter/streaming/StreamConnection.kt index 2e74e6bc..a2c966db 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/streaming/StreamConnection.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/streaming/StreamConnection.kt @@ -141,7 +141,7 @@ class StreamConnection( } private fun fireDeleteId(id: EntityId) { - if (PrefB.bpDontRemoveDeletedToot.invoke(manager.appState.pref)) return + if (PrefB.bpDontRemoveDeletedToot.value) return val timelineHost = acctGroup.account.apiHost manager.appState.columnList.forEach { runOnMainLooper { diff --git a/app/src/main/java/jp/juggler/subwaytooter/streaming/StreamManager.kt b/app/src/main/java/jp/juggler/subwaytooter/streaming/StreamManager.kt index 94775337..3ab22b54 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/streaming/StreamManager.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/streaming/StreamManager.kt @@ -7,8 +7,8 @@ import jp.juggler.subwaytooter.api.entity.Acct import jp.juggler.subwaytooter.api.entity.TootInstance import jp.juggler.subwaytooter.column.Column import jp.juggler.subwaytooter.pref.PrefB -import jp.juggler.subwaytooter.table.HighlightWord import jp.juggler.subwaytooter.table.SavedAccount +import jp.juggler.subwaytooter.table.daoHighlightWord import jp.juggler.util.coroutine.launchDefault import jp.juggler.util.log.LogCategory import kotlinx.coroutines.channels.Channel @@ -69,7 +69,7 @@ class StreamManager(val appState: AppState) { return acctGroup } - if (isScreenOn && !PrefB.bpDontUseStreaming(appState.pref)) { + if (isScreenOn && !PrefB.bpDontUseStreaming.value) { for (column in appState.columnList) { val accessInfo = column.accessInfo if (column.isDispose.get() || column.dontStreaming || accessInfo.isNA) continue @@ -101,7 +101,7 @@ class StreamManager(val appState: AppState) { } // ハイライトツリーを読み直す - val highlightTrie = HighlightWord.nameSet + val highlightTrie = daoHighlightWord.nameSet() acctGroups.values.forEach { // パーサーを更新する diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/AccountNotificationStatus.kt b/app/src/main/java/jp/juggler/subwaytooter/table/AccountNotificationStatus.kt new file mode 100644 index 00000000..f94eecef --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/table/AccountNotificationStatus.kt @@ -0,0 +1,257 @@ +package jp.juggler.subwaytooter.table + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.provider.BaseColumns +import jp.juggler.subwaytooter.api.entity.Acct +import jp.juggler.util.data.* +import jp.juggler.util.log.LogCategory + +class AccountNotificationStatus( + // DB上のID + var id: Long = 0L, + // 該当ユーザのacct + var acct: String = "", + // acctのハッシュ値 + var acctHash: String = "", + // アプリサーバから受け取ったハッシュ + var appServerHash: String? = null, + // プッシュ購読時に作成した秘密鍵 + var pushKeyPrivate: ByteArray? = null, + // プッシュ購読時に作成した公開鍵 + var pushKeyPublic: ByteArray? = null, + // プッシュ購読時に作成した乱数データ + var pushAuthSecret: ByteArray? = null, + // プッシュ購読時に取得したサーバ公開鍵 + var pushServerKey: ByteArray? = null, + // Push購読時にSNSサーバに指定したコールバックURL + // Misskeyで過去の購読を参照するために必要 + var lastPushEndpoint: String? = null, + // Pull通知チェックや通知を出す処理で発生したエラーの情報 + var lastNotificationError: String? = null, + // Push購読で発生したエラーの情報 + var lastSubscriptionError: String? = null, +) { + companion object : TableCompanion { + private val log = LogCategory("AccountNotificationStatus") + private const val TABLE = "account_notification_status" + override val table = TABLE + private const val COL_ID = BaseColumns._ID + private const val COL_ACCT = "a" + private const val COL_ACCT_HASH = "ah" + private const val COL_APP_SERVER_HASH = "ash" + private const val COL_PUSH_KEY_PRIVATE = "pk_private" + private const val COL_PUSH_KEY_PUBLIC = "pk_public" + private const val COL_PUSH_AUTH_SECRET = "pk_auth_secret" + private const val COL_PUSH_SERVER_KEY = "pk_server_key" + private const val COL_LAST_PUSH_ENDPOINT = "lpe" + private const val COL_LAST_NOTIFICATION_ERROR = "last_notification_error" + private const val COL_LAST_SUBSCRIPTION_ERROR = "last_subscription_error" + + val columnList = ColumnMeta.List(table = TABLE, initialVersion = 65).apply { + ColumnMeta(this, 0, COL_ID, ColumnMeta.TS_INT_PRIMARY_KEY_NOT_NULL) + ColumnMeta(this, 0, COL_ACCT, ColumnMeta.TS_EMPTY_NOT_NULL) + ColumnMeta(this, 0, COL_ACCT_HASH, ColumnMeta.TS_EMPTY_NOT_NULL) + ColumnMeta(this, 0, COL_APP_SERVER_HASH, ColumnMeta.TS_TEXT_NULL) + ColumnMeta(this, 0, COL_PUSH_KEY_PRIVATE, ColumnMeta.TS_BLOB_NULL) + ColumnMeta(this, 0, COL_PUSH_KEY_PUBLIC, ColumnMeta.TS_BLOB_NULL) + ColumnMeta(this, 0, COL_PUSH_AUTH_SECRET, ColumnMeta.TS_BLOB_NULL) + ColumnMeta(this, 0, COL_PUSH_SERVER_KEY, ColumnMeta.TS_BLOB_NULL) + ColumnMeta(this, 0, COL_LAST_PUSH_ENDPOINT, ColumnMeta.TS_TEXT_NULL) + ColumnMeta(this, 0, COL_LAST_NOTIFICATION_ERROR, ColumnMeta.TS_TEXT_NULL) + ColumnMeta(this, 0, COL_LAST_SUBSCRIPTION_ERROR, ColumnMeta.TS_TEXT_NULL) + + createExtra = { + arrayOf( + "create unique index if not exists ${TABLE}_la on $TABLE($COL_ACCT)", + "create index if not exists ${TABLE}_ah on $TABLE($COL_ACCT_HASH)", + "create index if not exists ${TABLE}_ash on $TABLE($COL_APP_SERVER_HASH)", + ) + } + } + + override fun onDBCreate(db: SQLiteDatabase) { + columnList.onDBCreate(db) + } + + override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion < 65 && newVersion >= 65) { + onDBCreate(db) + } + } + } + + @Suppress("MemberVisibilityCanBePrivate") + class ColIdx(cursor: Cursor) { + val idxId = cursor.getColumnIndex(COL_ID) + val idxAcct = cursor.getColumnIndex(COL_ACCT) + val idxAcctHash = cursor.getColumnIndex(COL_ACCT_HASH) + val idxAppServerHash = cursor.getColumnIndex(COL_APP_SERVER_HASH) + val idxPushKeyPrivate = cursor.getColumnIndex(COL_PUSH_KEY_PRIVATE) + val idxPushKeyPublic = cursor.getColumnIndex(COL_PUSH_KEY_PUBLIC) + val idxPushAuthSecret = cursor.getColumnIndex(COL_PUSH_AUTH_SECRET) + val idxPushServerKey = cursor.getColumnIndex(COL_PUSH_SERVER_KEY) + val idxLastPushEndpoint = cursor.getColumnIndex(COL_LAST_PUSH_ENDPOINT) + val idxLastNotificationError = cursor.getColumnIndex(COL_LAST_NOTIFICATION_ERROR) + val idxLastSubscriptionError = cursor.getColumnIndex(COL_LAST_SUBSCRIPTION_ERROR) + fun readRow(cursor: Cursor?) = + try { + cursor ?: error("cursor is null!") + AccountNotificationStatus( + id = cursor.getLong(idxId), + acct = cursor.getString(idxAcct), + acctHash = cursor.getString(idxAcctHash), + appServerHash = cursor.getStringOrNull(idxAppServerHash), + pushKeyPrivate = cursor.getBlobOrNull(idxPushKeyPrivate), + pushKeyPublic = cursor.getBlobOrNull(idxPushKeyPublic), + pushAuthSecret = cursor.getBlobOrNull(idxPushAuthSecret), + pushServerKey = cursor.getBlobOrNull(idxPushServerKey), + lastPushEndpoint = cursor.getStringOrNull(idxLastPushEndpoint), + lastNotificationError = cursor.getStringOrNull(idxLastNotificationError), + lastSubscriptionError = cursor.getStringOrNull(idxLastSubscriptionError), + ) + } catch (ex: Throwable) { + log.e("readRow failed.") + null + } + } + + // ID以外のカラムをContentValuesに変換する + fun toContentValues() = ContentValues().apply { + put(COL_ACCT, acct) + put(COL_ACCT_HASH, acctHash) + put(COL_APP_SERVER_HASH, appServerHash) + put(COL_PUSH_KEY_PRIVATE, pushKeyPrivate) + put(COL_PUSH_KEY_PUBLIC, pushKeyPublic) + put(COL_PUSH_AUTH_SECRET, pushAuthSecret) + put(COL_PUSH_SERVER_KEY, pushServerKey) + put(COL_LAST_PUSH_ENDPOINT, lastPushEndpoint) + put(COL_LAST_NOTIFICATION_ERROR, lastNotificationError) + put(COL_LAST_SUBSCRIPTION_ERROR, lastSubscriptionError) + } + + class Access(val db: SQLiteDatabase) { + fun replace(item: AccountNotificationStatus) = + item.toContentValues().replaceTo(db, TABLE).also { item.id = it } + + private fun Cursor?.readOne() = when (this?.moveToNext()) { + true -> ColIdx(this).readRow(this) + else -> null + } + + fun findByAcctHash(acctHash: String) = + db.queryById(TABLE, acctHash, COL_ACCT_HASH)?.use { it.readOne() } + + fun load(acct: Acct) = + db.queryById(TABLE, acct.ascii, COL_ACCT)?.use { it.readOne() } + + fun appServerHash(acct: Acct): String? = + load(acct)?.appServerHash + + fun lastEndpointUrl(acct: Acct): String? = + load(acct)?.lastPushEndpoint + + private fun newInstance(acct: Acct) = + AccountNotificationStatus( + acct = acct.ascii, + acctHash = acct.ascii.encodeUTF8().digestSHA256().encodeBase64Url() + ) + + fun loadOrCreate(acct: Acct): AccountNotificationStatus { + load(acct)?.let { return it } + return newInstance(acct).also { replace(it) } + } + + private fun idOrCreate(acct: Acct) = loadOrCreate(acct).id + + /** + * プッシュ購読の更新後にURLとキーを保存する + */ + fun savePushKey( + acct: Acct, + lastPushEndpoint: String, + pushKeyPrivate: ByteArray, + pushKeyPublic: ByteArray, + pushAuthSecret: ByteArray, + pushServerKey: ByteArray, + ) = ContentValues().apply { + put(COL_LAST_PUSH_ENDPOINT, lastPushEndpoint) + put(COL_PUSH_KEY_PRIVATE, pushKeyPrivate) + put(COL_PUSH_KEY_PUBLIC, pushKeyPublic) + put(COL_PUSH_AUTH_SECRET, pushAuthSecret) + put(COL_PUSH_SERVER_KEY, pushServerKey) + }.updateTo(db, TABLE, idOrCreate(acct).toString()) + + fun deleteLastEndpointUrl(acct: Acct) = + db.deleteById(TABLE, acct.ascii, COL_ACCT) + + fun savePushKey( + acct: Acct, + pushKeyPrivate: ByteArray, + pushKeyPublic: ByteArray, + pushAuthSecret: ByteArray, + ) = ContentValues().apply { + put(COL_PUSH_KEY_PRIVATE, pushKeyPrivate) + put(COL_PUSH_KEY_PUBLIC, pushKeyPublic) + put(COL_PUSH_AUTH_SECRET, pushAuthSecret) + }.updateTo(db, TABLE, idOrCreate(acct).toString()) + + fun saveServerKey( + acct: Acct, + lastPushEndpoint: String, + pushServerKey: ByteArray, + ) = ContentValues().apply { + put(COL_LAST_PUSH_ENDPOINT, lastPushEndpoint) + put(COL_PUSH_SERVER_KEY, pushServerKey) + }.updateTo(db, TABLE, idOrCreate(acct).toString()) + + fun deletePushKey(acct: Acct) = + ContentValues().apply { + putNull(COL_PUSH_KEY_PRIVATE) + putNull(COL_PUSH_KEY_PUBLIC) + putNull(COL_PUSH_AUTH_SECRET) + putNull(COL_PUSH_SERVER_KEY) + }.updateTo(db, TABLE, idOrCreate(acct).toString()) + + private fun mapAcctToHash() = buildMap { + db.rawQuery("select $COL_ACCT,$COL_ACCT_HASH from $TABLE", emptyArray()) + ?.use { cursor -> + val idxAcct = cursor.getColumnIndex(COL_ACCT) + val idxActHash = cursor.getColumnIndex(COL_ACCT_HASH) + while (cursor.moveToNext()) { + put(cursor.getString(idxAcct), cursor.getString(idxActHash)) + } + } + } + + // returns map of acctHash to acct + fun updateAcctHash(accts: Iterable) = + buildMap { + val mapAcctToHash = mapAcctToHash() + for (acct in accts) { + val hash = mapAcctToHash[acct.ascii] ?: loadOrCreate(acct).acctHash + put(hash, acct) + } + } + + fun saveAppServerHash(id: Long, appServerHash: String) = + ContentValues().apply { + put(COL_APP_SERVER_HASH, appServerHash) + }.updateTo(db, TABLE, id.toString()) + + + private fun updateSingleString(acct:Acct, col: String, value: String?) { + ContentValues().apply{ + put(col, value) + }.updateTo(db, TABLE,acct.ascii, COL_ACCT) + } + fun updateNotificationError(acct:Acct, text: String?) { + updateSingleString(acct, COL_LAST_NOTIFICATION_ERROR, text) + } + + fun updateSubscriptionError(acct:Acct, text: String?) { + updateSingleString(acct, COL_LAST_SUBSCRIPTION_ERROR, text) + } + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/AcctColor.kt b/app/src/main/java/jp/juggler/subwaytooter/table/AcctColor.kt index d63bcbe7..3e55f838 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/AcctColor.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/AcctColor.kt @@ -2,6 +2,7 @@ package jp.juggler.subwaytooter.table import android.content.ContentValues import android.content.Context +import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.provider.BaseColumns import android.text.Spannable @@ -12,75 +13,50 @@ import androidx.annotation.StringRes import androidx.collection.LruCache import jp.juggler.subwaytooter.api.entity.Acct import jp.juggler.subwaytooter.api.entity.TootAccount -import jp.juggler.subwaytooter.global.appDatabase import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory -class AcctColor { - - private var acctAscii: String - private var acctPretty: String - var color_fg: Int = 0 - var color_bg: Int = 0 - var nicknameSave: String? = null - var notification_sound: String? = null +class AcctColor( + var id: Long = 0L, + var timeSave: Long = 0L, + //@who@host ascii文字の大文字小文字は(sqliteにより)同一視される + var acctAscii: String = "", + // 未設定なら0、それ以外は色 + var colorFg: Int = 0, + // 未設定なら0、それ以外は色 + var colorBg: Int = 0, + // 未設定ならnullか空文字列 + var nicknameSave: String? = null, + // 未設定ならnullか空文字列 + var notificationSoundSaved: String? = null, +) { + var acct = Acct.parse(acctAscii) val nickname: String - get() = nicknameSave.notEmpty() ?: acctPretty + get() = nicknameSave.notEmpty() ?: acct.pretty - constructor( - acctAscii: String, - acctPretty: String, - nicknameSave: String, - color_fg: Int, - color_bg: Int, - notification_sound: String?, - ) { - this.acctAscii = acctAscii - this.acctPretty = acctPretty - this.nicknameSave = nicknameSave - this.color_fg = color_fg - this.color_bg = color_bg - this.notification_sound = notification_sound - } - - private constructor(acctAscii: String, acctPretty: String) { - this.acctAscii = acctAscii - this.acctPretty = acctPretty - } - - fun save(now: Long) { - - val key = acctAscii.lowercase() - - try { - val cv = ContentValues() - cv.put(COL_TIME_SAVE, now) - cv.put(COL_ACCT, key) - cv.put(COL_COLOR_FG, color_fg) - cv.put(COL_COLOR_BG, color_bg) - cv.put(COL_NICKNAME, nicknameSave ?: "") - cv.put( - COL_NOTIFICATION_SOUND, - if (notification_sound == null) "" else notification_sound - ) - appDatabase.replace(table, null, cv) - mMemoryCache.remove(key) - } catch (ex: Throwable) { - log.e(ex, "save failed.") - } - } + val notificationSound: String? + get() = notificationSoundSaved.notEmpty() companion object : TableCompanion { - private val log = LogCategory("AcctColor") - override val table = "acct_color" + private const val COL_ID = BaseColumns._ID + private const val COL_TIME_SAVE = "time_save" + private const val COL_ACCT = "ac" + private const val COL_COLOR_FG = "cf" + private const val COL_COLOR_BG = "cb" + private const val COL_NICKNAME = "nick" + private const val COL_NOTIFICATION_SOUND = "notification_sound" val columnList: ColumnMeta.List = ColumnMeta.List(table, 9).apply { - // not used, but must be defined - ColumnMeta(this, 0, BaseColumns._ID, "INTEGER PRIMARY KEY", primary = true) - + ColumnMeta(this, 0, COL_ID, "INTEGER PRIMARY KEY") + ColumnMeta(this, 0, COL_TIME_SAVE, "integer not null") + ColumnMeta(this, 0, COL_ACCT, "text not null") + ColumnMeta(this, 0, COL_COLOR_FG, "integer") + ColumnMeta(this, 0, COL_COLOR_BG, "integer") + ColumnMeta(this, 0, COL_NICKNAME, "text") + ColumnMeta(this, 17, COL_NOTIFICATION_SOUND, "text default ''") createExtra = { arrayOf( "create unique index if not exists ${table}_acct on $table($COL_ACCT)", @@ -89,26 +65,61 @@ class AcctColor { } } - private val COL_TIME_SAVE = ColumnMeta(columnList, 0, "time_save", "integer not null") + override fun onDBCreate(db: SQLiteDatabase) = + columnList.onDBCreate(db) - //@who@host ascii文字の大文字小文字は(sqliteにより)同一視される - private val COL_ACCT = ColumnMeta(columnList, 0, "ac", "text not null") - - // 未設定なら0、それ以外は色 - private val COL_COLOR_FG = ColumnMeta(columnList, 0, "cf", "integer") - - // 未設定なら0、それ以外は色 - private val COL_COLOR_BG = ColumnMeta(columnList, 0, "cb", "integer") - - // 未設定ならnullか空文字列 - private val COL_NICKNAME = ColumnMeta(columnList, 0, "nick", "text") - - // 未設定ならnullか空文字列 - private val COL_NOTIFICATION_SOUND = - ColumnMeta(columnList, 17, "notification_sound", "text default ''") + override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = + columnList.onDBUpgrade(db, oldVersion, newVersion) private const val CHAR_REPLACE: Char = 0x328A.toChar() + private val mMemoryCache = LruCache(2048) + } + + @Suppress("MemberVisibilityCanBePrivate") + class ColIdx(cursor: Cursor) { + val idxId = cursor.getColumnIndexOrThrow(COL_ID) + val idxTimeSave = cursor.getColumnIndexOrThrow(COL_TIME_SAVE) + val idxAcct = cursor.getColumnIndexOrThrow(COL_ACCT) + val idxColorFg = cursor.getColumnIndexOrThrow(COL_COLOR_FG) + val idxColorBg = cursor.getColumnIndexOrThrow(COL_COLOR_BG) + val idxNickname = cursor.getColumnIndexOrThrow(COL_NICKNAME) + val idxNotificationSound = cursor.getColumnIndexOrThrow(COL_NOTIFICATION_SOUND) + fun readRow(cursor: Cursor) = AcctColor( + id = cursor.getLong(idxId), + timeSave = cursor.getLong(idxTimeSave), + acctAscii = cursor.getString(idxAcct), + colorFg = cursor.getIntOrNull(idxColorFg) ?: 0, + colorBg = cursor.getIntOrNull(idxColorBg) ?: 0, + nicknameSave = cursor.getStringOrNull(idxNickname), + notificationSoundSaved = cursor.getStringOrNull(idxNotificationSound), + ) + + fun readOne(cursor: Cursor) = + when { + cursor.moveToNext() -> readRow(cursor) + else -> null + } + + fun readAll(cursor: Cursor) = buildList { + while (cursor.moveToNext()) { + add(readRow(cursor)) + } + } + } + + fun toContentValues(key: String) = ContentValues().apply { + put(COL_ACCT, key) + put(COL_COLOR_FG, colorFg) + put(COL_COLOR_BG, colorBg) + put(COL_NICKNAME, nicknameSave ?: "") + put(COL_NOTIFICATION_SOUND, notificationSoundSaved ?: "") + } + + class Access( + val db: SQLiteDatabase, + ) { + private val load_where = "$COL_ACCT=?" private val load_where_arg = object : ThreadLocal>() { @@ -117,92 +128,46 @@ class AcctColor { } } - private val mMemoryCache = LruCache(2048) - - override fun onDBCreate(db: SQLiteDatabase) = - columnList.onDBCreate(db) - - override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = - columnList.onDBUpgrade(db, oldVersion, newVersion) - - fun load(a: SavedAccount, who: TootAccount) = load(a.getFullAcct(who)) - fun load(a: SavedAccount) = load(a.acct) - fun load(acct: Acct) = load(acct.ascii, acct.pretty) - - fun load(acctAscii: String, acctPretty: String): AcctColor { + fun load(acctAscii: String): AcctColor { val key = acctAscii.lowercase() - val cached: AcctColor? = mMemoryCache.get(key) - if (cached != null) return cached - + mMemoryCache.get(key)?.let { return it } try { - val where_arg = load_where_arg.get() ?: arrayOfNulls(1) - where_arg[0] = key - appDatabase.query(table, null, load_where, where_arg, null, null, null) - .use { cursor -> - if (cursor.moveToNext()) { - - val ac = AcctColor(key, acctPretty) - - ac.color_fg = cursor.getIntOrNull(COL_COLOR_FG) ?: 0 - ac.color_bg = cursor.getIntOrNull(COL_COLOR_BG) ?: 0 - ac.nicknameSave = cursor.getStringOrNull(COL_NICKNAME) - ac.notification_sound = cursor.getStringOrNull(COL_NOTIFICATION_SOUND) - - mMemoryCache.put(key, ac) - return ac - } - } + db.queryById(table, key, COL_ACCT) + ?.use { ColIdx(it).readOne(it) } + ?.also { ac -> mMemoryCache.put(key, ac) } + ?.let { return it } } catch (ex: Throwable) { log.e(ex, "load failed.") } - log.d("lruCache size=${mMemoryCache.size()},hit=${mMemoryCache.hitCount()},miss=${mMemoryCache.missCount()}") - val ac = AcctColor(key, acctPretty) - mMemoryCache.put(key, ac) - return ac + return AcctColor(acctAscii = key).also { mMemoryCache.put(key, it) } } + fun load(a: SavedAccount, who: TootAccount) = load(a.getFullAcct(who)) + fun load(a: SavedAccount) = load(a.acct) + fun load(acct: Acct) = load(acct.ascii) + // fun getNickname(acct : String) : String { // val ac = load(acct) // val nickname = ac.nickname // return if(nickname != null && nickname.isNotEmpty()) nickname.sanitizeBDI() else acct // } - private fun getNickname(acctAscii: String, acctPretty: String): String = - load(acctAscii, acctPretty).nickname - - fun getNickname(acct: Acct): String = - getNickname(acct.ascii, acct.pretty) - - fun getNickname(sa: SavedAccount): String = - getNickname(sa.acct) - - fun getNickname(sa: SavedAccount, who: TootAccount): String = - getNickname(sa.getFullAcct(who)) - - fun getNicknameWithColor(sa: SavedAccount, who: TootAccount) = - getNicknameWithColor(sa.getFullAcct(who)) - - // fun getNicknameWithColor(sa:SavedAccount,acctArg:String) = -// getNicknameWithColor(sa.getFullAcct(Acct.parse(acctArg))) - fun getNicknameWithColor(acct: Acct) = - getNicknameWithColor(acct.ascii, acct.pretty) - - private fun getNicknameWithColor(acctAscii: String, acctPretty: String): CharSequence { - val ac = load(acctAscii, acctPretty) + private fun getNicknameWithColor(acctAscii: String): CharSequence { + val ac = load(acctAscii) val sb = SpannableStringBuilder(ac.nickname.sanitizeBDI()) val start = 0 val end = sb.length - if (ac.color_fg != 0) { + if (ac.colorFg != 0) { sb.setSpan( - ForegroundColorSpan(ac.color_fg), + ForegroundColorSpan(ac.colorFg), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } - if (ac.color_bg != 0) { + if (ac.colorBg != 0) { sb.setSpan( - BackgroundColorSpan(ac.color_bg), + BackgroundColorSpan(ac.colorBg), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) @@ -210,21 +175,32 @@ class AcctColor { return sb } - fun getNotificationSound(acct: Acct): String? { - return load(acct).notification_sound?.notEmpty() - } + fun getNicknameWithColor(acct: Acct) = getNicknameWithColor(acct.ascii) + fun getNicknameWithColor(sa: SavedAccount, who: TootAccount) = + getNicknameWithColor(sa.getFullAcct(who)) + fun getNickname(acctAscii: String) = load(acctAscii).nickname + fun getNickname(acct: Acct): String = load(acct.ascii).nickname + fun getNickname(sa: SavedAccount) = load(sa.acct.ascii).nickname + + fun getNickname(sa: SavedAccount, who: TootAccount): String = + getNickname(sa.getFullAcct(who)) + + fun getNotificationSound(acct: Acct) = + load(acct).notificationSound + + // fun getNicknameWithColor(sa:SavedAccount,acctArg:String) = +// getNicknameWithColor(sa.getFullAcct(Acct.parse(acctArg))) // fun getNotificationSound(acctAscii : String) : String? { // return load(acctAscii,"").notification_sound?.notEmpty() // // acctPretty is not used in this case // } - fun hasNickname(ac: AcctColor?): Boolean = + fun hasNickname(ac: AcctColor?) = null != ac?.nicknameSave?.notEmpty() - fun hasColorForeground(ac: AcctColor?) = (ac?.color_fg ?: 0) != 0 - - fun hasColorBackground(ac: AcctColor?) = (ac?.color_bg ?: 0) != 0 + fun hasColorForeground(ac: AcctColor?) = (ac?.colorFg ?: 0) != 0 + fun hasColorBackground(ac: AcctColor?) = (ac?.colorBg ?: 0) != 0 fun clearMemoryCache() { mMemoryCache.evictAll() @@ -244,17 +220,17 @@ class AcctColor { val c = sb[i] if (c != CHAR_REPLACE) continue sb.replace(i, i + 1, name) - if (ac.color_fg != 0) { + if (ac.colorFg != 0) { sb.setSpan( - ForegroundColorSpan(ac.color_fg), + ForegroundColorSpan(ac.colorFg), i, i + name.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } - if (ac.color_bg != 0) { + if (ac.colorBg != 0) { sb.setSpan( - BackgroundColorSpan(ac.color_bg), + BackgroundColorSpan(ac.colorBg), i, i + name.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE @@ -263,5 +239,19 @@ class AcctColor { } return sb } + + fun save(now: Long, item: AcctColor) { + + val key = item.acctAscii.lowercase() + + try { + item.toContentValues(key) + .apply { put(COL_TIME_SAVE, now) } + .replaceTo(db, table) + mMemoryCache.remove(key) + } catch (ex: Throwable) { + log.e(ex, "save failed.") + } + } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/AcctSet.kt b/app/src/main/java/jp/juggler/subwaytooter/table/AcctSet.kt index caf33a27..e1f76bc2 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/AcctSet.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/AcctSet.kt @@ -3,128 +3,132 @@ package jp.juggler.subwaytooter.table import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import android.provider.BaseColumns -import jp.juggler.subwaytooter.global.appDatabase import jp.juggler.util.data.ColumnMeta import jp.juggler.util.data.TableCompanion -import jp.juggler.util.data.put import jp.juggler.util.log.LogCategory -object AcctSet : TableCompanion { +//acct= @who@host ascii文字の大文字小文字は(sqliteにより)同一視される +class AcctSet private constructor() { - private val log = LogCategory("AcctSet") + companion object : TableCompanion { + private val log = LogCategory("AcctSet") + override val table = "acct_set" + private const val COL_ID = BaseColumns._ID + private const val COL_TIME_SAVE = "time_save" + private const val COL_ACCT = "acct" - override val table = "acct_set" + val columnList: ColumnMeta.List = ColumnMeta.List(table, 7).apply { + ColumnMeta(this, 0, COL_ID, "INTEGER PRIMARY KEY") + ColumnMeta(this, 0, COL_TIME_SAVE, "integer not null") + ColumnMeta(this, 0, COL_ACCT, "text not null") - val columnList: ColumnMeta.List = ColumnMeta.List(table, 7).apply { - ColumnMeta(this, 0, BaseColumns._ID, "INTEGER PRIMARY KEY", primary = true) - - createExtra = { - arrayOf( - "create unique index if not exists ${table}_acct on $table($COL_ACCT)", - "create index if not exists ${table}_time on $table($COL_TIME_SAVE)", - ) + createExtra = { + arrayOf( + "create unique index if not exists ${table}_acct on $table($COL_ACCT)", + "create index if not exists ${table}_time on $table($COL_TIME_SAVE)", + ) + } } - } - private val COL_TIME_SAVE = ColumnMeta(columnList, 0, "time_save", "integer not null") - //@who@host ascii文字の大文字小文字は(sqliteにより)同一視される - private val COL_ACCT = ColumnMeta(columnList, 0, "acct", "text not null") + override fun onDBCreate(db: SQLiteDatabase) = + columnList.onDBCreate(db) - private val prefix_search_where = "$COL_ACCT like ? escape '$'" + override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = + columnList.onDBUpgrade(db, oldVersion, newVersion) - private val prefix_search_where_arg = object : ThreadLocal>() { - override fun initialValue(): Array { - return arrayOfNulls(1) + private val prefix_search_where = "$COL_ACCT like ? escape '$'" + + private val prefix_search_where_arg = object : ThreadLocal>() { + override fun initialValue(): Array { + return arrayOfNulls(1) + } } } - override fun onDBCreate(db: SQLiteDatabase) = - columnList.onDBCreate(db) + class Access(val db: SQLiteDatabase) { - override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = - columnList.onDBUpgrade(db, oldVersion, newVersion) - - fun deleteOld(now: Long) { - try { - // 古いデータを掃除する - val expire = now - 86400000L * 365 - appDatabase.delete(table, "$COL_TIME_SAVE, offset: Int, length: Int) { - - try { - val cv = ContentValues() - cv.put(COL_TIME_SAVE, now) - - var bOK = false - val db = appDatabase - db.execSQL("BEGIN TRANSACTION") + fun deleteOld(now: Long) { try { - for (i in 0 until length) { - val acct = srcList[i + offset] ?: continue - cv.put(COL_ACCT, acct) - db.replace(table, null, cv) + // 古いデータを掃除する + val expire = now - 86400000L * 365 + db.delete(table, "$COL_TIME_SAVE, offset: Int, length: Int) { + + try { + val cv = ContentValues() + cv.put(COL_TIME_SAVE, now) + + var bOK = false + val db = db + db.execSQL("BEGIN TRANSACTION") + try { + for (i in 0 until length) { + val acct = srcList[i + offset] ?: continue + cv.put(COL_ACCT, acct) + db.replace(table, null, cv) + } + bOK = true + } catch (ex: Throwable) { + log.e(ex, "saveList failed.") + } + + if (bOK) { + db.execSQL("COMMIT TRANSACTION") + } else { + db.execSQL("ROLLBACK TRANSACTION") } - bOK = true } catch (ex: Throwable) { log.e(ex, "saveList failed.") } - - if (bOK) { - db.execSQL("COMMIT TRANSACTION") - } else { - db.execSQL("ROLLBACK TRANSACTION") - } - } catch (ex: Throwable) { - log.e(ex, "saveList failed.") } - } - private fun makePattern(src: String): String { - val sb = StringBuilder() - var i = 0 - val ie = src.length - while (i < ie) { - val c = src[i] - if (c == '%' || c == '_' || c == '$') { - sb.append('$') - } - sb.append(c) - ++i - } - // 前方一致検索にするため、末尾に%をつける - sb.append('%') - return sb.toString() - } - - fun searchPrefix(prefix: String, limit: Int): ArrayList { - try { - val where_arg = prefix_search_where_arg.get() ?: arrayOfNulls(1) - where_arg[0] = makePattern(prefix) - appDatabase.query( - table, - null, - prefix_search_where, - where_arg, - null, - null, - "$COL_ACCT asc limit $limit" - ).use { cursor -> - val dst = ArrayList(cursor.count) - val idx_acct = COL_ACCT.getIndex(cursor) - while (cursor.moveToNext()) { - dst.add(cursor.getString(idx_acct)) + private fun makePattern(src: String): String { + val sb = StringBuilder() + var i = 0 + val ie = src.length + while (i < ie) { + val c = src[i] + if (c == '%' || c == '_' || c == '$') { + sb.append('$') } - return dst + sb.append(c) + ++i } - } catch (ex: Throwable) { - log.e(ex, "searchPrefix failed.") + // 前方一致検索にするため、末尾に%をつける + sb.append('%') + return sb.toString() } - return ArrayList() + fun searchPrefix(prefix: String, limit: Int): ArrayList { + try { + val where_arg = prefix_search_where_arg.get() ?: arrayOfNulls(1) + where_arg[0] = makePattern(prefix) + db.query( + table, + null, + prefix_search_where, + where_arg, + null, + null, + "$COL_ACCT asc limit $limit" + ).use { cursor -> + val dst = ArrayList(cursor.count) + val idx_acct = cursor.getColumnIndexOrThrow(COL_ACCT) + while (cursor.moveToNext()) { + dst.add(cursor.getString(idx_acct)) + } + return dst + } + } catch (ex: Throwable) { + log.e(ex, "searchPrefix failed.") + } + + return ArrayList() + } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/global/AppDatabaseHolder.kt b/app/src/main/java/jp/juggler/subwaytooter/table/AppDatabaseHolder.kt similarity index 54% rename from app/src/main/java/jp/juggler/subwaytooter/global/AppDatabaseHolder.kt rename to app/src/main/java/jp/juggler/subwaytooter/table/AppDatabaseHolder.kt index fb22e825..57577f85 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/global/AppDatabaseHolder.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/AppDatabaseHolder.kt @@ -1,10 +1,17 @@ -package jp.juggler.subwaytooter.global +package jp.juggler.subwaytooter.table import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper -import jp.juggler.subwaytooter.table.* +import androidx.startup.AppInitializer +import androidx.startup.Initializer +import jp.juggler.subwaytooter.pref.LazyContextInitializer +import jp.juggler.subwaytooter.pref.lazyContext +import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory +import jp.juggler.util.os.applicationContextSafe +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference // 2017/4/25 v10 1=>2 SavedAccount に通知設定を追加 // 2017/4/25 v10 1=>2 NotificationTracking テーブルを追加 @@ -61,46 +68,46 @@ import jp.juggler.util.log.LogCategory // 2021/11/21 61=>62 SavedAccountテーブルに項目追加 // 2022/1/5 62=>63 SavedAccountテーブルに項目追加 // 2022/3/15 63=>64 SavedAccountテーブルに項目追加 +// 2023/2/2 64 => 65 PushMessage, AccountNotificationStatus テーブルの追加。 -const val DB_VERSION = 64 +const val DB_VERSION = 65 const val DB_NAME = "app_db" +// テーブルのリスト +// kotlin は配列を Compile-time Constant で作れないのでリストを2回書かないといけない val TABLE_LIST = arrayOf( - LogData, - SavedAccount, - ClientInfo, - MediaShown, - ContentWarning, - NotificationTracking, - NotificationCache, - MutedApp, - UserRelation, - AcctSet, - AcctColor, - MutedWord, - PostDraft, - TagSet, - HighlightWord, - FavMute, - SubscriptionServerKey + AcctColor.Companion, + AcctSet.Companion, + ClientInfo.Companion, + ContentWarning.Companion, + FavMute.Companion, + HighlightWord.Companion, + LogData.Companion, + MediaShown.Companion, + MutedApp.Companion, + MutedWord.Companion, + NotificationCache.Companion, + NotificationTracking.Companion, + PostDraft.Companion, + SavedAccount.Companion, + SubscriptionServerKey.Companion, + TagHistory.Companion, + UserRelation.Companion, + PushMessage.Companion, // v65 + AccountNotificationStatus.Companion // v65, ) -interface AppDatabaseHolder { - val database: SQLiteDatabase - - fun afterGlobalPrepare() -} - -class AppDatabaseHolderImpl(context: Context) : AppDatabaseHolder { +class AppDatabaseHolder( + val context: Context, + val dbFileName: String, + val dbSchemaVersion: Int, +) { companion object { - private val log = LogCategory("AppDatabaseHolderImpl") + private val log = LogCategory("AppDatabaseHolder") } - private class DBOpenHelper(context: Context) : - SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { - companion object { - private val log = LogCategory("DBOpenHelper") - } + private inner class DBOpenHelper(context: Context) : + SQLiteOpenHelper(context, dbFileName, null, dbSchemaVersion) { override fun onCreate(db: SQLiteDatabase) { log.d("onCreate") @@ -118,17 +125,94 @@ class AppDatabaseHolderImpl(context: Context) : AppDatabaseHolder { } } - private val openHelper = DBOpenHelper(context.applicationContext) + private val openHelper = DBOpenHelper(context) - override val database: SQLiteDatabase + val database: SQLiteDatabase get() = openHelper.writableDatabase - override fun afterGlobalPrepare() { + init { log.d("call deleteOld…") val now = System.currentTimeMillis() - AcctSet.deleteOld(now) - UserRelation.deleteOld(now) - ContentWarning.deleteOld(now) - MediaShown.deleteOld(now) + AcctSet.Access(database).deleteOld(now) + UserRelation.Access(database).deleteOld(now) + ContentWarning.Access(database).deleteOld(now) + MediaShown.Access(database).deleteOld(now) + PushMessage.Access(database).sweepOld(now) + } } + +class AppDatabaseHolderIniitalizer : Initializer { + override fun dependencies(): List>> = + listOf(LazyContextInitializer::class.java) + + override fun create(context: Context): AppDatabaseHolder { + return AppDatabaseHolder( + context.applicationContextSafe, + DB_NAME, + DB_VERSION, + ) + } +} + +var appDatabaseHolderOverride = + AtomicReference(null) + +val appDatabaseHolder + get() = appDatabaseHolderOverride.get() ?: AppInitializer.getInstance(lazyContext) + .initializeComponent(AppDatabaseHolderIniitalizer::class.java) + +val appDatabase + get() = appDatabaseHolder.database + +val daoUserRelation by lazy { + UserRelation.Access(appDatabase) +} +val daoMutedWord by lazy { + MutedWord.Access(appDatabase) +} +val daoMutedApp by lazy { + MutedApp.Access(appDatabase) +} +val daoAcctColor by lazy { + AcctColor.Access(appDatabase) +} +val daoNotificationTracking by lazy { + NotificationTracking.Access(appDatabase) +} +val daoSavedAccount by lazy { + SavedAccount.Access(appDatabase, lazyContext) +} +val daoFavMute by lazy { + FavMute.Access(appDatabase) +} +val daoTagHistory by lazy { + TagHistory.Access(appDatabase) +} +val daoHighlightWord by lazy { + HighlightWord.Access(appDatabase) +} +val daoSubscriptionServerKey by lazy { + SubscriptionServerKey.Access(appDatabase) +} +val daoAcctSet by lazy { + AcctSet.Access(appDatabase) +} +val daoClientInfo by lazy { + ClientInfo.Access(appDatabase) +} +val daoPostDraft by lazy { + PostDraft.Access(appDatabase) +} +val daoMediaShown by lazy { + MediaShown.Access(appDatabase) +} +val daoContentWarning by lazy { + ContentWarning.Access(appDatabase) +} +val daoNotificationCache by lazy { + NotificationCache.Access(appDatabase) +} +val daoAccountNotificationStatus by lazy{ + AccountNotificationStatus.Access(appDatabase) +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/ClientInfo.kt b/app/src/main/java/jp/juggler/subwaytooter/table/ClientInfo.kt index ae3eb48a..62c29ee4 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/ClientInfo.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/ClientInfo.kt @@ -4,77 +4,85 @@ import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import android.provider.BaseColumns import jp.juggler.subwaytooter.api.entity.Host -import jp.juggler.subwaytooter.global.appDatabase import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory -object ClientInfo : TableCompanion { - private val log = LogCategory("ClientInfo") +class ClientInfo private constructor() { + companion object : TableCompanion { + private val log = LogCategory("ClientInfo") + override val table = "client_info2" + private const val COL_ID = BaseColumns._ID + private const val COL_HOST = "h" + private const val COL_CLIENT_NAME = "cn" + private const val COL_RESULT = "r" - override val table = "client_info2" - val columnList: ColumnMeta.List = ColumnMeta.List(table, 19).apply { - ColumnMeta(this, 0, BaseColumns._ID, "INTEGER PRIMARY KEY", primary = true) - createExtra = { - arrayOf( - "create unique index if not exists ${table}_host_client_name on $table($COL_HOST,$COL_CLIENT_NAME)" - ) + val columnList: ColumnMeta.List = ColumnMeta.List(table, 19).apply { + ColumnMeta(this, 0, COL_ID, "INTEGER PRIMARY KEY") + ColumnMeta(this, 0, COL_HOST, "text not null") + ColumnMeta(this, 0, COL_CLIENT_NAME, "text not null") + ColumnMeta(this, 0, COL_RESULT, "text not null") + createExtra = { + arrayOf( + "create unique index if not exists ${table}_host_client_name on $table($COL_HOST,$COL_CLIENT_NAME)" + ) + } } + + override fun onDBCreate(db: SQLiteDatabase) = + columnList.onDBCreate(db) + + override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = + columnList.onDBUpgrade(db, oldVersion, newVersion) } - private val COL_HOST = ColumnMeta(columnList, 0, "h", "text not null") - private val COL_CLIENT_NAME = ColumnMeta(columnList, 0, "cn", "text not null") - private val COL_RESULT = ColumnMeta(columnList, 0, "r", "text not null") - override fun onDBCreate(db: SQLiteDatabase) = - columnList.onDBCreate(db) + class Access(val db: SQLiteDatabase) { - override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = - columnList.onDBUpgrade(db, oldVersion, newVersion) - - fun load(apiHost: Host, clientName: String): JsonObject? { - val instance = apiHost.pretty - try { - appDatabase.query( - table, - null, - "h=? and cn=?", - arrayOf(instance, clientName), - null, - null, - null - ).use { cursor -> - if (cursor.moveToFirst()) { - return cursor.getString(COL_RESULT).decodeJsonObject() + fun load(apiHost: Host, clientName: String): JsonObject? { + val instance = apiHost.pretty + try { + db.query( + table, + null, + "h=? and cn=?", + arrayOf(instance, clientName), + null, + null, + null + ).use { cursor -> + if (cursor.moveToFirst()) { + return cursor.getString(COL_RESULT).decodeJsonObject() + } } + } catch (ex: Throwable) { + log.e(ex, "load failed. apiHost=$apiHost") } - } catch (ex: Throwable) { - log.e(ex, "load failed. apiHost=$apiHost") + return null } - return null - } - fun save(apiHost: Host, clientName: String, json: String) { - try { - val cv = ContentValues().apply { - put(COL_HOST, apiHost.pretty) - put(COL_CLIENT_NAME, clientName) - put(COL_RESULT, json) + fun save(apiHost: Host, clientName: String, json: String) { + try { + val cv = ContentValues().apply { + put(COL_HOST, apiHost.pretty) + put(COL_CLIENT_NAME, clientName) + put(COL_RESULT, json) + } + db.replace(table, null, cv) + } catch (ex: Throwable) { + log.e(ex, "save failed. apiHost=$apiHost") } - appDatabase.replace(table, null, cv) - } catch (ex: Throwable) { - log.e(ex, "save failed. apiHost=$apiHost") } - } - // 単体テスト用。インスタンス名を指定して削除する - fun delete(apiHost: Host, clientName: String) { - try { - appDatabase.delete( - table, - "$COL_HOST=? and $COL_CLIENT_NAME=?", - arrayOf(apiHost.pretty, clientName) - ) - } catch (ex: Throwable) { - log.e(ex, "delete failed.") + // 単体テスト用。インスタンス名を指定して削除する + fun delete(apiHost: Host, clientName: String) { + try { + db.delete( + table, + "$COL_HOST=? and $COL_CLIENT_NAME=?", + arrayOf(apiHost.pretty, clientName) + ) + } catch (ex: Throwable) { + log.e(ex, "delete failed.") + } } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/ContentWarning.kt b/app/src/main/java/jp/juggler/subwaytooter/table/ContentWarning.kt index 0417d631..643105c9 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/ContentWarning.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/ContentWarning.kt @@ -4,98 +4,105 @@ import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import android.provider.BaseColumns import jp.juggler.subwaytooter.api.entity.TootStatus -import jp.juggler.subwaytooter.global.appDatabase import jp.juggler.util.data.ColumnMeta import jp.juggler.util.data.TableCompanion import jp.juggler.util.data.getBoolean -import jp.juggler.util.data.put import jp.juggler.util.log.LogCategory -object ContentWarning : TableCompanion { - private val log = LogCategory("ContentWarning") +class ContentWarning private constructor() { + companion object : TableCompanion { + private val log = LogCategory("ContentWarning") - override val table = "content_warning" + override val table = "content_warning" + private const val COL_ID = BaseColumns._ID + private const val COL_STATUS_URI = "su" + private const val COL_SHOWN = "sh" + private const val COL_TIME_SAVE = "time_save" - val columnList: ColumnMeta.List = ColumnMeta.List(table, 0).apply { - ColumnMeta(this, 0, BaseColumns._ID, "INTEGER PRIMARY KEY", primary = true) - deleteBeforeCreate = true - createExtra = { - arrayOf( - "create unique index if not exists ${table}_status_uri on $table($COL_STATUS_URI)", - "create index if not exists ${table}_time_save on $table($COL_TIME_SAVE)", - ) - } - } - private val COL_STATUS_URI = ColumnMeta(columnList, 0, "su", "text not null") - private val COL_SHOWN = ColumnMeta(columnList, 0, "sh", "integer not null") - private val COL_TIME_SAVE = ColumnMeta(columnList, 0, "time_save", "integer default 0") - - private val projection_shown = arrayOf(COL_SHOWN.name) - - override fun onDBCreate(db: SQLiteDatabase) = - columnList.onDBCreate(db) - - override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - // 特定バージョンと交差したらテーブルを削除して作り直す - fun intersect(x: Int) = (oldVersion < x && newVersion >= x) - if (intersect(36) || intersect(31) || intersect(5)) { - columnList.onDBCreate(db) - } - } - - fun deleteOld(now: Long) { - try { - // 古いデータを掃除する - val expire = now - 86400000L * 365 - appDatabase.delete(table, "$COL_TIME_SAVE - if (cursor.moveToFirst()) { - return cursor.getBoolean(COL_SHOWN) - } + val columnList: ColumnMeta.List = ColumnMeta.List(table, 0).apply { + ColumnMeta(this, 0, COL_ID, "INTEGER PRIMARY KEY") + ColumnMeta(this, 0, COL_STATUS_URI, "text not null") + ColumnMeta(this, 0, COL_SHOWN, "integer not null") + ColumnMeta(this, 0, COL_TIME_SAVE, "integer default 0") + deleteBeforeCreate = true + createExtra = { + arrayOf( + "create unique index if not exists ${table}_status_uri on $table($COL_STATUS_URI)", + "create index if not exists ${table}_time_save on $table($COL_TIME_SAVE)", + ) } - } catch (ex: Throwable) { - log.e(ex, "load failed.") } - return defaultValue + override fun onDBCreate(db: SQLiteDatabase) = + columnList.onDBCreate(db) + + override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + // 特定バージョンと交差したらテーブルを削除して作り直す + fun intersect(x: Int) = (oldVersion < x && newVersion >= x) + if (intersect(36) || intersect(31) || intersect(5)) { + columnList.onDBCreate(db) + } + } + + private val projection_shown = arrayOf(COL_SHOWN) } - fun save(uri: String, isShown: Boolean) = - saveImpl(uri, isShown) + class Access(val db: SQLiteDatabase) { - fun isShown(uri: String, defaultValue: Boolean) = - isShownImpl(uri, defaultValue) + fun deleteOld(now: Long) { + try { + // 古いデータを掃除する + val expire = now - 86400000L * 365 + db.delete(table, "$COL_TIME_SAVE + if (cursor.moveToFirst()) { + return cursor.getBoolean(COL_SHOWN) + } + } + } catch (ex: Throwable) { + log.e(ex, "load failed.") + } + + return defaultValue + } + + fun save(uri: String, isShown: Boolean) = + saveImpl(uri, isShown) + + fun isShown(uri: String, defaultValue: Boolean) = + isShownImpl(uri, defaultValue) + + fun save(status: TootStatus, isShown: Boolean) = + saveImpl(status.uri, isShown) + + fun isShown(status: TootStatus, defaultValue: Boolean) = + isShownImpl(status.uri, defaultValue) + } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/EmojiCacheDatabase.kt b/app/src/main/java/jp/juggler/subwaytooter/table/EmojiCacheDatabase.kt new file mode 100644 index 00000000..a5ae814d --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/table/EmojiCacheDatabase.kt @@ -0,0 +1,154 @@ +package jp.juggler.subwaytooter.table + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteDatabaseCorruptException +import android.database.sqlite.SQLiteOpenHelper +import android.provider.BaseColumns +import jp.juggler.util.data.TableCompanion +import jp.juggler.util.data.getBlobOrNull +import jp.juggler.util.data.getLong +import jp.juggler.util.log.LogCategory +import java.util.concurrent.TimeUnit + +private val log = LogCategory("EmojiCacheDatabase") + +// カスタム絵文字のキャッシュ専用のデータベースファイルを作る +// (DB破損などの際に削除してしまえるようにする) +private const val CACHE_DB_NAME = "emoji_cache_db" +private const val CACHE_DB_VERSION = 1 + +class EmojiCache( + val id: Long, + val timeUsed: Long, + val data: ByteArray, +) { + companion object : TableCompanion { + + override val table = "custom_emoji_cache" + + const val COL_ID = BaseColumns._ID + const val COL_TIME_SAVE = "time_save" + const val COL_TIME_USED = "time_used" + const val COL_URL = "url" + const val COL_DATA = "data" + + override fun onDBCreate(db: SQLiteDatabase) { + db.execSQL( + """create table if not exists $table + ($COL_ID INTEGER PRIMARY KEY + ,$COL_TIME_SAVE integer not null + ,$COL_TIME_USED integer not null + ,$COL_URL text not null + ,$COL_DATA blob not null + )""".trimIndent() + ) + db.execSQL("create unique index if not exists ${table}_url on $table($COL_URL)") + db.execSQL("create index if not exists ${table}_old on $table($COL_TIME_USED)") + } + + override fun onDBUpgrade( + db: SQLiteDatabase, + oldVersion: Int, + newVersion: Int, + ) { + } + } + + class Access(val db: SQLiteDatabase) { + fun load(url: String, now: Long) = + db.rawQuery( + "select $COL_ID,$COL_TIME_USED,$COL_DATA from $table where $COL_URL=?", + arrayOf(url) + )?.use { cursor -> + if (cursor.moveToNext()) { + EmojiCache( + id = cursor.getLong(COL_ID), + timeUsed = cursor.getLong(COL_TIME_USED), + data = cursor.getBlobOrNull(COL_DATA)!! + ).apply { + if (now - timeUsed >= 5 * 3600000L) { + db.update( + table, + ContentValues().apply { + put(COL_TIME_USED, now) + }, + "$COL_ID=?", + arrayOf(id.toString()) + ) + } + } + } else { + null + } + } + + fun sweep(now: Long) { + val expire = now - TimeUnit.DAYS.toMillis(30) + db.delete( + table, + "$COL_TIME_USED < ?", + arrayOf(expire.toString()) + ) + } + + fun update(url: String, data: ByteArray) { + val now = System.currentTimeMillis() + db.replace(table, + null, + ContentValues().apply { + put(COL_URL, url) + put(COL_DATA, data) + put(COL_TIME_USED, now) + put(COL_TIME_SAVE, now) + } + ) + } + } +} + +class EmojiCacheDbOpenHelper(val context: Context) : + SQLiteOpenHelper(context, CACHE_DB_NAME, null, CACHE_DB_VERSION) { + + private val tables = arrayOf(EmojiCache) + override fun onCreate(db: SQLiteDatabase) = + tables.forEach { it.onDBCreate(db) } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = + tables.forEach { it.onDBUpgrade(db, oldVersion, newVersion) } + + fun deleteDatabase() { + try { + close() + } catch (ex: Throwable) { + log.e(ex, "deleteDatabase: close() failed.") + } + try { + SQLiteDatabase.deleteDatabase(context.getDatabasePath(databaseName)) + } catch (ex: Throwable) { + log.e(ex, "deleteDatabase failed.") + } + } + + // DB処理を行い、SQLiteDatabaseCorruptExceptionを検出したらDBを削除してリトライする + fun access(block: EmojiCache.Access.() -> T?): T? { + for (nTry in 0 until 3) { + try { + val db = writableDatabase + if (db == null) { + log.e("access[$nTry]: writableDatabase returns null.") + break + } + return EmojiCache.Access(db).block() + } catch (ex: SQLiteDatabaseCorruptException) { + log.e(ex, "access[$nTry]: db corrupt!") + deleteDatabase() + } catch (ex: Throwable) { + log.e(ex, "access[$nTry]: failed.") + break + } + } + return null + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/FavMute.kt b/app/src/main/java/jp/juggler/subwaytooter/table/FavMute.kt index 82ea0a5a..a4bb246e 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/FavMute.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/FavMute.kt @@ -1,70 +1,89 @@ package jp.juggler.subwaytooter.table import android.content.ContentValues -import android.database.Cursor import android.database.sqlite.SQLiteDatabase +import android.provider.BaseColumns import jp.juggler.subwaytooter.api.entity.Acct -import jp.juggler.subwaytooter.global.appDatabase import jp.juggler.util.data.TableCompanion import jp.juggler.util.log.LogCategory -object FavMute : TableCompanion { +class FavMute( + var id: Long = 0L, + val acct: String = "", +) { + companion object : TableCompanion { + private val log = LogCategory("FavMute") + override val table = "fav_mute" + const val COL_ID = BaseColumns._ID + const val COL_ACCT = "acct" - private val log = LogCategory("FavMute") - - override val table = "fav_mute" - const val COL_ID = "_id" - const val COL_ACCT = "acct" - - override fun onDBCreate(db: SQLiteDatabase) { - log.d("onDBCreate!") - db.execSQL( - """create table if not exists $table + override fun onDBCreate(db: SQLiteDatabase) { + log.d("onDBCreate!") + db.execSQL( + """create table if not exists $table ($COL_ID INTEGER PRIMARY KEY ,$COL_ACCT text not null )""".trimIndent() - ) - db.execSQL("create unique index if not exists ${table}_acct on $table($COL_ACCT)") - } + ) + db.execSQL("create unique index if not exists ${table}_acct on $table($COL_ACCT)") + } - override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - if (oldVersion < 22 && newVersion >= 22) { - onDBCreate(db) + override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion < 22 && newVersion >= 22) { + onDBCreate(db) + } } } - fun save(acct: Acct?) { - acct ?: return - try { - val cv = ContentValues() - cv.put(COL_ACCT, acct.ascii) - appDatabase.replace(table, null, cv) - } catch (ex: Throwable) { - log.e(ex, "save failed.") - } - } + class Access(val db: SQLiteDatabase) { - fun delete(acct: Acct) { - try { - appDatabase.delete(table, "$COL_ACCT=?", arrayOf(acct.ascii)) - } catch (ex: Throwable) { - log.e(ex, "delete failed.") - } - } - - fun createCursor(): Cursor { - return appDatabase.query(table, null, null, null, null, null, "$COL_ACCT asc") - } - - val acctSet: HashSet - get() = HashSet().also { dst -> + fun save(acct: Acct?) { + acct ?: return try { - appDatabase.query(table, null, null, null, null, null, null) + val cv = ContentValues() + cv.put(COL_ACCT, acct.ascii) + db.replace(table, null, cv) + } catch (ex: Throwable) { + log.e(ex, "save failed.") + } + } + + fun delete(acct: Acct) { + try { + db.delete(table, "$COL_ACCT=?", arrayOf(acct.ascii)) + } catch (ex: Throwable) { + log.e(ex, "delete failed.") + } + } + + fun listAll() = + db.rawQuery( + "select * from $table order by $COL_ACCT asc", + emptyArray() + )?.use { cursor -> + buildList { + val idxId = cursor.getColumnIndex(COL_ID) + val idxAcct = cursor.getColumnIndex(COL_ACCT) + while (cursor.moveToNext()) { + add( + FavMute( + id = cursor.getLong(idxId), + acct = cursor.getString(idxAcct) + ) + ) + } + } + } ?: emptyList() + + + fun acctSet()= buildSet { + try { + db.query(table, null, null, null, null, null, null) .use { cursor -> val idx_name = cursor.getColumnIndex(COL_ACCT) while (cursor.moveToNext()) { val s = cursor.getString(idx_name) - dst.add(Acct.parse(s)) + add(Acct.parse(s)) } } } catch (ex: Throwable) { @@ -72,12 +91,13 @@ object FavMute : TableCompanion { } } - fun contains(acct: Acct): Boolean = - try { - appDatabase.query(table, null, "$COL_ACCT=?", arrayOf(acct.ascii), null, null, null) - ?.use { it.moveToNext() } - } catch (ex: Throwable) { - log.e(ex, "contains failed.") - null - } ?: false + fun contains(acct: Acct): Boolean = + try { + db.query(table, null, "$COL_ACCT=?", arrayOf(acct.ascii), null, null, null) + ?.use { it.moveToNext() } + } catch (ex: Throwable) { + log.e(ex, "contains failed.") + null + } ?: false + } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/HighlightWord.kt b/app/src/main/java/jp/juggler/subwaytooter/table/HighlightWord.kt index 8ef15888..368f7115 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/HighlightWord.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/HighlightWord.kt @@ -6,21 +6,93 @@ import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.provider.BaseColumns import jp.juggler.subwaytooter.App1 -import jp.juggler.subwaytooter.global.appDatabase import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory -import java.util.concurrent.atomic.AtomicReference -class HighlightWord { +class HighlightWord( + var id: Long = -1L, + var name: String? = null, + var color_bg: Int = 0, + var color_fg: Int = 0, + var sound_type: Int = 0, + var sound_uri: String? = null, + var speech: Int = 0, +) { + constructor(name: String) : this( + name = name, + sound_type = SOUND_TYPE_DEFAULT, + color_fg = -0x10000, + ) + + constructor(src: JsonObject) : this( + id = src.long(COL_ID) ?: -1L, + name = src.stringOrThrow(COL_NAME), + color_bg = src.optInt(COL_COLOR_BG), + color_fg = src.optInt(COL_COLOR_FG), + sound_type = src.optInt(COL_SOUND_TYPE), + sound_uri = src.string(COL_SOUND_URI), + speech = src.optInt(COL_SPEECH), + ) + + fun encodeJson(): JsonObject { + val dst = JsonObject() + dst[COL_ID] = id + dst[COL_NAME] = name + dst[COL_COLOR_BG] = color_bg + dst[COL_COLOR_FG] = color_fg + dst[COL_SOUND_TYPE] = sound_type + dst[COL_SPEECH] = speech + sound_uri?.let { dst[COL_SOUND_URI] = it } + return dst + } + + // ID以外のカラムをContentValuesに格納する + fun toContentValues() = ContentValues().apply { + if (name.isNullOrBlank()) error("HighlightWord.save(): name is empty") + put(COL_NAME, name) + put(COL_TIME_SAVE, System.currentTimeMillis()) + put(COL_COLOR_BG, color_bg) + put(COL_COLOR_FG, color_fg) + put(COL_SOUND_TYPE, sound_type) + put(COL_SOUND_URI, sound_uri.notEmpty()) + put(COL_SPEECH, speech) + } + + @Suppress("MemberVisibilityCanBePrivate") + class ColIdx(cursor: Cursor) { + val idxId = cursor.columnIndexOrThrow(COL_ID) + val idxName = cursor.columnIndexOrThrow(COL_NAME) + val idxColorBg = cursor.columnIndexOrThrow(COL_COLOR_BG) + val idxColorFg = cursor.columnIndexOrThrow(COL_COLOR_FG) + val idxSountType = cursor.columnIndexOrThrow(COL_SOUND_TYPE) + val idxSoundUri = cursor.columnIndexOrThrow(COL_SOUND_URI) + val idxSpeech = cursor.columnIndexOrThrow(COL_SPEECH) + + fun readRow(cursor: Cursor) = HighlightWord( + id = cursor.getLong(idxId), + name = cursor.getString(idxName), + color_bg = cursor.getInt(idxColorBg), + color_fg = cursor.getInt(idxColorFg), + sound_type = cursor.getInt(idxSountType), + sound_uri = cursor.getStringOrNull(idxSoundUri), + speech = cursor.getInt(idxSpeech), + ) + + fun readAll(cursor: Cursor) = buildList { + while (cursor.moveToNext()) { + add(readRow(cursor)) + } + } + + fun readOne(cursor: Cursor) = when { + cursor.moveToNext() -> readRow(cursor) + else -> null + } + } companion object : TableCompanion { - private val log = LogCategory("HighlightWord") - const val SOUND_TYPE_NONE = 0 - const val SOUND_TYPE_DEFAULT = 1 - const val SOUND_TYPE_CUSTOM = 2 - override val table = "highlight_word" const val COL_ID = BaseColumns._ID const val COL_NAME = "name" @@ -31,12 +103,6 @@ class HighlightWord { private const val COL_SOUND_URI = "sound_uri" private const val COL_SPEECH = "speech" - private const val selection_name = "$COL_NAME=?" - private const val selection_speech = "$COL_SPEECH<>0" - private const val selection_id = "$COL_ID=?" - - private val columns_name = arrayOf(COL_NAME) - override fun onDBCreate(db: SQLiteDatabase) { log.d("onDBCreate!") db.execSQL( @@ -69,196 +135,82 @@ class HighlightWord { } } - fun load(name: String): HighlightWord? { - try { - appDatabase.query(table, null, selection_name, arrayOf(name), null, null, null) - .use { cursor -> - if (cursor.moveToNext()) { - return HighlightWord(cursor, ColIdx(cursor)) - } - } - } catch (ex: Throwable) { - log.e(ex, "load failed.") - } - return null + const val SOUND_TYPE_NONE = 0 + const val SOUND_TYPE_DEFAULT = 1 + const val SOUND_TYPE_CUSTOM = 2 + } + + class Access(val db: SQLiteDatabase) { + + fun load(name: String) = try { + db.queryById(table, name, COL_NAME) + ?.use { ColIdx(it).readOne(it) } + } catch (ex: Throwable) { + log.e(ex, "load failed. name=$name") + null } - fun load(id: Long): HighlightWord? { - try { - appDatabase.query( - table, - null, - selection_id, - arrayOf(id.toString()), - null, - null, - null - ).use { cursor -> - if (cursor.moveToNext()) { - return HighlightWord(cursor, ColIdx(cursor)) - } - } - } catch (ex: Throwable) { - log.e(ex, "load failed. id=$id") - } - - return null + fun load(id: Long) = try { + db.queryById(table, id.toString(), COL_ID) + ?.use { ColIdx(it).readOne(it) } + } catch (ex: Throwable) { + log.e(ex, "load failed. id=$id") + null } - fun createCursor(): Cursor { - return appDatabase.query(table, null, null, null, null, null, "$COL_NAME asc") - } + fun listAll() = + db.queryAll(table, "$COL_NAME asc") + ?.use { ColIdx(it).readAll(it) } + ?: emptyList() - val nameSet: WordTrieTree? - get() { - val dst = WordTrieTree() + fun nameSet(): WordTrieTree? = + WordTrieTree().also { dst -> try { - appDatabase.query(table, columns_name, null, null, null, null, null) - .use { cursor -> - val idx_name = cursor.getColumnIndex(COL_NAME) + db.rawQuery("select $COL_NAME from $table", emptyArray()) + ?.use { cursor -> + val idxName = cursor.getColumnIndex(COL_NAME) while (cursor.moveToNext()) { - val s = cursor.getString(idx_name) - dst.add(s) + dst.add(cursor.getString(idxName)) } } } catch (ex: Throwable) { log.e(ex, "nameSet failed.") } + }.takeIf{ it.isNotEmpty } - return if (dst.isEmpty) null else dst - } + fun hasTextToSpeechHighlightWord(): Boolean = try { + (db.rawQuery( + "select $COL_NAME from $table where $COL_SPEECH<>0 limit 1", + emptyArray() + )?.use { it.count } ?: 0) > 0 + } catch (ex: Throwable) { + log.e(ex, "hasTextToSpeechHighlightWord failed.") + false + } - private val hasTextToSpeechHighlightWordCache = AtomicReference(null) - - fun hasTextToSpeechHighlightWord(): Boolean { - synchronized(this) { - var cached = hasTextToSpeechHighlightWordCache.get() - if (cached == null) { - cached = false - try { - appDatabase.query( - table, - columns_name, - selection_speech, - null, - null, - null, - null - ) - .use { cursor -> - while (cursor.moveToNext()) { - cached = true - } - } - } catch (ex: Throwable) { - log.e(ex, "hasTextToSpeechHighlightWord failed.") - } - hasTextToSpeechHighlightWordCache.set(cached) + fun save(context: Context, item: HighlightWord) { + try { + when (val id = item.id) { + -1L -> item.toContentValues().replaceTo(db, table) + .also { item.id = it } + else -> item.toContentValues().updateTo(db, table, id.toString()) } - return cached + } catch (ex: Throwable) { + log.e(ex, "save failed.") } + App1.getAppState(context).enableSpeech() } - } - var id = -1L - var name: String - var color_bg = 0 - var color_fg = 0 - var sound_type = 0 - var sound_uri: String? = null - var speech = 0 - - fun encodeJson(): JsonObject { - val dst = JsonObject() - dst[COL_ID] = id - dst[COL_NAME] = name - dst[COL_COLOR_BG] = color_bg - dst[COL_COLOR_FG] = color_fg - dst[COL_SOUND_TYPE] = sound_type - dst[COL_SPEECH] = speech - sound_uri?.let { dst[COL_SOUND_URI] = it } - return dst - } - - constructor(src: JsonObject) { - this.id = src.long(COL_ID) ?: -1L - this.name = src.stringOrThrow(COL_NAME) - this.color_bg = src.optInt(COL_COLOR_BG) - this.color_fg = src.optInt(COL_COLOR_FG) - this.sound_type = src.optInt(COL_SOUND_TYPE) - this.sound_uri = src.string(COL_SOUND_URI) - this.speech = src.optInt(COL_SPEECH) - } - - constructor(name: String) { - this.name = name - this.sound_type = SOUND_TYPE_DEFAULT - this.color_fg = -0x10000 - } - - class ColIdx(cursor: Cursor) { - val idxId = cursor.columnIndexOrThrow(COL_ID) - val idxName = cursor.columnIndexOrThrow(COL_NAME) - val idxColorBg = cursor.columnIndexOrThrow(COL_COLOR_BG) - val idxColorFg = cursor.columnIndexOrThrow(COL_COLOR_FG) - val idxSountType = cursor.columnIndexOrThrow(COL_SOUND_TYPE) - val idxSoundUri = cursor.columnIndexOrThrow(COL_SOUND_URI) - val idxSpeech = cursor.columnIndexOrThrow(COL_SPEECH) - } - - constructor(cursor: Cursor, colIdx: ColIdx) { - id = cursor.getLong(colIdx.idxId) - name = cursor.getString(colIdx.idxName) - color_bg = cursor.getInt(colIdx.idxColorBg) - color_fg = cursor.getInt(colIdx.idxColorFg) - sound_type = cursor.getInt(colIdx.idxSountType) - sound_uri = cursor.getStringOrNull(colIdx.idxSoundUri) - speech = cursor.getInt(colIdx.idxSpeech) - } - - fun save(context: Context) { - if (name.isEmpty()) error("HighlightWord.save(): name is empty") - - try { - val cv = ContentValues() - cv.put(COL_NAME, name) - cv.put(COL_TIME_SAVE, System.currentTimeMillis()) - cv.put(COL_COLOR_BG, color_bg) - cv.put(COL_COLOR_FG, color_fg) - cv.put(COL_SOUND_TYPE, sound_type) - - val sound_uri = this.sound_uri - if (sound_uri?.isEmpty() != false) { - cv.putNull(COL_SOUND_URI) - } else { - cv.put(COL_SOUND_URI, sound_uri) + fun delete(context: Context, item: HighlightWord) { + try { + db.execSQL( + "delete from $table where $COL_ID=?", + arrayOf(item.id.toString()) + ) + } catch (ex: Throwable) { + log.e(ex, "delete failed.") } - cv.put(COL_SPEECH, speech) - - if (id == -1L) { - id = appDatabase.replace(table, null, cv) - } else { - appDatabase.update(table, cv, selection_id, arrayOf(id.toString())) - } - } catch (ex: Throwable) { - log.e(ex, "save failed.") + App1.getAppState(context).enableSpeech() } - - synchronized(Companion) { - hasTextToSpeechHighlightWordCache.set(null) - } - App1.getAppState(context).enableSpeech() - } - - fun delete(context: Context) { - try { - appDatabase.delete(table, selection_id, arrayOf(id.toString())) - } catch (ex: Throwable) { - log.e(ex, "delete failed.") - } - synchronized(Companion) { - hasTextToSpeechHighlightWordCache.set(null) - } - App1.getAppState(context).enableSpeech() } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/LogData.kt b/app/src/main/java/jp/juggler/subwaytooter/table/LogData.kt index 7c28e3bd..6283ecbd 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/LogData.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/LogData.kt @@ -3,42 +3,56 @@ package jp.juggler.subwaytooter.table import android.database.sqlite.SQLiteDatabase import jp.juggler.util.data.* -object LogData : TableCompanion { - // private const val TAG = "SubwayTooter" +class LogData private constructor() { + companion object : TableCompanion { + // private const val TAG = "SubwayTooter" + override val table = "warning" + private const val COL_TIME = "t" + private const val COL_LEVEL = "l" + private const val COL_CATEGORY = "c" + private const val COL_MESSAGE = "m" - override val table = "warning" - - private const val COL_TIME = "t" - private const val COL_LEVEL = "l" - private const val COL_CATEGORY = "c" - private const val COL_MESSAGE = "m" - - @Suppress("unused") - const val LEVEL_ERROR = 100 - private const val LEVEL_WARNING = 200 - private const val LEVEL_INFO = 300 - private const val LEVEL_VERBOSE = 400 - private const val LEVEL_DEBUG = 500 - private const val LEVEL_HEARTBEAT = 600 - private const val LEVEL_FLOOD = 700 - - override fun onDBCreate(db: SQLiteDatabase) { - db.execSQL( - """create table if not exists $table + override fun onDBCreate(db: SQLiteDatabase) { + db.execSQL( + """create table if not exists $table (_id INTEGER PRIMARY KEY ,$COL_TIME integer not null ,$COL_LEVEL integer not null ,$COL_CATEGORY text not null ,$COL_MESSAGE text not null )""".trimIndent() - ) - db.execSQL("create index if not exists ${table}_time on $table($COL_TIME,$COL_LEVEL)") + ) + db.execSQL("create index if not exists ${table}_time on $table($COL_TIME,$COL_LEVEL)") + } + + override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + } + + @Suppress("unused") + const val LEVEL_ERROR = 100 + private const val LEVEL_WARNING = 200 + private const val LEVEL_INFO = 300 + private const val LEVEL_VERBOSE = 400 + private const val LEVEL_DEBUG = 500 + private const val LEVEL_HEARTBEAT = 600 + private const val LEVEL_FLOOD = 700 + + @Suppress("unused") + private fun getLogLevelString(level: Int): String { + return when { + level >= LEVEL_FLOOD -> "Flood" + level >= LEVEL_HEARTBEAT -> "HeartBeat" + level >= LEVEL_DEBUG -> "Debug" + level >= LEVEL_VERBOSE -> "Verbose" + level >= LEVEL_INFO -> "Info" + level >= LEVEL_WARNING -> "Warning" + else -> "Error" + } + } } - override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - } - -// fun insert(cv : ContentValues, time : Long, level : Int, category : String, message : String) : Long { + class Access + // fun insert(cv : ContentValues, time : Long, level : Int, category : String, message : String) : Long { // try { // Log.d(TAG, "$category: $message") // // try{ @@ -56,17 +70,4 @@ object LogData : TableCompanion { // } // return - 1L // } - - @Suppress("unused") - private fun getLogLevelString(level: Int): String { - return when { - level >= LEVEL_FLOOD -> "Flood" - level >= LEVEL_HEARTBEAT -> "HeartBeat" - level >= LEVEL_DEBUG -> "Debug" - level >= LEVEL_VERBOSE -> "Verbose" - level >= LEVEL_INFO -> "Info" - level >= LEVEL_WARNING -> "Warning" - else -> "Error" - } - } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/MediaShown.kt b/app/src/main/java/jp/juggler/subwaytooter/table/MediaShown.kt index f1421a50..e3f34658 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/MediaShown.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/MediaShown.kt @@ -4,106 +4,114 @@ import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import android.provider.BaseColumns import jp.juggler.subwaytooter.api.entity.TootStatus -import jp.juggler.subwaytooter.global.appDatabase import jp.juggler.util.data.ColumnMeta import jp.juggler.util.data.TableCompanion import jp.juggler.util.data.getBoolean -import jp.juggler.util.data.put import jp.juggler.util.log.LogCategory -object MediaShown : TableCompanion { - private val log = LogCategory("MediaShown") +class MediaShown private constructor() { + companion object : TableCompanion { + private val log = LogCategory("MediaShown") - override val table = "media_shown_misskey" + override val table = "media_shown_misskey" + val COL_ID = BaseColumns._ID + private val COL_HOST = "h" + private val COL_STATUS_ID = "si" + private val COL_SHOWN = "sh" + private val COL_TIME_SAVE = "time_save" - val columnList: ColumnMeta.List = ColumnMeta.List(table, 30).apply { - ColumnMeta(this, 0, BaseColumns._ID, "INTEGER PRIMARY KEY", primary = true) - deleteBeforeCreate = true - createExtra = { - arrayOf( - "create unique index if not exists ${table}_status_id on $table($COL_HOST,$COL_STATUS_ID)", - "create index if not exists ${table}_time_save on $table($COL_TIME_SAVE)", - ) - } - } - private val COL_HOST = ColumnMeta(columnList, 0, "h", "") - private val COL_STATUS_ID = ColumnMeta(columnList, 0, "si", "") - private val COL_SHOWN = ColumnMeta(columnList, 0, "sh", "") - private val COL_TIME_SAVE = ColumnMeta(columnList, 0, "time_save", "") - - private val projection_shown = arrayOf(COL_SHOWN.name) - - override fun onDBCreate(db: SQLiteDatabase) = - columnList.onDBCreate(db) - - override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - columnList.onDBUpgrade(db, oldVersion, newVersion) - - // 特定バージョンを交差したらテーブルを作り直す - if (oldVersion < 30 && newVersion >= 30) { - columnList.onDBCreate(db) - } - } - - fun deleteOld(now: Long) { - try { - val expire = now - 86400000L * 365 - appDatabase.delete(table, "$COL_TIME_SAVE - if (cursor.moveToFirst()) return cursor.getBoolean(COL_SHOWN) + val columnList: ColumnMeta.List = ColumnMeta.List(table, 30).apply { + ColumnMeta(this, 0, COL_ID, "INTEGER PRIMARY KEY") + ColumnMeta(this, 0, COL_HOST, "") + ColumnMeta(this, 0, COL_STATUS_ID, "") + ColumnMeta(this, 0, COL_SHOWN, "") + ColumnMeta(this, 0, COL_TIME_SAVE, "") + deleteBeforeCreate = true + createExtra = { + arrayOf( + "create unique index if not exists ${table}_status_id on $table($COL_HOST,$COL_STATUS_ID)", + "create index if not exists ${table}_time_save on $table($COL_TIME_SAVE)", + ) } - } catch (ex: Throwable) { - log.e(ex, "isShownImpl failed.") } - return defaultValue + + override fun onDBCreate(db: SQLiteDatabase) = + columnList.onDBCreate(db) + + override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + columnList.onDBUpgrade(db, oldVersion, newVersion) + + // 特定バージョンを交差したらテーブルを作り直す + if (oldVersion < 30 && newVersion >= 30) { + columnList.onDBCreate(db) + } + } + + private val projection_shown = arrayOf(COL_SHOWN) } - fun save(uri: String, isShown: Boolean) = - saveImpl(uri, uri, isShown) + class Access(val db: SQLiteDatabase) { - fun isShown(uri: String, defaultValue: Boolean) = - isShownImpl(uri, uri, defaultValue) + fun deleteOld(now: Long) { + try { + val expire = now - 86400000L * 365 + db.delete(table, "$COL_TIME_SAVE + if (cursor.moveToFirst()) return cursor.getBoolean(COL_SHOWN) + } + } catch (ex: Throwable) { + log.e(ex, "isShownImpl failed.") + } + return defaultValue + } + + fun save(uri: String, isShown: Boolean) = + saveImpl(uri, uri, isShown) + + fun isShown(uri: String, defaultValue: Boolean) = + isShownImpl(uri, uri, defaultValue) + + fun save(status: TootStatus, isShown: Boolean) = + saveImpl(status.hostAccessOrOriginal.ascii, status.id.toString(), isShown) + + fun isShown(status: TootStatus, defaultValue: Boolean) = + isShownImpl(status.hostAccessOrOriginal.ascii, status.id.toString(), defaultValue) + } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/MutedApp.kt b/app/src/main/java/jp/juggler/subwaytooter/table/MutedApp.kt index aa4f2dd8..448d993e 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/MutedApp.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/MutedApp.kt @@ -3,107 +3,132 @@ package jp.juggler.subwaytooter.table import android.content.ContentValues import android.database.Cursor import android.database.sqlite.SQLiteDatabase -import jp.juggler.subwaytooter.global.appDatabase import jp.juggler.util.data.TableCompanion +import jp.juggler.util.data.getStringOrNull +import jp.juggler.util.data.queryAll import jp.juggler.util.log.LogCategory -object MutedApp : TableCompanion { +// リスト要素のデータ +class MutedApp( + var id: Long = 0L, + var name: String = "", + var timeSave: Long = 0L, +) { + companion object : TableCompanion { + private val log = LogCategory("MutedApp") + override val table = "app_mute" + private const val COL_ID = "_id" + private const val COL_NAME = "name" + private const val COL_TIME_SAVE = "time_save" - private val log = LogCategory("MutedApp") - - override val table = "app_mute" - const val COL_ID = "_id" - const val COL_NAME = "name" - private const val COL_TIME_SAVE = "time_save" - - override fun onDBCreate(db: SQLiteDatabase) { - log.d("onDBCreate!") - db.execSQL( - """create table if not exists $table + override fun onDBCreate(db: SQLiteDatabase) { + log.d("onDBCreate!") + db.execSQL( + """create table if not exists $table ($COL_ID INTEGER PRIMARY KEY ,$COL_NAME text not null ,$COL_TIME_SAVE integer not null )""" + ) + db.execSQL( + "create unique index if not exists ${table}_name on $table($COL_NAME)" + ) + } + + override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion < 6 && newVersion >= 6) { + onDBCreate(db) + } + } + } + + @Suppress("MemberVisibilityCanBePrivate") + class ColIdx(cursor: Cursor) { + val idxId = cursor.getColumnIndex(COL_ID) + val idxName = cursor.getColumnIndex(COL_NAME) + val idxTimeSave = cursor.getColumnIndex(COL_TIME_SAVE) + + fun readRow(cursor: Cursor) = MutedApp( + id = cursor.getLong(idxId), + name = cursor.getStringOrNull(idxName) ?: "", + timeSave = cursor.getLong(idxTimeSave), ) - db.execSQL( - "create unique index if not exists ${table}_name on $table($COL_NAME)" - ) - } - override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - if (oldVersion < 6 && newVersion >= 6) { - onDBCreate(db) + fun readOne(cursor: Cursor) = when { + cursor.moveToNext() -> readRow(cursor) + else -> null + } + + fun readAll(cursor: Cursor) = buildList { + while (cursor.moveToNext()) { + add(readRow(cursor)) + } } } - fun save(appName: String?) { - if (appName == null) return - try { - val now = System.currentTimeMillis() - - val cv = ContentValues() - cv.put(COL_NAME, appName) - cv.put(COL_TIME_SAVE, now) - appDatabase.replace(table, null, cv) - } catch (ex: Throwable) { - log.e(ex, "save failed.") - } - } - - fun createCursor(): Cursor { - return appDatabase.query(table, null, null, null, null, null, "$COL_NAME asc") - } - - fun delete(name: String) { - try { - appDatabase.delete(table, "$COL_NAME=?", arrayOf(name)) - } catch (ex: Throwable) { - log.e(ex, "delete failed.") - } - } - - // private static final String[] isMuted_projection = new String[]{COL_NAME}; - // private static final String isMuted_where = COL_NAME+"=?"; - // private static final ThreadLocal isMuted_where_arg = new ThreadLocal() { - // @Override protected String[] initialValue() { - // return new String[1]; - // } - // }; - // public static boolean isMuted( String app_name ){ - // if( app_name == null ) return false; - // try{ - // String[] where_arg = isMuted_where_arg.get(); - // where_arg[0] = app_name; - // Cursor cursor = App1.getDB().query( table, isMuted_projection,isMuted_where , where_arg, null, null, null ); - // try{ - // if( cursor.moveToFirst() ){ - // return true; - // } - // }finally{ - // cursor.close(); - // } - // }catch( Throwable ex ){ - // warning.e( ex, "load failed." ); - // } - // return false; - // } - - val nameSet: HashSet - get() { - val dst = HashSet() + class Access(val db: SQLiteDatabase) { + fun save(appName: String?) { + if (appName == null) return try { - appDatabase.query(table, null, null, null, null, null, null) - .use { cursor -> + val now = System.currentTimeMillis() + val cv = ContentValues() + cv.put(COL_NAME, appName) + cv.put(COL_TIME_SAVE, now) + db.replace(table, null, cv) + } catch (ex: Throwable) { + log.e(ex, "save failed.") + } + } + + fun delete(name: String) { + try { + db.delete(table, "$COL_NAME=?", arrayOf(name)) + } catch (ex: Throwable) { + log.e(ex, "delete failed.") + } + } + + fun listAll() = db.queryAll(table, "$COL_NAME asc") + ?.use { ColIdx(it).readAll(it) } + ?: emptyList() + + fun nameSet() = buildSet { + try { + db.rawQuery("select $COL_NAME from $table", emptyArray()) + ?.use { cursor -> val idx_name = cursor.getColumnIndex(COL_NAME) while (cursor.moveToNext()) { - val s = cursor.getString(idx_name) - dst.add(s) + add(cursor.getString(idx_name)) } } } catch (ex: Throwable) { log.e(ex, "nameSet failed.") } - - return dst } + // private static final String[] isMuted_projection = new String[]{COL_NAME}; + // private static final String isMuted_where = COL_NAME+"=?"; + // private static final ThreadLocal isMuted_where_arg = new ThreadLocal() { + // @Override protected String[] initialValue() { + // return new String[1]; + // } + // }; + // public static boolean isMuted( String app_name ){ + // if( app_name == null ) return false; + // try{ + // String[] where_arg = isMuted_where_arg.get(); + // where_arg[0] = app_name; + // Cursor cursor = App1.getDB().query( table, isMuted_projection,isMuted_where , where_arg, null, null, null ); + // try{ + // if( cursor.moveToFirst() ){ + // return true; + // } + // }finally{ + // cursor.close(); + // } + // }catch( Throwable ex ){ + // warning.e( ex, "load failed." ); + // } + // return false; + // } + } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/MutedWord.kt b/app/src/main/java/jp/juggler/subwaytooter/table/MutedWord.kt index f2b84480..a9b5cecd 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/MutedWord.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/MutedWord.kt @@ -3,106 +3,122 @@ package jp.juggler.subwaytooter.table import android.content.ContentValues import android.database.Cursor import android.database.sqlite.SQLiteDatabase -import jp.juggler.subwaytooter.global.appDatabase -import jp.juggler.util.data.TableCompanion -import jp.juggler.util.data.WordTrieTree +import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory -object MutedWord : TableCompanion { +class MutedWord( + var id: Long = 0L, + var name: String = "", + var timeSave: Long = 0L, +) { + companion object : TableCompanion { + private val log = LogCategory("MutedWord") + override val table = "word_mute" + private const val COL_ID = "_id" + private const val COL_NAME = "name" + private const val COL_TIME_SAVE = "time_save" - private val log = LogCategory("MutedWord") - - override val table = "word_mute" - const val COL_ID = "_id" - const val COL_NAME = "name" - private const val COL_TIME_SAVE = "time_save" - - override fun onDBCreate(db: SQLiteDatabase) { - log.d("onDBCreate!") - db.execSQL( - """create table if not exists $table + override fun onDBCreate(db: SQLiteDatabase) { + log.d("onDBCreate!") + db.execSQL( + """create table if not exists $table ($COL_ID INTEGER PRIMARY KEY ,$COL_NAME text not null ,$COL_TIME_SAVE integer not null )""".trimIndent() + ) + db.execSQL("create unique index if not exists ${table}_name on $table(name)") + } + + override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion < 11 && newVersion >= 11) { + onDBCreate(db) + } + } + } + + @Suppress("MemberVisibilityCanBePrivate") + class ColIdx(cursor: Cursor) { + val idxId = cursor.getColumnIndexOrThrow(COL_ID) + val idxName = cursor.getColumnIndexOrThrow(COL_NAME) + val idxTimeSave = cursor.getColumnIndexOrThrow(COL_TIME_SAVE) + fun readRow(cursor: Cursor) = MutedWord( + id = cursor.getLong(idxId), + name = cursor.getStringOrNull(idxName) ?: "", + timeSave = cursor.getLong(idxTimeSave), ) - db.execSQL("create unique index if not exists ${table}_name on $table(name)") - } - override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - if (oldVersion < 11 && newVersion >= 11) { - onDBCreate(db) + fun readAll(cursor: Cursor) = buildList { + while (cursor.moveToNext()) { + add(readRow(cursor)) + } } } - fun save(word: String?) { - if (word == null) return - try { - val now = System.currentTimeMillis() - - val cv = ContentValues() - cv.put(COL_NAME, word) - cv.put(COL_TIME_SAVE, now) - appDatabase.replace(table, null, cv) - } catch (ex: Throwable) { - log.e(ex, "save failed.") - } - } - - fun createCursor(): Cursor { - return appDatabase.query(table, null, null, null, null, null, "$COL_NAME asc") - } - - fun delete(name: String) { - try { - appDatabase.delete(table, "$COL_NAME=?", arrayOf(name)) - } catch (ex: Throwable) { - log.e(ex, "delete failed.") - } - } - - // private static final String[] isMuted_projection = new String[]{COL_NAME}; - // private static final String isMuted_where = COL_NAME+"=?"; - // private static final ThreadLocal isMuted_where_arg = new ThreadLocal() { - // @Override protected String[] initialValue() { - // return new String[1]; - // } - // }; - // public static boolean isMuted( String app_name ){ - // if( app_name == null ) return false; - // try{ - // String[] where_arg = isMuted_where_arg.get(); - // where_arg[0] = app_name; - // Cursor cursor = App1.getDB().query( table, isMuted_projection,isMuted_where , where_arg, null, null, null ); - // try{ - // if( cursor.moveToFirst() ){ - // return true; - // } - // }finally{ - // cursor.close(); - // } - // }catch( Throwable ex ){ - // warning.e( ex, "load failed." ); - // } - // return false; - // } - - val nameSet: WordTrieTree - get() { - val dst = WordTrieTree() + class Access(val db: SQLiteDatabase) { + fun save(word: String?) { try { - appDatabase.query(table, null, null, null, null, null, null) - .use { cursor -> - val idx_name = cursor.getColumnIndex(COL_NAME) + word ?: return + ContentValues().apply { + put(COL_NAME, word) + put(COL_TIME_SAVE, System.currentTimeMillis()) + }.replaceTo(db, table) + } catch (ex: Throwable) { + log.e(ex, "save failed.") + } + } + + fun delete(name: String) { + try { + db.deleteById(table, name, COL_NAME) + } catch (ex: Throwable) { + log.e(ex, "delete failed.") + } + } + + fun nameSet() = WordTrieTree().also { dst -> + try { + db.rawQuery("select $COL_NAME from $table", emptyArray()) + ?.use { cursor -> + val idxName = cursor.getColumnIndex(COL_NAME) while (cursor.moveToNext()) { - val s = cursor.getString(idx_name) - dst.add(s) + dst.add(cursor.getString(idxName)) } } } catch (ex: Throwable) { log.e(ex, "nameSet failed.") } - - return dst } -} + + fun listAll() = + db.queryAll(table, "$COL_NAME asc") + ?.use { ColIdx(it).readAll(it) } + ?: emptyList() + + // private static final String[] isMuted_projection = new String[]{COL_NAME}; + // private static final String isMuted_where = COL_NAME+"=?"; + // private static final ThreadLocal isMuted_where_arg = new ThreadLocal() { + // @Override protected String[] initialValue() { + // return new String[1]; + // } + // }; + // public static boolean isMuted( String app_name ){ + // if( app_name == null ) return false; + // try{ + // String[] where_arg = isMuted_where_arg.get(); + // where_arg[0] = app_name; + // Cursor cursor = App1.getDB().query( table, isMuted_projection,isMuted_where , where_arg, null, null, null ); + // try{ + // if( cursor.moveToFirst() ){ + // return true; + // } + // }finally{ + // cursor.close(); + // } + // }catch( Throwable ex ){ + // warning.e( ex, "load failed." ); + // } + // return false; + // } + } +} \ No newline at end of file diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/NotificationCache.kt b/app/src/main/java/jp/juggler/subwaytooter/table/NotificationCache.kt index e1c6cf10..4e17d9b1 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/NotificationCache.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/NotificationCache.kt @@ -9,13 +9,13 @@ import jp.juggler.subwaytooter.api.TootApiResult import jp.juggler.subwaytooter.api.entity.EntityId import jp.juggler.subwaytooter.api.entity.TootNotification import jp.juggler.subwaytooter.api.entity.TootStatus -import jp.juggler.subwaytooter.global.appDatabase -import jp.juggler.util.* import jp.juggler.util.data.* -import jp.juggler.util.log.* +import jp.juggler.util.log.LogCategory import jp.juggler.util.network.toPostRequestBuilder -class NotificationCache(private val account_db_id: Long) { +class NotificationCache( + private val account_db_id: Long, +) { private var id = -1L @@ -74,26 +74,6 @@ class NotificationCache(private val account_db_id: Long) { private const val KEY_TIME_CREATED_AT = "<>KEY_TIME_CREATED_AT" - fun resetLastLoad(dbId: Long) { - try { - val cv = ContentValues() - cv.put(COL_LAST_LOAD, 0L) - appDatabase.update(table, cv, WHERE_AID, arrayOf(dbId.toString())) - } catch (ex: Throwable) { - log.e(ex, "resetLastLoad(db_id) failed.") - } - } - - fun resetLastLoad() { - try { - val cv = ContentValues() - cv.put(COL_LAST_LOAD, 0L) - appDatabase.update(table, cv, null, null) - } catch (ex: Throwable) { - log.e(ex, "resetLastLoad() failed.") - } - } - fun getEntityOrderId(account: SavedAccount, src: JsonObject): EntityId = if (account.isMisskey) { // 今のMisskeyはIDをIDとして使っても問題ないのだが、 @@ -106,6 +86,12 @@ class NotificationCache(private val account_db_id: Long) { EntityId.mayDefault(src.string("id")) } + fun parseNotificationType(accessInfo: SavedAccount, src: JsonObject): String = + when { + accessInfo.isMisskey -> src.string("type") + else -> src.string("type") + } ?: "?" + private fun makeNotificationUrlMastodon( flags: Int, sinceId: EntityId?, @@ -128,12 +114,29 @@ class NotificationCache(private val account_db_id: Long) { accessInfo.isMisskey -> TootStatus.parseTime(src.string("createdAt")) else -> TootStatus.parseTime(src.string("created_at")) } + } - fun parseNotificationType(accessInfo: SavedAccount, src: JsonObject): String = - when { - accessInfo.isMisskey -> src.string("type") - else -> src.string("type") - } ?: "?" + class Access(val db: SQLiteDatabase) { + + fun resetLastLoad(dbId: Long) { + try { + val cv = ContentValues() + cv.put(COL_LAST_LOAD, 0L) + db.update(table, cv, WHERE_AID, arrayOf(dbId.toString())) + } catch (ex: Throwable) { + log.e(ex, "resetLastLoad(db_id) failed.") + } + } + + fun resetLastLoad() { + try { + val cv = ContentValues() + cv.put(COL_LAST_LOAD, 0L) + db.update(table, cv, null, null) + } catch (ex: Throwable) { + log.e(ex, "resetLastLoad() failed.") + } + } fun deleteCache(dbId: Long) { try { @@ -141,56 +144,79 @@ class NotificationCache(private val account_db_id: Long) { cv.put(COL_ACCOUNT_DB_ID, dbId) cv.put(COL_LAST_LOAD, 0L) cv.putNull(COL_DATA) - appDatabase.replaceOrThrow(table, null, cv) + db.replaceOrThrow(table, null, cv) } catch (ex: Throwable) { log.e(ex, "deleteCache failed.") } } - } - // load into this object - fun load() { - try { - (appDatabase.query( - table, - null, - WHERE_AID, - arrayOf(account_db_id.toString()), - null, - null, - null - ) ?: error("null cursor")).use { cursor -> - if (!cursor.moveToFirst()) error("load: empty cursor.") - // 先にIDとlast_loadを読む - this.id = cursor.getLong(COL_ID) - this.last_load = cursor.getLong(COL_LAST_LOAD) - // データを読む箇所は失敗するかもしれない - cursor.getStringOrNull(COL_DATA) - ?.decodeJsonArray() - ?.objectList() - ?.let { data.addAll(it) } - } - } catch (ex: Throwable) { - if (ex.message?.contains("empty cursor") == true) { - // アカウント追加直後に起きるはず - log.w(ex, "empty cursor. (maybe first loading)") - } else { - log.e(ex, "load failed.") + fun save(item: NotificationCache) { + try { + item.run { + val cv = ContentValues() + cv.put(COL_ACCOUNT_DB_ID, account_db_id) + cv.put(COL_LAST_LOAD, last_load) + cv.put(COL_DATA, data.toJsonArray().toString()) + + val rv = db.replaceOrThrow(table, null, cv) + if (rv != -1L && id == -1L) id = rv + } + } catch (ex: Throwable) { + log.e(ex, "save failed.") } } - } - fun save() { - try { - val cv = ContentValues() - cv.put(COL_ACCOUNT_DB_ID, account_db_id) - cv.put(COL_LAST_LOAD, last_load) - cv.put(COL_DATA, data.toJsonArray().toString()) + // load into item + fun loadInto(item: NotificationCache) { + try { + item.run { + (db.query( + table, + null, + WHERE_AID, + arrayOf(account_db_id.toString()), + null, + null, + null + ) ?: error("null cursor")).use { cursor -> + if (!cursor.moveToFirst()) error("load: empty cursor.") + // 先にIDとlast_loadを読む + this.id = cursor.getLong(COL_ID) + this.last_load = cursor.getLong(COL_LAST_LOAD) + // データを読む箇所は失敗するかもしれない + cursor.getStringOrNull(COL_DATA) + ?.decodeJsonArray() + ?.objectList() + ?.let { data.addAll(it) } + } + } + } catch (ex: Throwable) { + if (ex.message?.contains("empty cursor") == true) { + // アカウント追加直後に起きるはず + log.w(ex, "empty cursor. (maybe first loading)") + } else { + log.e(ex, "load failed.") + } + } + } - val rv = appDatabase.replaceOrThrow(table, null, cv) - if (rv != -1L && id == -1L) id = rv - } catch (ex: Throwable) { - log.e(ex, "save failed.") + fun inject( + nc: NotificationCache, + account: SavedAccount, + list: List, + ) { + try { + val jsonList = list.map { it.json } + jsonList.forEach { item -> + item[KEY_TIME_CREATED_AT] = parseNotificationTime(account, item) + } + nc.data.addAll(jsonList) + nc.normalize(account) + } catch (ex: Throwable) { + log.e(ex, "inject failed.") + } finally { + save(nc) + } } } @@ -245,6 +271,7 @@ class NotificationCache(private val account_db_id: Long) { } suspend fun requestAsync( + dao: Access, client: TootApiClient, account: SavedAccount, flags: Int, @@ -289,7 +316,10 @@ class NotificationCache(private val account_db_id: Long) { val array = result.jsonArray if (array != null) { - account.updateNotificationError(null) + daoAccountNotificationStatus.updateNotificationError( + account.acct, + null, + ) // データをマージする array.objectList().forEach { item -> @@ -310,7 +340,7 @@ class NotificationCache(private val account_db_id: Long) { } catch (ex: Throwable) { log.e(ex, "${account.acct} requestAsync failed.") } finally { - save() + dao.save(this) } } @@ -320,21 +350,6 @@ class NotificationCache(private val account_db_id: Long) { .mapNotNull { getEntityOrderId(account, it).takeIf { id -> !id.isDefault } } .reduceOrNull { a, b -> maxComparable(a, b) } - fun inject(account: SavedAccount, list: List) { - try { - val jsonList = list.map { it.json } - jsonList.forEach { item -> - item[KEY_TIME_CREATED_AT] = parseNotificationTime(account, item) - } - data.addAll(jsonList) - normalize(account) - } catch (ex: Throwable) { - log.e(ex, "inject failed.") - } finally { - save() - } - } - // // // @@ -345,7 +360,7 @@ class NotificationCache(private val account_db_id: Long) { // val cv = ContentValues() // post_id.putTo(cv, COL_POST_ID) // cv.put(COL_POST_TIME, post_time) - // val rows = appDatabase.update(table, cv, WHERE_AID, arrayOf(account_db_id.toString())) + // val rows = db.update(table, cv, WHERE_AID, arrayOf(account_db_id.toString())) // log.d( // "updatePost account_db_id=%s,post=%s,%s last_data=%s,update_rows=%s" // , account_db_id diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/NotificationTracking.kt b/app/src/main/java/jp/juggler/subwaytooter/table/NotificationTracking.kt index 6a1c87dd..cdd4570e 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/NotificationTracking.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/NotificationTracking.kt @@ -3,12 +3,13 @@ package jp.juggler.subwaytooter.table import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import android.provider.BaseColumns +import jp.juggler.subwaytooter.api.entity.Acct import jp.juggler.subwaytooter.api.entity.EntityId import jp.juggler.subwaytooter.api.entity.putMayNull -import jp.juggler.subwaytooter.global.appDatabase import jp.juggler.util.data.TableCompanion import jp.juggler.util.data.getLong import jp.juggler.util.data.minComparable +import jp.juggler.util.data.replaceTo import jp.juggler.util.log.LogCategory import java.util.concurrent.ConcurrentHashMap @@ -51,53 +52,6 @@ class NotificationTracking { dirty = true; field = value } - fun save(acct: String) { - - if (dirty) { - try { - val cv = ContentValues() - cv.put(COL_ACCOUNT_DB_ID, accountDbId) - cv.put(COL_NOTIFICATION_TYPE, notificationType) - nid_read.putMayNull(cv, COL_NID_READ) - nid_show.putMayNull(cv, COL_NID_SHOW) - post_id.putMayNull(cv, COL_POST_ID) - cv.put(COL_POST_TIME, post_time) - - val rv = appDatabase.replaceOrThrow(table, null, cv) - if (rv != -1L && id == -1L) id = rv - - log.d("$acct/$notificationType save. post=($post_id,$post_time)") - dirty = false - clearCache(accountDbId, notificationType) - } catch (ex: Throwable) { - log.e(ex, "save failed.") - } - } - } - - fun updatePost(postId: EntityId, postTime: Long) { - this.post_id = postId - this.post_time = postTime - if (dirty) { - try { - val cv = ContentValues() - postId.putTo(cv, COL_POST_ID) - cv.put(COL_POST_TIME, postTime) - val rows = appDatabase.update( - table, - cv, - WHERE_AID, - arrayOf(accountDbId.toString(), notificationType) - ) - log.d("updatePost account_db_id=$accountDbId, nt=$notificationType, post=$postId,$postTime update_rows=$rows") - dirty = false - clearCache(accountDbId, notificationType) - } catch (ex: Throwable) { - log.e(ex, "updatePost failed.") - } - } - } - companion object : TableCompanion { private val log = LogCategory("NotificationTracking") @@ -182,8 +136,6 @@ class NotificationTracking { } } - ///////////////////////////////////////////////////////////////////////////////// - private val cache = ConcurrentHashMap>() @@ -215,29 +167,42 @@ class NotificationTracking { ///////////////////////////////////////////////////////////////////////////////// private const val WHERE_AID = "$COL_ACCOUNT_DB_ID=? and $COL_NOTIFICATION_TYPE=?" + } - fun load(acct: String, accountDbId: Long, notificationType: String): NotificationTracking { - loadCache(accountDbId, notificationType)?.let { dst -> - if (!dst.dirty) { + fun toContentValues() = ContentValues().apply { + put(COL_ACCOUNT_DB_ID, accountDbId) + put(COL_NOTIFICATION_TYPE, notificationType) + nid_read.putMayNull(this, COL_NID_READ) + nid_show.putMayNull(this, COL_NID_SHOW) + post_id.putMayNull(this, COL_POST_ID) + put(COL_POST_TIME, post_time) + } + + class Access(val db: SQLiteDatabase) { + + fun load( + // ログ出力だけに使う + acct: Acct, + accountDbId: Long, + notificationType: String, + ): NotificationTracking { + loadCache(accountDbId, notificationType) + ?.takeIf { !it.dirty } + ?.let { log.d( - "$acct/$notificationType load-cached. post=(${dst.post_id},${dst.post_time}), read=${dst.nid_read}, show=${dst.nid_show}" + "${acct.pretty}/$notificationType load-cached. post=(${it.post_id},${it.post_time}), read=${it.nid_read}, show=${it.nid_show}" ) - return dst + return it } - } + val whereArgs = arrayOf(accountDbId.toString(), notificationType) val dst = NotificationTracking() dst.accountDbId = accountDbId dst.notificationType = notificationType try { - appDatabase.query( - table, - null, - WHERE_AID, - arrayOf(accountDbId.toString(), notificationType), - null, - null, - null + db.rawQuery( + "select * from $table where $COL_ACCOUNT_DB_ID=? and $COL_NOTIFICATION_TYPE=?", + whereArgs, )?.use { cursor -> if (cursor.moveToFirst()) { dst.id = cursor.getLong(COL_ID) @@ -261,39 +226,31 @@ class NotificationTracking { log.i("$acct/$notificationType read>show! clip to $show") val cv = ContentValues() show.putTo(cv, COL_NID_READ) //変数名とキー名が異なるのに注意 - val where_args = - arrayOf(accountDbId.toString(), notificationType) - appDatabase.update(table, cv, WHERE_AID, where_args) + db.update(table, cv, WHERE_AID, whereArgs) } } } log.d( - "$acct/$notificationType load. post=(${dst.post_id},${dst.post_time}), read=${dst.nid_read}, show=${dst.nid_show}" + "${acct.pretty}/$notificationType load. post=(${dst.post_id},${dst.post_time}), read=${dst.nid_read}, show=${dst.nid_show}" ) saveCache(accountDbId, notificationType, dst) } } } catch (ex: Throwable) { - log.e(ex, "load failed.") + log.e(ex, "${acct.pretty} load failed.") } finally { dst.dirty = false } - return dst } fun updateRead(accountDbId: Long, notificationType: String) { try { - val where_args = arrayOf(accountDbId.toString(), notificationType) - appDatabase.query( - table, - arrayOf(COL_NID_SHOW, COL_NID_READ), - WHERE_AID, - where_args, - null, - null, - null + val whereArgs = arrayOf(accountDbId.toString(), notificationType) + db.rawQuery( + "select $COL_NID_SHOW, $COL_NID_READ from $table where $COL_ACCOUNT_DB_ID=? and $COL_NOTIFICATION_TYPE=?", + whereArgs, )?.use { cursor -> when { !cursor.moveToFirst() -> log.e("updateRead[$accountDbId,$notificationType]: can't find the data row.") @@ -312,7 +269,7 @@ class NotificationTracking { log.i("updateRead[$accountDbId,$notificationType]: update nid_read as $nid_show...") val cv = ContentValues() nid_show.putTo(cv, COL_NID_READ) //変数名とキー名が異なるのに注意 - appDatabase.update(table, cv, WHERE_AID, where_args) + db.update(table, cv, WHERE_AID, whereArgs) clearCache(accountDbId, notificationType) } } @@ -329,7 +286,7 @@ class NotificationTracking { val cv = ContentValues() cv.putNull(COL_POST_ID) cv.put(COL_POST_TIME, 0) - appDatabase.update(table, cv, null, null) + db.update(table, cv, null, null) cache.clear() } catch (ex: Throwable) { log.e(ex, "resetPostAll failed.") @@ -340,11 +297,53 @@ class NotificationTracking { fun resetTrackingState(accountDbId: Long?) { accountDbId ?: return try { - appDatabase.delete(table, "$COL_ACCOUNT_DB_ID=?", arrayOf(accountDbId.toString())) + db.delete(table, "$COL_ACCOUNT_DB_ID=?", arrayOf(accountDbId.toString())) cache.remove(accountDbId) } catch (ex: Throwable) { log.e(ex, "resetTrackingState failed.") } } + + fun save( + // ログ出力だけに使う + acct: Acct, + item: NotificationTracking, + ) { + try { + if (!item.dirty) return + val rowId = item.toContentValues() + .replaceTo(db, table) + if (item.id == -1L) item.id = rowId + + log.d("${acct.pretty}/${item.notificationType} save. post=(${item.post_id},${item.post_time})") + item.dirty = false + clearCache(item.accountDbId, item.notificationType) + } catch (ex: Throwable) { + log.e(ex, "save failed.") + } + } + + fun updatePost(postId: EntityId, postTime: Long, item: NotificationTracking) { + try { + item.post_id = postId + item.post_time = postTime + if (!item.dirty) return + val cv = item.toContentValues().apply { + postId.putTo(this, COL_POST_ID) + put(COL_POST_TIME, postTime) + } + val rows = db.update( + table, + cv, + WHERE_AID, + arrayOf(item.accountDbId.toString(), item.notificationType) + ) + log.d("updatePost account_db_id=${item.accountDbId}, nt=${item.notificationType}, post=${postId},${postTime} update_rows=${rows}") + item.dirty = false + clearCache(item.accountDbId, item.notificationType) + } catch (ex: Throwable) { + log.e(ex, "updatePost failed.") + } + } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/PostDraft.kt b/app/src/main/java/jp/juggler/subwaytooter/table/PostDraft.kt index 84f172c2..494ef6a8 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/PostDraft.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/PostDraft.kt @@ -5,35 +5,16 @@ import android.content.ContentValues import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.provider.BaseColumns -import jp.juggler.subwaytooter.global.appDatabase import jp.juggler.util.* -import jp.juggler.util.data.JsonObject -import jp.juggler.util.data.TableCompanion -import jp.juggler.util.data.decodeJsonObject -import jp.juggler.util.data.digestSHA256Hex +import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory -class PostDraft { - - var id: Long = 0 - var time_save: Long = 0 - var json: JsonObject? = null - var hash: String? = null - - fun delete() { - try { - appDatabase.delete(table, "$COL_ID=?", arrayOf(id.toString())) - } catch (ex: Throwable) { - log.e(ex, "delete failed.") - } - } - - class ColIdx(cursor: Cursor) { - val idx_id = cursor.getColumnIndex(COL_ID) - val idx_time_save = cursor.getColumnIndex(COL_TIME_SAVE) - val idx_json = cursor.getColumnIndex(COL_JSON) - val idx_hash = cursor.getColumnIndex(COL_HASH) - } +class PostDraft( + var id: Long = 0, + var time_save: Long = 0, + var json: JsonObject? = null, + var hash: String? = null, +) { companion object : TableCompanion { @@ -64,12 +45,33 @@ class PostDraft { onDBCreate(db) } } + } + + class ColIdx(cursor: Cursor) { + private val idxId = cursor.getColumnIndex(COL_ID) + private val idxTimeSave = cursor.getColumnIndex(COL_TIME_SAVE) + private val idxJson = cursor.getColumnIndex(COL_JSON) + private val idxHash = cursor.getColumnIndex(COL_HASH) + fun readRow(cursor: Cursor) = PostDraft( + id = cursor.getLong(idxId), + time_save = cursor.getLong(idxTimeSave), + hash = cursor.getString(idxHash), + json = try { + cursor.getString(idxJson).decodeJsonObject() + } catch (ex: Throwable) { + log.e(ex, "loadFromCursor failed.") + JsonObject() + } + ) + } + + class Access(val db: SQLiteDatabase) { private fun deleteOld(now: Long) { try { // 古いデータを掃除する val expire = now - 86400000L * 30 - appDatabase.delete(table, "$COL_TIME_SAVE if (cursor.moveToNext()) { val count = cursor.getInt(0) @@ -117,39 +127,19 @@ class PostDraft { // caller must close the cursor @SuppressLint("Recycle") - fun createCursor(): Cursor? = - try { - appDatabase.query( - table, - null, - null, - null, - null, - null, - "$COL_TIME_SAVE desc" - ) - } catch (ex: Throwable) { - log.e(ex, "createCursor failed.") - null - } + fun createCursor(): Cursor = + db.queryAll(table, "$COL_TIME_SAVE desc")!! - fun loadFromCursor(cursor: Cursor, colIdxArg: ColIdx?, position: Int): PostDraft? { - return if (!cursor.moveToPosition(position)) { + fun loadFromCursor( + cursor: Cursor, + colIdxArg: ColIdx?, + position: Int, + ): PostDraft? = when { + cursor.moveToPosition(position) -> + (colIdxArg ?: ColIdx(cursor)).readRow(cursor) + else -> { log.d("loadFromCursor: move failed. position=$position") null - } else { - PostDraft().also { dst -> - val colIdx = colIdxArg ?: ColIdx(cursor) - dst.id = cursor.getLong(colIdx.idx_id) - dst.time_save = cursor.getLong(colIdx.idx_time_save) - dst.hash = cursor.getString(colIdx.idx_hash) - dst.json = try { - cursor.getString(colIdx.idx_json).decodeJsonObject() - } catch (ex: Throwable) { - log.e(ex, "loadFromCursor failed.") - JsonObject() - } - } } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/PushMessage.kt b/app/src/main/java/jp/juggler/subwaytooter/table/PushMessage.kt new file mode 100644 index 00000000..ab1cdff9 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/table/PushMessage.kt @@ -0,0 +1,199 @@ +package jp.juggler.subwaytooter.table + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.provider.BaseColumns +import androidx.room.* +import jp.juggler.util.* +import jp.juggler.util.data.* +import jp.juggler.util.log.LogCategory +import java.util.concurrent.TimeUnit + +data class PushMessage( + // DBの主ID + var id: Long = 0L, + // 通知を受け取るアカウントのacct。通知のタイトルでもある + var loginAcct: String? = null, + // 通知情報に含まれるタイムスタンプ + var timestamp: Long = System.currentTimeMillis(), + // 通知を受信/保存した時刻 + var timeSave: Long = System.currentTimeMillis(), + // 通知を開いた/削除した時刻 + var timeDismiss: Long = 0L, + // 通知ID。(loginAcct + notificationId) で重複排除する。 + var notificationId: String? = null, + // 通知の種別。小アイコン、アクセント色、Misskeyの文言に影響する + var notificationType: String? = null, + // 通知表示の本文。 + var text: String? = null, + // 小アイコンURL。昔のMastodonはバッジ画像が提供されていた。 + var iconSmall: String? = null, + // 大アイコンURL。通知の原因となったユーザのアイコン画像。 + var iconLarge: String? = null, + // WebPushで送られたJSONデータ + var messageJson: JsonObject? = null, + // WebPushのデコードに使うJSONデータ + var headerJson: JsonObject? = null, + // アプリサーバから送られてきたバイナリデータ + var rawBody: ByteArray? = null, +) { + companion object : TableCompanion { + private val log = LogCategory("PushMessage") + const val TABLE = "push_message" + override val table = TABLE + private const val COL_ID = BaseColumns._ID + private const val COL_LOGIN_ACCT = "login_acct" + private const val COL_TIMESTAMP = "timestamp" + private const val COL_TIME_SAVE = "time_save" + private const val COL_TIME_DISMISS = "time_dismiss" + private const val COL_NOTIFICATION_ID = "notification_id" + private const val COL_NOTIFICATION_TYPE = "notification_type" + private const val COL_TEXT = "text" + private const val COL_ICON_SMALL = "icon_small" + private const val COL_ICON_LARGE = "icon_large" + private const val COL_MESSAGE_JSON = "message_json" + private const val COL_HEADER_JSON = "header_json" + private const val COL_RAW_BODY = "raw_body" + + val columnList = ColumnMeta.List(TABLE, initialVersion = 65).apply { + deleteBeforeCreate = true + ColumnMeta(this, 0, COL_ID, ColumnMeta.TS_INT_PRIMARY_KEY_NOT_NULL) + ColumnMeta(this, 0, COL_LOGIN_ACCT, ColumnMeta.TS_TEXT_NULL) + ColumnMeta(this, 0, COL_TIMESTAMP, ColumnMeta.TS_ZERO) + ColumnMeta(this, 0, COL_TIME_SAVE, ColumnMeta.TS_ZERO) + ColumnMeta(this, 0, COL_TIME_DISMISS, ColumnMeta.TS_ZERO) + ColumnMeta(this, 0, COL_NOTIFICATION_ID, ColumnMeta.TS_TEXT_NULL) + ColumnMeta(this, 0, COL_NOTIFICATION_TYPE, ColumnMeta.TS_TEXT_NULL) + ColumnMeta(this, 0, COL_TEXT, ColumnMeta.TS_TEXT_NULL) + ColumnMeta(this, 0, COL_ICON_SMALL, ColumnMeta.TS_TEXT_NULL) + ColumnMeta(this, 0, COL_ICON_LARGE, ColumnMeta.TS_TEXT_NULL) + ColumnMeta(this, 0, COL_MESSAGE_JSON, ColumnMeta.TS_TEXT_NULL) + ColumnMeta(this, 0, COL_HEADER_JSON, ColumnMeta.TS_TEXT_NULL) + ColumnMeta(this, 0, COL_RAW_BODY, ColumnMeta.TS_BLOB_NULL) + } + + override fun onDBCreate(db: SQLiteDatabase) { + columnList.onDBCreate(db) + } + + override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion < 65 && newVersion >= 65) { + onDBCreate(db) + } + columnList.onDBUpgrade(db, oldVersion, newVersion) + } + } + + @Suppress("MemberVisibilityCanBePrivate") + class ColIdx(cursor: Cursor) { + val idxId = cursor.getColumnIndex(COL_ID) + val idxLoginAcct = cursor.getColumnIndex(COL_LOGIN_ACCT) + val idxTimestamp = cursor.getColumnIndex(COL_TIMESTAMP) + val idxTimeSave = cursor.getColumnIndex(COL_TIME_SAVE) + val idxTimeDismiss = cursor.getColumnIndex(COL_TIME_DISMISS) + val idxNotificationId = cursor.getColumnIndex(COL_NOTIFICATION_ID) + val idxNotificationType = cursor.getColumnIndex(COL_NOTIFICATION_TYPE) + val idxText = cursor.getColumnIndex(COL_TEXT) + val idxIconSmall = cursor.getColumnIndex(COL_ICON_SMALL) + val idxIconLarge = cursor.getColumnIndex(COL_ICON_LARGE) + val idxMessageJson = cursor.getColumnIndex(COL_MESSAGE_JSON) + val idxHeaderJson = cursor.getColumnIndex(COL_HEADER_JSON) + val idxRawBody = cursor.getColumnIndex(COL_RAW_BODY) + + fun readRow(cursor: Cursor) = + try { + PushMessage( + id = cursor.getLong(idxId), + loginAcct = cursor.getStringOrNull(idxLoginAcct), + timestamp = cursor.getLong(idxTimestamp), + timeSave = cursor.getLong(idxTimeSave), + timeDismiss = cursor.getLong(idxTimeDismiss), + notificationId = cursor.getStringOrNull(idxNotificationId), + notificationType = cursor.getStringOrNull(idxNotificationType), + text = cursor.getStringOrNull(idxText), + iconSmall = cursor.getStringOrNull(idxIconSmall), + iconLarge = cursor.getStringOrNull(idxIconLarge), + messageJson = cursor.getStringOrNull(idxMessageJson)?.decodeJsonObject(), + headerJson = cursor.getStringOrNull(idxHeaderJson)?.decodeJsonObject(), + rawBody = cursor.getBlobOrNull(idxRawBody), + ) + } catch (ex: Throwable) { + log.e("readRow failed.") + null + } + } + + // ID以外のカラムをContentValuesに変換する + fun toContentValues() = ContentValues().apply { + put(COL_LOGIN_ACCT, loginAcct) + put(COL_TIMESTAMP, timestamp) + put(COL_TIME_SAVE, timeSave) + put(COL_TIME_DISMISS, timeDismiss) + put(COL_NOTIFICATION_ID, notificationId) + put(COL_NOTIFICATION_TYPE, notificationType) + put(COL_TEXT, text) + put(COL_ICON_SMALL, iconSmall) + put(COL_ICON_LARGE, iconLarge) + put(COL_MESSAGE_JSON, messageJson?.toString()) + put(COL_HEADER_JSON, headerJson?.toString()) + put(COL_RAW_BODY, rawBody) + } + + class Access(val db: SQLiteDatabase) { + // return id of new row + fun replace(item: PushMessage) = + item.toContentValues().replaceTo(db, TABLE).also { item.id = it } + + fun update(vararg items: PushMessage) = + items.sumOf { it.toContentValues().updateTo(db, TABLE, it.id.toString()) } + + fun delete(id: Long) = db.deleteById(TABLE, id.toString()) + + fun save(a: PushMessage): Long { + when (a.id) { + 0L -> a.id = replace(a) + else -> update(a) + } + return a.id + } + + private fun Cursor.readOne() = when (moveToNext()) { + true -> ColIdx(this).readRow(this) + else -> null + } + + fun find(messageId: Long): PushMessage? = + db.rawQuery( + "select * from $TABLE where $COL_ID=?", + arrayOf(messageId.toString()) + )?.use { it.readOne() } + + fun dismiss(messageId: Long) { + val pm = find(messageId) ?: return + if (pm.timeDismiss == 0L) { + pm.timeDismiss = System.currentTimeMillis() + update(pm) + } + } + + fun sweepOld(now: Long) { + try { + val expire = now - TimeUnit.DAYS.toMillis(30) + db.execSQL("delete from $TABLE where $COL_TIME_SAVE < $expire") + } catch (ex: Throwable) { + log.e(ex, "sweep failed.") + } + } + // @Query("select * from $TABLE where $COL_MESSAGE_DB_ID=:messageId") + // abstract fun findFlow(messageId: Long): Flow + // + // @Query("select * from $TABLE order by $COL_MESSAGE_DB_ID desc") + // abstract fun listFlow(): List + } + + override fun hashCode() = if (id == 0L) super.hashCode() else id.hashCode() + + override fun equals(other: Any?) = + id == (other as? PushMessage)?.id +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/SavedAccount.kt b/app/src/main/java/jp/juggler/subwaytooter/table/SavedAccount.kt index 46712e5f..6c2eb9ae 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/SavedAccount.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/SavedAccount.kt @@ -11,9 +11,9 @@ import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.auth.Auth2Result import jp.juggler.subwaytooter.api.auth.AuthBase import jp.juggler.subwaytooter.api.entity.* -import jp.juggler.subwaytooter.global.appDatabase import jp.juggler.subwaytooter.notification.checkNotificationImmediate import jp.juggler.subwaytooter.notification.checkNotificationImmediateAll +import jp.juggler.subwaytooter.pref.lazyContext import jp.juggler.subwaytooter.util.LinkHelper import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory @@ -33,7 +33,7 @@ class SavedAccount( apDomainArg: String? = null, var token_info: JsonObject? = null, var loginAccount: TootAccount? = null, // 疑似アカウントではnull - override val misskeyVersion: Int = 0, + override var misskeyVersion: Int = 0, ) : LinkHelper { // SavedAccountのロード時にhostを供給する必要があった @@ -84,9 +84,9 @@ class SavedAccount( var max_toot_chars = 0 - var lastNotificationError: String? = null - var last_subscription_error: String? = null - var last_push_endpoint: String? = null + private var lastNotificationError: String? = null + private var last_subscription_error: String? = null + private var last_push_endpoint: String? = null var image_resize: String? = null var image_max_megabytes: String? = null @@ -94,9 +94,9 @@ class SavedAccount( var push_policy: String? = null - private val extraJson = JsonObject() + private var extraJson = JsonObject() - private val jsonDelegates = JsonDelegates(extraJson) + private val jsonDelegates = JsonDelegates { extraJson } @JsonPropInt("movieTranscodeMode", 0) var movieTranscodeMode by jsonDelegates.int @@ -117,7 +117,7 @@ class SavedAccount( var notification_status_reference by jsonDelegates.boolean init { - log.i("afterAccountVerify sa.ctor acctArg=$acctArg") + log.i("ctor acctArg $acctArg") val tmpAcct = Acct.parse(acctArg) this.username = tmpAcct.username @@ -207,14 +207,8 @@ class SavedAccount( image_max_megabytes = cursor.getStringOrNull(COL_IMAGE_MAX_MEGABYTES) movie_max_megabytes = cursor.getStringOrNull(COL_MOVIE_MAX_MEGABYTES) push_policy = cursor.getStringOrNull(COL_PUSH_POLICY) - try { - cursor.getStringOrNull(COL_EXTRA_JSON) - ?.decodeJsonObject() - ?.entries - ?.forEach { extraJson[it.key] = it.value } - } catch (ex: Throwable) { - log.e(ex, "ctor failed.") - } + cursor.getStringOrNull(COL_EXTRA_JSON)?.decodeJsonObject() + ?.let { extraJson = it } } val isNA: Boolean @@ -223,127 +217,6 @@ class SavedAccount( val isPseudo: Boolean get() = username == "?" - fun delete() { - try { - appDatabase.delete(table, "$COL_ID=?", arrayOf(db_id.toString())) - } catch (ex: Throwable) { - log.e(ex, "SavedAccount.delete failed.") - errorEx(ex, "SavedAccount.delete failed.") - } - } - - fun updateTokenInfo(auth2Result: Auth2Result) { - if (db_id == INVALID_DB_ID) error("updateTokenInfo: missing db_id") - - this.token_info = auth2Result.tokenJson - this.loginAccount = auth2Result.tootAccount - - ContentValues().apply { - put(COL_TOKEN, auth2Result.tokenJson.toString()) - put(COL_ACCOUNT, auth2Result.accountJson.toString()) - put(COL_MISSKEY_VERSION, auth2Result.tootInstance.misskeyVersionMajor) - }.let { appDatabase.update(table, it, "$COL_ID=?", arrayOf(db_id.toString())) } - } - - fun saveSetting() { - - if (db_id == INVALID_DB_ID) error("saveSetting: missing db_id") - - ContentValues().apply { - put(COL_VISIBILITY, visibility.id.toString()) - - put(COL_DONT_HIDE_NSFW, dont_hide_nsfw) - put(COL_DONT_SHOW_TIMEOUT, dont_show_timeout) - put(COL_NOTIFICATION_MENTION, notification_mention) - put(COL_NOTIFICATION_BOOST, notification_boost) - put(COL_NOTIFICATION_FAVOURITE, notification_favourite) - put(COL_NOTIFICATION_FOLLOW, notification_follow) - put(COL_NOTIFICATION_FOLLOW_REQUEST, notification_follow_request) - put(COL_NOTIFICATION_REACTION, notification_reaction) - put(COL_NOTIFICATION_VOTE, notification_vote) - put(COL_NOTIFICATION_POST, notification_post) - put(COL_NOTIFICATION_UPDATE, notification_update) - - put(COL_CONFIRM_BOOST, confirm_boost) - put(COL_CONFIRM_FAVOURITE, confirm_favourite) - put(COL_CONFIRM_UNBOOST, confirm_unboost) - put(COL_CONFIRM_UNFAVOURITE, confirm_unfavourite) - put(COL_CONFIRM_FOLLOW, confirm_follow) - put(COL_CONFIRM_FOLLOW_LOCKED, confirm_follow_locked) - put(COL_CONFIRM_UNFOLLOW, confirm_unfollow) - put(COL_CONFIRM_POST, confirm_post) - put(COL_CONFIRM_REACTION, confirm_reaction) - put(COL_CONFIRM_UNBOOKMARK, confirm_unbookmark) - - put(COL_SOUND_URI, sound_uri) - put(COL_DEFAULT_TEXT, default_text) - - put(COL_DEFAULT_SENSITIVE, default_sensitive) - put(COL_EXPAND_CW, expand_cw) - put(COL_MAX_TOOT_CHARS, max_toot_chars) - - put(COL_IMAGE_RESIZE, image_resize) - put(COL_IMAGE_MAX_MEGABYTES, image_max_megabytes) - put(COL_MOVIE_MAX_MEGABYTES, movie_max_megabytes) - put(COL_PUSH_POLICY, push_policy) - put(COL_EXTRA_JSON, extraJson.toString()) - - // 以下のデータはUIからは更新しない - // notification_tag - // register_key - }.let { appDatabase.update(table, it, "$COL_ID=?", arrayOf(db_id.toString())) } - } - - // onResumeの時に設定を読み直す - fun reloadSetting(context: Context, newData: SavedAccount? = null) { - - if (db_id == INVALID_DB_ID) error("SavedAccount.reloadSetting missing db_id") - - // DBから削除されてるかもしれない - val b = newData ?: loadAccount(context, db_id) ?: return - - this.visibility = b.visibility - this.confirm_boost = b.confirm_boost - this.confirm_favourite = b.confirm_favourite - this.confirm_unboost = b.confirm_unboost - this.confirm_unfavourite = b.confirm_unfavourite - this.confirm_post = b.confirm_post - this.confirm_reaction = b.confirm_reaction - this.confirm_unbookmark = b.confirm_unbookmark - - this.dont_hide_nsfw = b.dont_hide_nsfw - this.dont_show_timeout = b.dont_show_timeout - this.token_info = b.token_info - this.notification_mention = b.notification_mention - this.notification_boost = b.notification_boost - this.notification_favourite = b.notification_favourite - this.notification_follow = b.notification_follow - this.notification_follow_request = b.notification_follow_request - this.notification_reaction = b.notification_reaction - this.notification_vote = b.notification_vote - this.notification_post = b.notification_post - this.notification_update = b.notification_update - this.notification_status_reference = b.notification_status_reference - - this.notification_tag = b.notification_tag - this.default_text = b.default_text - this.default_sensitive = b.default_sensitive - this.expand_cw = b.expand_cw - - this.sound_uri = b.sound_uri - - this.image_resize = b.image_resize - this.image_max_megabytes = b.image_max_megabytes - this.movie_max_megabytes = b.movie_max_megabytes - this.push_policy = b.push_policy - - this.movieTranscodeMode = b.movieTranscodeMode - this.movieTranscodeBitrate = b.movieTranscodeBitrate - this.movieTranscodeFramerate = b.movieTranscodeFramerate - this.movieTranscodeSquarePixels = b.movieTranscodeSquarePixels - this.lang = b.lang - } - fun getFullAcct(who: TootAccount?) = getFullAcct(who?.acct) fun isRemoteUser(who: TootAccount): Boolean = !isLocalUser(who.acct) @@ -381,8 +254,105 @@ class SavedAccount( private val log = LogCategory("SavedAccount") override val table = "access_info" + private const val COL_ID = BaseColumns._ID + private const val COL_HOST = "h" + private const val COL_DOMAIN = "d" + private const val COL_USER = "u" + private const val COL_ACCOUNT = "a" + private const val COL_TOKEN = "t" + private const val COL_VISIBILITY = "visibility" + private const val COL_CONFIRM_BOOST = "confirm_boost" + private const val COL_DONT_HIDE_NSFW = "dont_hide_nsfw" + private const val COL_NOTIFICATION_MENTION = "notification_mention" + private const val COL_NOTIFICATION_BOOST = "notification_boost" + private const val COL_NOTIFICATION_FAVOURITE = "notification_favourite" + private const val COL_NOTIFICATION_FOLLOW = "notification_follow" + private const val COL_NOTIFICATION_FOLLOW_REQUEST = "notification_follow_request" + private const val COL_NOTIFICATION_REACTION = "notification_reaction" + private const val COL_NOTIFICATION_VOTE = "notification_vote" + private const val COL_NOTIFICATION_POST = "notification_post" + private const val COL_NOTIFICATION_UPDATE = "notification_update" + private const val COL_CONFIRM_FOLLOW = "confirm_follow" + private const val COL_CONFIRM_FOLLOW_LOCKED = "confirm_follow_locked" + private const val COL_CONFIRM_UNFOLLOW = "confirm_unfollow" + private const val COL_CONFIRM_POST = "confirm_post" + private const val COL_CONFIRM_FAVOURITE = "confirm_favourite" + private const val COL_CONFIRM_UNBOOST = "confirm_unboost" + private const val COL_CONFIRM_UNFAVOURITE = "confirm_unfavourite" + private const val COL_CONFIRM_REACTION = "confirm_reaction" + private const val COL_CONFIRM_UNBOOKMARK = "confirm_unbookmark" + + // スキーマ13から + const val COL_NOTIFICATION_TAG = "notification_server" + const val COL_REGISTER_KEY = "register_key" + const val COL_REGISTER_TIME = "register_time" + private const val COL_SOUND_URI = "sound_uri" + private const val COL_DONT_SHOW_TIMEOUT = "dont_show_timeout" + private const val COL_DEFAULT_TEXT = "default_text" + private const val COL_MISSKEY_VERSION = "is_misskey" + private const val COL_DEFAULT_SENSITIVE = "default_sensitive" + private const val COL_EXPAND_CW = "expand_cw" + private const val COL_MAX_TOOT_CHARS = "max_toot_chars" + private const val COL_LAST_NOTIFICATION_ERROR = "last_notification_error" + private const val COL_LAST_SUBSCRIPTION_ERROR = "last_subscription_error" + private const val COL_LAST_PUSH_ENDPOINT = "last_push_endpoint" + private const val COL_IMAGE_RESIZE = "image_resize" + private const val COL_IMAGE_MAX_MEGABYTES = "image_max_megabytes" + private const val COL_MOVIE_MAX_MEGABYTES = "movie_max_megabytes" + private const val COL_PUSH_POLICY = "push_policy" + private const val COL_EXTRA_JSON = "extra_json" + + // COL_MISSKEY_VERSIONのカラム名がおかしいのは、昔はboolean扱いだったから + // 0: not misskey + // 1: old(v10) misskey + // 11: misskey v11 val columnList = ColumnMeta.List(table, 0).apply { + ColumnMeta(this, 0, COL_ID, "INTEGER PRIMARY KEY") + ColumnMeta(this, 0, COL_HOST, "text not null") + ColumnMeta(this, 56, COL_DOMAIN, "text") + ColumnMeta(this, 0, COL_USER, "text not null") + ColumnMeta(this, 0, COL_ACCOUNT, "text not null") + ColumnMeta(this, 0, COL_TOKEN, "text not null") + ColumnMeta(this, 0, COL_VISIBILITY, "text") + ColumnMeta(this, 0, COL_CONFIRM_BOOST, ColumnMeta.TS_TRUE) + ColumnMeta(this, 0, COL_DONT_HIDE_NSFW, ColumnMeta.TS_ZERO) + ColumnMeta(this, 2, COL_NOTIFICATION_MENTION, ColumnMeta.TS_TRUE) + ColumnMeta(this, 2, COL_NOTIFICATION_BOOST, ColumnMeta.TS_TRUE) + ColumnMeta(this, 2, COL_NOTIFICATION_FAVOURITE, ColumnMeta.TS_TRUE) + ColumnMeta(this, 2, COL_NOTIFICATION_FOLLOW, ColumnMeta.TS_TRUE) + ColumnMeta(this, 44, COL_NOTIFICATION_FOLLOW_REQUEST, ColumnMeta.TS_TRUE) + ColumnMeta(this, 33, COL_NOTIFICATION_REACTION, ColumnMeta.TS_TRUE) + ColumnMeta(this, 33, COL_NOTIFICATION_VOTE, ColumnMeta.TS_TRUE) + ColumnMeta(this, 57, COL_NOTIFICATION_POST, ColumnMeta.TS_TRUE) + ColumnMeta(this, 64, COL_NOTIFICATION_UPDATE, ColumnMeta.TS_TRUE) + ColumnMeta(this, 10, COL_CONFIRM_FOLLOW, ColumnMeta.TS_TRUE) + ColumnMeta(this, 10, COL_CONFIRM_FOLLOW_LOCKED, ColumnMeta.TS_TRUE) + ColumnMeta(this, 10, COL_CONFIRM_UNFOLLOW, ColumnMeta.TS_TRUE) + ColumnMeta(this, 10, COL_CONFIRM_POST, ColumnMeta.TS_TRUE) + ColumnMeta(this, 23, COL_CONFIRM_FAVOURITE, ColumnMeta.TS_TRUE) + ColumnMeta(this, 24, COL_CONFIRM_UNBOOST, ColumnMeta.TS_TRUE) + ColumnMeta(this, 24, COL_CONFIRM_UNFAVOURITE, ColumnMeta.TS_TRUE) + ColumnMeta(this, 61, COL_CONFIRM_REACTION, ColumnMeta.TS_TRUE) + ColumnMeta(this, 62, COL_CONFIRM_UNBOOKMARK, ColumnMeta.TS_TRUE) + ColumnMeta(this, 13, COL_NOTIFICATION_TAG, ColumnMeta.TS_EMPTY) + ColumnMeta(this, 14, COL_REGISTER_KEY, ColumnMeta.TS_EMPTY) + ColumnMeta(this, 14, COL_REGISTER_TIME, ColumnMeta.TS_ZERO) + ColumnMeta(this, 16, COL_SOUND_URI, ColumnMeta.TS_EMPTY) + ColumnMeta(this, 18, COL_DONT_SHOW_TIMEOUT, ColumnMeta.TS_ZERO) + ColumnMeta(this, 27, COL_DEFAULT_TEXT, ColumnMeta.TS_EMPTY) + ColumnMeta(this, 28, COL_MISSKEY_VERSION, ColumnMeta.TS_ZERO) + ColumnMeta(this, 38, COL_DEFAULT_SENSITIVE, ColumnMeta.TS_ZERO) + ColumnMeta(this, 38, COL_EXPAND_CW, ColumnMeta.TS_ZERO) + ColumnMeta(this, 39, COL_MAX_TOOT_CHARS, ColumnMeta.TS_ZERO) + ColumnMeta(this, 42, COL_LAST_NOTIFICATION_ERROR, "text") + ColumnMeta(this, 45, COL_LAST_SUBSCRIPTION_ERROR, "text") + ColumnMeta(this, 46, COL_LAST_PUSH_ENDPOINT, "text") + ColumnMeta(this, 59, COL_IMAGE_RESIZE, "text default null") + ColumnMeta(this, 59, COL_IMAGE_MAX_MEGABYTES, "text default null") + ColumnMeta(this, 59, COL_MOVIE_MAX_MEGABYTES, "text default null") + ColumnMeta(this, 60, COL_PUSH_POLICY, "text default null") + ColumnMeta(this, 63, COL_EXTRA_JSON, "text default null") createExtra = { arrayOf( "create index if not exists ${table}_user on $table(u)", @@ -391,113 +361,6 @@ class SavedAccount( } } - private val COL_ID = - ColumnMeta(columnList, 0, BaseColumns._ID, "INTEGER PRIMARY KEY", primary = true) - private val COL_HOST = ColumnMeta(columnList, 0, "h", "text not null") - private val COL_DOMAIN = ColumnMeta(columnList, 56, "d", "text") - private val COL_USER = ColumnMeta(columnList, 0, "u", "text not null") - private val COL_ACCOUNT = ColumnMeta(columnList, 0, "a", "text not null") - private val COL_TOKEN = ColumnMeta(columnList, 0, "t", "text not null") - - private val COL_VISIBILITY = ColumnMeta(columnList, 0, "visibility", "text") - private val COL_CONFIRM_BOOST = - ColumnMeta(columnList, 0, "confirm_boost", ColumnMeta.TS_TRUE) - private val COL_DONT_HIDE_NSFW = - ColumnMeta(columnList, 0, "dont_hide_nsfw", ColumnMeta.TS_ZERO) - - private val COL_NOTIFICATION_MENTION = - ColumnMeta(columnList, 2, "notification_mention", ColumnMeta.TS_TRUE) - private val COL_NOTIFICATION_BOOST = - ColumnMeta(columnList, 2, "notification_boost", ColumnMeta.TS_TRUE) - private val COL_NOTIFICATION_FAVOURITE = - ColumnMeta(columnList, 2, "notification_favourite", ColumnMeta.TS_TRUE) - private val COL_NOTIFICATION_FOLLOW = - ColumnMeta(columnList, 2, "notification_follow", ColumnMeta.TS_TRUE) - private val COL_NOTIFICATION_FOLLOW_REQUEST = - ColumnMeta(columnList, 44, "notification_follow_request", ColumnMeta.TS_TRUE) - private val COL_NOTIFICATION_REACTION = - ColumnMeta(columnList, 33, "notification_reaction", ColumnMeta.TS_TRUE) - private val COL_NOTIFICATION_VOTE = - ColumnMeta(columnList, 33, "notification_vote", ColumnMeta.TS_TRUE) - private val COL_NOTIFICATION_POST = - ColumnMeta(columnList, 57, "notification_post", ColumnMeta.TS_TRUE) - private val COL_NOTIFICATION_UPDATE = - ColumnMeta(columnList, 64, "notification_update", ColumnMeta.TS_TRUE) - - private val COL_CONFIRM_FOLLOW = - ColumnMeta(columnList, 10, "confirm_follow", ColumnMeta.TS_TRUE) - private val COL_CONFIRM_FOLLOW_LOCKED = - ColumnMeta(columnList, 10, "confirm_follow_locked", ColumnMeta.TS_TRUE) - private val COL_CONFIRM_UNFOLLOW = - ColumnMeta(columnList, 10, "confirm_unfollow", ColumnMeta.TS_TRUE) - private val COL_CONFIRM_POST = - ColumnMeta(columnList, 10, "confirm_post", ColumnMeta.TS_TRUE) - private val COL_CONFIRM_FAVOURITE = - ColumnMeta(columnList, 23, "confirm_favourite", ColumnMeta.TS_TRUE) - private val COL_CONFIRM_UNBOOST = - ColumnMeta(columnList, 24, "confirm_unboost", ColumnMeta.TS_TRUE) - private val COL_CONFIRM_UNFAVOURITE = - ColumnMeta(columnList, 24, "confirm_unfavourite", ColumnMeta.TS_TRUE) - private val COL_CONFIRM_REACTION = - ColumnMeta(columnList, 61, "confirm_reaction", ColumnMeta.TS_TRUE) - private val COL_CONFIRM_UNBOOKMARK = - ColumnMeta(columnList, 62, "confirm_unbookmark", ColumnMeta.TS_TRUE) - - // スキーマ13から - val COL_NOTIFICATION_TAG = - ColumnMeta(columnList, 13, "notification_server", ColumnMeta.TS_EMPTY) - - // スキーマ14から - val COL_REGISTER_KEY = ColumnMeta(columnList, 14, "register_key", ColumnMeta.TS_EMPTY) - val COL_REGISTER_TIME = ColumnMeta(columnList, 14, "register_time", ColumnMeta.TS_ZERO) - - // スキーマ16から - private val COL_SOUND_URI = ColumnMeta(columnList, 16, "sound_uri", ColumnMeta.TS_EMPTY) - - // スキーマ18から - private val COL_DONT_SHOW_TIMEOUT = - ColumnMeta(columnList, 18, "dont_show_timeout", ColumnMeta.TS_ZERO) - - // スキーマ27から - private val COL_DEFAULT_TEXT = - ColumnMeta(columnList, 27, "default_text", ColumnMeta.TS_EMPTY) - - // スキーマ28から - private val COL_MISSKEY_VERSION = - ColumnMeta(columnList, 28, "is_misskey", ColumnMeta.TS_ZERO) - // カラム名がおかしいのは、昔はboolean扱いだったから - // 0: not misskey - // 1: old(v10) misskey - // 11: misskey v11 - - private val COL_DEFAULT_SENSITIVE = - ColumnMeta(columnList, 38, "default_sensitive", ColumnMeta.TS_ZERO) - private val COL_EXPAND_CW = ColumnMeta(columnList, 38, "expand_cw", ColumnMeta.TS_ZERO) - private val COL_MAX_TOOT_CHARS = - ColumnMeta(columnList, 39, "max_toot_chars", ColumnMeta.TS_ZERO) - - private val COL_LAST_NOTIFICATION_ERROR = - ColumnMeta(columnList, 42, "last_notification_error", "text") - private val COL_LAST_SUBSCRIPTION_ERROR = - ColumnMeta(columnList, 45, "last_subscription_error", "text") - private val COL_LAST_PUSH_ENDPOINT = - ColumnMeta(columnList, 46, "last_push_endpoint", "text") - - private val COL_IMAGE_RESIZE = - ColumnMeta(columnList, 59, "image_resize", "text default null") - private val COL_IMAGE_MAX_MEGABYTES = - ColumnMeta(columnList, 59, "image_max_megabytes", "text default null") - private val COL_MOVIE_MAX_MEGABYTES = - ColumnMeta(columnList, 59, "movie_max_megabytes", "text default null") - - private val COL_PUSH_POLICY = ColumnMeta(columnList, 60, "push_policy", "text default null") - - private val COL_EXTRA_JSON = ColumnMeta(columnList, 63, "extra_json", "text default null") - - ///////////////////////////////// - // login information - const val INVALID_DB_ID = -1L - // アプリデータのインポート時に呼ばれる fun onDBDelete(db: SQLiteDatabase) { try { @@ -513,6 +376,10 @@ class SavedAccount( override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = columnList.onDBUpgrade(db, oldVersion, newVersion) + ///////////////////////////////// + + const val INVALID_DB_ID = -1L + val defaultResizeConfig = ResizeConfig(ResizeType.LongSide, 1280) internal val resizeConfigList = arrayOf( @@ -559,10 +426,20 @@ class SavedAccount( } } - fun insert( + const val LANG_WEB = "(web)" + const val LANG_DEVICE = "(device)" + + private const val REGISTER_KEY_UNREGISTERED = "unregistered" + } + + class Access( + val db: SQLiteDatabase, + val context: Context, + ) { + fun saveNew( acct: String, host: String, - domain: String?, + domain: String, account: JsonObject, token: JsonObject, misskeyVersion: Int = 0, @@ -575,28 +452,192 @@ class SavedAccount( put(COL_ACCOUNT, account.toString()) put(COL_TOKEN, token.toString()) put(COL_MISSKEY_VERSION, misskeyVersion) - }.let { appDatabase.insert(table, null, it) } + }.let { db.insert(table, null, it) } } catch (ex: Throwable) { log.e(ex, "SavedAccount.insert failed.") errorEx(ex, "SavedAccount.insert failed.") } } - const val LANG_WEB = "(web)" - const val LANG_DEVICE = "(device)" + fun updateTokenInfo(item: SavedAccount, auth2Result: Auth2Result) { + item.run { + if (db_id == INVALID_DB_ID) error("updateTokenInfo: missing db_id") - private const val REGISTER_KEY_UNREGISTERED = "unregistered" + this.token_info = auth2Result.tokenJson + this.loginAccount = auth2Result.tootAccount + + ContentValues().apply { + put(COL_TOKEN, auth2Result.tokenJson.toString()) + put(COL_ACCOUNT, auth2Result.accountJson.toString()) + put(COL_MISSKEY_VERSION, auth2Result.tootInstance.misskeyVersionMajor) + }.let { db.update(table, it, "$COL_ID=?", arrayOf(db_id.toString())) } + } + } + + /** + * ユーザ登録の確認手順が完了しているかどうか + * + * - マストドン以外だと何もしないはず + */ + suspend fun checkConfirmed(item: SavedAccount, client: TootApiClient) { + item.run { + // 承認待ち状態ではないならチェックしない + if (loginAccount?.id != EntityId.CONFIRMING) return + + // DBに保存されていないならチェックしない + if (db_id == INVALID_DB_ID) return + + // アクセストークンがないならチェックしない + val accessToken = bearerAccessToken ?: return + + // ユーザ情報を取得してみる。承認済みなら読めるはず + // 読めなければ例外が出る + val userJson = client.verifyAccount( + accessToken = accessToken, + outTokenInfo = null, + misskeyVersion = 0, // Mastodon only + ) + // 読めたらアプリ内の記録を更新する + TootParser(context, this).account(userJson)?.let { ta -> + this.loginAccount = ta + db.update( + table, + ContentValues().apply { + put(COL_ACCOUNT, userJson.toString()) + }, + "$COL_ID=?", + arrayOf(db_id.toString()) + ) + checkNotificationImmediateAll(context, onlySubscription = true) + checkNotificationImmediate(context, db_id) + } + } + } + + fun saveSetting(item: SavedAccount) { + item.run { + + if (db_id == INVALID_DB_ID) error("saveSetting: missing db_id") + + ContentValues().apply { + put(COL_VISIBILITY, visibility.id.toString()) + + put(COL_DONT_HIDE_NSFW, dont_hide_nsfw) + put(COL_DONT_SHOW_TIMEOUT, dont_show_timeout) + put(COL_NOTIFICATION_MENTION, notification_mention) + put(COL_NOTIFICATION_BOOST, notification_boost) + put(COL_NOTIFICATION_FAVOURITE, notification_favourite) + put(COL_NOTIFICATION_FOLLOW, notification_follow) + put(COL_NOTIFICATION_FOLLOW_REQUEST, notification_follow_request) + put(COL_NOTIFICATION_REACTION, notification_reaction) + put(COL_NOTIFICATION_VOTE, notification_vote) + put(COL_NOTIFICATION_POST, notification_post) + put(COL_NOTIFICATION_UPDATE, notification_update) + + put(COL_CONFIRM_BOOST, confirm_boost) + put(COL_CONFIRM_FAVOURITE, confirm_favourite) + put(COL_CONFIRM_UNBOOST, confirm_unboost) + put(COL_CONFIRM_UNFAVOURITE, confirm_unfavourite) + put(COL_CONFIRM_FOLLOW, confirm_follow) + put(COL_CONFIRM_FOLLOW_LOCKED, confirm_follow_locked) + put(COL_CONFIRM_UNFOLLOW, confirm_unfollow) + put(COL_CONFIRM_POST, confirm_post) + put(COL_CONFIRM_REACTION, confirm_reaction) + put(COL_CONFIRM_UNBOOKMARK, confirm_unbookmark) + + put(COL_SOUND_URI, sound_uri) + put(COL_DEFAULT_TEXT, default_text) + + put(COL_DEFAULT_SENSITIVE, default_sensitive) + put(COL_EXPAND_CW, expand_cw) + put(COL_MAX_TOOT_CHARS, max_toot_chars) + + put(COL_IMAGE_RESIZE, image_resize) + put(COL_IMAGE_MAX_MEGABYTES, image_max_megabytes) + put(COL_MOVIE_MAX_MEGABYTES, movie_max_megabytes) + put(COL_PUSH_POLICY, push_policy) + put(COL_EXTRA_JSON, extraJson.toString()) + + // 以下のデータはUIからは更新しない + // notification_tag + // register_key + }.let { db.update(table, it, "$COL_ID=?", arrayOf(db_id.toString())) } + } + } + + // onResumeの時に設定を読み直す + fun reloadSetting(item: SavedAccount, newData: SavedAccount? = null) { + item.run { + + if (db_id == INVALID_DB_ID) error("SavedAccount.reloadSetting missing db_id") + + // DBから削除されてるかもしれない + val b = newData ?: loadAccount(db_id) ?: return + + this.visibility = b.visibility + this.confirm_boost = b.confirm_boost + this.confirm_favourite = b.confirm_favourite + this.confirm_unboost = b.confirm_unboost + this.confirm_unfavourite = b.confirm_unfavourite + this.confirm_post = b.confirm_post + this.confirm_reaction = b.confirm_reaction + this.confirm_unbookmark = b.confirm_unbookmark + + this.dont_hide_nsfw = b.dont_hide_nsfw + this.dont_show_timeout = b.dont_show_timeout + this.token_info = b.token_info + this.notification_mention = b.notification_mention + this.notification_boost = b.notification_boost + this.notification_favourite = b.notification_favourite + this.notification_follow = b.notification_follow + this.notification_follow_request = b.notification_follow_request + this.notification_reaction = b.notification_reaction + this.notification_vote = b.notification_vote + this.notification_post = b.notification_post + this.notification_update = b.notification_update + this.notification_status_reference = b.notification_status_reference + + this.notification_tag = b.notification_tag + this.default_text = b.default_text + this.default_sensitive = b.default_sensitive + this.expand_cw = b.expand_cw + + this.sound_uri = b.sound_uri + + this.image_resize = b.image_resize + this.image_max_megabytes = b.image_max_megabytes + this.movie_max_megabytes = b.movie_max_megabytes + this.push_policy = b.push_policy + + this.movieTranscodeMode = b.movieTranscodeMode + this.movieTranscodeBitrate = b.movieTranscodeBitrate + this.movieTranscodeFramerate = b.movieTranscodeFramerate + this.movieTranscodeSquarePixels = b.movieTranscodeSquarePixels + this.lang = b.lang + } + } + + + + fun delete(dbId: Long) { + try { + db.deleteById(table, dbId.toString(), COL_ID) + } catch (ex: Throwable) { + log.e(ex, "SavedAccount.delete failed.") + errorEx(ex, "SavedAccount.delete failed.") + } + } fun clearRegistrationCache() { ContentValues().apply { put(COL_REGISTER_KEY, REGISTER_KEY_UNREGISTERED) put(COL_REGISTER_TIME, 0L) - }.let { appDatabase.update(table, it, null, null) } + }.let { db.update(table, it, null, null) } } - fun loadAccount(context: Context, dbId: Long): SavedAccount? { + fun loadAccount(dbId: Long): SavedAccount? = try { - appDatabase.query( + db.query( table, null, "$COL_ID=?", @@ -604,24 +645,24 @@ class SavedAccount( null, null, null - ) - .use { cursor -> - if (cursor.moveToFirst()) { - return parse(context, cursor) + )?.use { cursor -> + when { + cursor.moveToFirst() -> parse(lazyContext, cursor) + else -> { + log.e("moveToFirst failed. db_id=$dbId") + null } - log.e("moveToFirst failed. db_id=$dbId") } + } } catch (ex: Throwable) { log.e(ex, "loadAccount failed.") + null } - return null - } - - fun loadAccountList(context: Context) = + fun loadAccountList() = ArrayList().also { result -> try { - appDatabase.query( + db.query( table, null, null, @@ -631,22 +672,22 @@ class SavedAccount( null ).use { cursor -> while (cursor.moveToNext()) { - parse(context, cursor)?.let { result.add(it) } + parse(lazyContext, cursor)?.let { result.add(it) } } } } catch (ex: Throwable) { log.e(ex, "loadAccountList failed.") - context.showToast( + lazyContext.showToast( true, ex.withCaption("(SubwayTooter) broken in-app database?") ) } } - fun loadByTag(context: Context, tag: String): ArrayList { + fun loadByTag(tag: String): ArrayList { val result = ArrayList() try { - appDatabase.query( + db.query( table, null, "$COL_NOTIFICATION_TAG=?", @@ -672,13 +713,13 @@ class SavedAccount( /** * acctを指定してアカウントを取得する */ - fun loadAccountByAcct(context: Context, fullAcct: String) = + fun loadAccountByAcct(fullAcct: Acct) = try { - appDatabase.query( + db.query( table, null, "$COL_USER=?", - arrayOf(fullAcct), + arrayOf(fullAcct.ascii), null, null, null @@ -692,7 +733,7 @@ class SavedAccount( fun hasRealAccount(): Boolean { try { - appDatabase.query( + db.query( table, null, "$COL_USER NOT LIKE '?@%'", @@ -714,21 +755,17 @@ class SavedAccount( return false } - val count: Int - get() { - try { - appDatabase.query(table, arrayOf("count(*)"), null, null, null, null, null) - .use { cursor -> - if (cursor.moveToNext()) { - return cursor.getInt(0) - } - } - } catch (ex: Throwable) { - log.e(ex, "getCount failed.") - errorEx(ex, "SavedAccount.getCount failed.") - } - - return 0 + fun isSingleAccount(): Boolean = + try { + db.rawQuery( + "select count(*) from $table where $COL_USER NOT LIKE '?@%' limit 1", + emptyArray() + )?.use { + it.moveToNext() && it.getInt(0) == 1 + } ?: false + } catch (ex: Throwable) { + log.e(ex, "getCount failed.") + errorEx(ex, "SavedAccount.getCount failed.") } // private fun charAtLower(src : CharSequence, pos : Int) : Char { @@ -769,33 +806,13 @@ class SavedAccount( // return true // } - private val account_comparator = Comparator { a, b -> - var i: Int - - // NA > !NA - i = a.isNA.b2i() - b.isNA.b2i() - if (i != 0) return@Comparator i - - // pseudo > real - i = a.isPseudo.b2i() - b.isPseudo.b2i() - if (i != 0) return@Comparator i - - val sa = AcctColor.getNickname(a) - val sb = AcctColor.getNickname(b) - sa.compareTo(sb, ignoreCase = true) - } - - fun sort(accountList: MutableList) { - accountList.sortWith(account_comparator) - } - fun sweepBuggieData() { // https://github.com/tateisu/SubwayTooter/issues/107 // COL_ACCOUNTの内容がおかしければ削除する val list = ArrayList() try { - appDatabase.query( + db.query( table, null, "$COL_ACCOUNT like ?", @@ -804,8 +821,9 @@ class SavedAccount( null, null ).use { cursor -> + val idxId = cursor.getColumnIndexOrThrow(COL_ID) while (cursor.moveToNext()) { - list.add(COL_ID.getLong(cursor)) + list.add(cursor.getLong(idxId)) } } } catch (ex: Throwable) { @@ -814,17 +832,17 @@ class SavedAccount( list.forEach { try { - appDatabase.delete(table, "$COL_ID=?", arrayOf(it.toString())) + db.delete(table, "$COL_ID=?", arrayOf(it.toString())) } catch (ex: Throwable) { log.e(ex, "sweepBuggieData failed.") } } } + } - fun getAccessToken(): String? { - return token_info?.string("access_token") - } + val bearerAccessToken + get() = token_info?.string("access_token") val misskeyApiToken: String? get() = token_info?.string(AuthBase.KEY_API_KEY_MISSKEY) @@ -883,68 +901,6 @@ class SavedAccount( return myId != EntityId.CONFIRMING } - /** - * ユーザ登録の確認手順が完了しているかどうか - * - * - マストドン以外だと何もしないはず - */ - suspend fun checkConfirmed(context: Context, client: TootApiClient) { - // 承認待ち状態ではないならチェックしない - if (loginAccount?.id != EntityId.CONFIRMING) return - - // DBに保存されていないならチェックしない - if (db_id == INVALID_DB_ID) return - - // アクセストークンがないならチェックしない - val accessToken = getAccessToken() - ?: return - - // ユーザ情報を取得してみる。承認済みなら読めるはず - // 読めなければ例外が出る - val userJson = client.verifyAccount( - accessToken = accessToken, - outTokenInfo = null, - misskeyVersion = 0, // Mastodon only - ) - // 読めたらアプリ内の記録を更新する - TootParser(context, this).account(userJson)?.let { ta -> - this.loginAccount = ta - appDatabase.update( - table, - ContentValues().apply { - put(COL_ACCOUNT, userJson.toString()) - }, - "$COL_ID=?", - arrayOf(db_id.toString()) - ) - checkNotificationImmediateAll(context, onlySubscription = true) - checkNotificationImmediate(context, db_id) - } - } - - private fun updateSingleString(col: ColumnMeta, value: String?) { - if (db_id != INVALID_DB_ID) { - ContentValues() - .apply { put(col, value) } - .let { appDatabase.update(table, it, "$COL_ID=?", arrayOf(db_id.toString())) } - } - } - - fun updateNotificationError(text: String?) { - this.lastNotificationError = text - updateSingleString(COL_LAST_NOTIFICATION_ERROR, text) - } - - fun updateSubscriptionError(text: String?) { - this.last_subscription_error = text - updateSingleString(COL_LAST_SUBSCRIPTION_ERROR, text) - } - - fun updateLastPushEndpoint(text: String?) { - this.last_push_endpoint = text - updateSingleString(COL_LAST_PUSH_ENDPOINT, text) - } - override fun equals(other: Any?): Boolean = when (other) { is SavedAccount -> acct == other.acct diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/SavedAccountExt.kt b/app/src/main/java/jp/juggler/subwaytooter/table/SavedAccountExt.kt new file mode 100644 index 00000000..24c6362f --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/table/SavedAccountExt.kt @@ -0,0 +1,151 @@ +package jp.juggler.subwaytooter.table + +import android.content.Context +import jp.juggler.subwaytooter.api.TootApiClient +import jp.juggler.subwaytooter.api.TootApiResult +import jp.juggler.subwaytooter.api.entity.Acct +import jp.juggler.subwaytooter.api.entity.Host +import jp.juggler.subwaytooter.api.entity.InstanceCapability +import jp.juggler.subwaytooter.api.entity.TootInstance +import jp.juggler.subwaytooter.api.runApiTask +import jp.juggler.util.data.b2i +import jp.juggler.util.log.LogCategory +import kotlinx.coroutines.async +import kotlinx.coroutines.supervisorScope +import java.util.concurrent.ConcurrentHashMap + +private val log = LogCategory("SavedAccountExt") + +/** + * ニックネームをキャッシュするので、ソートする際に作り直すこと + */ +fun createAccountComparator() = object : Comparator { + + val mapNickname = ConcurrentHashMap() + + val SavedAccount.nicknameCached + get() = mapNickname.getOrPut(acct) { daoAcctColor.getNickname(acct) } + + override fun compare(a: SavedAccount, b: SavedAccount): Int { + var i: Int + + // NA > !NA + i = a.isNA.b2i() - b.isNA.b2i() + if (i != 0) return i + + // pseudo > real + i = a.isPseudo.b2i() - b.isPseudo.b2i() + if (i != 0) return i + + val sa = a.nicknameCached + val sb = b.nicknameCached + return sa.compareTo(sb, ignoreCase = true) + } +} + +fun List.sortedByNickname() = sortedWith(createAccountComparator()) + +fun MutableList.sortInplaceByNickname() = + sortWith(createAccountComparator()) + +fun accountListReorder( + src: List, + pickupHost: Host?, + filter: (SavedAccount) -> Boolean = { true }, +): MutableList { + val listSameHost = java.util.ArrayList() + val listOtherHost = java.util.ArrayList() + for (a in src) { + if (!filter(a)) continue + when (pickupHost) { + null, a.apDomain, a.apiHost -> listSameHost + else -> listOtherHost + }.add(a) + } + listSameHost.sortWith(createAccountComparator()) + listOtherHost.sortWith(createAccountComparator()) + listSameHost.addAll(listOtherHost) + return listSameHost +} + +// 疑似アカ以外のアカウントのリスト +fun accountListNonPseudo( + pickupHost: Host?, +) = accountListReorder( + daoSavedAccount.loadAccountList(), + pickupHost +) { !it.isPseudo } + +// 条件でフィルタする。サーバ情報を読む場合がある。 +suspend fun Context.accountListWithFilter( + pickupHost: Host?, + check: suspend (TootApiClient, SavedAccount) -> Boolean, +): MutableList? { + var resultList: MutableList? = null + runApiTask { client -> + supervisorScope { + resultList = daoSavedAccount.loadAccountList() + .map { + async { + try { + if (check(client, it)) it else null + } catch (ex: Throwable) { + log.e(ex, "accountListWithFilter failed.") + null + } + } + } + .mapNotNull { it.await() } + .let { accountListReorder(it, pickupHost) } + } + if (client.isApiCancelled()) null else TootApiResult() + } + return resultList +} + +suspend fun Context.accountListCanQuote(pickupHost: Host? = null) = + accountListWithFilter(pickupHost) { client, a -> + when { + client.isApiCancelled() -> false + a.isPseudo -> false + a.isMisskey -> true + else -> { + val (ti, ri) = TootInstance.getEx(client.copy(), account = a) + if (ti == null) { + ri?.error?.let { log.w(it) } + false + } else InstanceCapability.quote(ti) + } + } + } + +suspend fun Context.accountListCanReaction(pickupHost: Host? = null) = + accountListWithFilter(pickupHost) { client, a -> + when { + client.isApiCancelled() -> false + a.isPseudo -> false + a.isMisskey -> true + else -> { + val (ti, ri) = TootInstance.getEx(client.copy(), account = a) + if (ti == null) { + ri?.error?.let { log.w(it) } + false + } else InstanceCapability.emojiReaction(a, ti) + } + } + } + +suspend fun Context.accountListCanSeeMyReactions(pickupHost: Host? = null) = + accountListWithFilter(pickupHost) { client, a -> + when { + client.isApiCancelled() -> false + a.isPseudo -> false + else -> { + val (ti, ri) = TootInstance.getEx(client.copy(), account = a) + if (ti == null) { + ri?.error?.let { log.w(it) } + false + } else InstanceCapability.listMyReactions(a, ti) + } + } + } diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/SubscriptionServerKey.kt b/app/src/main/java/jp/juggler/subwaytooter/table/SubscriptionServerKey.kt index 87145420..99ee2e6f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/SubscriptionServerKey.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/SubscriptionServerKey.kt @@ -3,77 +3,81 @@ package jp.juggler.subwaytooter.table import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import android.provider.BaseColumns -import jp.juggler.subwaytooter.global.appDatabase import jp.juggler.util.data.TableCompanion import jp.juggler.util.data.getString import jp.juggler.util.log.LogCategory -object SubscriptionServerKey : TableCompanion { +class SubscriptionServerKey private constructor() { + companion object : TableCompanion { - private val log = LogCategory("ServerKey") + private val log = LogCategory("ServerKey") - override val table = "subscription_server_key2" - private const val COL_ID = BaseColumns._ID - private const val COL_CLIENT_IDENTIFIER = "ci" - private const val COL_SERVER_KEY = "sk" + override val table = "subscription_server_key2" + private const val COL_ID = BaseColumns._ID + private const val COL_CLIENT_IDENTIFIER = "ci" + private const val COL_SERVER_KEY = "sk" - private val findColumns = arrayOf(COL_SERVER_KEY) - - private val findWhereArgs = object : ThreadLocal>() { - override fun initialValue(): Array { - return arrayOfNulls(1) - } - } - - override fun onDBCreate(db: SQLiteDatabase) { - log.d("onDBCreate!") - db.execSQL( - """create table if not exists $table + override fun onDBCreate(db: SQLiteDatabase) { + log.d("onDBCreate!") + db.execSQL( + """create table if not exists $table ($COL_ID INTEGER PRIMARY KEY ,$COL_CLIENT_IDENTIFIER text not null ,$COL_SERVER_KEY text not null )""".trimIndent() - ) - db.execSQL("create unique index if not exists ${table}_ti on $table($COL_CLIENT_IDENTIFIER)") - } - - override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - if (oldVersion < 26 && newVersion >= 26) { - onDBCreate(db) + ) + db.execSQL("create unique index if not exists ${table}_ti on $table($COL_CLIENT_IDENTIFIER)") } - } - fun find(clientIdentifier: String): String? { - try { - val whereArgs = findWhereArgs.get() ?: arrayOfNulls(1) - whereArgs[0] = clientIdentifier - appDatabase.query( - table, - findColumns, - "$COL_CLIENT_IDENTIFIER=?", - whereArgs, - null, - null, - null - )?.use { cursor -> - if (cursor.moveToNext()) { - return cursor.getString(COL_SERVER_KEY) - } + override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion < 26 && newVersion >= 26) { + onDBCreate(db) + } + } + + private val findColumns = arrayOf(COL_SERVER_KEY) + + private val findWhereArgs = object : ThreadLocal>() { + override fun initialValue(): Array { + return arrayOfNulls(1) } - } catch (ex: Throwable) { - log.e(ex, "query failed.") } - return null } - fun save(clientIdentifier: String, serverKey: String) { - try { - val cv = ContentValues() - cv.put(COL_CLIENT_IDENTIFIER, clientIdentifier) - cv.put(COL_SERVER_KEY, serverKey) - appDatabase.replace(table, null, cv) - } catch (ex: Throwable) { - log.e(ex, "save failed.") + class Access(val db: SQLiteDatabase) { + + fun find(clientIdentifier: String): String? { + try { + val whereArgs = findWhereArgs.get() ?: arrayOfNulls(1) + whereArgs[0] = clientIdentifier + db.query( + table, + findColumns, + "$COL_CLIENT_IDENTIFIER=?", + whereArgs, + null, + null, + null + )?.use { cursor -> + if (cursor.moveToNext()) { + return cursor.getString(COL_SERVER_KEY) + } + } + } catch (ex: Throwable) { + log.e(ex, "query failed.") + } + return null + } + + fun save(clientIdentifier: String, serverKey: String) { + try { + val cv = ContentValues() + cv.put(COL_CLIENT_IDENTIFIER, clientIdentifier) + cv.put(COL_SERVER_KEY, serverKey) + db.replace(table, null, cv) + } catch (ex: Throwable) { + log.e(ex, "save failed.") + } } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/TagHistory.kt b/app/src/main/java/jp/juggler/subwaytooter/table/TagHistory.kt new file mode 100644 index 00000000..055bcb71 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/table/TagHistory.kt @@ -0,0 +1,144 @@ +package jp.juggler.subwaytooter.table + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import jp.juggler.util.data.TableCompanion +import jp.juggler.util.log.LogCategory + +class TagHistory private constructor() { + companion object : TableCompanion { + private val log = LogCategory("TagHistory") + override val table = "tag_set" + private const val COL_TIME_SAVE = "time_save" + private const val COL_TAG = "tag" // タグ。先頭の#を含まない + + override fun onDBCreate(db: SQLiteDatabase) { + log.d("onDBCreate!") + db.execSQL( + """create table if not exists $table + (_id INTEGER PRIMARY KEY + ,$COL_TIME_SAVE integer not null + ,$COL_TAG text not null + )""".trimIndent() + ) + db.execSQL("create unique index if not exists ${table}_tag on $table($COL_TAG)") + db.execSQL("create index if not exists ${table}_time on $table($COL_TIME_SAVE)") + } + + override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion < 15 && newVersion >= 15) { + onDBCreate(db) + } + } + + private const val prefix_search_where = "$COL_TAG like ? escape '$'" + + private val prefix_search_where_arg = object : ThreadLocal>() { + override fun initialValue(): Array { + return Array(1) { null } + } + } + } + + class Access(val db: SQLiteDatabase) { + + // XXX: タグ履歴って掃除する必要あるかな? + // fun deleteOld(now : Long) { + // try { + // // 古いデータを掃除する + // val expire = now - 86400000L * 365 + // db.delete(table, COL_TIME_SAVE + ", offset: Int, length: Int) { + + try { + val cv = ContentValues() + cv.put(COL_TIME_SAVE, now) + + var bOK = false + val db = db + db.execSQL("BEGIN TRANSACTION") + try { + for (i in 0 until length) { + val acct = srcList.elementAtOrNull(i + offset) ?: continue + cv.put(COL_TAG, acct) + db.replace(table, null, cv) + } + bOK = true + } catch (ex: Throwable) { + log.e(ex, "saveList failed.") + } + + if (bOK) { + db.execSQL("COMMIT TRANSACTION") + } else { + db.execSQL("ROLLBACK TRANSACTION") + } + } catch (ex: Throwable) { + log.e(ex, "saveList failed.") + } + } + + private fun makePattern(src: String): String { + val sb = StringBuilder() + + // エスケープしながらコピー + for (element in src) { + when (element) { + '%', '_', '$' -> sb.append('$') + } + sb.append(element) + } + + // 前方一致検索にするため、末尾に%をつける + sb.append('%') + + return sb.toString() + } + + fun searchPrefix(prefix: String, limit: Int): ArrayList { + val dst = ArrayList() + + try { + val where_arg = prefix_search_where_arg.get() ?: arrayOfNulls(1) + where_arg[0] = makePattern(prefix) + db.query( + table, + null, + prefix_search_where, + where_arg, + null, + null, + "$COL_TAG asc limit $limit" + ).use { cursor -> + dst.ensureCapacity(cursor.count) + val idx_acct = cursor.getColumnIndex(COL_TAG) + while (cursor.moveToNext()) { + dst.add("#" + cursor.getString(idx_acct)) + } + } + } catch (ex: Throwable) { + log.e(ex, "searchPrefix failed.") + } + + return dst + } + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/TagSet.kt b/app/src/main/java/jp/juggler/subwaytooter/table/TagSet.kt deleted file mode 100644 index 0646138d..00000000 --- a/app/src/main/java/jp/juggler/subwaytooter/table/TagSet.kt +++ /dev/null @@ -1,142 +0,0 @@ -package jp.juggler.subwaytooter.table - -import android.content.ContentValues -import android.database.sqlite.SQLiteDatabase -import jp.juggler.subwaytooter.global.appDatabase -import jp.juggler.util.data.TableCompanion -import jp.juggler.util.log.LogCategory - -object TagSet : TableCompanion { - - private val log = LogCategory("TagSet") - - override val table = "tag_set" - private const val COL_TIME_SAVE = "time_save" - private const val COL_TAG = "tag" // タグ。先頭の#を含まない - - private const val prefix_search_where = "$COL_TAG like ? escape '$'" - - private val prefix_search_where_arg = object : ThreadLocal>() { - override fun initialValue(): Array { - return Array(1) { null } - } - } - - override fun onDBCreate(db: SQLiteDatabase) { - log.d("onDBCreate!") - db.execSQL( - """create table if not exists $table - (_id INTEGER PRIMARY KEY - ,$COL_TIME_SAVE integer not null - ,$COL_TAG text not null - )""".trimIndent() - ) - db.execSQL("create unique index if not exists ${table}_tag on $table($COL_TAG)") - db.execSQL("create index if not exists ${table}_time on $table($COL_TIME_SAVE)") - } - - override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - if (oldVersion < 15 && newVersion >= 15) { - onDBCreate(db) - } - } - - // XXX: タグ履歴って掃除する必要あるかな? - // fun deleteOld(now : Long) { - // try { - // // 古いデータを掃除する - // val expire = now - 86400000L * 365 - // appDatabase.delete(table, COL_TIME_SAVE + ", offset: Int, length: Int) { - - try { - val cv = ContentValues() - cv.put(COL_TIME_SAVE, now) - - var bOK = false - val db = appDatabase - db.execSQL("BEGIN TRANSACTION") - try { - for (i in 0 until length) { - val acct = srcList.elementAtOrNull(i + offset) ?: continue - cv.put(COL_TAG, acct) - db.replace(table, null, cv) - } - bOK = true - } catch (ex: Throwable) { - log.e(ex, "saveList failed.") - } - - if (bOK) { - db.execSQL("COMMIT TRANSACTION") - } else { - db.execSQL("ROLLBACK TRANSACTION") - } - } catch (ex: Throwable) { - log.e(ex, "saveList failed.") - } - } - - private fun makePattern(src: String): String { - val sb = StringBuilder() - - // エスケープしながらコピー - for (element in src) { - when (element) { - '%', '_', '$' -> sb.append('$') - } - sb.append(element) - } - - // 前方一致検索にするため、末尾に%をつける - sb.append('%') - - return sb.toString() - } - - fun searchPrefix(prefix: String, limit: Int): ArrayList { - val dst = ArrayList() - - try { - val where_arg = prefix_search_where_arg.get() ?: arrayOfNulls(1) - where_arg[0] = makePattern(prefix) - appDatabase.query( - table, - null, - prefix_search_where, - where_arg, - null, - null, - "$COL_TAG asc limit $limit" - ).use { cursor -> - dst.ensureCapacity(cursor.count) - val idx_acct = cursor.getColumnIndex(COL_TAG) - while (cursor.moveToNext()) { - dst.add("#" + cursor.getString(idx_acct)) - } - } - } catch (ex: Throwable) { - log.e(ex, "searchPrefix failed.") - } - - return dst - } -} diff --git a/app/src/main/java/jp/juggler/subwaytooter/table/UserRelation.kt b/app/src/main/java/jp/juggler/subwaytooter/table/UserRelation.kt index 2e4540d1..ab06854c 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/table/UserRelation.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/table/UserRelation.kt @@ -9,24 +9,38 @@ import jp.juggler.subwaytooter.api.entity.Acct import jp.juggler.subwaytooter.api.entity.EntityId import jp.juggler.subwaytooter.api.entity.TootAccount import jp.juggler.subwaytooter.api.entity.TootRelationShip -import jp.juggler.subwaytooter.global.appDatabase import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory -class UserRelation { - - var following = false // 認証ユーザからのフォロー状態にある - var followed_by = false // 認証ユーザは被フォロー状態にある - var blocking = false // 認証ユーザからブロックした - var blocked_by = false // 認証ユーザからブロックされた(Misskeyのみ。Mastodonでは常にfalse) - var muting = false - var requested = false // 認証ユーザからのフォローは申請中である - var requested_by = false // 相手から認証ユーザへのフォローリクエスト申請中(Misskeyのみ。Mastodonでは常にfalse) - var following_reblogs = 0 // このユーザからのブーストをTLに表示する - var endorsed = false // ユーザをプロフィールで紹介する - var notifying = false // ユーザの投稿を通知する - var note: String? = null - +class UserRelation( + var id: Long = 0L, + var timeSave: Long = 0L, + // SavedAccount のDB_ID。 疑似アカウント用のエントリは -2L + var dbId: Long = 0L, + // ターゲットアカウントのID + var whoId: String = "", + // 認証ユーザからのフォロー状態にある + var following: Boolean = false, + // 認証ユーザは被フォロー状態にある + var followed_by: Boolean = false, + // 認証ユーザからブロックした + var blocking: Boolean = false, + // 認証ユーザからブロックされた(Misskeyのみ。Mastodonでは常にfalse) + var blocked_by: Boolean = false, + var muting: Boolean = false, + // 認証ユーザからのフォローは申請中である + var requested: Boolean = false, + // 相手から認証ユーザへのフォローリクエスト申請中(Misskeyのみ。Mastodonでは常にfalse) + var requested_by: Boolean = false, + // このユーザからのブーストをTLに表示する + var following_reblogs: Int = 0, + // ユーザをプロフィールで紹介する + var endorsed: Boolean = false, + // ログインユーザが該当ユーザに対して何かメモができる + var note: String? = null, + // ユーザの投稿を通知する + var notifying: Boolean = false, +) { // 認証ユーザからのフォロー状態 fun getFollowing(who: TootAccount?): Boolean { return if (requested && !following && who != null && !who.locked) true else following @@ -38,20 +52,41 @@ class UserRelation { } companion object : TableCompanion { - - const val REBLOG_HIDE = - 0 // don't show the boosts from target account will be shown on authorized user's home TL. - const val REBLOG_SHOW = - 1 // show the boosts from target account will be shown on authorized user's home TL. - const val REBLOG_UNKNOWN = 2 // not following, or instance don't support hide reblog. - - private val mMemoryCache = androidx.collection.LruCache(2048) - - private val log = LogCategory("UserRelationMisskey") - + private val log = LogCategory("UserRelation") override val table = "user_relation_misskey" + private const val COL_ID = BaseColumns._ID + private const val COL_TIME_SAVE = "time_save" + private const val COL_DB_ID = "db_id" + private const val COL_WHO_ID = "who_id" + private const val COL_FOLLOWING = "following" + private const val COL_FOLLOWED_BY = "followed_by" + private const val COL_BLOCKING = "blocking" + private const val COL_MUTING = "muting" + private const val COL_REQUESTED = "requested" + private const val COL_FOLLOWING_REBLOGS = "following_reblogs" + private const val COL_ENDORSED = "endorsed" + private const val COL_BLOCKED_BY = "blocked_by" + private const val COL_REQUESTED_BY = "requested_by" + private const val COL_NOTE = "note" + private const val COL_NOTIFYING = "notifying" + val columnList: ColumnMeta.List = ColumnMeta.List(table, 30).apply { + ColumnMeta(this, 0, COL_ID, "INTEGER PRIMARY KEY") + ColumnMeta(this, 0, COL_TIME_SAVE, "integer not null") + ColumnMeta(this, 0, COL_DB_ID, "integer not null") + ColumnMeta(this, 0, COL_WHO_ID, "text not null") + ColumnMeta(this, 0, COL_FOLLOWING, "integer not null") + ColumnMeta(this, 0, COL_FOLLOWED_BY, "integer not null") + ColumnMeta(this, 0, COL_BLOCKING, "integer not null") + ColumnMeta(this, 0, COL_MUTING, "integer not null") + ColumnMeta(this, 0, COL_REQUESTED, "integer not null") + ColumnMeta(this, 0, COL_FOLLOWING_REBLOGS, "integer not null") + ColumnMeta(this, 32, COL_ENDORSED, "integer default 0") + ColumnMeta(this, 34, COL_BLOCKED_BY, "integer default 0") + ColumnMeta(this, 35, COL_REQUESTED_BY, "integer default 0") + ColumnMeta(this, 55, COL_NOTE, "text default null") + ColumnMeta(this, 58, COL_NOTIFYING, "integer default 0") createExtra = { arrayOf( "create unique index if not exists ${table}_id on $table($COL_DB_ID,$COL_WHO_ID)", @@ -61,41 +96,76 @@ class UserRelation { deleteBeforeCreate = true } - val COL_ID = - ColumnMeta(columnList, 0, BaseColumns._ID, "INTEGER PRIMARY KEY", primary = true) - private val COL_TIME_SAVE = ColumnMeta(columnList, 0, "time_save", "integer not null") - - // SavedAccount のDB_ID。 疑似アカウント用のエントリは -2L - private val COL_DB_ID = ColumnMeta(columnList, 0, "db_id", "integer not null") - - // ターゲットアカウントのID - val COL_WHO_ID = ColumnMeta(columnList, 0, "who_id", "text not null") - private val COL_FOLLOWING = ColumnMeta(columnList, 0, "following", "integer not null") - private val COL_FOLLOWED_BY = ColumnMeta(columnList, 0, "followed_by", "integer not null") - private val COL_BLOCKING = ColumnMeta(columnList, 0, "blocking", "integer not null") - private val COL_MUTING = ColumnMeta(columnList, 0, "muting", "integer not null") - private val COL_REQUESTED = ColumnMeta(columnList, 0, "requested", "integer not null") - private val COL_FOLLOWING_REBLOGS = - ColumnMeta(columnList, 0, "following_reblogs", "integer not null") - private val COL_ENDORSED = ColumnMeta(columnList, 32, "endorsed", "integer default 0") - private val COL_BLOCKED_BY = ColumnMeta(columnList, 34, "blocked_by", "integer default 0") - private val COL_REQUESTED_BY = - ColumnMeta(columnList, 35, "requested_by", "integer default 0") - private val COL_NOTE = ColumnMeta(columnList, 55, "note", "text default null") - private val COL_NOTIFYING = ColumnMeta(columnList, 58, "notifying", "integer default 0") - - private const val DB_ID_PSEUDO = -2L - override fun onDBCreate(db: SQLiteDatabase) = columnList.onDBCreate(db) override fun onDBUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = columnList.onDBUpgrade(db, oldVersion, newVersion) + const val DB_ID_PSEUDO = -2L + + const val REBLOG_HIDE = + 0 // don't show the boosts from target account will be shown on authorized user's home TL. + const val REBLOG_SHOW = + 1 // show the boosts from target account will be shown on authorized user's home TL. + const val REBLOG_UNKNOWN = 2 // not following, or instance don't support hide reblog. + + private val mMemoryCache = androidx.collection.LruCache(2048) + } + + @Suppress("MemberVisibilityCanBePrivate") + class ColIdx(cursor: Cursor) { + val idxId = cursor.getColumnIndexOrThrow(COL_ID) + val idxTimeSave = cursor.getColumnIndexOrThrow(COL_TIME_SAVE) + val idxDbId = cursor.getColumnIndexOrThrow(COL_DB_ID) + val idxWhoId = cursor.getColumnIndexOrThrow(COL_WHO_ID) + val idxFollowing = cursor.getColumnIndexOrThrow(COL_FOLLOWING) + val idxFollowedBy = cursor.getColumnIndexOrThrow(COL_FOLLOWED_BY) + val idxBlocking = cursor.getColumnIndexOrThrow(COL_BLOCKING) + val idxMuting = cursor.getColumnIndexOrThrow(COL_MUTING) + val idxRequested = cursor.getColumnIndexOrThrow(COL_REQUESTED) + val idxFollowingReblogs = cursor.getColumnIndexOrThrow(COL_FOLLOWING_REBLOGS) + val idxEndorsed = cursor.getColumnIndexOrThrow(COL_ENDORSED) + val idxBlockedBy = cursor.getColumnIndexOrThrow(COL_BLOCKED_BY) + val idxRequestedBy = cursor.getColumnIndexOrThrow(COL_REQUESTED_BY) + val idxNote = cursor.getColumnIndexOrThrow(COL_NOTE) + val idxNotifying = cursor.getColumnIndexOrThrow(COL_NOTIFYING) + fun readRow(cursor: Cursor) = UserRelation( + id = cursor.getLong(idxId), + timeSave = cursor.getLong(idxTimeSave), + dbId = cursor.getLong(idxDbId), + whoId = cursor.getString(idxWhoId), + following = cursor.getBoolean(idxFollowing), + followed_by = cursor.getBoolean(idxFollowedBy), + blocking = cursor.getBoolean(idxBlocking), + muting = cursor.getBoolean(idxMuting), + requested = cursor.getBoolean(idxRequested), + following_reblogs = cursor.getInt(idxFollowingReblogs), + endorsed = cursor.getBoolean(idxEndorsed), + blocked_by = cursor.getBoolean(idxBlockedBy), + requested_by = cursor.getBoolean(idxRequestedBy), + note = cursor.getStringOrNull(idxNote), + notifying = cursor.getBoolean(idxNotifying), + ) + + fun readOne(cursor: Cursor) = + when { + cursor.moveToNext() -> readRow(cursor) + else -> null + } + + fun readAll(cursor: Cursor) = buildList { + while (cursor.moveToNext()) { + add(readRow(cursor)) + } + } + } + + class Access(val db: SQLiteDatabase) { fun deleteOld(now: Long) { try { val expire = now - 86400000L * 365 - appDatabase.delete(table, "$COL_TIME_SAVE) { - val db = appDatabase db.execSQL("BEGIN TRANSACTION") - val bOK = try { - val cv = ContentValues() - cv.put(COL_TIME_SAVE, now) - cv.put(COL_DB_ID, dbId) + val cv = ContentValues().apply { + put(COL_TIME_SAVE, now) + put(COL_DB_ID, dbId) + } for (src in srcList) { - val id = src.id.toString() - cv.put(COL_WHO_ID, id) cv.fromTootRelationShip(src) - db.replaceOrThrow(table, null, cv) + cv.put(COL_WHO_ID, src.id.toString()) + cv.replaceTo(db, table) } true } catch (ex: Throwable) { log.e(ex, "saveList failed.") false } - when { !bOK -> db.execSQL("ROLLBACK TRANSACTION") else -> { @@ -194,11 +259,11 @@ class UserRelation { src ?: return try { ContentValues().apply { + fromUserRelation(src) put(COL_TIME_SAVE, now) put(COL_DB_ID, dbId) put(COL_WHO_ID, whoId) - fromUserRelation(src) - }.let { appDatabase.replaceOrThrow(table, null, it) } + }.replaceTo(db, table) mMemoryCache.remove(key(dbId, whoId)) } catch (ex: Throwable) { log.e(ex, "save failed.") @@ -212,26 +277,25 @@ class UserRelation { start: Int, end: Int, ) { - val db = appDatabase db.execSQL("BEGIN TRANSACTION") val bOK = try { - val cv = ContentValues() - cv.put(COL_TIME_SAVE, now) - cv.put(COL_DB_ID, dbId) + val cv = ContentValues().apply { + put(COL_TIME_SAVE, now) + put(COL_DB_ID, dbId) + } for (i in start until end) { val entry = srcList[i] - val id = entry.key + val whoId = entry.key.toString() val src = entry.value - cv.put(COL_WHO_ID, id.toString()) cv.fromUserRelation(src) - db.replaceOrThrow(table, null, cv) + cv.put(COL_WHO_ID, whoId) + cv.replaceTo(db, table) } true } catch (ex: Throwable) { log.e(ex, "saveList failed.") false } - when { !bOK -> db.execSQL("ROLLBACK TRANSACTION") else -> { @@ -247,17 +311,16 @@ class UserRelation { // Misskeyのリレーション取得APIから fun saveListMisskeyRelationApi(now: Long, dbId: Long, list: ArrayList) { - val db = appDatabase db.execSQL("BEGIN TRANSACTION") val bOK = try { - val cv = ContentValues() - cv.put(COL_TIME_SAVE, now) - cv.put(COL_DB_ID, dbId) + val cv = ContentValues().apply { + put(COL_TIME_SAVE, now) + put(COL_DB_ID, dbId) + } for (src in list) { - val id = src.id.toString() - cv.put(COL_WHO_ID, id) cv.fromTootRelationShip(src) - db.replace(table, null, cv) + cv.put(COL_WHO_ID, src.id.toString()) + cv.replaceTo(db, table) } true } catch (ex: Throwable) { @@ -275,10 +338,21 @@ class UserRelation { } } - private val loadWhere = "$COL_DB_ID=? and $COL_WHO_ID=?" + fun saveUserRelation(a: SavedAccount, src: TootRelationShip?): UserRelation? { + src ?: return null + val now = System.currentTimeMillis() + return save1Mastodon(now, a.db_id, src) + } - private val loadWhereArg = object : ThreadLocal>() { - override fun initialValue(): Array = Array(2) { null } + fun saveUserRelationMisskey( + a: SavedAccount, + whoId: EntityId, + parser: TootParser, + ): UserRelation? { + val now = System.currentTimeMillis() + val relation = parser.getMisskeyUserRelation(whoId) + save1Misskey(now, a.db_id, whoId.toString(), relation) + return relation } fun load(dbId: Long, whoId: EntityId): UserRelation { @@ -294,37 +368,22 @@ class UserRelation { } fun load(dbId: Long, whoId: String): UserRelation { - try { - val where_arg = loadWhereArg.get() ?: arrayOfNulls(2) - where_arg[0] = dbId.toString() - where_arg[1] = whoId - appDatabase.query(table, null, loadWhere, where_arg, null, null, null) - .use { cursor -> - if (cursor.moveToNext()) { - val dst = UserRelation() - dst.following = cursor.getBoolean(COL_FOLLOWING) - dst.followed_by = cursor.getBoolean(COL_FOLLOWED_BY) - dst.blocking = cursor.getBoolean(COL_BLOCKING) - dst.muting = cursor.getBoolean(COL_MUTING) - dst.requested = cursor.getBoolean(COL_REQUESTED) - dst.following_reblogs = cursor.getInt(COL_FOLLOWING_REBLOGS) - dst.endorsed = cursor.getBoolean(COL_ENDORSED) - dst.blocked_by = cursor.getBoolean(COL_BLOCKED_BY) - dst.requested_by = cursor.getBoolean(COL_REQUESTED_BY) - dst.notifying = cursor.getBoolean(COL_NOTIFYING) - - dst.note = cursor.getStringOrNull(COL_NOTE) - return dst - } - } + db.rawQuery( + "select * from $table where $COL_DB_ID=? and $COL_WHO_ID=?", + arrayOf(dbId.toString(), whoId) + )?.use { ColIdx(it).readOne(it) } + ?.let { return it } } catch (ex: Throwable) { log.e(ex, "load failed.") } return UserRelation() } - // MisskeyはUserエンティティにユーザリレーションが含まれたり含まれなかったりする + /** + * srcの情報をparser内部のmisskeyUserRelationMapに追加する + * - MisskeyはUserエンティティにユーザリレーションが含まれたり含まれなかったりする + */ fun fromAccount(parser: TootParser, src: JsonObject, id: EntityId) { // アカウントのjsonがユーザリレーションを含まないなら何もしない @@ -336,7 +395,6 @@ class UserRelation { val map = parser.misskeyUserRelationMap if (map.containsKey(id)) return - map[id] = UserRelation().apply { following = src.optBoolean("isFollowing") followed_by = src.optBoolean("isFollowed") @@ -349,28 +407,25 @@ class UserRelation { } } - fun loadPseudo(acct: Acct) = load(DB_ID_PSEUDO, acct.ascii) - - fun createCursorPseudoMuted(): Cursor = - appDatabase.query( - table, - arrayOf(COL_ID.name, COL_WHO_ID.name), - "$COL_DB_ID=$DB_ID_PSEUDO and ( $COL_MUTING=1 or $COL_BLOCKING=1 )", - null, - null, - null, - "$COL_WHO_ID asc" - ) + fun savePseudo(acct: String, src: UserRelation) = + save1Misskey(System.currentTimeMillis(), DB_ID_PSEUDO, acct, src) fun deletePseudo(rowId: Long) { try { - appDatabase.delete(table, "$COL_ID=$rowId", null) + db.deleteById(table, rowId.toString(), COL_ID) } catch (ex: Throwable) { log.e(ex, "deletePseudo failed. rowId=$rowId") } } - } - fun savePseudo(acct: String) = - save1Misskey(System.currentTimeMillis(), DB_ID_PSEUDO, acct, this) + fun loadPseudo(acct: Acct) = load(DB_ID_PSEUDO, acct.ascii) + + fun listPseudoMuted() = + db.rawQuery( + "select $COL_ID,$COL_WHO_ID from $table where $COL_DB_ID=$DB_ID_PSEUDO and ($COL_MUTING=1 or $COL_BLOCKING=1) order by $COL_WHO_ID asc", + emptyArray() + )?.use { + ColIdx(it).readAll(it) + } ?: emptyList() + } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/AppOpener.kt b/app/src/main/java/jp/juggler/subwaytooter/util/AppOpener.kt index 7368784d..53201d3d 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/AppOpener.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/AppOpener.kt @@ -3,7 +3,6 @@ package jp.juggler.subwaytooter.util import android.app.Activity import android.content.ComponentName import android.content.Intent -import android.content.SharedPreferences import android.net.Uri import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent @@ -19,7 +18,6 @@ import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.TootStatus.Companion.findStatusIdFromUrl import jp.juggler.subwaytooter.api.entity.TootTag.Companion.findHashtagFromUrl import jp.juggler.subwaytooter.pref.PrefB -import jp.juggler.subwaytooter.pref.pref import jp.juggler.subwaytooter.span.LinkInfo import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.util.data.* @@ -126,7 +124,7 @@ fun Activity.openBrowser(url: String?) = openBrowser(url.mayUri()) // Chrome Custom Tab を開く -fun Activity.openCustomTab(url: String?, pref: SharedPreferences = pref()) { +fun Activity.openCustomTab(url: String?) { url ?: return if (url.isEmpty()) { @@ -134,7 +132,7 @@ fun Activity.openCustomTab(url: String?, pref: SharedPreferences = pref()) { return } - if (PrefB.bpDontUseCustomTabs(pref)) { + if (PrefB.bpDontUseCustomTabs.value) { openBrowser(url) return } @@ -160,7 +158,7 @@ fun Activity.openCustomTab(url: String?, pref: SharedPreferences = pref()) { ) } - if (url.startsWith("http") && PrefB.bpPriorChrome(pref)) { + if (url.startsWith("http") && PrefB.bpPriorChrome.value) { try { // 初回はChrome指定で試す val cn = ComponentName( @@ -184,7 +182,7 @@ fun Activity.openCustomTab(url: String?, pref: SharedPreferences = pref()) { } fun Activity.openCustomTab(ta: TootAttachment) = - openCustomTab(ta.getLargeUrl(pref())) + openCustomTab(ta.getLargeUrl()) fun openCustomTab( activity: ActMain, diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentPicker.kt b/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentPicker.kt index 0c58e2cf..b73aaaf6 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentPicker.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentPicker.kt @@ -6,8 +6,9 @@ import android.net.Uri import android.provider.MediaStore import androidx.appcompat.app.AppCompatActivity import jp.juggler.subwaytooter.R -import jp.juggler.subwaytooter.dialog.ActionsDialog +import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.kJson +import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.data.GetContentResultEntry import jp.juggler.util.data.UriSerializer import jp.juggler.util.data.handleGetContentResult @@ -132,34 +133,35 @@ class AttachmentPicker( fun openPicker() { if (!prPickAttachment.checkOrLaunch()) return - - with(activity) { - val a = ActionsDialog() - a.addAction(getString(R.string.pick_images)) { - openAttachmentChooser(R.string.pick_images, "image/*", "video/*") + activity.run { + launchAndShowError { + actionsDialog { + action(getString(R.string.pick_images)) { + openAttachmentChooser(R.string.pick_images, "image/*", "video/*") + } + action(getString(R.string.pick_videos)) { + openAttachmentChooser(R.string.pick_videos, "video/*") + } + action(getString(R.string.pick_audios)) { + openAttachmentChooser(R.string.pick_audios, "audio/*") + } + action(getString(R.string.image_capture)) { + performCamera() + } + action(getString(R.string.video_capture)) { + performCapture( + MediaStore.ACTION_VIDEO_CAPTURE, + "can't open video capture app." + ) + } + action(getString(R.string.voice_capture)) { + performCapture( + MediaStore.Audio.Media.RECORD_SOUND_ACTION, + "can't open voice capture app." + ) + } + } } - a.addAction(getString(R.string.pick_videos)) { - openAttachmentChooser(R.string.pick_videos, "video/*") - } - a.addAction(getString(R.string.pick_audios)) { - openAttachmentChooser(R.string.pick_audios, "audio/*") - } - a.addAction(getString(R.string.image_capture)) { - performCamera() - } - a.addAction(getString(R.string.video_capture)) { - performCapture( - MediaStore.ACTION_VIDEO_CAPTURE, - "can't open video capture app." - ) - } - a.addAction(getString(R.string.voice_capture)) { - performCapture( - MediaStore.Audio.Media.RECORD_SOUND_ACTION, - "can't open voice capture app." - ) - } - a.show(this, null) } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/CustomEmojiCache.kt b/app/src/main/java/jp/juggler/subwaytooter/util/CustomEmojiCache.kt index 396aee99..41004de8 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/CustomEmojiCache.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/CustomEmojiCache.kt @@ -1,20 +1,16 @@ package jp.juggler.subwaytooter.util -import android.content.ContentValues import android.content.Context -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteDatabaseCorruptException -import android.database.sqlite.SQLiteOpenHelper import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas import android.graphics.RectF import android.os.Handler import android.os.SystemClock -import android.provider.BaseColumns import com.caverock.androidsvg.SVG import jp.juggler.apng.ApngFrames import jp.juggler.subwaytooter.App1 +import jp.juggler.subwaytooter.table.EmojiCacheDbOpenHelper import jp.juggler.util.data.* import jp.juggler.util.log.* import kotlinx.coroutines.channels.Channel @@ -40,123 +36,6 @@ class CustomEmojiCache( private val elapsedTime: Long get() = SystemClock.elapsedRealtime() - - // カスタム絵文字のキャッシュ専用のデータベースファイルを作る - // (DB破損などの際に削除してしまえるようにする) - private const val CACHE_DB_NAME = "emoji_cache_db" - private const val CACHE_DB_VERSION = 1 - } - - private class DbCache( - val id: Long, - val timeUsed: Long, - val data: ByteArray, - ) { - - companion object : TableCompanion { - - override val table = "custom_emoji_cache" - - const val COL_ID = BaseColumns._ID - const val COL_TIME_SAVE = "time_save" - const val COL_TIME_USED = "time_used" - const val COL_URL = "url" - const val COL_DATA = "data" - - override fun onDBCreate(db: SQLiteDatabase) { - db.execSQL( - """create table if not exists $table - ($COL_ID INTEGER PRIMARY KEY - ,$COL_TIME_SAVE integer not null - ,$COL_TIME_USED integer not null - ,$COL_URL text not null - ,$COL_DATA blob not null - )""".trimIndent() - ) - db.execSQL("create unique index if not exists ${table}_url on $table($COL_URL)") - db.execSQL("create index if not exists ${table}_old on $table($COL_TIME_USED)") - } - - override fun onDBUpgrade( - db: SQLiteDatabase, - oldVersion: Int, - newVersion: Int, - ) { - } - - fun load(db: SQLiteDatabase, url: String, now: Long) = - db.rawQuery( - "select $COL_ID,$COL_TIME_USED,$COL_DATA from $table where $COL_URL=?", - arrayOf(url) - )?.use { cursor -> - if (cursor.moveToNext()) { - DbCache( - id = cursor.getLong(COL_ID), - timeUsed = cursor.getLong(COL_TIME_USED), - data = cursor.getBlobOrNull(COL_DATA)!! - ).apply { - if (now - timeUsed >= 5 * 3600000L) { - db.update( - table, - ContentValues().apply { - put(COL_TIME_USED, now) - }, - "$COL_ID=?", - arrayOf(id.toString()) - ) - } - } - } else { - null - } - } - - fun sweep(db: SQLiteDatabase, now: Long) { - val expire = now - TimeUnit.DAYS.toMillis(30) - db.delete( - table, - "$COL_TIME_USED < ?", - arrayOf(expire.toString()) - ) - } - - fun update(db: SQLiteDatabase, url: String, data: ByteArray) { - val now = System.currentTimeMillis() - db.replace(table, - null, - ContentValues().apply { - put(COL_URL, url) - put(COL_DATA, data) - put(COL_TIME_USED, now) - put(COL_TIME_SAVE, now) - } - ) - } - } - } - - private class DbOpenHelper(val context: Context) : - SQLiteOpenHelper(context, CACHE_DB_NAME, null, CACHE_DB_VERSION) { - - private val tables = arrayOf(DbCache) - override fun onCreate(db: SQLiteDatabase) = - tables.forEach { it.onDBCreate(db) } - - override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = - tables.forEach { it.onDBUpgrade(db, oldVersion, newVersion) } - - fun deleteDatabase() { - try { - close() - } catch (ex: Throwable) { - log.e(ex, "deleteDatabase: close() failed.") - } - try { - SQLiteDatabase.deleteDatabase(context.getDatabasePath(databaseName)) - } catch (ex: Throwable) { - log.e(ex, "deleteDatabase failed.") - } - } } private class CacheItem(val url: String, var frames: ApngFrames?) { @@ -180,7 +59,7 @@ class CustomEmojiCache( // キャンセル操作の都合上、アクセス時に排他が必要 private val queue = LinkedList() - private val dbOpenHelper = DbOpenHelper(context) + private val emojiCacheDatabase = EmojiCacheDbOpenHelper(context) private var lastSweepDbCache = 0L @@ -190,27 +69,6 @@ class CustomEmojiCache( private val workers = (1..4).map { Worker(workerLock) }.toList() - // DB処理を行い、SQLiteDatabaseCorruptExceptionを検出したらDBを削除してリトライする - private fun useDbCache(block: (SQLiteDatabase) -> T?): T? { - for (nTry in 0 until 3) { - try { - val db = dbOpenHelper.writableDatabase - if (db == null) { - log.e("useDbCache[$nTry]: writableDatabase returns null.") - break - } - return block(db) - } catch (ex: SQLiteDatabaseCorruptException) { - log.e(ex, "useDbCache[$nTry]: db corrupt!") - dbOpenHelper.deleteDatabase() - } catch (ex: Throwable) { - log.e(ex, "useDbCache[$nTry]: failed.") - break - } - } - return null - } - // ネットワーク接続が切り替わったタイミングでエラーキャッシュをクリアする fun onNetworkChanged() { cacheError.clear() @@ -293,7 +151,7 @@ class CustomEmojiCache( cache.clear() cacheError.clear() } - dbOpenHelper.deleteDatabase() + emojiCacheDatabase.deleteDatabase() } private inner class Worker(waiter: Channel) : WorkerBase(waiter) { @@ -321,7 +179,7 @@ class CustomEmojiCache( val now = System.currentTimeMillis() if (now - lastSweepDbCache >= TimeUnit.DAYS.toMillis(1)) { lastSweepDbCache = now - useDbCache { DbCache.sweep(it, now) } + emojiCacheDatabase.access { sweep(now) } } } @@ -361,7 +219,7 @@ class CustomEmojiCache( // データベースからロードしてみる ts = elapsedTime - val dbCache = useDbCache { DbCache.load(it, request.url, now) } + val dbCache = emojiCacheDatabase.access { load(request.url, now) } te = elapsedTime if (te - ts >= 200L) log.d("DbCache.load ${te - ts}ms") @@ -380,9 +238,7 @@ class CustomEmojiCache( if (te - ts >= 200L) log.d("image get? ${te - ts}ms") if (data != null) { - useDbCache { db -> - DbCache.update(db, request.url, data) - } + emojiCacheDatabase.access { update(request.url, data) } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/CustomShare.kt b/app/src/main/java/jp/juggler/subwaytooter/util/CustomShare.kt index e21ac854..6b45142f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/CustomShare.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/CustomShare.kt @@ -36,22 +36,22 @@ object CustomShare { val defaultComponentName: String? when (target) { CustomShareTarget.Translate -> { - src = PrefS.spTranslateAppComponent() + src = PrefS.spTranslateAppComponent.value defaultComponentName = translate_app_component_default } CustomShareTarget.CustomShare1 -> { - src = PrefS.spCustomShare1() + src = PrefS.spCustomShare1.value defaultComponentName = null } CustomShareTarget.CustomShare2 -> { - src = PrefS.spCustomShare2() + src = PrefS.spCustomShare2.value defaultComponentName = null } CustomShareTarget.CustomShare3 -> { - src = PrefS.spCustomShare3() + src = PrefS.spCustomShare3.value defaultComponentName = null } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/EmojiDecoder.kt b/app/src/main/java/jp/juggler/subwaytooter/util/EmojiDecoder.kt index c1aae148..cdba926d 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/EmojiDecoder.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/EmojiDecoder.kt @@ -11,12 +11,12 @@ import jp.juggler.subwaytooter.emoji.CustomEmoji import jp.juggler.subwaytooter.emoji.EmojiMap import jp.juggler.subwaytooter.emoji.UnicodeEmoji import jp.juggler.subwaytooter.pref.PrefB -import jp.juggler.subwaytooter.pref.pref import jp.juggler.subwaytooter.span.EmojiImageSpan import jp.juggler.subwaytooter.span.HighlightSpan import jp.juggler.subwaytooter.span.NetworkEmojiSpan import jp.juggler.subwaytooter.span.createSpan import jp.juggler.subwaytooter.table.HighlightWord +import jp.juggler.subwaytooter.table.daoHighlightWord import jp.juggler.util.data.asciiPattern import jp.juggler.util.data.codePointBefore import jp.juggler.util.log.LogCategory @@ -34,7 +34,7 @@ object EmojiDecoder { var useTwemoji = true fun customEmojiSeparator() = - if (PrefB.bpCustomEmojiSeparatorZwsp()) { + if (PrefB.bpCustomEmojiSeparatorZwsp.value) { '\u200B' } else { ' ' @@ -116,7 +116,8 @@ object EmojiDecoder { private fun applyHighlight(start: Int, end: Int) { val list = options.highlightTrie?.matchList(sb, start, end) ?: return for (range in list) { - val word = HighlightWord.load(range.word) ?: continue + val word = daoHighlightWord.load(range.word) + ?: continue sb.setSpan( HighlightSpan(word.color_fg, word.color_bg), range.start, @@ -175,7 +176,7 @@ object EmojiDecoder { openNormalText() sb.append(text) } - PrefB.bpUseTwemoji(context) -> { + PrefB.bpUseTwemoji.value -> { closeNormalText() val start = sb.length sb.append(text) @@ -358,10 +359,8 @@ object EmojiDecoder { val emojiMapCustom = options.emojiMapCustom val emojiMapProfile = options.emojiMapProfile - val useEmojioneShortcode = when (val context = options.context) { - null -> false - else -> PrefB.bpEmojioneShortcode(context.pref()) - } + val useEmojioneShortcode = PrefB.bpEmojioneShortcode.value + val disableEmojiAnimation = PrefB.bpDisableEmojiAnimation.value splitShortCode(s, callback = object : ShortCodeSplitterCallback { override fun onString(part: String) { @@ -383,7 +382,7 @@ object EmojiDecoder { // カスタム絵文字 fun CustomEmoji.customEmojiToUrl(): String = when { - PrefB.bpDisableEmojiAnimation() && staticUrl?.isNotEmpty() == true -> + disableEmojiAnimation && staticUrl?.isNotEmpty() == true -> this.staticUrl else -> this.url @@ -466,7 +465,7 @@ object EmojiDecoder { s: String, emojiMapCustom: HashMap? = null, ): String { - val decodeEmojioneShortcode = PrefB.bpEmojioneShortcode() + val decodeEmojioneShortcode = PrefB.bpEmojioneShortcode.value val sb = StringBuilder() @@ -506,7 +505,7 @@ object EmojiDecoder { val sb = SpannableStringBuilder() - if (PrefB.bpUseTwemoji(context)) { + if (PrefB.bpUseTwemoji.value) { val start = 0 sb.append(' ') val end = sb.length diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/HTMLDecoder.kt b/app/src/main/java/jp/juggler/subwaytooter/util/HTMLDecoder.kt index 3b08ff29..d58df990 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/HTMLDecoder.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/HTMLDecoder.kt @@ -10,6 +10,7 @@ import android.text.style.RelativeSizeSpan import android.text.style.StrikethroughSpan import android.text.style.StyleSpan import jp.juggler.subwaytooter.R +import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.entity.Acct import jp.juggler.subwaytooter.api.entity.EntityId import jp.juggler.subwaytooter.api.entity.TootMention @@ -17,8 +18,9 @@ import jp.juggler.subwaytooter.api.entity.TootStatus import jp.juggler.subwaytooter.mfm.MisskeyMarkdownDecoder import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.span.* -import jp.juggler.subwaytooter.table.AcctColor import jp.juggler.subwaytooter.table.HighlightWord +import jp.juggler.subwaytooter.table.daoAcctColor +import jp.juggler.subwaytooter.table.daoHighlightWord import jp.juggler.util.data.* import jp.juggler.util.log.LogCategory import jp.juggler.util.ui.fontSpan @@ -746,7 +748,7 @@ object HTMLDecoder { val list = options.highlightTrie?.matchList(sb, start, end) if (list != null) { for (range in list) { - val word = HighlightWord.load(range.word) ?: continue + val word = daoHighlightWord.load(range.word) ?: continue sb.setSpan( HighlightSpan(word.color_fg, word.color_bg), range.start, @@ -970,9 +972,10 @@ object HTMLDecoder { } fun decodeMentions( - linkHelper: LinkHelper, + parser: TootParser, status: TootStatus, ): Spannable? { + val linkHelper = parser.linkHelper val mentionList: List? = status.mentions val link_tag: Any = status @@ -992,8 +995,8 @@ object HTMLDecoder { val linkInfo = if (fullAcct != null) { LinkInfo( url = item.url, - caption = "@${(if (PrefB.bpMentionFullAcct()) fullAcct else item.acct).pretty}", - ac = AcctColor.load(fullAcct), + caption = "@${(if (PrefB.bpMentionFullAcct.value) fullAcct else item.acct).pretty}", + ac = daoAcctColor.load(fullAcct), mention = item, tag = link_tag ) @@ -1081,8 +1084,8 @@ object HTMLDecoder { '@' -> { fun afterFullAcctResolved(fullAcct: Acct) { - linkInfo.ac = AcctColor.load(fullAcct) - if (options.mentionFullAcct || PrefB.bpMentionFullAcct()) { + linkInfo.ac = daoAcctColor.load(fullAcct) + if (options.mentionFullAcct || PrefB.bpMentionFullAcct.value) { linkInfo.caption = "@${fullAcct.pretty}" } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/LoadIcon.kt b/app/src/main/java/jp/juggler/subwaytooter/util/LoadIcon.kt new file mode 100644 index 00000000..6b953100 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/util/LoadIcon.kt @@ -0,0 +1,46 @@ +package jp.juggler.subwaytooter.util + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import jp.juggler.util.log.LogCategory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine + +private val log = LogCategory("LoadIcon") + +@OptIn(ExperimentalCoroutinesApi::class) +suspend fun Context.loadIcon(url: String?, size: Int): Bitmap? = try { + suspendCancellableCoroutine { cont -> + @Suppress("ThrowableNotThrown") + val target = object : CustomTarget() { + override fun onLoadFailed(errorDrawable: Drawable?) { + if (cont.isActive) cont.resume(null) {} + if (!url.isNullOrEmpty()) log.w("onLoadFailed. url=$url") + } + + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + if (cont.isActive) cont.resume(resource) { resource.recycle() } + } + + override fun onLoadCleared(placeholder: Drawable?) { + if (cont.isActive) cont.resume(null) {} + if (!url.isNullOrEmpty()) log.w("onLoadCleared. url=$url") + } + } + Glide.with(this) + .asBitmap() + .load(url) + .override(size) + .into(target) + cont.invokeOnCancellation { + Glide.with(this).clear(target) + } + } +} catch (ex: Throwable) { + log.w(ex, "url=$url") + null +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/PopupAutoCompleteAcct.kt b/app/src/main/java/jp/juggler/subwaytooter/util/PopupAutoCompleteAcct.kt index 441932bc..e193051a 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/PopupAutoCompleteAcct.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/PopupAutoCompleteAcct.kt @@ -79,7 +79,7 @@ internal class PopupAutoCompleteAcct( et: MyEditText, selStart: Int, selEnd: Int, - acctList: ArrayList?, + acctList: List?, pickerCaption: String?, pickerCallback: Runnable?, ) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/PostImpl.kt b/app/src/main/java/jp/juggler/subwaytooter/util/PostImpl.kt index 0219085d..22036ec4 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/PostImpl.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/PostImpl.kt @@ -5,14 +5,16 @@ import androidx.appcompat.app.AppCompatActivity import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.entity.* +import jp.juggler.subwaytooter.auth.AuthRepo import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.emoji.CustomEmoji import jp.juggler.subwaytooter.getVisibilityString import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.span.MyClickableSpan -import jp.juggler.subwaytooter.table.AcctColor import jp.juggler.subwaytooter.table.SavedAccount -import jp.juggler.subwaytooter.table.TagSet +import jp.juggler.subwaytooter.table.daoAcctColor +import jp.juggler.subwaytooter.table.daoSavedAccount +import jp.juggler.subwaytooter.table.daoTagHistory import jp.juggler.util.* import jp.juggler.util.coroutine.AppDispatchers import jp.juggler.util.data.* @@ -88,6 +90,10 @@ class PostImpl( else -> 40 // TootPollsType.Mastodon } + private val authRepo by lazy { + AuthRepo(activity) + } + private fun preCheckPollItemOne(list: List, idx: Int, item: String) { // 選択肢が長すぎる @@ -142,7 +148,7 @@ class PostImpl( checkFun: (TootInstance) -> Boolean, ) { if (actual != extra || checkFun(instance)) return - val strVisibility = getVisibilityString(activity, account.isMisskey, extra) + val strVisibility = extra.getVisibilityString(account.isMisskey) errorApiResult( activity.getString( R.string.server_has_no_support_of_visibility, @@ -380,7 +386,7 @@ class PostImpl( } val count = tagList.size if (count > 0) { - TagSet.saveList(System.currentTimeMillis(), tagList, 0, count) + daoTagHistory.saveList(System.currentTimeMillis(), tagList, 0, count) } } } @@ -419,7 +425,7 @@ class PostImpl( error("misskey has no scheduled status API") } - if (PrefB.bpWarnHashtagAsciiAndNonAscii()) { + if (PrefB.bpWarnHashtagAsciiAndNonAscii.value) { TootTag.findHashtags(content, account.isMisskey) ?.filter { val hasAscii = reAscii.matcher(it).find() @@ -456,11 +462,14 @@ class PostImpl( } activity.confirm( - activity.getString(R.string.confirm_post_from, AcctColor.getNickname(account)), + activity.getString( + R.string.confirm_post_from, + daoAcctColor.getNickname(account) + ), account.confirm_post ) { newConfirmEnabled -> account.confirm_post = newConfirmEnabled - account.saveSetting() + daoSavedAccount.saveSetting(account) } // ボタン連打判定 @@ -538,7 +547,7 @@ class PostImpl( isPut -> Request.Builder().put(requestBody) else -> Request.Builder().post(requestBody) }.also { - if (!PrefB.bpDontDuplicationCheck()) { + if (!PrefB.bpDontDuplicationCheck.value) { val digest = (bodyString + account.acct.ascii).digestSHA256Hex() it.header("Idempotency-Key", digest) } diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/PrivacyPolicyChecker.kt b/app/src/main/java/jp/juggler/subwaytooter/util/PrivacyPolicyChecker.kt index 8cb26140..54b1353f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/PrivacyPolicyChecker.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/PrivacyPolicyChecker.kt @@ -1,27 +1,25 @@ package jp.juggler.subwaytooter.util import android.content.Context -import android.content.SharedPreferences import androidx.annotation.RawRes import androidx.appcompat.app.AlertDialog import jp.juggler.subwaytooter.ActMain import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.pref.PrefS -import jp.juggler.subwaytooter.pref.pref -import jp.juggler.subwaytooter.pref.put import jp.juggler.util.data.decodeUTF8 import jp.juggler.util.data.digestSHA256 import jp.juggler.util.data.encodeBase64Url import jp.juggler.util.data.loadRawResource +import jp.juggler.util.ui.dismissSafe +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.suspendCancellableCoroutine import java.lang.ref.WeakReference +import kotlin.coroutines.resumeWithException // 利用規約 // 同意済みかどうか調べる // 関連データを提供する -class PrivacyPolicyChecker( - val context: Context, - val pref: SharedPreferences = context.pref(), -) { +class PrivacyPolicyChecker(val context: Context) { val bytes by lazy { @RawRes val resId = when (context.getString(R.string.language_code)) { "ja" -> R.raw.privacy_policy_ja @@ -37,29 +35,39 @@ class PrivacyPolicyChecker( val agreed: Boolean get() = when { bytes.isEmpty() -> true - else -> digest == PrefS.spAgreedPrivacyPolicyDigest(pref) + else -> digest == PrefS.spAgreedPrivacyPolicyDigest.value } } -fun ActMain.checkPrivacyPolicy() { - +suspend fun ActMain.checkPrivacyPolicy() :Boolean { // 既に表示中かもしれない - if (dlgPrivacyPolicy?.get()?.isShowing == true) return - - val checker = PrivacyPolicyChecker(this, pref) + if (dlgPrivacyPolicy?.get()?.isShowing == true){ + throw CancellationException() + } // 同意ずみなら表示しない - if (checker.agreed) return + val checker = PrivacyPolicyChecker(this) + if (checker.agreed) return true - AlertDialog.Builder(this) - .setTitle(R.string.privacy_policy) - .setMessage(checker.text) - .setOnCancelListener { finish() } - .setNegativeButton(R.string.cancel) { _, _ -> finish() } - .setPositiveButton(R.string.agree) { _, _ -> - pref.edit().put(PrefS.spAgreedPrivacyPolicyDigest, checker.digest).apply() - } - .create() - .also { dlgPrivacyPolicy = WeakReference(it) } - .show() + return suspendCancellableCoroutine {cont-> + val dialog = AlertDialog.Builder(this) + .setTitle(R.string.privacy_policy) + .setMessage(checker.text) + .setOnCancelListener { finish() } + .setNegativeButton(R.string.cancel) { _, _ -> + finish() + if(cont.isActive) cont.resume(false){} + } + .setPositiveButton(R.string.agree) { _, _ -> + PrefS.spAgreedPrivacyPolicyDigest.value = checker.digest + if(cont.isActive) cont.resume(true){} + } + .setOnDismissListener { + if(cont.isActive) cont.resumeWithException(CancellationException()) + } + .create() + dlgPrivacyPolicy = WeakReference(dialog) + cont.invokeOnCancellation { dialog.dismissSafe() } + dialog.show() + } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/TootTextEncoder.kt b/app/src/main/java/jp/juggler/subwaytooter/util/TootTextEncoder.kt index d851fa6e..be691916 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/TootTextEncoder.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/TootTextEncoder.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.Intent import androidx.annotation.StringRes import jp.juggler.subwaytooter.ActText -import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.pref.PrefB @@ -184,37 +183,34 @@ object TootTextEncoder { ) { val text = when (enquete.pollType) { - TootPollsType.Misskey -> { - val sb2 = StringBuilder().append(item.decoded_text) + TootPollsType.Misskey -> StringBuilder().apply { + append(item.decoded_text) if (enquete.ownVoted) { - sb2.append(" / ") - sb2.append(context.getString(R.string.vote_count_text, item.votes)) - if (item.isVoted) sb2.append(' ').append(0x2713.toChar()) + append(" / ") + append(context.getString(R.string.vote_count_text, item.votes)) + if (item.isVoted) { + append(' ') + append(0x2713.toChar()) + } } - sb2 } - TootPollsType.FriendsNico -> { item.decoded_text } - - TootPollsType.Mastodon, TootPollsType.Notestock -> if (canVote) { - item.decoded_text - } else { - val sb2 = StringBuilder().append(item.decoded_text) - if (!canVote) { - sb2.append(" / ") - sb2.append( + TootPollsType.Mastodon, TootPollsType.Notestock -> when { + canVote -> item.decoded_text + else -> StringBuilder().apply { + append(item.decoded_text) + append(" / ") + append( when (val v = item.votes) { null -> context.getString(R.string.vote_count_unavailable) else -> context.getString(R.string.vote_count_text, v) } ) } - sb2 } } - sb.addAfterLine(text) } @@ -302,7 +298,7 @@ object TootTextEncoder { addHeader(context, sb, R.string.send_header_account_created_at, who.created_at) addHeader(context, sb, R.string.send_header_account_statuses_count, who.statuses_count) - if (!PrefB.bpHideFollowCount(App1.getAppState(context).pref)) { + if (!PrefB.bpHideFollowCount.value) { addHeader( context, sb, diff --git a/app/src/main/java/jp/juggler/subwaytooter/view/MyNetworkImageView.kt b/app/src/main/java/jp/juggler/subwaytooter/view/MyNetworkImageView.kt index 7d098e65..297acb5f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/view/MyNetworkImageView.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/view/MyNetworkImageView.kt @@ -81,7 +81,7 @@ class MyNetworkImageView : AppCompatImageView { mCornerRadius = r - val gifUrl = if (PrefB.bpEnableGifAnimation()) gifUrlArg else null + val gifUrl = if (PrefB.bpEnableGifAnimation.value) gifUrlArg else null if (gifUrl?.isNotEmpty() == true) { mUrl = gifUrl diff --git a/app/src/main/res/drawable/outline_add_reaction_24.xml b/app/src/main/res/drawable/outline_add_reaction_24.xml new file mode 100644 index 00000000..21be0a39 --- /dev/null +++ b/app/src/main/res/drawable/outline_add_reaction_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/outline_alternate_email_24.xml b/app/src/main/res/drawable/outline_alternate_email_24.xml new file mode 100644 index 00000000..713c961f --- /dev/null +++ b/app/src/main/res/drawable/outline_alternate_email_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/outline_group_add_24.xml b/app/src/main/res/drawable/outline_group_add_24.xml new file mode 100644 index 00000000..e1439efc --- /dev/null +++ b/app/src/main/res/drawable/outline_group_add_24.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/app/src/main/res/drawable/outline_poll_24.xml b/app/src/main/res/drawable/outline_poll_24.xml new file mode 100644 index 00000000..00c3cae3 --- /dev/null +++ b/app/src/main/res/drawable/outline_poll_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/act_alert.xml b/app/src/main/res/layout/act_alert.xml new file mode 100644 index 00000000..59fd93ce --- /dev/null +++ b/app/src/main/res/layout/act_alert.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/dlg_suspend_progress.xml b/app/src/main/res/layout/dlg_suspend_progress.xml new file mode 100644 index 00000000..7c3d9695 --- /dev/null +++ b/app/src/main/res/layout/dlg_suspend_progress.xml @@ -0,0 +1,29 @@ + + + + + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index a67c5ba4..664a1125 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1189,6 +1189,37 @@ 次へ [%1$s, %2$s] サーバ種別はアプリからのユーザ登録に対応していません。 保護されたカラムですが、削除しますか? + 下書きに退避して、新しい投稿を編集しますか? + + + 注意 + アプリの動作に関する注意を表示します。 + プッシュ通知 + SNSサーバから送られたプッシュ通知を表示します。 + プッシュ通知関連の処理中 + プッシュの購読や画像データのロード等、関連する処理の間は表示されます。 + 通知チェック中 + バックグラウンドでプル通知チェックを行っている間は表示されます。 + サーバータイムアウト + SNSの通知をチェックする際にサーバとの通信がタイムアウトすると表示されます。 + SNS通知 + プル通知チェックでSNSサーバから取得した情報を表示します。 + + + プッシュ配送サービス + SNSサーバからのプッシュデータを中継するのに使うプッシュ配送サービスを選択します。 + + + プッシュサービスの選択 + Google FCM(Firebase Cloud Messaging) + なし + 現在の購読をそのまま利用可能です + 現在の購読を利用できますが、既読通知はオフにしました。 + 現在の購読がありましたが、指示により削除しました。 + プッシュ配送サービスをアプリサーバに登録できていませんが、購読が不要な状況なので問題ありません。 + プッシュ配送サービスをアプリサーバに登録できていません。プッシュ配送サービスを再度選択すると改善する場合があります。 + プッシュサービスの古い情報を削除しています。 + プログレスダイアログのテスト diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b9360844..4ebb8e68 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1196,4 +1196,39 @@ Next step [%1$s, %2$s] server type does not support user registration from app. Remove column that mark as \"don\'t close\" ? + + + + Alert + shows various alerts + Push message + shows various push messages from SNS server. + Pull notification + notifications pulled from SNS + Push notification handler + Handling tasks about push notification + real-time message notifier + checking new notifications from SNS + Server timeout + showing errors about checking notifications from SNS + + + Push distributor + select push distributer that is used to deliver push notification, + + + Select push delivery service + Google FCM(Firebase Cloud Messaging) + none + Approved. + Continues current subscription. + Continues current subscription. (sendReadMessage has been turned off.) + Push subscription has been canceled. + The push delivery service has not been registered on the app server, but there is no problem because the subscription is not required. + The push delivery service could not be registered with the app server. Selecting the push delivery service again may improve the situation. + incorrect chars \"%1$s\" + Account list + Save to drafts and edit new post? + Removing old distributer… + progress dialog test diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 63721832..f1c1ad7a 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -1,19 +1,20 @@ - + - + - - + + + diff --git a/base/build.gradle b/base/build.gradle index 745cca30..03a70146 100644 --- a/base/build.gradle +++ b/base/build.gradle @@ -73,8 +73,6 @@ dependencies { api "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" api "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVersion" api "androidx.recyclerview:recyclerview:1.2.1" - api "androidx.room:room-ktx:$roomVersion" - api "androidx.room:room-runtime:$roomVersion" api "androidx.startup:startup-runtime:$startupVersion" api "androidx.work:work-runtime-ktx:$workVersion" api "androidx.work:work-runtime:$workVersion" @@ -99,6 +97,7 @@ dependencies { api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion" api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinxCoroutinesVersion" api "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$kotlinxCoroutinesVersion" + api "org.jetbrains.kotlinx:kotlinx-datetime:0.4.0" api "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1" api "ru.gildor.coroutines:kotlin-coroutines-okhttp:1.0" diff --git a/base/src/androidTest/java/jp/juggler/WebPushCryptTest.kt b/base/src/androidTest/java/jp/juggler/WebPushCryptTest.kt index 744487ca..106ebdfe 100644 --- a/base/src/androidTest/java/jp/juggler/WebPushCryptTest.kt +++ b/base/src/androidTest/java/jp/juggler/WebPushCryptTest.kt @@ -4,7 +4,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import jp.juggler.crypt.* import jp.juggler.util.data.decodeBase64 import jp.juggler.util.data.decodeJsonObject -import jp.juggler.util.log.AdbLog +import jp.juggler.util.log.LogCategory import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith @@ -13,6 +13,9 @@ import java.security.interfaces.ECPrivateKey @Suppress("SpellCheckingInspection") @RunWith(AndroidJUnit4::class) class WebPushCryptTest { + companion object { + private val log = LogCategory("WebPushCryptTest") + } // https://developers.google.com/web/updates/2016/03/web-push-encryption @@ -188,7 +191,7 @@ class WebPushCryptTest { .decodeBase64() Aes128GcmDecoder(body.byteRangeReader()).run { - AdbLog.i("recordSize=$recordSize, keyId.size=${keyId.size}") + log.i("recordSize=$recordSize, keyId.size=${keyId.size}") assertEquals( "salt", @@ -238,7 +241,7 @@ class WebPushCryptTest { .decodeBase64() Aes128GcmDecoder(rawBody.byteRangeReader()).run { - AdbLog.i("recordSize=$recordSize, keyId.size=${keyId.size}, encryptedContent.size=${encryptedContent.size}") + log.i("recordSize=$recordSize, keyId.size=${keyId.size}, encryptedContent.size=${encryptedContent.size}") deriveKeyRfc8188(inputKey.toByteRange()) decode() }.decodeUTF8().let { diff --git a/base/src/main/java/jp/juggler/crypt/ByteRange.kt b/base/src/main/java/jp/juggler/crypt/ByteRange.kt index dfb82911..9357f765 100644 --- a/base/src/main/java/jp/juggler/crypt/ByteRange.kt +++ b/base/src/main/java/jp/juggler/crypt/ByteRange.kt @@ -39,7 +39,7 @@ class ByteRange( encodeBase64String(toByteArray()) fun decodeUTF8() = - java.lang.String(ba, start, size, UTF8) + String(ba, start, size, StandardCharsets.UTF_8) fun copyElements( dst: ByteArray, diff --git a/base/src/main/java/jp/juggler/util/data/ColumnMeta.kt b/base/src/main/java/jp/juggler/util/data/ColumnMeta.kt index e5358491..2a7791d9 100644 --- a/base/src/main/java/jp/juggler/util/data/ColumnMeta.kt +++ b/base/src/main/java/jp/juggler/util/data/ColumnMeta.kt @@ -3,9 +3,12 @@ package jp.juggler.util.data import android.content.ContentValues import android.database.Cursor import android.database.sqlite.SQLiteDatabase +import android.provider.BaseColumns import androidx.annotation.IntRange import jp.juggler.util.log.LogCategory +private val log = LogCategory("ColumnMeta") + ///////////////////////////////////////////////////////////// // SQLite にBooleanをそのまま保存することはできないのでInt型との変換が必要になる @@ -75,14 +78,18 @@ class ColumnMeta( val version: Int, val name: String, val typeSpec: String, - val primary: Boolean = false, ) : Comparable { companion object { - private val log = LogCategory("ColumnMeta") + const val TS_INT_PRIMARY_KEY = "INTEGER PRIMARY KEY" + const val TS_INT_PRIMARY_KEY_NOT_NULL = "INTEGER NOT NULL PRIMARY KEY" const val TS_EMPTY = "text default ''" + const val TS_EMPTY_NOT_NULL = "text not null default ''" const val TS_ZERO = "integer default 0" + const val TS_ZERO_NOT_NULL = "integer not null default 0" const val TS_TRUE = "integer default 1" + const val TS_TEXT_NULL = "blob default null" + const val TS_BLOB_NULL = "blob default null" } class List( @@ -126,6 +133,8 @@ class ColumnMeta( } } + val primary = typeSpec.contains("primary", ignoreCase = true) + // テーブル作成時のソート override fun compareTo(other: ColumnMeta): Int { // プライマリキーを先頭にする @@ -150,20 +159,49 @@ class ColumnMeta( fun getLong(cursor: Cursor) = cursor.getLong(getIndex(cursor)) } -fun ContentValues.putNull(key: ColumnMeta) = putNull(key.name) -fun ContentValues.put(key: ColumnMeta, v: Boolean?) = put(key.name, v?.b2i()) -fun ContentValues.put(key: ColumnMeta, v: String?) = put(key.name, v) -fun ContentValues.put(key: ColumnMeta, v: Byte?) = put(key.name, v) -fun ContentValues.put(key: ColumnMeta, v: Short?) = put(key.name, v) -fun ContentValues.put(key: ColumnMeta, v: Int?) = put(key.name, v) -fun ContentValues.put(key: ColumnMeta, v: Long?) = put(key.name, v) -fun ContentValues.put(key: ColumnMeta, v: Float?) = put(key.name, v) -fun ContentValues.put(key: ColumnMeta, v: Double?) = put(key.name, v) -fun ContentValues.put(key: ColumnMeta, v: ByteArray?) = put(key.name, v) +//fun ContentValues.putNull(key: ColumnMeta) = putNull(key.name) +//fun ContentValues.put(key: ColumnMeta, v: Boolean?) = put(key.name, v?.b2i()) +//fun ContentValues.put(key: ColumnMeta, v: String?) = put(key.name, v) +//fun ContentValues.put(key: ColumnMeta, v: Byte?) = put(key.name, v) +//fun ContentValues.put(key: ColumnMeta, v: Short?) = put(key.name, v) +//fun ContentValues.put(key: ColumnMeta, v: Int?) = put(key.name, v) +//fun ContentValues.put(key: ColumnMeta, v: Long?) = put(key.name, v) +//fun ContentValues.put(key: ColumnMeta, v: Float?) = put(key.name, v) +//fun ContentValues.put(key: ColumnMeta, v: Double?) = put(key.name, v) +//fun ContentValues.put(key: ColumnMeta, v: ByteArray?) = put(key.name, v) fun Cursor.getInt(key: ColumnMeta) = getInt(key.name) -fun Cursor.getBoolean(key: ColumnMeta) = getInt(key.name).i2b() +fun Cursor.getBoolean(key: ColumnMeta,defVal:Boolean = false) = + getIntOrNull(key.name)?.i2b()?:defVal +fun Cursor.getBoolean(key: String,defVal:Boolean = false) = + getIntOrNull(key)?.i2b()?:defVal +fun Cursor.getBoolean(idx:Int,defVal:Boolean = false) = + getIntOrNull(idx)?.i2b()?:defVal fun Cursor.getLong(key: ColumnMeta) = getLong(key.name) fun Cursor.getIntOrNull(key: ColumnMeta) = getIntOrNull(key.name) fun Cursor.getString(key: ColumnMeta): String = getString(key.name) fun Cursor.getStringOrNull(key: ColumnMeta): String? = getStringOrNull(key.name) + +fun ContentValues.replaceTo(db: SQLiteDatabase, table: String) = + db.replace(table, null, this) + +fun ContentValues.updateTo( + db: SQLiteDatabase, + table: String, + id: String, + colName: String = BaseColumns._ID, +) = db.update(table, this, "$colName=?", arrayOf(id)) + +fun SQLiteDatabase.deleteById(table: String, id: String, colName: String = BaseColumns._ID) = + delete(table, "$colName=?", arrayOf(id)) + +fun SQLiteDatabase.queryById( + table: String, + id: String, + colName: String = BaseColumns._ID, +): Cursor? = rawQuery("select * from $table where $colName=?", arrayOf(id)) + +fun SQLiteDatabase.queryAll( + table: String, + orderPhrase: String, +): Cursor? = rawQuery("select * from $table order by $orderPhrase", emptyArray()) diff --git a/base/src/main/java/jp/juggler/util/data/JsonDelegate.kt b/base/src/main/java/jp/juggler/util/data/JsonDelegate.kt index 4cf099e8..21118849 100644 --- a/base/src/main/java/jp/juggler/util/data/JsonDelegate.kt +++ b/base/src/main/java/jp/juggler/util/data/JsonDelegate.kt @@ -9,7 +9,7 @@ annotation class JsonPropBoolean(val key: String, val defVal: Boolean) class JsonDelegate(val parent: JsonDelegates) -class JsonDelegates(val src: JsonObject) { +class JsonDelegates(val jsonGetter: () -> JsonObject) { val int = JsonDelegate(this) val string = JsonDelegate(this) val boolean = JsonDelegate(this) @@ -20,10 +20,10 @@ private fun getMetaString(property: KProperty<*>) = ?: error("${property.name}, required=String, defined=(missing)") operator fun JsonDelegate.getValue(thisRef: Any?, property: KProperty<*>): String = - getMetaString(property).let { parent.src.string(it.key) ?: it.defVal } + getMetaString(property).let { parent.jsonGetter().string(it.key) ?: it.defVal } operator fun JsonDelegate.setValue(thisRef: Any?, property: KProperty<*>, value: String) { - getMetaString(property).let { parent.src[it.key] = value } + getMetaString(property).let { parent.jsonGetter()[it.key] = value } } private fun getMetaInt(property: KProperty<*>) = @@ -31,10 +31,10 @@ private fun getMetaInt(property: KProperty<*>) = ?: error("${property.name}, required=Int, defined=(missing)") operator fun JsonDelegate.getValue(thisRef: Any?, property: KProperty<*>): Int = - getMetaInt(property).let { parent.src.int(it.key) ?: it.defVal } + getMetaInt(property).let { parent.jsonGetter().int(it.key) ?: it.defVal } operator fun JsonDelegate.setValue(thisRef: Any?, property: KProperty<*>, value: Int) { - getMetaInt(property).let { parent.src[it.key] = value } + getMetaInt(property).let { parent.jsonGetter()[it.key] = value } } private fun getMetaBoolean(property: KProperty<*>) = @@ -42,8 +42,8 @@ private fun getMetaBoolean(property: KProperty<*>) = ?: error("${property.name}, required=Boolean, defined=(missing)") operator fun JsonDelegate.getValue(thisRef: Any?, property: KProperty<*>): Boolean = - getMetaBoolean(property).let { parent.src.boolean(it.key) ?: it.defVal } + getMetaBoolean(property).let { parent.jsonGetter().boolean(it.key) ?: it.defVal } operator fun JsonDelegate.setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) { - getMetaBoolean(property).let { parent.src[it.key] = value } + getMetaBoolean(property).let { parent.jsonGetter()[it.key] = value } } diff --git a/base/src/main/java/jp/juggler/util/data/PrimitiveUtils.kt b/base/src/main/java/jp/juggler/util/data/PrimitiveUtils.kt index 2e75f094..5a9961fd 100644 --- a/base/src/main/java/jp/juggler/util/data/PrimitiveUtils.kt +++ b/base/src/main/java/jp/juggler/util/data/PrimitiveUtils.kt @@ -25,10 +25,10 @@ fun > T.clip(min: T, max: T) = // usage: number.notZero() ?: fallback // equivalent: if(this != 0 ) this else null -fun Int.notZero(): Int? = if (this != 0) this else null -fun Long.notZero(): Long? = if (this != 0L) this else null -fun Float.notZero(): Float? = if (this != 0f) this else null -fun Double.notZero(): Double? = if (this != .0) this else null +fun Int?.notZero(): Int? = if (this != null && this != 0) this else null +fun Long?.notZero(): Long? = if (this != null && this != 0L) this else null +fun Float?.notZero(): Float? = if (this != null && this != 0f) this else null +fun Double?.notZero(): Double? = if (this != null && this != .0) this else null //////////////////////////////////////////////////////////////////// // boolean diff --git a/base/src/main/java/jp/juggler/util/data/WordTrieTree.kt b/base/src/main/java/jp/juggler/util/data/WordTrieTree.kt index 0441eb3c..b54d67d5 100644 --- a/base/src/main/java/jp/juggler/util/data/WordTrieTree.kt +++ b/base/src/main/java/jp/juggler/util/data/WordTrieTree.kt @@ -66,6 +66,9 @@ class WordTrieTree { val isEmpty: Boolean get() = nodeRoot.childNodes.size() == 0 + val isNotEmpty: Boolean + get() = nodeRoot.childNodes.size() != 0 + // 単語の追加 fun add( s: String, diff --git a/base/src/main/java/jp/juggler/util/log/AdbLog.kt b/base/src/main/java/jp/juggler/util/log/AdbLog.kt deleted file mode 100644 index 82fe961c..00000000 --- a/base/src/main/java/jp/juggler/util/log/AdbLog.kt +++ /dev/null @@ -1,174 +0,0 @@ -package jp.juggler.util.log - -import android.util.Log -import jp.juggler.base.BuildConfig -import jp.juggler.util.data.notEmpty -import java.io.PrintWriter -import java.io.StringWriter -import kotlin.math.min - -object AdbLog { - private const val APP_TAG = "AppTag_PushReceiver" - private const val MAX_LOG_LENGTH = 4000 - - @Suppress("RegExpSimplifiable") - val reAnonymousClass = """(\.\$[0-9]+)+$""".toRegex() - - private val callStackTag: String - get() { - // DO NOT switch this to Thread.getCurrentThread().getStackTrace(). The test will pass - // because Robolectric runs them on the JVM but on Android the elements are different. - @Suppress("ThrowingExceptionsWithoutMessageOrCause") - val trace = Throwable().stackTrace - val stackTraceElement = trace.elementAtOrNull(4) - ?: error("callStackTag: stacktrace didn't have enough elements: are you using proguard?") - - stackTraceElement.fileName.notEmpty() - ?.let { - when (val lastDotPos = it.lastIndexOf('.')) { - -1 -> it - else -> it.substring(0, lastDotPos) - } - } - ?.let { return it } - - return reAnonymousClass.replace(stackTraceElement.className, "") - .let { it.substring(it.lastIndexOf('.') + 1) } - } - - private fun Throwable.dump(): String { - // Don't replace this with Log.getStackTraceString() - it hides - // UnknownHostException, which is not what we want. - val sw = StringWriter(256) - val pw = PrintWriter(sw, false) - this.printStackTrace(pw) - pw.flush() - return sw.toString() - } - - private fun printlnOrWtf(priority: Int, message: String) { - try { - if (priority == Log.ASSERT) { - Log.wtf(APP_TAG, message) - } else { - Log.println(priority, APP_TAG, message) - } - } catch (ignored: Throwable) { - // 単体テストで呼ばれた? - println(message) - } - } - - private inline fun splitLines( - prefix: String, - message: CharSequence, - block: (CharSequence) -> Unit, - ) { - val limit = MAX_LOG_LENGTH - prefix.length - if (message.length < limit) { - block(prefix + message) - } else { - // Split by line, then ensure each line can fit into Log's maximum length. - val length = message.length - var i = 0 - while (i < length) { - val newline = message.indexOf('\n', i).takeIf { it >= 0 } ?: length - do { - val end = min(newline, i + limit) - val part = message.subSequence(i, end) - block(prefix + part) - i = end - } while (i < newline) - // skip \n - ++i - } - } - } - - fun log( - priority: Int, - ex: Throwable? = null, - messageArg: String? = null, - prefixArg: String? = null, - ) { - if (priority < Log.INFO && !BuildConfig.DEBUG) return - - // 本文の先頭に付与するprefix - val tag = (prefixArg ?: callStackTag) - val prefix = when { - tag.isBlank() -> "" - else -> "$tag: " - } - - val stackTrace = ex?.dump() - - // 本文とスタックトレース - val sb = StringBuilder() - val message = messageArg?.takeIf { it.isNotEmpty() }?.also { sb.append(it) } - if (stackTrace != null) { - if (message != null) sb.append("\n") - sb.append(stackTrace) - } - - splitLines(prefix, sb) { - printlnOrWtf(priority, it.toString()) - } -// LogEntity.saveLog(priority, tag, message, stackTrace) - } - - @JvmStatic - fun v(msg: String) = log(Log.VERBOSE, messageArg = msg) - - @JvmStatic - fun d(msg: String) = log(Log.DEBUG, messageArg = msg) - - @JvmStatic - fun i(msg: String) = log(Log.INFO, messageArg = msg) - - @JvmStatic - fun w(msg: String) = log(Log.WARN, messageArg = msg) - - @JvmStatic - fun e(msg: String) = log(Log.ERROR, messageArg = msg) - - @JvmStatic - fun wtf(msg: String) = log(Log.ASSERT, messageArg = msg) - - @JvmStatic - fun recordFirebaseCrashlytics(ex: Throwable?) { - try { - ex ?: return -// FirebaseCrashlytics.getInstance().recordException(ex) - } catch (ignored: Throwable) { - // 単体テストで FirebaseCrashlytics.getInstance() が例外を出す - } - } - - @JvmStatic - @JvmOverloads - fun i(ex: Throwable?, msg: String? = null) { - recordFirebaseCrashlytics(ex) - log(Log.INFO, ex = ex, messageArg = msg) - } - - @JvmStatic - @JvmOverloads - fun w(ex: Throwable?, msg: String? = null) { - recordFirebaseCrashlytics(ex) - log(Log.WARN, ex = ex, messageArg = msg) - } - - @JvmStatic - @JvmOverloads - fun e(ex: Throwable?, msg: String? = null) { - recordFirebaseCrashlytics(ex) - log(Log.ERROR, ex = ex, messageArg = msg) - } - - @JvmStatic - @JvmOverloads - fun wtf(ex: Throwable?, msg: String? = null) { - recordFirebaseCrashlytics(ex) - log(Log.ASSERT, ex = ex, messageArg = msg) - } -} diff --git a/base/src/main/java/jp/juggler/util/log/Benchmark.kt b/base/src/main/java/jp/juggler/util/log/Benchmark.kt index 4fd5562a..47d7cbb8 100644 --- a/base/src/main/java/jp/juggler/util/log/Benchmark.kt +++ b/base/src/main/java/jp/juggler/util/log/Benchmark.kt @@ -3,11 +3,11 @@ package jp.juggler.util.log import android.os.SystemClock import jp.juggler.base.BuildConfig -private val log = LogCategory("Benchmark") +val benchmarkLog = LogCategory("Benchmark") val benchmarkLimitDefault = if (BuildConfig.DEBUG) 10L else 100L -fun benchmark( +inline fun benchmark( caption: String, limit: Long = benchmarkLimitDefault, block: () -> T, @@ -15,7 +15,7 @@ fun benchmark( val start = SystemClock.elapsedRealtime() val rv = block() val duration = SystemClock.elapsedRealtime() - start - if (duration >= limit) log.w("benchmark: ${duration}ms : $caption") + if (duration >= limit) benchmarkLog.w("benchmark: ${duration}ms : $caption") return rv } diff --git a/base/src/main/java/jp/juggler/util/os/AppStandbyBucketUtils.kt b/base/src/main/java/jp/juggler/util/os/AppStandbyBucketUtils.kt new file mode 100644 index 00000000..210543f2 --- /dev/null +++ b/base/src/main/java/jp/juggler/util/os/AppStandbyBucketUtils.kt @@ -0,0 +1,40 @@ +package jp.juggler.util.os + +import android.app.ActivityManager +import jp.juggler.util.log.LogCategory + +private val log = LogCategory("AppStandbyCheck") + +private val importanceMap = listOf( + 100 to "IMPORTANCE_FOREGROUND", + 125 to "IMPORTANCE_FOREGROUND_SERVICE", + 130 to "IMPORTANCE_PERCEPTIBLE_PRE_26", + 150 to "IMPORTANCE_TOP_SLEEPING_PRE_28", + 170 to "IMPORTANCE_CANT_SAVE_STATE_PRE_26", + 200 to "IMPORTANCE_VISIBLE", + 230 to "IMPORTANCE_PERCEPTIBLE", + 300 to "IMPORTANCE_SERVICE", + 325 to "IMPORTANCE_TOP_SLEEPING", + 350 to "IMPORTANCE_CANT_SAVE_STATE", + 400 to "IMPORTANCE_CACHED", + 500 to "IMPORTANCE_EMPTY", + 1000 to "IMPORTANCE_GONE", +) + +fun importanceString(n: Int): String = + importanceMap.firstOrNull { it.first >= n }?.second ?: "(not found)" + +fun checkAppForeground(caption: String) { + val appProcessInfo = ActivityManager.RunningAppProcessInfo() + ActivityManager.getMyMemoryState(appProcessInfo) + when (val importance = appProcessInfo.importance) { + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, + ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE, + -> { + log.i("$caption: app is foreground. $importance ${importanceString(importance)} thread=${Thread.currentThread().name}") + } + else -> { + log.w("$caption: app is background. $importance ${importanceString(importance)} thread=${Thread.currentThread().name}") + } + } +} diff --git a/base/src/main/java/jp/juggler/util/os/ContextUtils.kt b/base/src/main/java/jp/juggler/util/os/ContextUtils.kt new file mode 100644 index 00000000..44832db8 --- /dev/null +++ b/base/src/main/java/jp/juggler/util/os/ContextUtils.kt @@ -0,0 +1,11 @@ +package jp.juggler.util.os + +import android.content.Context + +/** + * インストゥルメントテストのContextは + * applicationContext がnullを返す。 + * この場合は元のcontextを補うのがベストだろう。 + */ +val Context.applicationContextSafe: Context + get() = applicationContext ?: this diff --git a/base/src/main/java/jp/juggler/util/time/KotlinxDateTimeUtils.kt b/base/src/main/java/jp/juggler/util/time/KotlinxDateTimeUtils.kt new file mode 100644 index 00000000..1e0a73e8 --- /dev/null +++ b/base/src/main/java/jp/juggler/util/time/KotlinxDateTimeUtils.kt @@ -0,0 +1,47 @@ +/** + * kotlinx-datetime を使った日時関連のユーティリティ + * - api "org.jetbrains.kotlinx:kotlinx-datetime:0.4.0" + * - desugar で Java 8 の日時APIを使えるようにする必要がある + * - https://developer.android.com/studio/write/java8-support?hl=ja + * - coreLibraryDesugaringEnabled true + * - coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.2.2" + */ +package jp.juggler.util.time + +import jp.juggler.util.log.LogCategory +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +private val log = LogCategory("TimeUtils") + +/** + * kotlinx-datetime の Instant.parse は ISO8601フォーマットを受け付ける + */ +fun String.parseTimeIso8601() = + when { + isBlank() -> null + else -> try { + Instant.parse(this).toEpochMilliseconds() + } catch (ex: Throwable) { + log.w("parseTime failed. $this") + null + } + } + +/** + * UI表示用。フォーマットは適当。 + */ +fun Long.formatLocalTime(): String { + val tz = TimeZone.currentSystemDefault() + val lt = Instant.fromEpochMilliseconds(this).toLocalDateTime(tz) + return "%d/%02d/%02d %02d:%02d:%02d.%03d".format( + lt.year, + lt.monthNumber, + lt.dayOfMonth, + lt.hour, + lt.minute, + lt.second, + lt.nanosecond / 1_000_000, + ) +} diff --git a/build.gradle b/build.gradle index 9cea5e3e..3c31e12d 100644 --- a/build.gradle +++ b/build.gradle @@ -17,7 +17,6 @@ buildscript { ext.kotlinxCoroutinesVersion = "1.6.4" ext.lifecycleVersion = "2.5.1" ext.okhttpVersion = "4.10.0" - ext.roomVersion = "2.5.0" ext.stBuildToolsVersion = "33.0.1" ext.stCompileSdkVersion = 33 ext.stMinSdkVersion = 26 diff --git a/readRoomSchemaError.pl b/readRoomSchemaError.pl new file mode 100644 index 00000000..6acf0acd --- /dev/null +++ b/readRoomSchemaError.pl @@ -0,0 +1,75 @@ +#!/usr/bin/perl -- +use strict; +use warnings; +use feature qw(say); +use Data::Dump qw(dump); + +my $text ; +my $start; +my $end; + + +my $expected; +my $found; +my $target; +while(){ + s/\A\s+//; + s/\s+\z//; + if( not length){ + next; + if($_ eq "Expected:"){ + $target = \$expected; + }elsif($_ eq "Found:"){ + $target = \$found; + }else{ + $$target = $_; + last if $expected and $found; + } +} +$found or die "missing 'Found' schema."; +$expected or die "missing 'Found' schema."; + +sub parse{ + my($line)=@_; + my @result; + while( $line =~ s/([\w_]+=Column{[^}]+}),? ?//){ + push @result,$1; + } + while( $line =~ s/(Index{[^}]+}),? ?//){ + push @result,$1; + } + push @result,$line; + return [sort @result]; +} + +my $f = parse($found); +my $e = parse($expected); + +my + + + + +TableInfo{name='acct_color', columns={ +nick=Column{name='nick', type='TEXT', affinity='2', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, +time_save=Column{name='time_save', type='INTEGER', affinity='3', notNull=true, primaryKeyPosition=0, defaultValue='undefined'}, +ac=Column{name='ac', type='TEXT', affinity='2', notNull=true, primaryKeyPosition=0, defaultValue='undefined'}, +cf=Column{name='cf', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, +notification_sound=Column{name='notification_sound', type='TEXT', affinity='2', notNull=true, primaryKeyPosition=0, defaultValue='undefined'}, +_id=Column{name='_id', type='INTEGER', affinity='3', notNull=true, primaryKeyPosition=1, defaultValue='undefined'}, +cb=Column{name='cb', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'} +}, foreignKeys=[], indices=[ +Index{name='acct_color_time', unique=false, columns=[time_save], orders=[ASC]'}, +Index{name='acct_color_acct', unique=true, columns=[ac], orders=[ASC]'} +]} +Found: +TableInfo{name='acct_color', columns={_id=Column{name='_id', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=1, defaultValue='undefined'}, ac=Column{name='ac', type='text', affinity='2', notNull=true, primaryKeyPosition=0, defaultValue='undefined'}, cb=Column{name='cb', type='integer', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, cf=Column{name='cf', type='integer', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, nick=Column{name='nick', type='text', affinity='2', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, notification_sound=Column{name='notification_sound', type='text', affinity='2', notNull=false, primaryKeyPosition=0, defaultValue=''''}, time_save=Column{name='time_save', type='integer', affinity='3', notNull=true, primaryKeyPosition=0, defaultValue='undefined'}}, foreignKeys=[], indices=[Index{name='acct_color_time', unique=false, columns=[time_save], orders=[ASC]'}, Index{name='acct_color_acct', unique=true, columns=[ac], orders=[ASC]'}]} + + + + +__END__ +Expected: +TableInfo{name='acct_color', columns={nick=Column{name='nick', type='TEXT', affinity='2', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, time_save=Column{name='time_save', type='INTEGER', affinity='3', notNull=true, primaryKeyPosition=0, defaultValue='undefined'}, ac=Column{name='ac', type='TEXT', affinity='2', notNull=true, primaryKeyPosition=0, defaultValue='undefined'}, cf=Column{name='cf', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, notification_sound=Column{name='notification_sound', type='TEXT', affinity='2', notNull=true, primaryKeyPosition=0, defaultValue='undefined'}, _id=Column{name='_id', type='INTEGER', affinity='3', notNull=true, primaryKeyPosition=1, defaultValue='undefined'}, cb=Column{name='cb', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}}, foreignKeys=[], indices=[Index{name='acct_color_time', unique=false, columns=[time_save], orders=[ASC]'}, Index{name='acct_color_acct', unique=true, columns=[ac], orders=[ASC]'}]} +Found: +TableInfo{name='acct_color', columns={_id=Column{name='_id', type='INTEGER', affinity='3', notNull=false, primaryKeyPosition=1, defaultValue='undefined'}, ac=Column{name='ac', type='text', affinity='2', notNull=true, primaryKeyPosition=0, defaultValue='undefined'}, cb=Column{name='cb', type='integer', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, cf=Column{name='cf', type='integer', affinity='3', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, nick=Column{name='nick', type='text', affinity='2', notNull=false, primaryKeyPosition=0, defaultValue='undefined'}, notification_sound=Column{name='notification_sound', type='text', affinity='2', notNull=false, primaryKeyPosition=0, defaultValue=''''}, time_save=Column{name='time_save', type='integer', affinity='3', notNull=true, primaryKeyPosition=0, defaultValue='undefined'}}, foreignKeys=[], indices=[Index{name='acct_color_time', unique=false, columns=[time_save], orders=[ASC]'}, Index{name='acct_color_acct', unique=true, columns=[ac], orders=[ASC]'}]}