SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/actmain/ActMainIntent.kt

460 lines
17 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
)
}
}
}
}
}
}
}