1
0
mirror of https://github.com/AllanWang/Frost-for-Facebook.git synced 2024-11-09 12:32:30 +01:00

Merge pull request #1945 from AllanWang/webview

This commit is contained in:
Allan Wang 2023-06-20 17:25:26 -07:00 committed by GitHub
commit 66ba83ee2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 2567 additions and 176 deletions

View File

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="17" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.21" />
</component>

View File

@ -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"

View File

@ -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<MainActivity>(
intentBuilder = {
flags =

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<WebView?>(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<ContentState>.() -> 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))
// }
// }
// }

View File

@ -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.
*

View File

@ -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,

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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)

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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())

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<String, String>()
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("&amp;", "&")
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<String> {
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"
)
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<String>): 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<BrowserState, BrowserAction> {
override fun invoke(
context: MiddlewareContext<BrowserState, BrowserAction>,
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))
}
}

View File

@ -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<String>): 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<BrowserState, BrowserAction> {
override fun invoke(
context: MiddlewareContext<BrowserState, BrowserAction>,
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))
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}
}

View File

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

View File

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

View File

@ -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.
*

View File

@ -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<GeckoContextId?> =

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<MainTabItem>) {
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<MainTabItem>,
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<MainTabItem>,
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<FrostWebCompose>) {
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))
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.web
import android.text.TextUtils
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
interface FrostAdBlock {
val data: Set<String>
/**
* 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))
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<CookieEntity>()
// 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"
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<FrostAdBlock>) {
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
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<FrostWebState, FrostWebAction>
class FrostLoggerMiddleware : FrostWebMiddleware {
override fun invoke(
context: MiddlewareContext<FrostWebState, FrostWebAction>,
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<FrostWebState, FrostWebAction>,
next: (FrostWebAction) -> Unit,
action: FrostWebAction
) {
when (action) {
else -> next(action)
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<FbItem>) : 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
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<HomeTabSessionState>?
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 <T : SessionState> List<T>.updateTabs(
tabId: WebTargetId,
update: (T) -> T,
): List<SessionState>? {
val tabIndex = indexOfFirst { it.id == tabId }
if (tabIndex == -1) return null
return subList(0, tabIndex) + update(get(tabIndex)) + subList(tabIndex + 1, size)
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<HomeTabSessionState> = 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
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<Middleware<FrostWebState, FrostWebAction>> = emptyList(),
) :
Store<FrostWebState, FrostWebAction>(
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 }
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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 <R> observeAsComposableStateFrom(
store: FrostWebStore,
observe: (SessionState?) -> R,
): State<SessionState?> {
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
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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)),
)

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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,
)

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<Array<Uri>?>,
// 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()
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<InjectorContract> =
// 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

View File

@ -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