diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 217e5c51f..686b2a0c0 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,5 +1,8 @@ + + diff --git a/app-compose/build.gradle b/app-compose/build.gradle index 33304cb4b..352d49e55 100644 --- a/app-compose/build.gradle +++ b/app-compose/build.gradle @@ -156,7 +156,7 @@ dependencies { androidTestImplementation "androidx.test.ext:junit:1.1.5" androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1" - def hilt = "2.43.2" + def hilt = "2.46.1" implementation "com.google.dagger:hilt-android:${hilt}" kapt "com.google.dagger:hilt-android-compiler:${hilt}" @@ -171,6 +171,7 @@ dependencies { implementation "com.squareup.moshi:moshi-kotlin:${moshi}" implementation "com.squareup.moshi:moshi-adapters:${moshi}" ksp "com.squareup.moshi:moshi-kotlin-codegen:${moshi}" + implementation "com.squareup.okhttp3:okhttp:5.0.0-alpha.11" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1" diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt index 6d62097b7..f6f88adc3 100644 --- a/app-compose/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt @@ -19,6 +19,7 @@ package com.pitchedapps.frost import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import com.google.common.flogger.FluentLogger import com.pitchedapps.frost.components.FrostDataStore import com.pitchedapps.frost.db.FrostDb @@ -26,12 +27,15 @@ import com.pitchedapps.frost.ext.FrostAccountId import com.pitchedapps.frost.ext.idData import com.pitchedapps.frost.ext.launchActivity import com.pitchedapps.frost.extension.FrostCoreExtension +import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.main.MainActivity +import com.pitchedapps.frost.web.state.FrostWebStore +import com.pitchedapps.frost.web.state.TabAction +import com.pitchedapps.frost.web.state.TabListAction +import com.pitchedapps.frost.web.state.state.HomeTabSessionState import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -43,22 +47,44 @@ import kotlinx.coroutines.withContext * and will launch another activity without history after doing initialization work. */ @AndroidEntryPoint -class StartActivity : AppCompatActivity(), CoroutineScope by MainScope() { +class StartActivity : AppCompatActivity() { @Inject lateinit var frostDb: FrostDb + @Inject lateinit var dataStore: FrostDataStore + @Inject lateinit var frostCoreExtension: FrostCoreExtension + @Inject lateinit var store: FrostWebStore + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - launch { + lifecycleScope.launch { val id = withContext(Dispatchers.IO) { getCurrentAccountId() } - frostCoreExtension.install() + // frostCoreExtension.install() logger.atInfo().log("Starting Frost with id %s", id) + // TODO load real tabs + store.dispatch(TabListAction.SetHomeTabs(data = listOf(FbItem.Feed, FbItem.Menu))) + // Test something scrollable + store.dispatch( + TabAction( + tabId = HomeTabSessionState.homeTabId(0), + TabAction.ContentAction.UpdateUrlAction( + "https://github.com/AllanWang/Frost-for-Facebook" + ), + ), + ) + store.dispatch( + TabAction( + tabId = HomeTabSessionState.homeTabId(1), + TabAction.ContentAction.UpdateUrlAction("https://github.com/AllanWang/KAU"), + ), + ) + launchActivity( intentBuilder = { flags = diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/webview/FrostWebCompose.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/webview/FrostWebCompose.kt new file mode 100644 index 000000000..4af7739d3 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/compose/webview/FrostWebCompose.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2021 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.compose.webview + +import android.webkit.WebView +import android.widget.FrameLayout +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.widget.NestedScrollView +import com.google.common.flogger.FluentLogger +import com.pitchedapps.frost.ext.WebTargetId +import com.pitchedapps.frost.view.NestedWebView +import com.pitchedapps.frost.web.state.FrostWebStore +import com.pitchedapps.frost.web.state.TabAction +import com.pitchedapps.frost.web.state.TabAction.ResponseAction.LoadUrlResponseAction +import com.pitchedapps.frost.web.state.TabAction.ResponseAction.WebStepResponseAction +import com.pitchedapps.frost.web.state.get +import com.pitchedapps.frost.web.state.state.ContentState +import com.pitchedapps.frost.webview.FrostChromeClient +import com.pitchedapps.frost.webview.FrostWebViewClient +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import mozilla.components.lib.state.ext.flow +import mozilla.components.lib.state.ext.observeAsState + +class FrostWebCompose( + val tabId: WebTargetId, + private val store: FrostWebStore, + private val client: FrostWebViewClient, + private val chromeClient: FrostChromeClient, +) { + + private fun FrostWebStore.dispatch(action: TabAction.Action) { + dispatch(TabAction(tabId = tabId, action = action)) + } + + /** + * Webview implementation in compose + * + * Based off of + * https://github.com/google/accompanist/blob/main/web/src/main/java/com/google/accompanist/web/WebView.kt + * + * @param modifier A compose modifier + * @param captureBackPresses Set to true to have this Composable capture back presses and navigate + * the WebView back. navigation from outside the composable. + * @param onCreated Called when the WebView is first created, this can be used to set additional + * settings on the WebView. WebChromeClient and WebViewClient should not be set here as they + * will be subsequently overwritten after this lambda is called. + * @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved if + * you need to save and restore state in this WebView. + */ + @Composable + fun WebView( + modifier: Modifier = Modifier, + captureBackPresses: Boolean = true, + onCreated: (WebView) -> Unit = {}, + onDispose: (WebView) -> Unit = {}, + ) { + + var webView by remember { mutableStateOf(null) } + + logger.atInfo().log("Webview %s %s", tabId, webView?.hashCode()) + + webView?.let { wv -> + val lifecycleOwner = LocalLifecycleOwner.current + + val canGoBack by + store.observeAsState(initialValue = false) { it[tabId]?.content?.canGoBack == true } + + BackHandler(captureBackPresses && canGoBack) { wv.goBack() } + + LaunchedEffect(wv, store) { + fun storeFlow(action: suspend Flow.() -> Unit) = launch { + store.flow(lifecycleOwner).mapNotNull { it[tabId]?.content }.action() + } + + storeFlow { + mapNotNull { it.transientState.targetUrl } + .distinctUntilChanged() + .collect { url -> + store.dispatch(LoadUrlResponseAction(url)) + wv.loadUrl(url) + } + } + storeFlow { + mapNotNull { it.transientState.navStep } + .distinctUntilChanged() + .filter { it != 0 } + .collect { steps -> + store.dispatch(WebStepResponseAction(steps)) + if (wv.canGoBackOrForward(steps)) { + wv.goBackOrForward(steps) + } else { + logger.atWarning().log("web %s cannot go back %d steps", tabId, steps) + } + } + } + } + } + + AndroidView( + factory = { context -> + val childView = + NestedWebView(context) + .apply { + onCreated(this) + + logger.atInfo().log("Created webview for %s", tabId) + + this.layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + + // state.viewState?.let { + // this.restoreState(it) + // } + + webChromeClient = chromeClient + webViewClient = client + + val url = store.state[tabId]?.content?.url + if (url != null) loadUrl(url) + } + .also { webView = it } + + // Workaround a crash on certain devices that expect WebView to be + // wrapped in a ViewGroup. + // b/243567497 + val parentLayout = NestedScrollView(context) + parentLayout.layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + parentLayout.addView(childView) + + parentLayout + }, + modifier = modifier, + onRelease = { parentFrame -> + val wv = parentFrame.getChildAt(0) as WebView + onDispose(wv) + logger.atInfo().log("Released webview for %s", tabId) + }, + ) + } + + companion object { + private val logger = FluentLogger.forEnclosingClass() + } +} + + +// override fun onReceivedError( +// view: WebView, +// request: WebResourceRequest?, +// error: WebResourceError? +// ) { +// super.onReceivedError(view, request, error) +// +// if (error != null) { +// state.errorsForCurrentRequest.add(WebViewError(request, error)) +// } +// } +// } diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/ext/FrostExt.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/ext/FrostExt.kt index d5497f1dd..f34d8f3fb 100644 --- a/app-compose/src/main/kotlin/com/pitchedapps/frost/ext/FrostExt.kt +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/ext/FrostExt.kt @@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.map */ @JvmInline value class FrostAccountId(val id: Long) +@JvmInline value class WebTargetId(val id: String) + /** * Representation of gecko context id. * diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt index 75f577aca..28af0532d 100644 --- a/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt @@ -39,6 +39,7 @@ import androidx.compose.material.icons.filled.Store import androidx.compose.material.icons.filled.Today import androidx.compose.ui.graphics.vector.ImageVector import com.pitchedapps.frost.R +import com.pitchedapps.frost.ext.WebTargetId import com.pitchedapps.frost.main.MainTabItem import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid @@ -118,8 +119,9 @@ enum class FbItem( } /** Converts [FbItem] to [MainTabItem]. */ -fun FbItem.tab(context: Context): MainTabItem = +fun FbItem.tab(context: Context, id: WebTargetId): MainTabItem = MainTabItem( + id = id, title = context.getString(titleId), icon = icon, url = url, diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt new file mode 100644 index 000000000..b343b2df9 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2018 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.facebook + +/** + * Created by Allan Wang on 21/12/17. + * + * Collection of regex matchers Input text must be properly unescaped + * + * See [StringEscapeUtils] + */ + +/** Matches the fb_dtsg component of a page containing it as a hidden value */ +val FB_DTSG_MATCHER: Regex by lazy { Regex("name=\"fb_dtsg\" value=\"(.*?)\"") } +val FB_REV_MATCHER: Regex by lazy { Regex("\"app_version\":\"(.*?)\"") } + +/** Matches user id from cookie */ +val FB_USER_MATCHER: Regex = Regex("c_user=([0-9]*);") + +val FB_EPOCH_MATCHER: Regex = Regex(":([0-9]+)") +val FB_NOTIF_ID_MATCHER: Regex = Regex("notif_([0-9]+)") +val FB_MESSAGE_NOTIF_ID_MATCHER: Regex = Regex("(?:thread|user)_fbid_([0-9]+)") +val FB_CSS_URL_MATCHER: Regex = Regex("url\\([\"|']?(.*?)[\"|']?\\)") +val FB_JSON_URL_MATCHER: Regex = Regex("\"(http.*?)\"") +val FB_IMAGE_ID_MATCHER: Regex = Regex("fbcdn.*?/[0-9]+_([0-9]+)_") +val FB_REDIRECT_URL_MATCHER: Regex = Regex("url=(.*?fbcdn.*?)\"") + +operator fun MatchResult?.get(groupIndex: Int) = this?.groupValues?.get(groupIndex) diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbUrl.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbUrl.kt new file mode 100644 index 000000000..b910dace8 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbUrl.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.facebook + +import com.pitchedapps.frost.facebook.FbUrlFormatter.Companion.VIDEO_REDIRECT +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +/** [true] if url contains [FACEBOOK_COM] */ +inline val String?.isFacebookUrl + get() = this != null && (contains(FACEBOOK_COM) || contains(FBCDN_NET)) + +inline val String?.isMessengerUrl + get() = this != null && contains(MESSENGER_COM) + +inline val String?.isFbCookie + get() = this != null && contains("c_user") + +/** [true] if url is a video and can be accepted by VideoViewer */ +inline val String.isVideoUrl + get() = startsWith(VIDEO_REDIRECT) || (startsWith("https://video-") && contains(FBCDN_NET)) + +/** [true] if url directly leads to a usable image */ +inline val String.isImageUrl: Boolean + get() { + return contains(FBCDN_NET) && (contains(".png") || contains(".jpg")) + } + +/** [true] if url can be retrieved to get a direct image url */ +inline val String.isIndirectImageUrl: Boolean + get() { + return contains("/photo/view_full_size/") && contains("fbid=") + } + +/** [true] if url can be displayed in a different webview */ +inline val String?.isIndependent: Boolean + get() { + if (this == null || length < 5) return false // ignore short queries + if (this[0] == '#' && !contains('/')) return false // ignore element values + if (startsWith("http") && !isFacebookUrl) return true // ignore non facebook urls + if (dependentSegments.any { contains(it) }) return false // ignore known dependent segments + return true + } + +val dependentSegments = + arrayOf( + "photoset_token", + "direct_action_execute", + "messages/?pageNum", + "sharer.php", + "events/permalink", + "events/feed/watch", + /* + * Add new members to groups + * + * No longer dependent again as of 12/20/2018 + */ + // "madminpanel", + /** Editing images */ + "/confirmation/?", + /** Remove entry from "people you may know" */ + "/pymk/xout/", + /* + * Facebook messages have the following cases for the tid query + * mid* or id* for newer threads, which can be launched in new windows + * or a hash for old threads, which must be loaded on old threads + */ + "messages/read/?tid=id", + "messages/read/?tid=mid", + // For some reason townhall doesn't load independently + // This will allow it to load, but going back unfortunately messes up the menu client + // See https://github.com/AllanWang/Frost-for-Facebook/issues/1593 + "/townhall/" + ) + +inline val String?.isExplicitIntent + get() = this != null && (startsWith("intent://") || startsWith("market://")) + +fun String.urlEncode(): String = URLEncoder.encode(this, StandardCharsets.UTF_8.name()) diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbUrlFormatter.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbUrlFormatter.kt new file mode 100644 index 000000000..465eb29e4 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/facebook/FbUrlFormatter.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2018 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.facebook + +import android.net.Uri +import com.google.common.flogger.FluentLogger +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +/** + * Created by Allan Wang on 2017-07-07. + * + * Custom url builder so we can easily test it without the Android framework + */ +inline val String.formattedFbUrl: String + get() = FbUrlFormatter(this).toString() + +inline val Uri.formattedFbUri: Uri + get() { + val url = toString() + return if (url.startsWith("http")) Uri.parse(url.formattedFbUrl) else this + } + +class FbUrlFormatter(url: String) { + private val queries = mutableMapOf() + private val cleaned: String + + /** + * Formats all facebook urls + * + * The order is very important: + * 1. Wrapper links (discardables) are stripped away, resulting in the actual link + * 2. CSS encoding is converted to normal encoding + * 3. Url is completely decoded + * 4. Url is split into sections + */ + init { + cleaned = clean(url) + } + + fun clean(url: String): String { + if (url.isBlank()) return "" + var cleanedUrl = url + if (cleanedUrl.startsWith("#!")) cleanedUrl = cleanedUrl.substring(2) + val urlRef = cleanedUrl + discardable.forEach { cleanedUrl = cleanedUrl.replace(it, "", true) } + val changed = cleanedUrl != urlRef + converter.forEach { (k, v) -> cleanedUrl = cleanedUrl.replace(k, v, true) } + try { + cleanedUrl = URLDecoder.decode(cleanedUrl, StandardCharsets.UTF_8.name()) + } catch (e: Exception) { + logger.atWarning().withCause(e).log("Failed url formatting") + return url + } + cleanedUrl = cleanedUrl.replace("&", "&") + if (changed && !cleanedUrl.contains("?")) // ensure we aren't missing '?' + cleanedUrl = cleanedUrl.replaceFirst("&", "?") + val qm = cleanedUrl.indexOf("?") + if (qm > -1) { + cleanedUrl.substring(qm + 1).split("&").forEach { + val p = it.split("=") + queries[p[0]] = p.elementAtOrNull(1) ?: "" + } + cleanedUrl = cleanedUrl.substring(0, qm) + } + discardableQueries.forEach { queries.remove(it) } + // Convert desktop urls to mobile ones + cleanedUrl = cleanedUrl.replace(WWW_FACEBOOK_COM, FACEBOOK_BASE_COM) + if (cleanedUrl.startsWith("/")) cleanedUrl = FB_URL_BASE + cleanedUrl.substring(1) + cleanedUrl = + cleanedUrl.replaceFirst( + ".facebook.com//", + ".facebook.com/" + ) // sometimes we are given a bad url + logger.atFinest().log("Formatted url from %s to %s", url, cleanedUrl) + return cleanedUrl + } + + override fun toString(): String = + buildString { + append(cleaned) + if (queries.isNotEmpty()) { + append("?") + queries.forEach { (k, v) -> + if (v.isEmpty()) { + append("${k.urlEncode()}&") + } else { + append("${k.urlEncode()}=${v.urlEncode()}&") + } + } + } + } + .removeSuffix("&") + + fun toLogList(): List { + val list = mutableListOf(cleaned) + queries.forEach { (k, v) -> list.add("\n- $k\t=\t$v") } + list.add("\n\n${toString()}") + return list + } + + companion object { + + private val logger = FluentLogger.forEnclosingClass() + + const val VIDEO_REDIRECT = "/video_redirect/?src=" + + /** + * Items here are explicitly removed from the url Taken from FaceSlim + * https://github.com/indywidualny/FaceSlim/blob/master/app/src/main/java/org/indywidualni/fblite/util/Miscellany.java + * + * Note: Typically, in this case, the redirect url should have all the necessary queries I am + * unsure how Facebook reacts in all cases, so the ones after the redirect are appended on + * afterwards That shouldn't break anything + */ + val discardable = + arrayOf( + "http://lm.facebook.com/l.php?u=", + "https://lm.facebook.com/l.php?u=", + "http://m.facebook.com/l.php?u=", + "https://m.facebook.com/l.php?u=", + "http://touch.facebook.com/l.php?u=", + "https://touch.facebook.com/l.php?u=", + VIDEO_REDIRECT + ) + + /** + * Queries that are not necessary for independent links + * + * acontext is not required for "friends interested in" notifications + */ + val discardableQueries = + arrayOf( + "ref", + "refid", + "SharedWith", + "fbclid", + "h", + "_ft_", + "_tn_", + "_xt_", + "bacr", + "frefs", + "hc_ref", + "loc_ref", + "pn_ref" + ) + + val converter = + listOf( + "\\3C " to "%3C", + "\\3E " to "%3E", + "\\23 " to "%23", + "\\25 " to "%25", + "\\7B " to "%7B", + "\\7D " to "%7D", + "\\7C " to "%7C", + "\\5C " to "%5C", + "\\5E " to "%5E", + "\\7E " to "%7E", + "\\5B " to "%5B", + "\\5D " to "%5D", + "\\60 " to "%60", + "\\3B " to "%3B", + "\\2F " to "%2F", + "\\3F " to "%3F", + "\\3A " to "%3A", + "\\40 " to "%40", + "\\3D " to "%3D", + "\\26 " to "%26", + "\\24 " to "%24", + "\\2B " to "%2B", + "\\22 " to "%22", + "\\2C " to "%2C", + "\\20 " to "%20" + ) + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/hilt/FrostGeckoModule.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/hilt/FrostGeckoModule.kt new file mode 100644 index 000000000..1dab7148c --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/hilt/FrostGeckoModule.kt @@ -0,0 +1,173 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.hilt + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import com.google.common.flogger.FluentLogger +import com.pitchedapps.frost.BuildConfig +import com.pitchedapps.frost.R +import com.pitchedapps.frost.components.usecases.HomeTabsUseCases +import com.pitchedapps.frost.main.MainActivity +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.util.Optional +import javax.inject.Singleton +import kotlin.jvm.optionals.getOrNull +import mozilla.components.browser.engine.gecko.GeckoEngine +import mozilla.components.browser.engine.gecko.fetch.GeckoViewFetchClient +import mozilla.components.browser.engine.gecko.permission.GeckoSitePermissionsStorage +import mozilla.components.browser.icons.BrowserIcons +import mozilla.components.browser.session.storage.SessionStorage +import mozilla.components.browser.state.action.BrowserAction +import mozilla.components.browser.state.action.EngineAction +import mozilla.components.browser.state.engine.EngineMiddleware +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.DefaultSettings +import mozilla.components.concept.engine.Engine +import mozilla.components.concept.engine.Settings +import mozilla.components.concept.engine.permission.SitePermissionsStorage +import mozilla.components.concept.fetch.Client +import mozilla.components.feature.prompts.PromptMiddleware +import mozilla.components.feature.sitepermissions.OnDiskSitePermissionsStorage +import mozilla.components.feature.webnotifications.WebNotificationFeature +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext +import mozilla.components.support.base.android.NotificationsDelegate +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoRuntimeSettings + +@Module +@InstallIn(SingletonComponent::class) +internal object FrostGeckoModule { + private val logger = FluentLogger.forEnclosingClass() + + @Provides + @Singleton + fun geckoRuntime(@ApplicationContext context: Context): GeckoRuntime { + val settings = + GeckoRuntimeSettings.Builder() + .consoleOutput(BuildConfig.DEBUG) + .loginAutofillEnabled(true) + // .debugLogging(false) + .debugLogging(BuildConfig.DEBUG) + .javaScriptEnabled(true) + .build() + + return GeckoRuntime.create(context, settings) + } + + @Provides + @Singleton + fun client(@ApplicationContext context: Context, runtime: GeckoRuntime): Client { + return GeckoViewFetchClient(context, runtime) + } + + @Provides + @Singleton + fun settings(@Frost userAgent: Optional): Settings { + return DefaultSettings(userAgentString = userAgent.getOrNull()) + } + + @Provides + @Singleton + fun engine( + @ApplicationContext context: Context, + settings: Settings, + runtime: GeckoRuntime + ): Engine { + return GeckoEngine(context, settings, runtime) + } + + @Provides + @Singleton + fun browserIcons(@ApplicationContext context: Context, client: Client): BrowserIcons { + return BrowserIcons(context, client) + } + + @Provides + @Singleton + fun sitePermissionStorage( + @ApplicationContext context: Context, + runtime: GeckoRuntime + ): SitePermissionsStorage { + return GeckoSitePermissionsStorage(runtime, OnDiskSitePermissionsStorage(context)) + } + + @Provides + @Singleton + fun sessionStorage(@ApplicationContext context: Context, engine: Engine): SessionStorage { + return SessionStorage(context, engine) + } + + private class LoggerMiddleWare : Middleware { + override fun invoke( + context: MiddlewareContext, + next: (BrowserAction) -> Unit, + action: BrowserAction + ) { + if (action is EngineAction.LoadUrlAction) { + logger.atInfo().log("BrowserAction: LoadUrlAction %s", action.url) + } else { + logger.atInfo().log("BrowserAction: %s - %s", action::class.simpleName, action) + } + next(action) + } + } + + @Provides + @Singleton + fun browserStore( + @ApplicationContext context: Context, + icons: BrowserIcons, + sitePermissionsStorage: SitePermissionsStorage, + engine: Engine, + notificationsDelegate: NotificationsDelegate, + ): BrowserStore { + + val middleware = buildList { + if (BuildConfig.DEBUG) add(LoggerMiddleWare()) + add(HomeTabsUseCases.HomeMiddleware()) + add(PromptMiddleware()) + // add(DownloadMiddleware(context, DownloadService::class.java)) + addAll(EngineMiddleware.create(engine)) + } + + val store = BrowserStore(middleware = middleware) + icons.install(engine, store) + WebNotificationFeature( + context = context, + engine = engine, + browserIcons = icons, + smallIcon = R.mipmap.ic_launcher_round, + sitePermissionsStorage = sitePermissionsStorage, + activityClass = MainActivity::class.java, + notificationsDelegate = notificationsDelegate, + ) + return store + } + + @Provides + @Singleton + fun notificationDelegate(@ApplicationContext context: Context): NotificationsDelegate { + return NotificationsDelegate(NotificationManagerCompat.from(context)) + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/hilt/FrostModule.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/hilt/FrostModule.kt index af2fd44b4..3a3e5af1a 100644 --- a/app-compose/src/main/kotlin/com/pitchedapps/frost/hilt/FrostModule.kt +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/hilt/FrostModule.kt @@ -16,46 +16,15 @@ */ package com.pitchedapps.frost.hilt -import android.content.Context -import androidx.core.app.NotificationManagerCompat import com.google.common.flogger.FluentLogger -import com.pitchedapps.frost.BuildConfig -import com.pitchedapps.frost.R -import com.pitchedapps.frost.components.usecases.HomeTabsUseCases -import com.pitchedapps.frost.main.MainActivity +import com.pitchedapps.frost.web.FrostAdBlock import dagger.BindsOptionalOf import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import java.util.Optional import javax.inject.Qualifier import javax.inject.Singleton -import kotlin.jvm.optionals.getOrNull -import mozilla.components.browser.engine.gecko.GeckoEngine -import mozilla.components.browser.engine.gecko.fetch.GeckoViewFetchClient -import mozilla.components.browser.engine.gecko.permission.GeckoSitePermissionsStorage -import mozilla.components.browser.icons.BrowserIcons -import mozilla.components.browser.session.storage.SessionStorage -import mozilla.components.browser.state.action.BrowserAction -import mozilla.components.browser.state.action.EngineAction -import mozilla.components.browser.state.engine.EngineMiddleware -import mozilla.components.browser.state.state.BrowserState -import mozilla.components.browser.state.store.BrowserStore -import mozilla.components.concept.engine.DefaultSettings -import mozilla.components.concept.engine.Engine -import mozilla.components.concept.engine.Settings -import mozilla.components.concept.engine.permission.SitePermissionsStorage -import mozilla.components.concept.fetch.Client -import mozilla.components.feature.prompts.PromptMiddleware -import mozilla.components.feature.sitepermissions.OnDiskSitePermissionsStorage -import mozilla.components.feature.webnotifications.WebNotificationFeature -import mozilla.components.lib.state.Middleware -import mozilla.components.lib.state.MiddlewareContext -import mozilla.components.support.base.android.NotificationsDelegate -import org.mozilla.geckoview.GeckoRuntime -import org.mozilla.geckoview.GeckoRuntimeSettings @Qualifier annotation class Frost @@ -63,9 +32,11 @@ import org.mozilla.geckoview.GeckoRuntimeSettings @InstallIn(SingletonComponent::class) interface FrostBindModule { @BindsOptionalOf @Frost fun userAgent(): String + + @BindsOptionalOf fun adBlock(): FrostAdBlock } -/** Module containing core Mozilla injections. */ +/** Module containing core Frost injections. */ @Module @InstallIn(SingletonComponent::class) object FrostModule { @@ -86,115 +57,4 @@ object FrostModule { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.90 Safari/537.36" @Provides @Singleton @Frost fun userAgent(): String = USER_AGENT_WINDOWS_FROST - - @Provides - @Singleton - fun geckoRuntime(@ApplicationContext context: Context): GeckoRuntime { - val settings = - GeckoRuntimeSettings.Builder() - .consoleOutput(BuildConfig.DEBUG) - .loginAutofillEnabled(true) - // .debugLogging(false) - .debugLogging(BuildConfig.DEBUG) - .javaScriptEnabled(true) - .build() - - return GeckoRuntime.create(context, settings) - } - - @Provides - @Singleton - fun client(@ApplicationContext context: Context, runtime: GeckoRuntime): Client { - return GeckoViewFetchClient(context, runtime) - } - - @Provides - @Singleton - fun settings(@Frost userAgent: Optional): Settings { - return DefaultSettings(userAgentString = userAgent.getOrNull()) - } - - @Provides - @Singleton - fun engine( - @ApplicationContext context: Context, - settings: Settings, - runtime: GeckoRuntime - ): Engine { - return GeckoEngine(context, settings, runtime) - } - - @Provides - @Singleton - fun browserIcons(@ApplicationContext context: Context, client: Client): BrowserIcons { - return BrowserIcons(context, client) - } - - @Provides - @Singleton - fun sitePermissionStorage( - @ApplicationContext context: Context, - runtime: GeckoRuntime - ): SitePermissionsStorage { - return GeckoSitePermissionsStorage(runtime, OnDiskSitePermissionsStorage(context)) - } - - @Provides - @Singleton - fun sessionStorage(@ApplicationContext context: Context, engine: Engine): SessionStorage { - return SessionStorage(context, engine) - } - - private class LoggerMiddleWare : Middleware { - override fun invoke( - context: MiddlewareContext, - next: (BrowserAction) -> Unit, - action: BrowserAction - ) { - if (action is EngineAction.LoadUrlAction) { - logger.atInfo().log("BrowserAction: LoadUrlAction %s", action.url) - } else { - logger.atInfo().log("BrowserAction: %s - %s", action::class.simpleName, action) - } - next(action) - } - } - - @Provides - @Singleton - fun browserStore( - @ApplicationContext context: Context, - icons: BrowserIcons, - sitePermissionsStorage: SitePermissionsStorage, - engine: Engine, - notificationsDelegate: NotificationsDelegate, - ): BrowserStore { - - val middleware = buildList { - if (BuildConfig.DEBUG) add(LoggerMiddleWare()) - add(HomeTabsUseCases.HomeMiddleware()) - add(PromptMiddleware()) - // add(DownloadMiddleware(context, DownloadService::class.java)) - addAll(EngineMiddleware.create(engine)) - } - - val store = BrowserStore(middleware = middleware) - icons.install(engine, store) - WebNotificationFeature( - context = context, - engine = engine, - browserIcons = icons, - smallIcon = R.mipmap.ic_launcher_round, - sitePermissionsStorage = sitePermissionsStorage, - activityClass = MainActivity::class.java, - notificationsDelegate = notificationsDelegate, - ) - return store - } - - @Provides - @Singleton - fun notificationDelegate(@ApplicationContext context: Context): NotificationsDelegate { - return NotificationsDelegate(NotificationManagerCompat.from(context)) - } } diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/hilt/FrostWebViewModule.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/hilt/FrostWebViewModule.kt new file mode 100644 index 000000000..e03b913b9 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/hilt/FrostWebViewModule.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.hilt + +import android.webkit.CookieManager +import com.google.common.flogger.FluentLogger +import com.pitchedapps.frost.BuildConfig +import com.pitchedapps.frost.web.state.FrostLoggerMiddleware +import com.pitchedapps.frost.web.state.FrostWebReducer +import com.pitchedapps.frost.web.state.FrostWebStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** Module containing core WebView injections. */ +@Module +@InstallIn(SingletonComponent::class) +object FrostWebViewModule { + + private val logger = FluentLogger.forEnclosingClass() + + @Provides @Singleton fun cookieManager(): CookieManager = CookieManager.getInstance() + + @Provides + @Singleton + fun frostWebStore(frostWebReducer: FrostWebReducer): FrostWebStore { + val middleware = buildList { if (BuildConfig.DEBUG) add(FrostLoggerMiddleware()) } + + return FrostWebStore(frostWebReducer = frostWebReducer, middleware = middleware) + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainActivity.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainActivity.kt index fa000a466..23192ccfc 100644 --- a/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainActivity.kt +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainActivity.kt @@ -22,9 +22,10 @@ import androidx.activity.compose.setContent import androidx.core.view.WindowCompat import com.google.common.flogger.FluentLogger import com.pitchedapps.frost.compose.FrostTheme -import com.pitchedapps.frost.facebook.FbItem -import com.pitchedapps.frost.facebook.tab +import com.pitchedapps.frost.web.state.FrostWebStore import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import mozilla.components.lib.state.ext.observeAsState /** * Main activity. @@ -34,19 +35,25 @@ import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject lateinit var store: FrostWebStore + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) logger.atInfo().log("onCreate main activity") WindowCompat.setDecorFitsSystemWindows(window, false) - val tabs = FbItem.defaults().map { it.tab(this) } // TODO allow custom tabs - setContent { FrostTheme { - MainScreen( - tabs = tabs, - ) + // MainScreen( + // tabs = tabs, + // ) + + val tabs = + store.observeAsState(initialValue = null) { it.homeTabs.map { it.tab } }.value + ?: return@FrostTheme + + MainScreenWebView(homeTabs = tabs) } } } diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainData.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainData.kt index e40f191e0..75aeb74db 100644 --- a/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainData.kt +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainData.kt @@ -17,6 +17,12 @@ package com.pitchedapps.frost.main import androidx.compose.ui.graphics.vector.ImageVector +import com.pitchedapps.frost.ext.WebTargetId /** Data representation of a single main tab entry. */ -data class MainTabItem(val title: String, val icon: ImageVector, val url: String) +data class MainTabItem( + val id: WebTargetId, + val title: String, + val icon: ImageVector, + val url: String +) diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreen.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreen.kt index 5f3af63cb..b6f4b0cc8 100644 --- a/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreen.kt +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreen.kt @@ -43,21 +43,6 @@ import com.pitchedapps.frost.ext.GeckoContextId import com.pitchedapps.frost.ext.components import mozilla.components.browser.state.helper.Target -@Composable -fun MainScreen2(modifier: Modifier) { - // Scaffold( - // modifier = modifier, - // topBar = { - // MainTopBar(modifier = modifier) - // }, - // ) -} - -@Composable -fun MainTopBar(modifier: Modifier) { - // TopAppBar(title = { /*TODO*/ }) -} - /** * Screen for MainActivity. * diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreenViewModel.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreenViewModel.kt index bdb9784d5..88aea4845 100644 --- a/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreenViewModel.kt +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreenViewModel.kt @@ -26,6 +26,8 @@ import com.pitchedapps.frost.ext.idData import com.pitchedapps.frost.ext.toContextId import com.pitchedapps.frost.extension.FrostCoreExtension import com.pitchedapps.frost.hilt.FrostComponents +import com.pitchedapps.frost.web.state.FrostWebStore +import com.pitchedapps.frost.webview.FrostWebComposer import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -39,6 +41,9 @@ internal constructor( @ApplicationContext context: Context, val components: FrostComponents, val frostCoreExtension: FrostCoreExtension, + val store: FrostWebStore, + val frostWebComposer: FrostWebComposer, +// sample: FrostWebEntrySample, ) : ViewModel() { val contextIdFlow: Flow = diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreenWebView.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreenWebView.kt new file mode 100644 index 000000000..c9da5835c --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/main/MainScreenWebView.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.main + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import com.pitchedapps.frost.compose.webview.FrostWebCompose +import com.pitchedapps.frost.ext.WebTargetId +import com.pitchedapps.frost.web.state.FrostWebStore +import com.pitchedapps.frost.web.state.TabListAction.SelectHomeTab +import com.pitchedapps.frost.webview.FrostWebComposer +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import mozilla.components.lib.state.ext.observeAsState + +@Composable +fun MainScreenWebView(modifier: Modifier = Modifier, homeTabs: List) { + val vm: MainScreenViewModel = viewModel() + + val selectedHomeTab by vm.store.observeAsState(initialValue = null) { it.selectedHomeTab } + + Scaffold( + modifier = modifier, + topBar = { MainTopBar(modifier = modifier) }, + bottomBar = { + MainBottomBar( + selectedTab = selectedHomeTab, + items = homeTabs, + onSelect = { vm.store.dispatch(SelectHomeTab(it)) }, + ) + }, + ) { paddingValues -> + MainScreenWebContainer( + modifier = Modifier.padding(paddingValues), + selectedTab = selectedHomeTab, + items = homeTabs, + store = vm.store, + frostWebComposer = vm.frostWebComposer, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainTopBar(modifier: Modifier = Modifier) { + TopAppBar(modifier = modifier, title = { Text(text = "Title") }) +} + +@Composable +fun MainBottomBar( + modifier: Modifier = Modifier, + selectedTab: WebTargetId?, + items: List, + onSelect: (WebTargetId) -> Unit +) { + NavigationBar(modifier = modifier) { + items.forEach { item -> + NavigationBarItem( + icon = { Icon(item.icon, contentDescription = item.title) }, + selected = selectedTab == item.id, + onClick = { onSelect(item.id) }, + ) + } + } +} + +@Composable +private fun MainScreenWebContainer( + modifier: Modifier, + selectedTab: WebTargetId?, + items: List, + store: FrostWebStore, + frostWebComposer: FrostWebComposer +) { + val homeTabComposables = remember(items) { items.map { frostWebComposer.create(it.id) } } + + PullRefresh( + modifier = modifier, + store = store, + ) { + MainPager(selectedTab, items = homeTabComposables) + // homeTabComposables.find { it.tabId == selectedTab }?.WebView() + + // MultiViewContainer(store = store) + + // SampleContainer(selectedTab = selectedTab, items = items) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MainPager(selectedTab: WebTargetId?, items: List) { + + val pagerState = rememberPagerState { items.size } + + LaunchedEffect(selectedTab, items) { + val i = items.indexOfFirst { it.tabId == selectedTab } + if (i != -1) { + pagerState.scrollToPage(i) + } + } + + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + beyondBoundsPageCount = 10, // Do not allow view release + ) { page -> + items[page].WebView() + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun PullRefresh(modifier: Modifier, store: FrostWebStore, content: @Composable () -> Unit) { + val refreshScope = rememberCoroutineScope() + var refreshing by remember { mutableStateOf(false) } + + fun refresh() = + refreshScope.launch { + refreshing = true + delay(1500) + refreshing = false + } + + val state = rememberPullRefreshState(refreshing, ::refresh) + + Box(modifier.pullRefresh(state)) { + content() + + PullRefreshIndicator(refreshing, state, Modifier.align(Alignment.TopCenter)) + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/view/NestedWebView.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/view/NestedWebView.kt new file mode 100644 index 000000000..dbc955871 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/view/NestedWebView.kt @@ -0,0 +1,201 @@ +/* + * Copyright 2018 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.view + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.webkit.WebView +import androidx.core.view.NestedScrollingChild3 +import androidx.core.view.NestedScrollingChildHelper +import androidx.core.view.ViewCompat + +/** + * Created by Allan Wang on 20/12/17. + * + * Webview extension that handles nested scrolls + */ +class NestedWebView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : + WebView(context, attrs, defStyleAttr), NestedScrollingChild3 { + + // No JvmOverloads due to hilt + constructor(context: Context) : this(context, null) + + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + + private lateinit var childHelper: NestedScrollingChildHelper + private var lastY: Int = 0 + private val scrollOffset = IntArray(2) + private val scrollConsumed = IntArray(2) + private var nestedOffsetY: Int = 0 + + init { + init() + } + + fun init() { + // To avoid leaking constructor + childHelper = NestedScrollingChildHelper(this) + } + + /** + * Handle nested scrolling against SwipeRecyclerView Courtesy of takahirom + * + * https://github.com/takahirom/webview-in-coordinatorlayout/blob/master/app/src/main/java/com/github/takahirom/webview_in_coodinator_layout/NestedWebView.java + */ + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(ev: MotionEvent): Boolean { + val event = MotionEvent.obtain(ev) + val action = event.action + if (action == MotionEvent.ACTION_DOWN) nestedOffsetY = 0 + val eventY = event.y.toInt() + event.offsetLocation(0f, nestedOffsetY.toFloat()) + val returnValue: Boolean + when (action) { + MotionEvent.ACTION_MOVE -> { + var deltaY = lastY - eventY + // NestedPreScroll + if (dispatchNestedPreScroll(0, deltaY, scrollConsumed, scrollOffset)) { + deltaY -= scrollConsumed[1] + } + lastY = eventY - scrollOffset[1] + returnValue = super.onTouchEvent(event) + // NestedScroll + if (dispatchNestedScroll(0, scrollOffset[1], 0, deltaY, scrollOffset)) { + event.offsetLocation(0f, scrollOffset[1].toFloat()) + nestedOffsetY += scrollOffset[1] + lastY -= scrollOffset[1] + } + } + MotionEvent.ACTION_DOWN -> { + returnValue = super.onTouchEvent(event) + lastY = eventY + startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL) + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + returnValue = super.onTouchEvent(event) + stopNestedScroll() + } + else -> return false + } + return returnValue + } + + /* + * --------------------------------------------- + * Nested Scrolling Content + * --------------------------------------------- + */ + + override fun setNestedScrollingEnabled(enabled: Boolean) { + childHelper.isNestedScrollingEnabled = enabled + } + + override fun isNestedScrollingEnabled() = childHelper.isNestedScrollingEnabled + + override fun startNestedScroll(axes: Int, type: Int): Boolean { + TODO("not implemented") + } + + override fun startNestedScroll(axes: Int) = childHelper.startNestedScroll(axes) + + override fun stopNestedScroll(type: Int) { + TODO("not implemented") + } + + override fun stopNestedScroll() = childHelper.stopNestedScroll() + + override fun hasNestedScrollingParent(type: Int): Boolean { + TODO("not implemented") + } + + override fun hasNestedScrollingParent() = childHelper.hasNestedScrollingParent() + + override fun dispatchNestedScroll( + dxConsumed: Int, + dyConsumed: Int, + dxUnconsumed: Int, + dyUnconsumed: Int, + offsetInWindow: IntArray?, + type: Int, + consumed: IntArray + ) = + childHelper.dispatchNestedScroll( + dxConsumed, + dyConsumed, + dxUnconsumed, + dyUnconsumed, + offsetInWindow, + type, + consumed, + ) + + override fun dispatchNestedScroll( + dxConsumed: Int, + dyConsumed: Int, + dxUnconsumed: Int, + dyUnconsumed: Int, + offsetInWindow: IntArray?, + type: Int + ) = + childHelper.dispatchNestedScroll( + dxConsumed, + dyConsumed, + dxUnconsumed, + dyUnconsumed, + offsetInWindow, + type, + ) + + override fun dispatchNestedScroll( + dxConsumed: Int, + dyConsumed: Int, + dxUnconsumed: Int, + dyUnconsumed: Int, + offsetInWindow: IntArray? + ) = + childHelper.dispatchNestedScroll( + dxConsumed, + dyConsumed, + dxUnconsumed, + dyUnconsumed, + offsetInWindow, + ) + + override fun dispatchNestedPreScroll( + dx: Int, + dy: Int, + consumed: IntArray?, + offsetInWindow: IntArray?, + type: Int + ): Boolean = childHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type) + + override fun dispatchNestedPreScroll( + dx: Int, + dy: Int, + consumed: IntArray?, + offsetInWindow: IntArray? + ) = childHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow) + + override fun dispatchNestedFling(velocityX: Float, velocityY: Float, consumed: Boolean) = + childHelper.dispatchNestedFling(velocityX, velocityY, consumed) + + override fun dispatchNestedPreFling(velocityX: Float, velocityY: Float) = + childHelper.dispatchNestedPreFling(velocityX, velocityY) +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/web/FrostAdBlock.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/FrostAdBlock.kt new file mode 100644 index 000000000..75d5d2915 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/FrostAdBlock.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.web + +import android.text.TextUtils +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +interface FrostAdBlock { + + val data: Set + + /** + * Initialize ad block data. + * + * Required to be called once + */ + fun init() +} + +fun FrostAdBlock.isAd(url: String?): Boolean { + url ?: return false + val httpUrl = url.toHttpUrlOrNull() ?: return false + return isAdHost(httpUrl.host) +} + +tailrec fun FrostAdBlock.isAdHost(host: String): Boolean { + if (TextUtils.isEmpty(host)) return false + val index = host.indexOf(".") + if (index < 0 || index + 1 < host.length) return false + if (data.contains(host)) return true + return isAdHost(host.substring(index + 1)) +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/web/FrostCookie.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/FrostCookie.kt new file mode 100644 index 000000000..2fa685c18 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/FrostCookie.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.web + +import android.content.Context +import android.webkit.CookieManager +import com.google.common.flogger.FluentLogger +import com.pitchedapps.frost.facebook.FACEBOOK_COM +import com.pitchedapps.frost.facebook.HTTPS_FACEBOOK_COM +import com.pitchedapps.frost.facebook.HTTPS_MESSENGER_COM +import com.pitchedapps.frost.facebook.MESSENGER_COM +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext + +class FrostCookie @Inject internal constructor(private val cookieManager: CookieManager) { + + val fbCookie: String? + get() = cookieManager.getCookie(HTTPS_FACEBOOK_COM) + + val messengerCookie: String? + get() = cookieManager.getCookie(HTTPS_MESSENGER_COM) + + private suspend fun CookieManager.suspendSetWebCookie(domain: String, cookie: String?): Boolean { + cookie ?: return true + return withContext(NonCancellable) { + // Save all cookies regardless of result, then check if all succeeded + val result = + cookie.split(";").map { async { setSingleWebCookie(domain, it) } }.awaitAll().all { it } + logger.atInfo().log("Cookies set for %s, %b", domain, result) + result + } + } + + private suspend fun CookieManager.setSingleWebCookie(domain: String, cookie: String): Boolean = + suspendCoroutine { cont -> + setCookie(domain, cookie.trim()) { cont.resume(it) } + } + + private suspend fun CookieManager.removeAllCookies(): Boolean = suspendCoroutine { cont -> + removeAllCookies { cont.resume(it) } + } + + suspend fun save(id: Long) { + logger.atInfo().log("Saving cookies for %d", id) + // prefs.userId = id + cookieManager.flush() + // val cookie = CookieEntity(prefs.userId, null, webCookie) + // cookieDao.save(cookie) + } + + suspend fun reset() { + // prefs.userId = -1L + withContext(Dispatchers.Main + NonCancellable) { + with(cookieManager) { + removeAllCookies() + flush() + } + } + } + + suspend fun switchUser(id: Long) { + // val cookie = cookieDao.selectById(id) ?: return L.e { "No cookie for id" } + // switchUser(cookie) + } + + // suspend fun switchUser(cookie: CookieEntity?) { + // if (cookie?.cookie == null) { + // L.d { "Switching User; null cookie" } + // return + // } + // withContext(Dispatchers.Main + NonCancellable) { + // L.d { "Switching User" } + // prefs.userId = cookie.id + // CookieManager.getInstance().apply { + // removeAllCookies() + // suspendSetWebCookie(FB_COOKIE_DOMAIN, cookie.cookie) + // suspendSetWebCookie(MESSENGER_COOKIE_DOMAIN, cookie.cookieMessenger) + // flush() + // } + // } + // } + + /** Helper function to remove the current cookies and launch the proper login page */ + suspend fun logout(context: Context, deleteCookie: Boolean = true) { + // val cookies = arrayListOf() + // if (context is Activity) cookies.addAll(context.cookies().filter { it.id != prefs.userId + // }) + // logout(prefs.userId, deleteCookie) + // context.launchLogin(cookies, true) + } + + /** Clear the cookies of the given id */ + suspend fun logout(id: Long, deleteCookie: Boolean = true) { + logger.atInfo().log("Logging out user %d", id) + // TODO save cookies? + if (deleteCookie) { + // cookieDao.deleteById(id) + logger.atInfo().log("Deleted cookies") + } + reset() + } + + /** + * Notifications may come from different accounts, and we need to switch the cookies to load them + * When coming back to the main app, switch back to our original account before continuing + */ + suspend fun switchBackUser() { + // if (prefs.prevId == -1L) return + // val prevId = prefs.prevId + // prefs.prevId = -1L + // if (prevId != prefs.userId) { + // switchUser(prevId) + // L.d { "Switch back user" } + // L._d { "${prefs.userId} to $prevId" } + // } + } + + companion object { + private val logger = FluentLogger.forEnclosingClass() + + /** Domain information. Dot prefix still matters for Android browsers. */ + private const val FB_COOKIE_DOMAIN = ".$FACEBOOK_COM" + private const val MESSENGER_COOKIE_DOMAIN = ".$MESSENGER_COM" + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/web/FrostWebHelper.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/FrostWebHelper.kt new file mode 100644 index 000000000..6f4d2385d --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/FrostWebHelper.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.web + +import com.pitchedapps.frost.facebook.isFacebookUrl +import com.pitchedapps.frost.facebook.isMessengerUrl +import java.util.Optional +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +class FrostWebHelper @Inject internal constructor(frostAdBlock: Optional) { + private val frostAdBlock = frostAdBlock.getOrNull() + + /** Returns true if url should be intercepted (replaced with blank resource) */ + fun shouldInterceptUrl(url: String): Boolean { + val httpUrl = url.toHttpUrlOrNull() ?: return false + val host = httpUrl.host + if (host.contains("facebook") || host.contains("fbcdn")) return false + if (frostAdBlock?.isAdHost(host) == true) return true + return false + } + + /** + * Returns true if url should allow refreshes. + * + * Some urls are known to be invalid entrypoints, and cannot be refreshed. Others may contain + * editable data without save state, so disabling swipe to refresh is preferred. + */ + fun allowUrlSwipeToRefresh(url: String): Boolean { + if (url.isMessengerUrl) return false + if (!url.isFacebookUrl) return true + if (url.contains("soft=composer")) return false + if (url.contains("sharer.php") || url.contains("sharer-dialog.php")) return false + return true + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostMiddleware.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostMiddleware.kt new file mode 100644 index 000000000..66840c7fb --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostMiddleware.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.web.state + +import com.google.common.flogger.FluentLogger +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext + +typealias FrostWebMiddleware = Middleware + +class FrostLoggerMiddleware : FrostWebMiddleware { + override fun invoke( + context: MiddlewareContext, + next: (FrostWebAction) -> Unit, + action: FrostWebAction + ) { + logger.atInfo().log("FrostWebAction: %s - %s", action::class.simpleName, action) + next(action) + } + + companion object { + private val logger = FluentLogger.forEnclosingClass() + } +} + +class FrostCookieMiddleware : FrostWebMiddleware { + override fun invoke( + context: MiddlewareContext, + next: (FrostWebAction) -> Unit, + action: FrostWebAction + ) { + when (action) { + else -> next(action) + } + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostWebAction.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostWebAction.kt new file mode 100644 index 000000000..44af46de9 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostWebAction.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.web.state + +import com.pitchedapps.frost.ext.WebTargetId +import com.pitchedapps.frost.facebook.FbItem +import mozilla.components.lib.state.Action + +/** + * See + * https://github.com/mozilla-mobile/firefox-android/blob/main/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/action/BrowserAction.kt + * + * For firefox example + */ +sealed interface FrostWebAction : Action + +/** + * [FrostWebAction] dispatched to indicate that the store is initialized and ready to use. This + * action is dispatched automatically before any other action is processed. Its main purpose is to + * trigger initialization logic in middlewares. The action itself has no effect on the + * [FrostWebState]. + */ +object InitAction : FrostWebAction + +/** Actions affecting multiple tabs */ +sealed interface TabListAction : FrostWebAction { + data class SetHomeTabs(val data: List) : TabListAction + + data class SelectHomeTab(val id: WebTargetId) : TabListAction +} + +/** Action affecting a single tab */ +data class TabAction(val tabId: WebTargetId, val action: Action) : FrostWebAction { + sealed interface Action + + sealed interface ContentAction : Action { + + /** Action indicating current url state. */ + data class UpdateUrlAction(val url: String) : ContentAction + + /** Action indicating current title state. */ + data class UpdateTitleAction(val title: String?) : ContentAction + + data class UpdateNavigationAction(val canGoBack: Boolean, val canGoForward: Boolean) : + ContentAction + + data class UpdateProgressAction(val progress: Int) : ContentAction + } + + /** Action triggered by user, leading to transient state changes. */ + sealed interface UserAction : Action { + + /** Action to load new url. */ + data class LoadUrlAction(val url: String) : UserAction + + object GoBackAction : UserAction + + object GoForwardAction : UserAction + } + + /** Response triggered by webview, indicating [UserAction] fulfillment. */ + sealed interface ResponseAction : Action { + + data class LoadUrlResponseAction(val url: String) : ResponseAction + + data class WebStepResponseAction(val steps: Int) : ResponseAction + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostWebReducer.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostWebReducer.kt new file mode 100644 index 000000000..9e2c429af --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostWebReducer.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.web.state + +import com.pitchedapps.frost.ext.WebTargetId +import com.pitchedapps.frost.web.state.reducer.ContentStateReducer +import com.pitchedapps.frost.web.state.reducer.TabListReducer +import com.pitchedapps.frost.web.state.state.FloatingTabSessionState +import com.pitchedapps.frost.web.state.state.HomeTabSessionState +import com.pitchedapps.frost.web.state.state.SessionState +import javax.inject.Inject + +/** + * See + * https://github.com/mozilla-mobile/firefox-android/blob/main/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/reducer/BrowserStateReducer.kt + * + * For firefox example + */ +class FrostWebReducer +@Inject +internal constructor( + private val tabListReducer: TabListReducer, + private val contentStateReducer: ContentStateReducer +) { + fun reduce(state: FrostWebState, action: FrostWebAction): FrostWebState { + return when (action) { + is InitAction -> state + is TabListAction -> tabListReducer.reduce(state, action) + is TabAction -> + state.updateTabState(action.tabId) { session -> + val newContent = contentStateReducer.reduce(session.content, action.action) + session.createCopy(content = newContent) + } + } + } +} + +@Suppress("Unchecked_Cast") +internal fun FrostWebState.updateTabState( + tabId: WebTargetId, + update: (SessionState) -> SessionState, +): FrostWebState { + val floatingTabMatch = floatingTab?.takeIf { it.id == tabId } + if (floatingTabMatch != null) + return copy(floatingTab = update(floatingTabMatch) as FloatingTabSessionState) + + val newHomeTabs = homeTabs.updateTabs(tabId, update) as List? + if (newHomeTabs != null) return copy(homeTabs = newHomeTabs) + return this +} + +/** + * Finds the corresponding tab in the list and replaces it using [update]. + * + * @param tabId ID of the tab to change. + * @param update Returns a new version of the tab state. + */ +internal fun List.updateTabs( + tabId: WebTargetId, + update: (T) -> T, +): List? { + val tabIndex = indexOfFirst { it.id == tabId } + if (tabIndex == -1) return null + return subList(0, tabIndex) + update(get(tabIndex)) + subList(tabIndex + 1, size) +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostWebState.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostWebState.kt new file mode 100644 index 000000000..3e621f7d1 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostWebState.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.web.state + +import com.pitchedapps.frost.ext.FrostAccountId +import com.pitchedapps.frost.ext.WebTargetId +import com.pitchedapps.frost.web.state.state.FloatingTabSessionState +import com.pitchedapps.frost.web.state.state.HomeTabSessionState +import mozilla.components.lib.state.State + +/** + * See + * https://github.com/mozilla-mobile/firefox-android/blob/main/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/state/BrowserState.kt + * + * for Firefox example. + */ +data class FrostWebState( + val auth: AuthWebState = AuthWebState(), + val selectedHomeTab: WebTargetId? = null, + val homeTabs: List = emptyList(), + var floatingTab: FloatingTabSessionState? = null, +) : State + +/** + * Auth web state. + * + * Unlike GeckoView, WebView currently has a singleton cookie manager. + * + * Cookies are tied to the entire app, rather than per tab. + * + * @param currentUser User based on loaded cookies + * @param homeUser User selected for home screen + */ +data class AuthWebState( + val currentUser: AuthUser = AuthUser.Unknown, + val homeUser: AuthUser = AuthUser.Unknown, +) { + sealed interface AuthUser { + data class User(val id: FrostAccountId) : AuthUser + + data class Transitioning(val targetId: FrostAccountId?) : AuthUser + + object LoggedOut : AuthUser + + object Unknown : AuthUser + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostWebStore.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostWebStore.kt new file mode 100644 index 000000000..f5b0f1703 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/FrostWebStore.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.web.state + +import com.pitchedapps.frost.ext.WebTargetId +import com.pitchedapps.frost.web.state.state.SessionState +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.Store + +/** + * See + * https://github.com/mozilla-mobile/firefox-android/blob/main/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/store/BrowserStore.kt + * + * For firefox example. + */ +class FrostWebStore( + initialState: FrostWebState = FrostWebState(), + frostWebReducer: FrostWebReducer, + middleware: List> = emptyList(), +) : + Store( + initialState, + frostWebReducer::reduce, + middleware, + "FrostStore", + ) { + init { + dispatch(InitAction) + } +} + +operator fun FrostWebState.get(tabId: WebTargetId): SessionState? { + if (floatingTab?.id == tabId) return floatingTab + return homeTabs.find { it.id == tabId } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/helper/Target.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/helper/Target.kt new file mode 100644 index 000000000..9e92a9d1d --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/helper/Target.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.web.state.helper + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import com.pitchedapps.frost.ext.WebTargetId +import com.pitchedapps.frost.web.state.FrostWebState +import com.pitchedapps.frost.web.state.FrostWebStore +import com.pitchedapps.frost.web.state.state.SessionState +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.ext.observeAsComposableState + +/** + * Helper for allowing a component consumer to specify which tab a component should target. + * + * Based off of mozilla.components.browser.state.helper.Target: + * https://github.com/mozilla-mobile/firefox-android/blob/main/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/helper/Target.kt + */ +sealed class Target { + /** + * Looks up this target in the given [FrostWebStore] and returns the matching [SessionState] if + * available. Otherwise returns `null`. + * + * @param store to lookup this target in. + */ + fun lookupIn(store: FrostWebStore): SessionState? = lookupIn(store.state) + + /** + * Looks up this target in the given [FrostWebState] and returns the matching [SessionState] if + * available. Otherwise returns `null`. + * + * @param state to lookup this target in. + */ + abstract fun lookupIn(state: FrostWebState): SessionState? + + /** + * Observes this target and represents the mapped state (using [map]) via [State]. + * + * Everytime the [Store] state changes and the result of the [observe] function changes for this + * state, the returned [State] will be updated causing recomposition of every [State.value] usage. + * + * The [Store] observer will automatically be removed when this composable disposes or the current + * [LifecycleOwner] moves to the [Lifecycle.State.DESTROYED] state. + * + * @param store that should get observed + * @param observe function that maps a [SessionState] to the (sub) state that should get observed + * for changes. + */ + @Composable + fun observeAsComposableStateFrom( + store: FrostWebStore, + observe: (SessionState?) -> R, + ): State { + return store.observeAsComposableState( + map = { state -> lookupIn(state) }, + observe = { state -> observe(lookupIn(state)) }, + ) + } + + data class HomeTab(val id: WebTargetId) : Target() { + override fun lookupIn(state: FrostWebState): SessionState? { + return state.homeTabs.find { it.id == id } + } + } + + object FloatingTab : Target() { + override fun lookupIn(state: FrostWebState): SessionState? { + return state.floatingTab + } + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/reducer/ContentStateReducer.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/reducer/ContentStateReducer.kt new file mode 100644 index 000000000..14d4602e4 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/reducer/ContentStateReducer.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.web.state.reducer + +import com.pitchedapps.frost.web.state.TabAction.Action +import com.pitchedapps.frost.web.state.TabAction.ContentAction.UpdateNavigationAction +import com.pitchedapps.frost.web.state.TabAction.ContentAction.UpdateProgressAction +import com.pitchedapps.frost.web.state.TabAction.ContentAction.UpdateTitleAction +import com.pitchedapps.frost.web.state.TabAction.ContentAction.UpdateUrlAction +import com.pitchedapps.frost.web.state.TabAction.ResponseAction +import com.pitchedapps.frost.web.state.TabAction.ResponseAction.LoadUrlResponseAction +import com.pitchedapps.frost.web.state.TabAction.ResponseAction.WebStepResponseAction +import com.pitchedapps.frost.web.state.TabAction.UserAction +import com.pitchedapps.frost.web.state.TabAction.UserAction.GoBackAction +import com.pitchedapps.frost.web.state.TabAction.UserAction.GoForwardAction +import com.pitchedapps.frost.web.state.TabAction.UserAction.LoadUrlAction +import com.pitchedapps.frost.web.state.state.ContentState +import com.pitchedapps.frost.web.state.state.TransientWebState +import javax.inject.Inject + +internal class ContentStateReducer @Inject internal constructor() { + + fun reduce(state: ContentState, action: Action): ContentState { + return when (action) { + is UpdateUrlAction -> state.copy(url = action.url) + is UpdateProgressAction -> state.copy(progress = action.progress) + is UpdateNavigationAction -> + state.copy( + canGoBack = action.canGoBack, + canGoForward = action.canGoForward, + ) + is UpdateTitleAction -> state.copy(title = action.title) + is UserAction -> + state.copy( + transientState = + FrostTransientWebReducer.reduce( + state.transientState, + action, + ), + ) + is ResponseAction -> + state.copy( + transientState = + FrostTransientFulfillmentWebReducer.reduce( + state.transientState, + action, + ), + ) + } + } +} + +private object FrostTransientWebReducer { + fun reduce(state: TransientWebState, action: UserAction): TransientWebState { + return when (action) { + is LoadUrlAction -> state.copy(targetUrl = action.url) + is GoBackAction -> state.copy(navStep = state.navStep - 1) + is GoForwardAction -> state.copy(navStep = state.navStep + 1) + } + } +} + +private object FrostTransientFulfillmentWebReducer { + fun reduce(state: TransientWebState, action: ResponseAction): TransientWebState { + return when (action) { + is LoadUrlResponseAction -> + if (state.targetUrl == action.url) state.copy(targetUrl = null) else state + is WebStepResponseAction -> state.copy(navStep = state.navStep - action.steps) + } + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/reducer/TabListReducer.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/reducer/TabListReducer.kt new file mode 100644 index 000000000..6ee0d6332 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/reducer/TabListReducer.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.web.state.reducer + +import android.content.Context +import com.pitchedapps.frost.facebook.FbItem +import com.pitchedapps.frost.facebook.tab +import com.pitchedapps.frost.web.state.AuthWebState +import com.pitchedapps.frost.web.state.FrostWebState +import com.pitchedapps.frost.web.state.TabListAction +import com.pitchedapps.frost.web.state.TabListAction.SetHomeTabs +import com.pitchedapps.frost.web.state.state.ContentState +import com.pitchedapps.frost.web.state.state.HomeTabSessionState +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +internal class TabListReducer +@Inject +internal constructor( + @ApplicationContext private val context: Context, +) { + fun reduce(state: FrostWebState, action: TabListAction): FrostWebState { + return when (action) { + is SetHomeTabs -> { + val tabs = + action.data.mapIndexed { i, fbItem -> fbItem.toHomeTabSession(context, i, state.auth) } + state.copy(homeTabs = tabs) + } + is TabListAction.SelectHomeTab -> state.copy(selectedHomeTab = action.id) + } + } +} + +private fun FbItem.toHomeTabSession( + context: Context, + i: Int, + auth: AuthWebState +): HomeTabSessionState = + HomeTabSessionState( + userId = auth.currentUser, + content = ContentState(url = url), + tab = tab(context, id = HomeTabSessionState.homeTabId(i)), + ) diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/state/SessionState.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/state/SessionState.kt new file mode 100644 index 000000000..bb5671b67 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/web/state/state/SessionState.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.web.state.state + +import com.pitchedapps.frost.ext.WebTargetId +import com.pitchedapps.frost.main.MainTabItem +import com.pitchedapps.frost.web.state.AuthWebState.AuthUser + +/** Data representation of single session. */ +interface SessionState { + val id: WebTargetId + val userId: AuthUser + val content: ContentState + + fun createCopy( + id: WebTargetId = this.id, + userId: AuthUser = this.userId, + content: ContentState = this.content + ): SessionState +} + +/** Session for home screen, which includes nav bar data */ +data class HomeTabSessionState( + override val userId: AuthUser, + override val content: ContentState, + val tab: MainTabItem, +) : SessionState { + + override val id: WebTargetId + get() = tab.id + + override fun createCopy(id: WebTargetId, userId: AuthUser, content: ContentState) = + copy(userId = userId, content = content, tab = tab.copy(id = id)) + + companion object { + fun homeTabId(index: Int): WebTargetId = WebTargetId("home-tab--$index") + } +} + +data class FloatingTabSessionState( + override val id: WebTargetId, + override val userId: AuthUser, + override val content: ContentState, +) : SessionState { + override fun createCopy(id: WebTargetId, userId: AuthUser, content: ContentState) = + copy(id = id, userId = userId, content = content) +} + +/** Data relating to webview content */ +data class ContentState( + val url: String, + val title: String? = null, + val progress: Int = 0, + val loading: Boolean = false, + val canGoBack: Boolean = false, + val canGoForward: Boolean = false, + val transientState: TransientWebState = TransientWebState(), +) + +/** + * Transient web state. + * + * While we typically don't want to store this, our webview is not a composable, and requires a + * bridge to handle events. + * + * This state is not a list of pending actions, but rather a snapshot of the expected changes so + * that conflicting events can be ignored. + * + * @param targetUrl url destination if nonnull + * @param navStep pending steps. Positive = steps forward, negative = steps backward + */ +data class TransientWebState( + val targetUrl: String? = null, + val navStep: Int = 0, +) diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/webview/FrostChromeClients.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/webview/FrostChromeClients.kt new file mode 100644 index 000000000..bc819f3ff --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/webview/FrostChromeClients.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2018 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.webview + +import android.graphics.Bitmap +import android.webkit.ConsoleMessage +import android.webkit.WebChromeClient +import android.webkit.WebView +import com.google.common.flogger.FluentLogger +import com.pitchedapps.frost.ext.WebTargetId +import com.pitchedapps.frost.web.state.FrostWebStore +import com.pitchedapps.frost.web.state.TabAction +import com.pitchedapps.frost.web.state.TabAction.ContentAction.UpdateProgressAction +import com.pitchedapps.frost.web.state.TabAction.ContentAction.UpdateTitleAction +import com.pitchedapps.frost.web.state.get + +/** The default chrome client */ +class FrostChromeClient(private val tabId: WebTargetId, private val store: FrostWebStore) : + WebChromeClient() { + + private fun FrostWebStore.dispatch(action: TabAction.Action) { + dispatch(TabAction(tabId = tabId, action = action)) + } + + override fun getDefaultVideoPoster(): Bitmap? = + super.getDefaultVideoPoster() ?: Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888) + + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + logger + .atInfo() + .log("Chrome Console %d: %s", consoleMessage.lineNumber(), consoleMessage.message()) + return true + } + + override fun onReceivedTitle(view: WebView, title: String) { + super.onReceivedTitle(view, title) + if (title.startsWith("http")) return + store.dispatch(UpdateTitleAction(title)) + } + + override fun onProgressChanged(view: WebView, newProgress: Int) { + super.onProgressChanged(view, newProgress) + // TODO remove? + if (store.state[tabId]?.content?.progress == 100) return + store.dispatch(UpdateProgressAction(newProgress)) + } + + // override fun onShowFileChooser( + // webView: WebView, + // filePathCallback: ValueCallback?>, + // fileChooserParams: FileChooserParams + // ): Boolean { + // callbacks.openMediaPicker(filePathCallback, fileChooserParams) + // return true + // } + + // override fun onJsAlert(view: WebView, url: String, message: String, result: JsResult): Boolean + // { + // callbacks.onJsAlert(url, message, result) + // return true + // } + // + // override fun onJsConfirm(view: WebView, url: String, message: String, result: JsResult): + // Boolean { + // callbacks.onJsConfirm(url, message, result) + // return true + // } + // + // override fun onJsBeforeUnload( + // view: WebView, + // url: String, + // message: String, + // result: JsResult + // ): Boolean { + // callbacks.onJsBeforeUnload(url, message, result) + // return true + // } + // + // override fun onJsPrompt( + // view: WebView, + // url: String, + // message: String, + // defaultValue: String?, + // result: JsPromptResult + // ): Boolean { + // callbacks.onJsPrompt(url, message, defaultValue, result) + // return true + // } + + // override fun onGeolocationPermissionsShowPrompt( + // origin: String, + // callback: GeolocationPermissions.Callback + // ) { + // L.i { "Requesting geolocation" } + // context.kauRequestPermissions(PERMISSION_ACCESS_FINE_LOCATION) { granted, _ -> + // L.i { "Geolocation response received; ${if (granted) "granted" else "denied"}" } + // callback(origin, granted, true) + // } + // } + + companion object { + private val logger = FluentLogger.forEnclosingClass() + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/webview/FrostWeb.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/webview/FrostWeb.kt new file mode 100644 index 000000000..0d7d0a40f --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/webview/FrostWeb.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2021 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.webview + +import android.webkit.WebViewClient +import com.pitchedapps.frost.ext.WebTargetId +import com.pitchedapps.frost.web.FrostWebHelper +import com.pitchedapps.frost.web.state.FrostWebStore +import dagger.BindsInstance +import dagger.Module +import dagger.Provides +import dagger.hilt.DefineComponent +import dagger.hilt.EntryPoint +import dagger.hilt.EntryPoints +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject +import javax.inject.Qualifier +import javax.inject.Scope + +/** + * Defines a new scope for Frost web related content. + * + * This is a subset of [dagger.hilt.android.scopes.ViewScoped] + */ +@Scope +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.TYPE, AnnotationTarget.CLASS) +annotation class FrostWebScoped + +@Qualifier annotation class FrostWeb + +@FrostWebScoped @DefineComponent(parent = SingletonComponent::class) interface FrostWebComponent + +@DefineComponent.Builder +interface FrostWebComponentBuilder { + + @BindsInstance fun tabId(@FrostWeb tabId: WebTargetId): FrostWebComponentBuilder + + fun build(): FrostWebComponent +} + +@Module +@InstallIn(FrostWebComponent::class) +internal object FrostWebModule { + + @Provides + fun client( + @FrostWeb tabId: WebTargetId, + store: FrostWebStore, + webHelper: FrostWebHelper + ): WebViewClient = FrostWebViewClient(tabId, store, webHelper) +} + +/** + * Using this injection seems to be buggy, leading to an invalid param tabId error: + * + * Cause: not a valid name: tabId-4xHwVBUParam + */ +class FrostWebEntrySample +@Inject +internal constructor(private val frostWebComponentBuilder: FrostWebComponentBuilder) { + fun test(tabId: WebTargetId): WebViewClient { + val component = frostWebComponentBuilder.tabId(tabId).build() + return EntryPoints.get(component, FrostWebEntryPoint::class.java).client() + } + + @EntryPoint + @InstallIn(FrostWebComponent::class) + interface FrostWebEntryPoint { + fun client(): WebViewClient + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/webview/FrostWebComposer.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/webview/FrostWebComposer.kt new file mode 100644 index 000000000..a33375806 --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/webview/FrostWebComposer.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.webview + +import com.pitchedapps.frost.compose.webview.FrostWebCompose +import com.pitchedapps.frost.ext.WebTargetId +import com.pitchedapps.frost.web.FrostWebHelper +import com.pitchedapps.frost.web.state.FrostWebStore +import javax.inject.Inject + +class FrostWebComposer +@Inject +internal constructor( + private val store: FrostWebStore, + private val webHelper: FrostWebHelper, +) { + + fun create(tabId: WebTargetId): FrostWebCompose { + val client = FrostWebViewClient(tabId, store, webHelper) + val chromeClient = FrostChromeClient(tabId, store) + return FrostWebCompose(tabId, store, client, chromeClient) + } +} diff --git a/app-compose/src/main/kotlin/com/pitchedapps/frost/webview/FrostWebViewClients.kt b/app-compose/src/main/kotlin/com/pitchedapps/frost/webview/FrostWebViewClients.kt new file mode 100644 index 000000000..a100319fb --- /dev/null +++ b/app-compose/src/main/kotlin/com/pitchedapps/frost/webview/FrostWebViewClients.kt @@ -0,0 +1,258 @@ +/* + * Copyright 2018 Allan Wang + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.pitchedapps.frost.webview + +import android.graphics.Bitmap +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import com.google.common.flogger.FluentLogger +import com.pitchedapps.frost.ext.WebTargetId +import com.pitchedapps.frost.facebook.FACEBOOK_BASE_COM +import com.pitchedapps.frost.facebook.WWW_FACEBOOK_COM +import com.pitchedapps.frost.facebook.isExplicitIntent +import com.pitchedapps.frost.web.FrostWebHelper +import com.pitchedapps.frost.web.state.FrostWebStore +import com.pitchedapps.frost.web.state.TabAction +import com.pitchedapps.frost.web.state.TabAction.ContentAction.UpdateNavigationAction +import com.pitchedapps.frost.web.state.TabAction.ContentAction.UpdateProgressAction +import com.pitchedapps.frost.web.state.TabAction.ContentAction.UpdateTitleAction +import java.io.ByteArrayInputStream + +/** + * Created by Allan Wang on 2017-05-31. + * + * Collection of webview clients + */ + +/** The base of all webview clients Used to ensure that resources are properly intercepted */ +abstract class BaseWebViewClient : WebViewClient() { + + protected abstract val webHelper: FrostWebHelper + + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + val requestUrl = request.url?.toString() ?: return null + return if (webHelper.shouldInterceptUrl(requestUrl)) BLANK_RESOURCE else null + } + + companion object { + val BLANK_RESOURCE = + WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream("".toByteArray())) + } +} + +/** The default webview client */ +class FrostWebViewClient( + private val tabId: WebTargetId, + private val store: FrostWebStore, + override val webHelper: FrostWebHelper +) : BaseWebViewClient() { + + private fun FrostWebStore.dispatch(action: TabAction.Action) { + dispatch(TabAction(tabId = tabId, action = action)) + } + + /** True if current url supports refresh. See [doUpdateVisitedHistory] for updates */ + internal var urlSupportsRefresh: Boolean = true + + override fun doUpdateVisitedHistory(view: WebView, url: String, isReload: Boolean) { + super.doUpdateVisitedHistory(view, url, isReload) + urlSupportsRefresh = webHelper.allowUrlSwipeToRefresh(url) + store.dispatch( + UpdateNavigationAction( + canGoBack = view.canGoBack(), + canGoForward = view.canGoForward(), + ), + ) + // web.parent.swipeAllowedByPage = urlSupportsRefresh + // view.jsInject(JsAssets.AUTO_RESIZE_TEXTAREA.maybe(prefs.autoExpandTextBox), prefs = prefs) + // v { "History $url; refresh $urlSupportsRefresh" } + } + + /** Main injections for facebook content */ + // protected open val facebookJsInjectors: List = + // listOf( + // // CssHider.CORE, + // CssHider.HEADER, + // CssHider.COMPOSER.maybe(!prefs.showComposer), + // CssHider.STORIES.maybe(!prefs.showStories), + // CssHider.PEOPLE_YOU_MAY_KNOW.maybe(!prefs.showSuggestedFriends), + // CssHider.SUGGESTED_GROUPS.maybe(!prefs.showSuggestedGroups), + // CssHider.SUGGESTED_POSTS.maybe(!prefs.showSuggestedPosts), + // themeProvider.injector(ThemeCategory.FACEBOOK), + // CssHider.NON_RECENT.maybe( + // (web.url?.contains("?sk=h_chr") ?: false) && prefs.aggressiveRecents + // ), + // CssHider.ADS, + // CssHider.POST_ACTIONS.maybe(!prefs.showPostActions), + // CssHider.POST_REACTIONS.maybe(!prefs.showPostReactions), + // CssAsset.FullSizeImage.maybe(prefs.fullSizeImage), + // JsAssets.DOCUMENT_WATCHER, + // JsAssets.HORIZONTAL_SCROLLING, + // JsAssets.AUTO_RESIZE_TEXTAREA.maybe(prefs.autoExpandTextBox), + // JsAssets.CLICK_A, + // JsAssets.CONTEXT_A, + // JsAssets.MEDIA, + // JsAssets.SCROLL_STOP, + // ) + // + // private fun WebView.facebookJsInject() { + // jsInject(*facebookJsInjectors.toTypedArray(), prefs = prefs) + // } + // + // private fun WebView.messengerJsInject() { + // jsInject(themeProvider.injector(ThemeCategory.MESSENGER), prefs = prefs) + // } + + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + store.dispatch(UpdateProgressAction(0)) + store.dispatch(UpdateTitleAction(null)) + // v { "loading $url ${web.settings.userAgentString}" } + // refresh.offer(true) + } + + // private fun injectBackgroundColor() { + // web?.setBackgroundColor( + // when { + // isMain -> Color.TRANSPARENT + // web.url.isFacebookUrl -> themeProvider.bgColor.withAlpha(255) + // else -> Color.WHITE + // } + // ) + // } + + // override fun onPageCommitVisible(view: WebView, url: String?) { + // super.onPageCommitVisible(view, url) + // injectBackgroundColor() + // when { + // url.isFacebookUrl -> { + // v { "FB Page commit visible" } + // view.facebookJsInject() + // } + // url.isMessengerUrl -> { + // v { "Messenger Page commit visible" } + // view.messengerJsInject() + // } + // else -> { + // // refresh.offer(false) + // } + // } + // } + + override fun onPageFinished(view: WebView, url: String) { + // if (!url.isFacebookUrl && !url.isMessengerUrl) { + // refresh.offer(false) + // return + // } + // onPageFinishedActions(url) + } + + // internal open fun onPageFinishedActions(url: String) { + // if (url.startsWith("${FbItem.Messages.url}/read/") && prefs.messageScrollToBottom) { + // web.pageDown(true) + // } + // injectAndFinish() + // } + + // Temp open + // internal open fun injectAndFinish() { + // v { "page finished reveal" } + // // refresh.offer(false) + // injectBackgroundColor() + // when { + // web.url.isFacebookUrl -> { + // web.jsInject( + // JsActions.LOGIN_CHECK, + // JsAssets.TEXTAREA_LISTENER, + // JsAssets.HEADER_BADGES.maybe(isMain), + // prefs = prefs + // ) + // web.facebookJsInject() + // } + // web.url.isMessengerUrl -> { + // web.messengerJsInject() + // } + // } + // } + + fun handleHtml(html: String?) { + logger.atFine().log("Handle html: %s", html) + } + + fun emit(flag: Int) { + logger.atInfo().log("Emit %d", flag) + } + + /** + * Helper to format the request and launch it returns true to override the url returns false if we + * are already in an overlaying activity + */ + // private fun WebView.launchRequest(request: WebResourceRequest): Boolean { + // v { "Launching url: ${request.url}" } + // return requestWebOverlay(request.url.toString()) + // } + + // private fun launchImage(url: String, text: String? = null, cookie: String? = null): Boolean { + // v { "Launching image: $url" } + // web.context.launchImageActivity(url, text, cookie) + // if (web.canGoBack()) web.goBack() + // return true + // } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + logger.atFinest().log("Url loading: %s", request.url) + val path = request.url?.path ?: return super.shouldOverrideUrlLoading(view, request) + logger.atFinest().log("Url path: %s", path) + val url = request.url.toString() + if (url.isExplicitIntent) { + // view.context.startActivityForUri(request.url) + return true + } + // if (path.startsWith("/composer/")) { + // return launchRequest(request) + // } + // if (url.isIndirectImageUrl) { + // return launchImage(url.formattedFbUrl, cookie = fbCookie.webCookie) + // } + // if (url.isImageUrl) { + // return launchImage(url.formattedFbUrl) + // } + // if (prefs.linksInDefaultApp && view.context.startActivityForUri(request.url)) { + // return true + // } + // Convert desktop urls to mobile ones + if (url.contains("https://www.facebook.com") && webHelper.allowUrlSwipeToRefresh(url)) { + view.loadUrl(url.replace(WWW_FACEBOOK_COM, FACEBOOK_BASE_COM)) + return true + } + return super.shouldOverrideUrlLoading(view, request) + } + + companion object { + private val logger = FluentLogger.forEnclosingClass() + } +} + +private const val EMIT_THEME = 0b1 +private const val EMIT_ID = 0b10 +private const val EMIT_COMPLETE = EMIT_THEME or EMIT_ID +private const val EMIT_FINISH = 0 diff --git a/build.gradle b/build.gradle index d063f5f57..d7e690e25 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,14 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { dependencies { - classpath('com.google.dagger:hilt-android-gradle-plugin:2.43.2') + // https://mvnrepository.com/artifact/com.google.dagger/hilt-android-gradle-plugin + classpath('com.google.dagger:hilt-android-gradle-plugin:2.46.1') } } plugins { - id 'com.android.application' version '8.0.2' apply false + // https://mvnrepository.com/artifact/com.android.application/com.android.application.gradle.plugin?repo=google + id 'com.android.application' version '8.1.0-beta04' apply false id 'com.android.library' version '8.0.2' apply false id 'org.jetbrains.kotlin.android' version '1.8.21' apply false // https://mvnrepository.com/artifact/com.google.devtools.ksp/com.google.devtools.ksp.gradle.plugin