/* Copyright 2018 Conny Duck * * This file is a part of Tusky. * * This program is free software; you can redistribute it and/or modify it under the terms of the * GNU General Public License as published by the Free Software Foundation; either version 3 of the * License, or (at your option) any later version. * * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ package com.keylesspalace.tusky import android.content.Intent import android.os.Bundle import android.view.View import android.widget.LinearLayout import android.widget.Toast import androidx.annotation.VisibleForTesting import androidx.lifecycle.Lifecycle import com.google.android.material.bottomsheet.BottomSheetBehavior import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.LinkHelper import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider import com.uber.autodispose.autoDispose import io.reactivex.android.schedulers.AndroidSchedulers import java.net.URI import java.net.URISyntaxException import javax.inject.Inject /** this is the base class for all activities that open links * links are checked against the api if they are mastodon links so they can be openend in Tusky * Subclasses must have a bottom sheet with Id item_status_bottom_sheet in their layout hierachy */ abstract class BottomSheetActivity : BaseActivity() { lateinit var bottomSheet: BottomSheetBehavior var searchUrl: String? = null @Inject lateinit var mastodonApi: MastodonApi private val forceBrowser = arrayOf("リモートで結果を表示") override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) val bottomSheetLayout: LinearLayout = findViewById(R.id.item_status_bottom_sheet) bottomSheet = BottomSheetBehavior.from(bottomSheetLayout) bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN bottomSheet.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { if (newState == BottomSheetBehavior.STATE_HIDDEN) { cancelActiveSearch() } } override fun onSlide(bottomSheet: View, slideOffset: Float) {} }) } open fun viewUrl(url: String, text: String, lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER) { if (forceBrowser.contains(text)) { openLink(url) return } if (!looksLikeMastodonUrl(url)) { openLink(url) return } mastodonApi.searchObservable( query = url, resolve = true ).observeOn(AndroidSchedulers.mainThread()) .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) .subscribe({ (accounts, statuses) -> if (getCancelSearchRequested(url)) { return@subscribe } onEndSearch(url) if (statuses.isNotEmpty()) { viewThread(statuses[0].id, statuses[0].url) return@subscribe } else if (accounts.isNotEmpty()) { viewAccount(accounts[0].id) return@subscribe } performUrlFallbackAction(url, lookupFallbackBehavior) }, { if (!getCancelSearchRequested(url)) { onEndSearch(url) performUrlFallbackAction(url, lookupFallbackBehavior) } }) onBeginSearch(url) } open fun viewThread(statusId: String, url: String?) { if (!isSearching()) { val intent = Intent(this, ViewThreadActivity::class.java) intent.putExtra("id", statusId) intent.putExtra("url", url) startActivityWithSlideInAnimation(intent) } } open fun viewAccount(id: String) { val intent = AccountActivity.getIntent(this, id) startActivityWithSlideInAnimation(intent) } protected open fun performUrlFallbackAction(url: String, fallbackBehavior: PostLookupFallbackBehavior) { when (fallbackBehavior) { PostLookupFallbackBehavior.OPEN_IN_BROWSER -> openLink(url) PostLookupFallbackBehavior.DISPLAY_ERROR -> Toast.makeText(this, getString(R.string.post_lookup_error_format, url), Toast.LENGTH_SHORT).show() } } @VisibleForTesting fun onBeginSearch(url: String) { searchUrl = url showQuerySheet() } @VisibleForTesting fun getCancelSearchRequested(url: String): Boolean { return url != searchUrl } @VisibleForTesting fun isSearching(): Boolean { return searchUrl != null } @VisibleForTesting fun onEndSearch(url: String?) { if (url == searchUrl) { // Don't clear query if there's no match, // since we might just now be getting the response for a canceled search searchUrl = null hideQuerySheet() } } @VisibleForTesting fun cancelActiveSearch() { if (isSearching()) { onEndSearch(searchUrl) } } @VisibleForTesting open fun openLink(url: String) { LinkHelper.openLink(url, this) } private fun showQuerySheet() { bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED } private fun hideQuerySheet() { bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN } } // https://mastodon.foo.bar/@User // https://mastodon.foo.bar/@User/43456787654678 // https://pleroma.foo.bar/users/User // https://pleroma.foo.bar/users/43456787654678 // https://pleroma.foo.bar/notice/43456787654678 // https://pleroma.foo.bar/objects/d4643c42-3ae0-4b73-b8b0-c725f5819207 // https://mastodon.foo.bar/users/User/statuses/000000000000000000 // https://new.misskey.foo.bar/notes/012789abyz // username@example.com fun looksLikeMastodonUrl(urlString: String): Boolean { val uri: URI try { uri = URI(urlString) } catch (e: URISyntaxException) { return false } if (uri.query != null || uri.fragment != null || uri.path == null) { return false } val path = uri.path return path.matches("^/@[^/]+$".toRegex()) || path.matches("^/users/[^/]+$".toRegex()) || path.matches("^/@[^/]+/\\d+$".toRegex()) || path.matches("^/notice/\\d+$".toRegex()) || path.matches("^/objects/[-a-f0-9]+$".toRegex()) || path.matches("^/users/[^/]+/statuses/[0-9]+$".toRegex()) || path.matches("^/notes/[a-z0-9]+$".toRegex()) || path.matches("^[^@]+@[^@]+$".toRegex()) } enum class PostLookupFallbackBehavior { OPEN_IN_BROWSER, DISPLAY_ERROR, }