package jp.juggler.subwaytooter.actmain 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 import jp.juggler.subwaytooter.action.conversationOtherInstance import jp.juggler.subwaytooter.action.openActPostImpl import jp.juggler.subwaytooter.action.userProfile import jp.juggler.subwaytooter.api.auth.Auth2Result import jp.juggler.subwaytooter.api.auth.AuthBase import jp.juggler.subwaytooter.api.auth.authRepo import jp.juggler.subwaytooter.api.entity.Acct import jp.juggler.subwaytooter.api.entity.TootAccount import jp.juggler.subwaytooter.api.entity.TootStatus.Companion.findStatusIdFromUrl import jp.juggler.subwaytooter.api.entity.TootVisibility import jp.juggler.subwaytooter.api.runApiTask2 import jp.juggler.subwaytooter.api.showApiError import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.column.startLoading import jp.juggler.subwaytooter.dialog.DlgConfirm.okDialog import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.dialog.pickAccount import jp.juggler.subwaytooter.dialog.runInProgress 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.FcmFlavor 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.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.unifiedpush.android.connector.UnifiedPush private val log = LogCategory("ActMainIntent") // ActOAuthCallbackで受け取ったUriを処理する fun ActMain.handleIntentUri(uri: Uri) { try { log.i("handleIntentUri $uri") when (uri.scheme) { FcmFlavor.CUSTOM_SCHEME -> handleCustomSchemaUri(uri) else -> handleOtherUri(uri) } } catch (ex: Throwable) { log.e(ex, "handleIntentUri failed.") showToast(ex, "handleIntentUri failed.") } } fun ActMain.handleOtherUri(uri: Uri): Boolean { val url = uri.toString() if (uri.scheme == "web+activitypub" && uri.authority == "post") { val postUri = uri.pathSegments?.elementAtOrNull(0) log.i("postUri=$postUri") if (!postUri.isNullOrEmpty()) { conversationOtherInstance( pos = defaultInsertPosition, urlArg = postUri, statusIdOriginal = null, hostAccess = null, statusIdAccess = null, isReference = false, ) return true } } url.findStatusIdFromUrl()?.let { statusInfo -> // ステータスをアプリ内で開く conversationOtherInstance( defaultInsertPosition, statusInfo.url, statusInfo.statusId, statusInfo.host, statusInfo.statusId, isReference = statusInfo.isReference, ) return true } TootAccount.reAccountUrl.matcher(url).takeIf { it.find() }?.let { m -> // ユーザページをアプリ内で開く val host = m.groupEx(1)!! val user = m.groupEx(2)!!.decodePercent() val instance = m.groupEx(3)?.decodePercent() if (instance?.isNotEmpty() == true) { userProfile( defaultInsertPosition, null, Acct.parse(user, instance), userUrl = "https://$instance/@$user", originalUrl = url ) } else { userProfile( defaultInsertPosition, null, acct = Acct.parse(user, host), userUrl = url, ) } return true } TootAccount.reAccountUrl2.matcher(url).takeIf { it.find() }?.let { m -> // intentFilterの都合でこの形式のURLが飛んでくることはないのだが…。 val host = m.groupEx(1)!! val user = m.groupEx(2)!!.decodePercent() userProfile( defaultInsertPosition, null, acct = Acct.parse(user, host), userUrl = url, ) return true } // このアプリでは処理できないURLだった // 外部ブラウザを開きなおそうとすると無限ループの恐れがある // アプリケーションチューザーを表示する val errorMessage = getString(R.string.cant_handle_uri_of, url) try { // Android 6.0以降 // MATCH_DEFAULT_ONLY だと標準の設定に指定されたアプリがあるとソレしか出てこない // MATCH_ALL を指定すると 以前と同じ挙動になる val queryFlag = PackageManager.MATCH_ALL // queryIntentActivities に渡すURLは実在しないホストのものにする val intent = Intent(Intent.ACTION_VIEW, "https://dummy.subwaytooter.club/".toUri()) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK val myName = packageName val resolveInfoList = packageManager.queryIntentActivitiesCompat(intent, queryFlag) .filter { myName != it.activityInfo.packageName } if (resolveInfoList.isEmpty()) error("resolveInfoList is empty.") // このアプリ以外の選択肢を集める val choiceList = resolveInfoList .map { Intent(Intent.ACTION_VIEW, uri).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) `package` = it.activityInfo.packageName setClassName(it.activityInfo.packageName, it.activityInfo.name) } }.toMutableList() val chooser = Intent.createChooser(choiceList.removeAt(0), errorMessage) // 2つめ以降はEXTRAに渡す chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, choiceList.toTypedArray()) // 指定した選択肢でチューザーを作成して開く startActivity(chooser) return true } catch (ex: Throwable) { log.e(ex, "can't open app to handle intent.") } AlertDialog.Builder(this) .setCancelable(true) .setMessage(errorMessage) .setPositiveButton(R.string.close, null) .show() return false } private fun ActMain.handleCustomSchemaUri(uri: Uri) = launchAndShowError { val dataIdString = uri.getQueryParameter("db_id") if (dataIdString == null) { // OAuth2 認証コールバック // subwaytooter://oauth(\d*)/?... handleOAuth2Callback(uri) } else { // ${FcmFlavor.customScheme}://notification_click/?db_id=(db_id) handleNotificationClick(uri, dataIdString) } } private fun ActMain.handleNotificationClick(uri: Uri, dataIdString: String) { try { val account = dataIdString.toLongOrNull() ?.let { daoSavedAccount.loadAccount(it) } if (account == null) { showToast(true, "handleNotificationClick: missing SavedAccount. id=$dataIdString") return } pushRepo.onTapNotification(account) recycleClickedNotification(this, uri) val columnList = appState.columnList val column = columnList.firstOrNull { it.type == ColumnType.NOTIFICATIONS && it.accessInfo == account && !it.systemNotificationNotRelated }?.also { scrollToColumn(columnList.indexOf(it)) } ?: addColumn( true, defaultInsertPosition, account, ColumnType.NOTIFICATIONS ) // 通知を読み直す if (!column.bInitialLoading) column.startLoading() } catch (ex: Throwable) { log.e(ex, "handleNotificationClick failed.") } } private fun ActMain.handleOAuth2Callback(uri: Uri) { launchMain { try { val auth2Result = runApiTask2 { client -> AuthBase.findAuthForAuthCallback(client, uri.toString()) .authStep2(uri) } afterAccountVerify(auth2Result) } catch (ex: Throwable) { showApiError(ex) } } } val accountVerifyMutex = Mutex() /** * アカウントを確認した後に呼ばれる * @return 何かデータを更新したら真 */ suspend fun ActMain.afterAccountVerify(auth2Result: Auth2Result): Boolean = auth2Result.run { accountVerifyMutex.withLock { // ユーザ情報中のacctはfull acct ではないので、組み立てる val newAcct = Acct.parse(tootAccount.username, apDomain) // full acctだよな? """\A[^@]+@[^@]+\z""".toRegex().find(newAcct.ascii) ?: error("afterAccountAdd: incorrect userAcct. ${newAcct.ascii}") // 「アカウント追加のハズが既存アカウントで認証していた」 // 「アクセストークン更新のハズが別アカウントで認証していた」 // などを防止するため、full acctでアプリ内DBを検索 when (val sa = daoSavedAccount.loadAccountByAcct(newAcct)) { null -> afterAccountAdd(newAcct, auth2Result) else -> afterAccessTokenUpdate(auth2Result, sa) } } } private suspend fun ActMain.afterAccessTokenUpdate( auth2Result: Auth2Result, sa: SavedAccount, ): Boolean { log.i("afterAccessTokenUpdate token ${sa.bearerAccessToken ?: sa.misskeyApiToken} =>${auth2Result.tokenJson}") // DBの情報を更新する authRepo.updateTokenInfo(sa, auth2Result) // 各カラムの持つアカウント情報をリロードする reloadAccountSetting(daoSavedAccount.loadAccountList()) // 自動でリロードする appState.columnList .filter { it.accessInfo == sa } .forEach { it.startLoading() } // 通知の更新が必要かもしれない checkNotificationImmediateAll(this, onlyEnqueue = true) checkNotificationImmediate(this, sa.db_id) updatePushDistributer() showToast(false, R.string.access_token_updated_for, sa.acct.pretty) return true } private suspend fun ActMain.afterAccountAdd( newAcct: Acct, auth2Result: Auth2Result, ): Boolean { val ta = auth2Result.tootAccount val rowId = daoSavedAccount.saveNew( acct = newAcct.ascii, host = auth2Result.apiHost.ascii, domain = auth2Result.apDomain.ascii, account = auth2Result.accountJson, token = auth2Result.tokenJson, misskeyVersion = auth2Result.tootInstance.misskeyVersionMajor, ) val account = daoSavedAccount.loadAccount(rowId) if (account == null) { showToast(false, "loadAccount failed.") return false } var bModified = false if (account.loginAccount?.locked == true) { bModified = true account.visibility = TootVisibility.PrivateFollowers } if (!account.isMisskey) { val source = ta.source if (source != null) { val privacy = TootVisibility.parseMastodon(source.privacy) if (privacy != null) { bModified = true account.visibility = privacy } // XXX ta.source.sensitive パラメータを読んで「添付画像をデフォルトでNSFWにする」を実現する // 現在、アカウント設定にはこの項目はない( 「NSFWな添付メディアを隠さない」はあるが全く別の効果) } if (bModified) { daoSavedAccount.save(account) } } // 適当にカラムを追加する addColumn(false, defaultInsertPosition, account, ColumnType.HOME, protect = true) if (daoSavedAccount.isSingleAccount()) { addColumn(false, defaultInsertPosition, account, ColumnType.NOTIFICATIONS, protect = true) addColumn(false, defaultInsertPosition, account, ColumnType.LOCAL, protect = true) addColumn(false, defaultInsertPosition, account, ColumnType.FEDERATE, protect = true) } // 通知の更新が必要かもしれない checkNotificationImmediateAll(this, onlyEnqueue = true) checkNotificationImmediate(this, account.db_id) updatePushDistributer() showToast(false, R.string.account_confirmed) return true } fun ActMain.handleSharedIntent(intent: Intent) { launchMain { ActMain.sharedIntent2 = intent val ai = pickAccount( bAllowPseudo = false, bAuto = true, message = getString(R.string.account_picker_toot), ) ActMain.sharedIntent2 = null ai?.let { openActPostImpl(it.db_id, sharedIntent = intent) } } } // アカウントを追加/更新したらappServerHashの取得をやりなおす suspend fun ActMain.updatePushDistributer() { when { fcmHandler.noFcm(this) && prefDevice.pushDistributor.isNullOrEmpty() -> { selectPushDistributor() // 選択しなかった場合は購読の更新を行わない } else -> { runInProgress(cancellable = false) { reporter -> withContext(AppDispatchers.DEFAULT) { pushRepo.switchDistributor( prefDevice.pushDistributor, reporter = reporter ) } } } } } 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 } val upDistrobutors = UnifiedPush.getDistributors( context, features = ArrayList(listOf(UnifiedPush.FEATURE_BYTES_MESSAGE)) ) val hasFcm = fcmHandler.hasFcm(context) if (upDistrobutors.isEmpty() && !hasFcm) { okDialog(R.string.push_distributor_not_available) } else { actionsDialog(getString(R.string.select_push_delivery_service)) { if (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 upDistrobutors) { 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 ) } } } } } } }