1
0
mirror of https://github.com/AllanWang/Frost-for-Facebook.git synced 2024-11-08 12:02:33 +01:00

Merge pull request #1948 from AllanWang/compose

This commit is contained in:
Allan Wang 2023-06-20 23:55:18 -07:00 committed by GitHub
commit a0ed0c6c1d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
160 changed files with 6703 additions and 51 deletions

View File

@ -6,8 +6,6 @@
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
</value>
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">

View File

@ -1,8 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11">
<bytecodeTargetLevel target="17">
<module name="Frost-for-Facebook.buildSrc" target="11" />
<module name="Frost-for-Facebook.buildSrc.main" target="11" />
<module name="Frost-for-Facebook.buildSrc.test" target="11" />
</bytecodeTargetLevel>
</component>
</project>

View File

@ -1,6 +0,0 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="/*&#10; * Copyright &amp;#36;year Allan Wang&#10; *&#10; * This program is free software: you can redistribute it and/or modify&#10; * it under the terms of the GNU General Public License as published by&#10; * the Free Software Foundation, either version 3 of the License, or&#10; * (at your option) any later version.&#10; *&#10; * This program is distributed in the hope that it will be useful,&#10; * but WITHOUT ANY WARRANTY; without even the implied warranty of&#10; * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the&#10; * GNU General Public License for more details.&#10; *&#10; * You should have received a copy of the GNU General Public License&#10; * along with this program. If not, see &lt;http://www.gnu.org/licenses/&gt;.&#10; */" />
<option name="myName" value="GPLv3" />
</copyright>
</component>

9
.idea/kotlinc.xml Normal file
View File

@ -0,0 +1,9 @@
<?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>
</project>

View File

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DesignSurface">
<option name="filePathToZoomLevelMap">
@ -52,7 +51,7 @@
</value>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

View File

@ -11,7 +11,18 @@
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.pitchedapps.frost)
**This project is no longer actively maintained**. I still use it, but I've largely been off of Facebook for years. Bugs relating to logins are region dependent, and web wrappers don't have stable APIs, so please use at your own discretion.
**This project is undergoing a full rewrite**. The latest snapshot of the old project is available at https://github.com/AllanWang/Frost-for-Facebook/tree/legacy.
While a rewrite isn't necessary to keep the project going, I wanted to take some time to learn new things, and also to support some big features:
* Views to compose. This will make things a lot more structured, and will simplify dynamic colors/themes, which I've included in my projects well before Material You.
* WebView to GeckoView. This will add support for web extensions, and provide more functionality. Admittedly, this has been a huge pain to learn, and there are things that are far easier with webviews, but I think I'm at a point where I've answered my main questions.
* Web extensions allows for actual ad blocks, and theme updates without pushing new apks
* I intend on adding notification support for friend requests, though that could have been done with the old build
The direction I'm taking Frost v4.0.0 is to simplify a lot of things, and to leverage other extensions/libraries where possible. GeckoView also makes it easier to support multi account, in case I want to extend this beyond Facebook at some point in the future.
---
**Note** Some keystores are public for the sake of automatic builds and consistent signing across devices.
This means that others can build apps with the same signature. The only valid download sources are through my github releases and F-Droid.

1
app-compose/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

252
app-compose/build.gradle Normal file
View File

@ -0,0 +1,252 @@
plugins {
id "com.android.application"
id "org.jetbrains.kotlin.android"
id "kotlin-kapt"
id "dagger.hilt.android.plugin"
id "com.google.devtools.ksp"
id "com.google.protobuf"
id "app.cash.sqldelight"
}
apply from: '../spotless.gradle'
android {
compileSdk 33
defaultConfig {
applicationId "com.pitchedapps.frost"
minSdk 26
targetSdk 33
versionCode 1
versionName "1.0"
multiDexEnabled = true
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
// GeckoView
// https://bugzilla.mozilla.org/show_bug.cgi?id=1571175
// Replaces `android.bundle.enableUncompressedNativeLibs = false` in gradle.properties
packagingOptions {
jniLibs {
useLegacyPackaging = true
}
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
// https://developer.android.com/jetpack/androidx/releases/compose-compiler
kotlinCompilerExtensionVersion = "1.4.7"
}
buildTypes {
debug {
minifyEnabled false
shrinkResources false
applicationIdSuffix ".compose.debug"
versionNameSuffix "-compose-debug"
signingConfig signingConfigs.debug
resValue "string", "frost_name", "Frost Compose Debug"
resValue "string", "frost_web", "Frost Compose Web Debug"
// kotlinOptions.freeCompilerArgs += compilerArgs
}
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
test.java.srcDirs += 'src/test/kotlin'
androidTest.java.srcDirs += 'src/androidTest/kotlin'
main.assets.srcDirs += ['src/web/assets']
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
applicationVariants.configureEach { variant ->
variant.outputs.all {
outputFileName = "${project.APP_ID}-${variant.buildType.name}.apk"
}
}
namespace 'com.pitchedapps.frost'
def testKeystoreFile = file('../files/test.keystore')
def testPropFile = file('../files/test.properties')
def withTestSigning = testKeystoreFile.exists() && testPropFile.exists()
def releaseKeystoreFile = file('../files/release.keystore')
def releasePropFile = file('../files/release.properties')
def withReleaseSigning = releaseKeystoreFile.exists() && releasePropFile.exists()
signingConfigs {
debug {
storeFile file("../files/debug.keystore")
storePassword "debugKey"
keyAlias "debugKey"
keyPassword "debugKey"
}
if (withTestSigning) {
def testProps = new Properties()
testPropFile.withInputStream { testProps.load(it) }
test {
storeFile testKeystoreFile
storePassword testProps.getProperty('storePassword')
keyAlias testProps.getProperty('keyAlias')
keyPassword testProps.getProperty('keyPassword')
}
}
if (withReleaseSigning) {
def releaseProps = new Properties()
releasePropFile.withInputStream { releaseProps.load(it) }
release {
storeFile releaseKeystoreFile
storePassword releaseProps.getProperty('storePassword')
keyAlias releaseProps.getProperty('keyAlias')
keyPassword releaseProps.getProperty('keyPassword')
}
}
}
}
// Select the Glean from GeckoView.
// `service-sync-logins` requires Glean, which pulls in glean-native,
// but that's also provided by geckoview-omni, so now we need to select which one to use.
project.configurations.configureEach {
resolutionStrategy.capabilitiesResolution.withCapability("org.mozilla.telemetry:glean-native") {
def toBeSelected = candidates.find { it.id instanceof ModuleComponentIdentifier && it.id.module.contains('geckoview') }
if (toBeSelected != null) {
select(toBeSelected)
}
because 'use GeckoView Glean instead of standalone Glean'
}
}
dependencies {
implementation "androidx.core:core-ktx:1.10.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1"
implementation "androidx.appcompat:appcompat:1.6.1"
implementation "com.google.android.material:material:1.9.0"
// implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1"
// implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1"
implementation "androidx.multidex:multidex:2.0.1"
testImplementation "junit:junit:4.13.2"
testImplementation "com.google.truth:truth:1.1.3"
testImplementation "org.json:json:20220320"
androidTestImplementation "androidx.test.ext:junit:1.1.5"
androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
def hilt = "2.46.1"
implementation "com.google.dagger:hilt-android:${hilt}"
kapt "com.google.dagger:hilt-android-compiler:${hilt}"
testImplementation "com.google.dagger:hilt-android:${hilt}"
kaptTest "com.google.dagger:hilt-android-compiler:${hilt}"
def flogger = "0.7.1"
implementation "com.google.flogger:flogger:${flogger}"
implementation "com.google.flogger:flogger-system-backend:${flogger}"
def moshi = "1.14.0"
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"
// https://maven.mozilla.org/maven2/org/mozilla/components/browser-engine-gecko/maven-metadata.xml
// https://github.com/mozilla-mobile/reference-browser/blob/master/buildSrc/src/main/java/AndroidComponents.kt
// https://nightly.maven.mozilla.org/maven2/org/mozilla/components/browser-engine-gecko/maven-metadata.xml
def mozillaAndroidComponents = "116.0.20230617040331"
implementation "org.mozilla.components:lib-state:${mozillaAndroidComponents}"
// Kept for reference; not needed
implementation "org.mozilla.components:browser-state:${mozillaAndroidComponents}"
// Compose
def composeVersion = "1.4.3"
implementation("androidx.compose.ui:ui:${composeVersion}")
implementation("androidx.activity:activity-compose:1.7.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
// Tooling support (Previews, etc.)
implementation("androidx.compose.ui:ui-tooling:${composeVersion}")
// Foundation (Border, Background, Box, Image, Scroll, shapes, animations, etc.)
implementation("androidx.compose.foundation:foundation:${composeVersion}")
// Material Design
implementation("androidx.compose.material:material:${composeVersion}")
implementation 'androidx.compose.material3:material3:1.2.0-alpha02'
// Material design icons
implementation("androidx.compose.material:material-icons-core:${composeVersion}")
implementation("androidx.compose.material:material-icons-extended:${composeVersion}")
implementation("br.com.devsrsouza.compose.icons:font-awesome:1.1.0")
implementation("androidx.datastore:datastore:1.0.0")
implementation("com.google.protobuf:protobuf-kotlin-lite:3.23.3")
implementation("app.cash.sqldelight:android-driver:2.0.0-rc01")
// https://mvnrepository.com/artifact/org.apache.commons/commons-text
implementation("org.apache.commons:commons-text:1.10.0")
}
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.22.3'
}
plugins {
javalite {
artifact = 'com.google.protobuf:protoc-gen-javalite:3.0.0'
}
}
generateProtoTasks {
all().each { task ->
task.builtins {
java {
option 'lite'
}
kotlin {
option 'lite'
}
}
}
}
}
configurations {
configureEach {
// https://stackoverflow.com/q/69817925
exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel-ktx'
}
}
sqldelight {
databases {
register("FrostDb") {
packageName.set("com.pitchedapps.frost.db")
schemaOutputDirectory.set(file("src/main/sqldelight/databases"))
}
}
}

21
app-compose/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,142 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application
android:name=".FrostApp"
android:allowBackup="true"
android:extractNativeLibs="false"
android:fullBackupContent="@xml/frost_backup_rules"
android:icon="@mipmap/ic_launcher_round"
android:label="@string/frost_name"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/FrostTheme"
android:usesCleartextTraffic="true"
tools:ignore="UnusedAttribute">
<activity
android:name=".StartActivity"
android:exported="true"
android:noHistory="true"
android:theme="@style/FrostTheme.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
Config changes taken from Fenix with:
locale
addition
https://github.com/mozilla-mobile/fenix/blob/main/app/src/main/AndroidManifest.xml
-->
<activity
android:name=".main.MainActivity"
android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|layoutDirection|smallestScreenSize|screenLayout|locale"
android:hardwareAccelerated="true" />
<!-- <activity-->
<!-- android:name=".activities.FrostWebActivity"-->
<!-- android:autoRemoveFromRecents="true"-->
<!-- android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|layoutDirection|smallestScreenSize|screenLayout|locale"-->
<!-- android:exported="true"-->
<!-- android:hardwareAccelerated="true"-->
<!-- android:label="@string/frost_web"-->
<!-- android:launchMode="singleInstance"-->
<!-- android:taskAffinity="com.pitchedapps.frost.single.web"-->
<!-- android:theme="@style/FrostTheme.Overlay.Slide">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.intent.action.SEND" />-->
<!-- <category android:name="android.intent.category.DEFAULT" />-->
<!-- <data android:mimeType="text/plain" />-->
<!-- </intent-filter>-->
<!-- <intent-filter-->
<!-- android:autoVerify="true"-->
<!-- tools:ignore="UnusedAttribute">-->
<!-- <action android:name="android.intent.action.VIEW" />-->
<!-- <category android:name="android.intent.category.DEFAULT" />-->
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
<!-- <data-->
<!-- android:host="m.facebook.com"-->
<!-- android:scheme="http" />-->
<!-- <data-->
<!-- android:host="m.facebook.com"-->
<!-- android:scheme="https" />-->
<!-- <data-->
<!-- android:host="mobile.facebook.com"-->
<!-- android:scheme="http" />-->
<!-- <data-->
<!-- android:host="mobile.facebook.com"-->
<!-- android:scheme="https" />-->
<!-- <data-->
<!-- android:host="touch.facebook.com"-->
<!-- android:scheme="http" />-->
<!-- <data-->
<!-- android:host="touch.facebook.com"-->
<!-- android:scheme="https" />-->
<!-- <data-->
<!-- android:host="fb.com"-->
<!-- android:scheme="http" />-->
<!-- <data-->
<!-- android:host="fb.com"-->
<!-- android:scheme="https" />-->
<!-- <data-->
<!-- android:host="fb.me"-->
<!-- android:scheme="http" />-->
<!-- <data-->
<!-- android:host="fb.me"-->
<!-- android:scheme="https" />-->
<!-- <data-->
<!-- android:host="facebook.com"-->
<!-- android:scheme="http" />-->
<!-- <data-->
<!-- android:host="facebook.com"-->
<!-- android:scheme="https" />-->
<!-- <data-->
<!-- android:host="www.facebook.com"-->
<!-- android:scheme="http" />-->
<!-- <data-->
<!-- android:host="www.facebook.com"-->
<!-- android:scheme="https" />-->
<!-- <data-->
<!-- android:host="messenger.com"-->
<!-- android:scheme="http" />-->
<!-- <data-->
<!-- android:host="messenger.com"-->
<!-- android:scheme="https" />-->
<!-- <data-->
<!-- android:host="www.messenger.com"-->
<!-- android:scheme="http" />-->
<!-- <data-->
<!-- android:host="www.messenger.com"-->
<!-- android:scheme="https" />-->
<!-- </intent-filter>-->
<!-- </activity>-->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@ -0,0 +1,77 @@
/*
* 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
import android.app.Activity
import android.app.Application
import android.os.Bundle
import com.google.common.flogger.FluentLogger
import com.pitchedapps.frost.components.FrostComponents
import com.pitchedapps.frost.webview.injection.FrostJsInjectors
import com.pitchedapps.frost.webview.injection.assets.JsAssets
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import javax.inject.Provider
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
/** Frost Application. */
@HiltAndroidApp
class FrostApp : Application() {
@Inject lateinit var componentsProvider: Provider<FrostComponents>
@Inject lateinit var frostJsInjectors: Provider<FrostJsInjectors>
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
registerActivityLifecycleCallbacks(
object : ActivityLifecycleCallbacks {
override fun onActivityPaused(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityDestroyed(activity: Activity) {
logger.atFine().log("Activity %s destroyed", activity.localClassName)
}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
logger.atFine().log("Activity %s created", activity.localClassName)
}
},
)
}
MainScope().launch { setup() }
}
private suspend fun setup() {
JsAssets.load(this)
frostJsInjectors.get().load()
}
companion object {
private val logger = FluentLogger.forEnclosingClass()
}
}

View File

@ -0,0 +1,97 @@
/*
* 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
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.google.common.flogger.FluentLogger
import com.pitchedapps.frost.components.FrostDataStore
import com.pitchedapps.frost.db.FrostDb
import com.pitchedapps.frost.ext.FrostAccountId
import com.pitchedapps.frost.ext.idData
import com.pitchedapps.frost.ext.launchActivity
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.main.MainActivity
import com.pitchedapps.frost.web.state.FrostWebStore
import com.pitchedapps.frost.web.usecases.UseCases
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Start activity.
*
* This is the launcher activity, and should not be moved/renamed. The activity itself is transient,
* and will launch another activity without history after doing initialization work.
*/
@AndroidEntryPoint
class StartActivity : ComponentActivity() {
@Inject lateinit var frostDb: FrostDb
@Inject lateinit var dataStore: FrostDataStore
@Inject lateinit var store: FrostWebStore
@Inject lateinit var useCases: UseCases
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
val id = withContext(Dispatchers.IO) { getCurrentAccountId() }
logger.atInfo().log("Starting Frost with id %s", id)
// TODO load real tabs
useCases.homeTabs.setHomeTabs(listOf(FbItem.Feed, FbItem.Menu))
launchActivity<MainActivity>(
intentBuilder = {
flags =
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP
},
)
}
}
private suspend fun getCurrentAccountId(): FrostAccountId {
val currentId = dataStore.account.idData.firstOrNull()
if (currentId != null) return currentId
val newId = getAnyAccountId()
dataStore.account.updateData { it.toBuilder().setAccountId(newId).build() }
return FrostAccountId(newId)
}
private fun getAnyAccountId(): Long {
val account = frostDb.accountsQueries.selectAll().executeAsOneOrNull()
if (account != null) return account.id
frostDb.accountsQueries.insertNew()
return frostDb.accountsQueries.selectAll().executeAsOne().id
}
companion object {
private val logger = FluentLogger.forEnclosingClass()
}
}

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.components
import com.pitchedapps.frost.web.state.FrostWebStore
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
/**
* Core injections.
*
* All injections here are providers to avoid cyclic dependencies.
*/
@Singleton
class Core
@Inject
internal constructor(
private val storeProvider: Provider<FrostWebStore>,
) {
val store: FrostWebStore
get() = storeProvider.get()
}

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.components
import com.pitchedapps.frost.web.usecases.UseCases
import javax.inject.Inject
import javax.inject.Singleton
/**
* Main components containing other core components.
*
* Modelled off of Focus:
* https://github.com/mozilla-mobile/focus-android/blob/main/app/src/main/java/org/mozilla/focus/Components.kt
* but with hilt
*/
@Singleton
class FrostComponents
@Inject
internal constructor(
val core: Core,
val useCases: UseCases,
val dataStore: FrostDataStore,
)

View File

@ -0,0 +1,39 @@
/*
* 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.components
import androidx.datastore.core.DataStore
import com.pitchedapps.frost.proto.Account
import com.pitchedapps.frost.proto.settings.Appearance
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
/** DataStore injections. */
@Singleton
class FrostDataStore
@Inject
internal constructor(
private val accountProvider: Provider<DataStore<Account>>,
private val appearanceProvider: Provider<DataStore<Appearance>>,
) {
val account: DataStore<Account>
get() = accountProvider.get()
val appearance: DataStore<Appearance>
get() = appearanceProvider.get()
}

View File

@ -0,0 +1,65 @@
/*
* 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.compose
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
/** Main Frost compose theme. */
@Composable
fun FrostTheme(
isDarkTheme: Boolean = isSystemInDarkTheme(),
isDynamicColor: Boolean = true,
transparent: Boolean = true,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val context = LocalContext.current
val dynamicColor = isDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val colorScheme =
remember(dynamicColor, isDarkTheme, transparent) {
when {
dynamicColor && isDarkTheme -> {
dynamicDarkColorScheme(context)
}
dynamicColor && !isDarkTheme -> {
dynamicLightColorScheme(context)
}
isDarkTheme -> darkColorScheme()
else -> lightColorScheme()
}
}
MaterialTheme(colorScheme = colorScheme) {
Surface(
modifier = modifier,
color = if (transparent) Color.Transparent else MaterialTheme.colorScheme.surface,
content = content,
)
}
}

View File

@ -0,0 +1,181 @@
/*
* 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.FrostWebView
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.FrostWeb
import com.pitchedapps.frost.webview.FrostWebScoped
import com.pitchedapps.frost.webview.FrostWebViewClient
import javax.inject.Inject
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
@FrostWebScoped
class FrostWebCompose
@Inject
internal constructor(
@FrostWeb 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 =
FrostWebView(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 }
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()
}
}

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.ext
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import com.pitchedapps.frost.FrostApp
import com.pitchedapps.frost.components.FrostComponents
/** Launches new activities. */
inline fun <reified T : Activity> Context.launchActivity(
clearStack: Boolean = false,
bundleBuilder: Bundle.() -> Unit = {},
intentBuilder: Intent.() -> Unit = {}
) {
val intent = Intent(this, T::class.java)
if (clearStack) {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
}
intent.intentBuilder()
val bundle = Bundle()
bundle.bundleBuilder()
startActivity(intent, bundle.takeIf { !it.isEmpty })
if (clearStack && this is Activity) {
finish()
}
}
/** Component access through application context. */
val Context.components: FrostComponents
get() = (applicationContext as FrostApp).componentsProvider.get()

View File

@ -0,0 +1,64 @@
/*
* 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.ext
import androidx.datastore.core.DataStore
import com.pitchedapps.frost.proto.Account
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/*
* Cannot use inline value classes with Dagger due to Kapt:
* https://github.com/google/dagger/issues/2930
*/
/**
* Representation of unique frost account id.
*
* Account ids are identifiers specific to Frost, and group ids/info from other sites.
*/
data class FrostAccountId(val id: Long)
data class WebTargetId(val id: String)
/**
* Representation of gecko context id.
*
* Id is used to split cookies between accounts. [GeckoContextId] must be fixed per
* [FrostAccountId].
*/
@JvmInline value class GeckoContextId(val id: String)
/**
* Helper to get [FrostAccountId] from account data store.
*
* If account id is not initialized, returns null.
*/
val DataStore<Account>.idData: Flow<FrostAccountId?>
get() = data.map { if (it.hasAccountId()) FrostAccountId(it.accountId) else null }
/**
* Convert accountId to contextId.
*
* Note that contextId cannot be modified, as it is linked to all cookie info. Account ids start at
* 1, and it is important not to allow a default [GeckoContextId] with id 0. Doing so would be a
* bug, and may cause users to mix logins from multiple Frost accounts.
*/
fun FrostAccountId.toContextId(): GeckoContextId {
require(id > 0L) { "Invalid accountId $id" }
return GeckoContextId(id = "frost-context-$id")
}

View File

@ -0,0 +1,36 @@
/*
* 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 2017-06-01. */
const val FACEBOOK_COM = "facebook.com"
const val MESSENGER_COM = "messenger.com"
const val FBCDN_NET = "fbcdn.net"
const val WWW_FACEBOOK_COM = "www.$FACEBOOK_COM"
const val WWW_MESSENGER_COM = "www.$MESSENGER_COM"
const val HTTPS_FACEBOOK_COM = "https://$WWW_FACEBOOK_COM"
const val HTTPS_MESSENGER_COM = "https://$WWW_MESSENGER_COM"
const val FACEBOOK_BASE_COM = "touch.$FACEBOOK_COM"
const val FB_URL_BASE = "https://$FACEBOOK_BASE_COM/"
const val FACEBOOK_MBASIC_COM = "mbasic.$FACEBOOK_COM"
const val FB_URL_MBASIC_BASE = "https://$FACEBOOK_MBASIC_COM/"
fun profilePictureUrl(id: Long) = "https://graph.facebook.com/$id/picture?type=large"
const val FB_LOGIN_URL = "${FB_URL_BASE}login"
const val FB_HOME_URL = "${FB_URL_BASE}home.php"
const val MESSENGER_THREAD_PREFIX = "$HTTPS_MESSENGER_COM/t/"

View File

@ -0,0 +1,140 @@
/*
* 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 android.content.Context
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons.Default as MaterialIcons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.Cake
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.EventNote
import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Newspaper
import androidx.compose.material.icons.filled.Note
import androidx.compose.material.icons.filled.PersonAddAlt1
import androidx.compose.material.icons.filled.Photo
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
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
import compose.icons.fontawesomeicons.solid.GlobeAmericas
/**
* Fb page info.
*
* All pages here are independent, in that they can be loaded directly. [key] must be final, as it
* is used to store tab info.
*/
enum class FbItem(
val key: String,
@StringRes val titleId: Int,
val icon: ImageVector,
val relativeUrl: String,
) {
ActivityLog(
key = "activity_log",
titleId = R.string.activity_log,
icon = MaterialIcons.List,
relativeUrl = "me/allactivity",
),
Birthdays(
key = "birthdays",
titleId = R.string.birthdays,
icon = MaterialIcons.Cake,
relativeUrl = "events/birthdays",
),
Events("events", R.string.events, MaterialIcons.EventNote, "events/upcoming"),
Feed("feed", R.string.feed, MaterialIcons.Newspaper, ""),
FeedMostRecent(
"feed_most_recent",
R.string.most_recent,
MaterialIcons.History,
"home.php?sk=h_chr",
),
FeedTopStories("feed_top_stories", R.string.top_stories, MaterialIcons.Star, "home.php?sk=h_nor"),
Friends("friends", R.string.friends, MaterialIcons.PersonAddAlt1, "friends/center/requests"),
Groups("groups", R.string.groups, MaterialIcons.Group, "groups"),
Marketplace("marketplace", R.string.marketplace, MaterialIcons.Store, "marketplace"),
Menu("menu", R.string.menu, MaterialIcons.Menu, "bookmarks"),
Messages("messages", R.string.messages, MaterialIcons.ChatBubble, "messages"),
Notes("notes", R.string.notes, MaterialIcons.Note, "notes"),
Notifications(
"notifications",
R.string.notifications,
FontAwesomeIcons.Solid.GlobeAmericas,
"notifications",
),
OnThisDay("on_this_day", R.string.on_this_day, MaterialIcons.Today, "onthisday"),
Pages("pages", R.string.pages, MaterialIcons.Flag, "pages"),
Photos("photos", R.string.photos, MaterialIcons.Photo, "me/photos"),
Profile("profile", R.string.profile, MaterialIcons.AccountCircle, "me"),
Saved("saved", R.string.saved, MaterialIcons.Bookmark, "saved"),
Settings("settings", R.string.settings, MaterialIcons.Settings, "settings"),
;
val url = "$FB_URL_BASE$relativeUrl"
val isFeed: Boolean
get() =
when (this) {
Feed,
FeedMostRecent,
FeedTopStories -> true
else -> false
}
companion object {
private val values = values().associateBy { it.key }
fun fromKey(key: String) = values[key]
fun defaults() = listOf(Feed, Messages, Notifications, Menu)
}
}
/** Converts [FbItem] to [MainTabItem]. */
fun FbItem.tab(context: Context, id: WebTargetId): MainTabItem =
MainTabItem(
id = id,
title = context.getString(titleId),
icon = icon,
url = url,
)
/// ** Note that this url only works if a query (?q=) is provided */
// _SEARCH("search", R.string.kau_search, GoogleMaterial.Icon.gmd_search, "search/top"),
//
/// ** Non mbasic search cannot be parsed. */
// _SEARCH_PARSE(
// R.string.kau_search,
// GoogleMaterial.Icon.gmd_search,
// "search/top",
// prefix = FB_URL_MBASIC_BASE,
// ),

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,40 @@
/*
* 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 app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import com.pitchedapps.frost.db.FrostDb
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/** Module containing SQL injections. */
@Module
@InstallIn(SingletonComponent::class)
object FrostDbModule {
@Singleton
@Provides
fun frostDb(@ApplicationContext context: Context): FrostDb {
val driver: SqlDriver = AndroidSqliteDriver(FrostDb.Schema, context, "frost.db")
return FrostDb(driver)
}
}

View File

@ -0,0 +1,59 @@
/*
* 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 com.google.common.flogger.FluentLogger
import com.pitchedapps.frost.web.FrostAdBlock
import dagger.BindsOptionalOf
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Qualifier
@Qualifier annotation class Frost
@Module
@InstallIn(SingletonComponent::class)
interface FrostBindModule {
@BindsOptionalOf @Frost fun userAgent(): String
@BindsOptionalOf fun adBlock(): FrostAdBlock
}
/** Module containing core Frost injections. */
@Module
@InstallIn(SingletonComponent::class)
object FrostModule {
private val logger = FluentLogger.forEnclosingClass()
/**
* Windows based user agent.
*
* Note that Facebook's mobile webpage for mobile user agents is completely different from the
* desktop ones. All elements become divs, so nothing can be queried. There is a new UI too, but
* it doesn't seem worth migrating all other logic over.
*/
private const val USER_AGENT_WINDOWS =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/112.0"
private const val USER_AGENT_WINDOWS_FROST =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.70 Safari/537.36"
@Provides @Frost fun userAgent(): String = USER_AGENT_WINDOWS_FROST
}

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

@ -0,0 +1,64 @@
/*
* 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 com.squareup.moshi.*
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import okio.Buffer
import org.json.JSONException
import org.json.JSONObject
/** Module containing Moshi injections. */
@Module
@InstallIn(SingletonComponent::class)
object MoshiModule {
@Provides
@Singleton
fun moshi(): Moshi {
return Moshi.Builder()
// .add(ExtensionType.moshiFactory())
.add(JSONObjectAdapter())
.addLast(KotlinJsonAdapterFactory())
.build()
}
private class JSONObjectAdapter {
@FromJson
fun fromJson(reader: JsonReader): JSONObject? {
// Handle map data only; ignore rest
return (reader.readJsonValue() as? Map<*, *>)?.let { data ->
try {
JSONObject(data)
} catch (e: JSONException) {
null
}
}
}
@ToJson
fun toJson(writer: JsonWriter, value: JSONObject?) {
value?.let { writer.value(Buffer().writeUtf8(value.toString())) }
}
}
}

View File

@ -0,0 +1,67 @@
/*
* 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 android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.view.WindowCompat
import com.google.common.flogger.FluentLogger
import com.pitchedapps.frost.R
import com.pitchedapps.frost.compose.FrostTheme
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.
*
* Contains tab layouts for browsing.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var store: FrostWebStore
override fun onCreate(savedInstanceState: Bundle?) {
// TODO make configurable
setTheme(R.style.FrostTheme_Transparent)
super.onCreate(savedInstanceState)
logger.atInfo().log("onCreate main activity")
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
FrostTheme {
// MainScreen(
// tabs = tabs,
// )
val tabs =
store.observeAsState(initialValue = null) { it.homeTabs.map { it.tab } }.value
?: return@FrostTheme
MainScreenWebView(homeTabs = tabs)
}
}
}
companion object {
private val logger = FluentLogger.forEnclosingClass()
}
}

View File

@ -0,0 +1,28 @@
/*
* 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.ui.graphics.vector.ImageVector
import com.pitchedapps.frost.ext.WebTargetId
/** Data representation of a single main tab entry. */
data class MainTabItem(
val id: WebTargetId,
val title: String,
val icon: ImageVector,
val url: String
)

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.main
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.pitchedapps.frost.components.FrostComponents
import com.pitchedapps.frost.ext.GeckoContextId
import com.pitchedapps.frost.ext.idData
import com.pitchedapps.frost.ext.toContextId
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
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@HiltViewModel
class MainScreenViewModel
@Inject
internal constructor(
@ApplicationContext context: Context,
val components: FrostComponents,
val store: FrostWebStore,
val frostWebComposer: FrostWebComposer,
) : ViewModel() {
val contextIdFlow: Flow<GeckoContextId?> =
components.dataStore.account.idData.map { it?.toContextId() }
var tabIndex: Int by mutableStateOf(0)
}

View File

@ -0,0 +1,172 @@
/*
* 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.compose.ui.graphics.Color
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,
containerColor = Color.Transparent,
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,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.persistent
import android.content.Context
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.Serializer
import androidx.datastore.dataStoreFile
import com.google.protobuf.InvalidProtocolBufferException
import com.pitchedapps.frost.proto.Account
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Singleton
private object AccountProtoSerializer : Serializer<Account> {
override val defaultValue: Account = Account.getDefaultInstance()
override suspend fun readFrom(input: InputStream): Account {
try {
return Account.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: Account, output: OutputStream) = t.writeTo(output)
}
@Module
@InstallIn(SingletonComponent::class)
object AccountProtoModule {
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Account> =
DataStoreFactory.create(
AccountProtoSerializer,
) {
context.dataStoreFile("account.pb")
}
}

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.persistent.settings
import android.content.Context
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.Serializer
import androidx.datastore.dataStoreFile
import com.google.protobuf.InvalidProtocolBufferException
import com.pitchedapps.frost.proto.settings.Appearance
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Singleton
private object AppearanceProtoSerializer : Serializer<Appearance> {
override val defaultValue: Appearance = Appearance.getDefaultInstance()
override suspend fun readFrom(input: InputStream): Appearance {
try {
return Appearance.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: Appearance, output: OutputStream) = t.writeTo(output)
}
@Module
@InstallIn(SingletonComponent::class)
object AppearanceProtoModule {
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Appearance> =
DataStoreFactory.create(
AppearanceProtoSerializer,
) {
context.dataStoreFile("settings/appearance.pb")
}
}

View File

@ -0,0 +1,54 @@
/*
* 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.view
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import com.pitchedapps.frost.hilt.Frost
import dagger.hilt.android.AndroidEntryPoint
import java.util.Optional
import javax.inject.Inject
@AndroidEntryPoint
class FrostWebView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
NestedWebView(context, attrs, defStyleAttr) {
@Inject @Frost lateinit var userAgent: Optional<String>
init {
userAgent.ifPresent {
settings.userAgentString = it
println("Set user agent to $it")
}
with(settings) {
// noinspection SetJavaScriptEnabled
javaScriptEnabled = true
mediaPlaybackRequiresUserGesture = false // TODO check if we need this
allowFileAccess = true
// textZoom
domStorageEnabled = true
setLayerType(LAYER_TYPE_HARDWARE, null)
setBackgroundColor(Color.TRANSPARENT)
// Download listener
// JS Interface
}
}
}

View File

@ -0,0 +1,197 @@
/*
* 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
*/
open 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 =
childHelper.startNestedScroll(axes, type)
override fun startNestedScroll(axes: Int) = childHelper.startNestedScroll(axes)
override fun stopNestedScroll(type: Int) = childHelper.stopNestedScroll(type)
override fun stopNestedScroll() = childHelper.stopNestedScroll()
override fun hasNestedScrollingParent(type: Int): Boolean =
childHelper.hasNestedScrollingParent(type)
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,66 @@
/*
* 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.components.FrostDataStore
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Singleton
/** Snapshot of UI options based on user preferences */
interface FrostWebUiOptions {
val theme: Theme
enum class Theme {
Original,
Light,
Dark,
Amoled,
Glass // Custom
}
}
/**
* Singleton to provide snapshots of [FrostWebUiOptions].
*
* This is a mutable class, and does not provide change listeners. We will update activities
* manually when needed.
*/
@Singleton
class FrostWebUiSnapshot(private val dataStore: FrostDataStore) {
@Volatile
var options: FrostWebUiOptions = defaultOptions()
private set
private val stale = AtomicBoolean(true)
private fun defaultOptions(): FrostWebUiOptions =
object : FrostWebUiOptions {
override val theme: FrostWebUiOptions.Theme = FrostWebUiOptions.Theme.Original
}
/** Fetch new snapshot and update other singletons */
suspend fun reload() {
if (!stale.getAndSet(false)) return
// todo load
}
fun markAsStale() {
stale.set(true)
}
}

View File

@ -0,0 +1,66 @@
/*
* 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
) {
if (logInfo(action)) {
logger.atInfo().log("FrostWebAction: %s", action)
} else {
logger.atFine().log("FrostWebAction: %s", action)
}
next(action)
}
private fun logInfo(action: FrostWebAction): Boolean {
when (action) {
is TabAction ->
when (action.action) {
is TabAction.ContentAction.UpdateProgressAction -> return false
else -> {}
}
else -> {}
}
return true
}
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>, val selectedTab: Int? = 0) : 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,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.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) }
val selectedTab = action.selectedTab?.let { HomeTabSessionState.homeTabId(it) }
state.copy(
homeTabs = tabs,
selectedHomeTab = selectedTab,
)
}
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,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.usecases
import com.pitchedapps.frost.ext.WebTargetId
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.web.state.FrostWebStore
import com.pitchedapps.frost.web.state.TabListAction.SelectHomeTab
import com.pitchedapps.frost.web.state.TabListAction.SetHomeTabs
import javax.inject.Inject
/** Use cases for the home screen. */
class HomeTabsUseCases @Inject internal constructor(private val store: FrostWebStore) {
/**
* Create the provided tabs.
*
* If there are existing tabs, they will be replaced.
*/
fun setHomeTabs(items: List<FbItem>) {
store.dispatch(SetHomeTabs(items))
}
/**
* Select home tab based on index.
*
* If the index is OOB, the selected tab will be null.
*/
fun selectHomeTab(tabId: WebTargetId) {
store.dispatch(SelectHomeTab(tabId))
}
}

View File

@ -0,0 +1,90 @@
/*
* 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.usecases
import com.pitchedapps.frost.ext.WebTargetId
import com.pitchedapps.frost.web.state.FrostWebStore
import com.pitchedapps.frost.web.state.TabAction
import javax.inject.Inject
class TabUseCases
@Inject
internal constructor(
private val store: FrostWebStore,
val requests: TabRequestUseCases,
val responses: TabResponseUseCases,
) {
fun updateUrl(tabId: WebTargetId, url: String) {
store.dispatch(TabAction(tabId = tabId, action = TabAction.ContentAction.UpdateUrlAction(url)))
}
fun updateTitle(tabId: WebTargetId, title: String?) {
store.dispatch(
TabAction(
tabId = tabId,
action = TabAction.ContentAction.UpdateTitleAction(title),
),
)
}
fun updateNavigation(tabId: WebTargetId, canGoBack: Boolean, canGoForward: Boolean) {
store.dispatch(
TabAction(
tabId = tabId,
action =
TabAction.ContentAction.UpdateNavigationAction(
canGoBack = canGoBack,
canGoForward = canGoForward,
),
),
)
}
}
class TabRequestUseCases @Inject internal constructor(private val store: FrostWebStore) {
fun requestUrl(tabId: WebTargetId, url: String) {
store.dispatch(TabAction(tabId = tabId, action = TabAction.UserAction.LoadUrlAction(url)))
}
fun goBack(tabId: WebTargetId) {
store.dispatch(TabAction(tabId = tabId, action = TabAction.UserAction.GoBackAction))
}
fun goForward(tabId: WebTargetId) {
store.dispatch(TabAction(tabId = tabId, action = TabAction.UserAction.GoForwardAction))
}
}
class TabResponseUseCases @Inject internal constructor(private val store: FrostWebStore) {
fun respondUrl(tabId: WebTargetId, url: String) {
store.dispatch(
TabAction(
tabId = tabId,
action = TabAction.ResponseAction.LoadUrlResponseAction(url),
),
)
}
fun respondSteps(tabId: WebTargetId, steps: Int) {
store.dispatch(
TabAction(
tabId = tabId,
action = TabAction.ResponseAction.WebStepResponseAction(steps),
),
)
}
}

View File

@ -0,0 +1,33 @@
/*
* 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.usecases
import javax.inject.Inject
import javax.inject.Singleton
/**
* Collection of frost use cases.
*
* Note that included use cases are not lazily loaded.
*/
@Singleton
class UseCases
@Inject
internal constructor(
val homeTabs: HomeTabsUseCases,
val tabs: TabUseCases,
)

View File

@ -0,0 +1,122 @@
/*
* 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
import javax.inject.Inject
/** The default chrome client */
@FrostWebScoped
class FrostChromeClient
@Inject
internal constructor(@FrostWeb 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,71 @@
/*
* 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 com.pitchedapps.frost.compose.webview.FrostWebCompose
import com.pitchedapps.frost.ext.WebTargetId
import dagger.BindsInstance
import dagger.hilt.DefineComponent
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
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 = ViewModelComponent::class) interface FrostWebComponent
@DefineComponent.Builder
interface FrostWebComponentBuilder {
fun tabId(@BindsInstance @FrostWeb tabId: WebTargetId): FrostWebComponentBuilder
fun build(): FrostWebComponent
}
/**
* Using this injection seems to be buggy, leading to an invalid param tabId error:
*
* Cause: not a valid name: tabId-4xHwVBUParam
*/
class FrostWebComposer
@Inject
internal constructor(private val frostWebComponentBuilder: FrostWebComponentBuilder) {
fun create(tabId: WebTargetId): FrostWebCompose {
val component = frostWebComponentBuilder.tabId(tabId).build()
return EntryPoints.get(component, FrostWebEntryPoint::class.java).compose()
}
@EntryPoint
@InstallIn(FrostWebComponent::class)
interface FrostWebEntryPoint {
fun compose(): FrostWebCompose
}
}

View File

@ -0,0 +1,278 @@
/*
* 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.WebResourceError
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.facebook.isFacebookUrl
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 com.pitchedapps.frost.webview.injection.FrostJsInjectors
import java.io.ByteArrayInputStream
import javax.inject.Inject
/**
* 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 */
@FrostWebScoped
class FrostWebViewClient
@Inject
internal constructor(
@FrostWeb private val tabId: WebTargetId,
private val store: FrostWebStore,
override val webHelper: FrostWebHelper,
private val frostJsInjectors: FrostJsInjectors,
) : 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 WebView.injectBackgroundColor(url: String?) {
// setBackgroundColor(
// when {
// isMain -> Color.TRANSPARENT
// url.isFacebookUrl -> themeProvider.bgColor.withAlpha(255)
// else -> Color.WHITE
// }
// )
// }
override fun onPageCommitVisible(view: WebView, url: String?) {
super.onPageCommitVisible(view, url)
when {
url.isFacebookUrl -> frostJsInjectors.facebookInjectOnPageCommitVisible(view, url)
}
}
// 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)
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
super.onReceivedError(view, request, error)
}
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

@ -0,0 +1,69 @@
/*
* 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.injection
import android.content.Context
import android.webkit.WebView
import com.google.common.flogger.FluentLogger
import com.pitchedapps.frost.webview.injection.assets.JsActions
import com.pitchedapps.frost.webview.injection.assets.JsAssets
import com.pitchedapps.frost.webview.injection.assets.inject
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.BufferedReader
import java.io.FileNotFoundException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Singleton
class FrostJsInjectors
@Inject
internal constructor(
@ApplicationContext private val context: Context,
) {
@Volatile private var theme: JsInjector = JsInjector.EMPTY
fun facebookInjectOnPageCommitVisible(view: WebView, url: String?) {
logger.atInfo().log("inject page commit visible %b", theme != JsInjector.EMPTY)
listOf(theme, JsAssets.CLICK_A).inject(view)
}
private fun getTheme(): JsInjector {
return try {
val content =
context.assets
.open("frost/css/facebook/themes/material_glass.css")
.bufferedReader()
.use(BufferedReader::readText)
logger.atInfo().log("css %s", content)
JsBuilder().css(content).single("material_glass").build()
} catch (e: FileNotFoundException) {
logger.atSevere().withCause(e).log("CssAssets file not found")
JsActions.EMPTY
}
}
suspend fun load() {
withContext(Dispatchers.IO) { theme = getTheme() }
}
companion object {
private val logger = FluentLogger.forEnclosingClass()
}
}

View File

@ -0,0 +1,109 @@
/*
* 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.injection
import android.webkit.WebView
import org.apache.commons.text.StringEscapeUtils
interface JsInjector {
fun inject(webView: WebView)
companion object {
val EMPTY: JsInjector = EmptyJsInjector
operator fun invoke(content: String): JsInjector =
object : JsInjector {
override fun inject(webView: WebView) {
webView.evaluateJavascript(content, null)
}
}
}
}
private object EmptyJsInjector : JsInjector {
override fun inject(webView: WebView) {
// Noop
}
}
data class OneShotJsInjector(val tag: String, val injector: JsInjector) : JsInjector {
override fun inject(webView: WebView) {
// TODO
}
}
class JsBuilder {
private val css = StringBuilder()
private val js = StringBuilder()
private var tag: String? = null
fun css(css: String): JsBuilder {
this.css.append(StringEscapeUtils.escapeEcmaScript(css))
return this
}
fun js(content: String): JsBuilder {
this.js.append(content)
return this
}
fun single(tag: String): JsBuilder {
this.tag = tag // TODO TagObfuscator.obfuscateTag(tag)
return this
}
fun build() = JsInjector(toString())
override fun toString(): String {
val tag = this.tag
val builder =
StringBuilder().apply {
if (css.isNotBlank()) {
val cssMin = css.replace(Regex("\\s*\n\\s*"), "")
append("var a=document.createElement('style');")
append("a.innerHTML='$cssMin';")
if (tag != null) {
append("a.id='$tag';")
}
append("document.head.appendChild(a);")
}
if (js.isNotBlank()) {
append(js)
}
}
var content = builder.toString()
if (tag != null) {
content = singleInjector(tag, content)
}
return wrapAnonymous(content)
}
private fun wrapAnonymous(body: String) = "(function(){$body})();"
private fun singleInjector(tag: String, content: String) =
"""
if (!window.hasOwnProperty("$tag")) {
console.log("Registering $tag");
window.$tag = true;
$content
}
"""
.trimIndent()
}

View File

@ -0,0 +1,35 @@
/*
* Copyright 2020 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.injection.assets
import android.webkit.WebView
import com.pitchedapps.frost.webview.injection.JsBuilder
import com.pitchedapps.frost.webview.injection.JsInjector
/** Small misc inline css assets */
enum class CssActions(private val content: String) : JsInjector {
FullSizeImage(
"div._4prr[style*=\"max-width\"][style*=\"max-height\"]{max-width:none !important;max-height:none !important}",
);
private val injector: JsInjector =
JsBuilder().css(content).single("css-small-assets-$name").build()
override fun inject(webView: WebView) {
injector.inject(webView)
}
}

View File

@ -0,0 +1,63 @@
/*
* 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.injection.assets
import android.webkit.WebView
import com.pitchedapps.frost.webview.injection.JsBuilder
import com.pitchedapps.frost.webview.injection.JsInjector
/**
* Created by Allan Wang on 2017-05-31.
*
* List of elements to hide
*/
enum class CssHider(private vararg val items: String) : JsInjector {
CORE("[data-sigil=m_login_upsell]", "[role=progressbar]"),
HEADER(
"#header:not(.mFuturePageHeader):not(.titled)",
"#mJewelNav",
"[data-sigil=MTopBlueBarHeader]",
"#header-notices",
"[data-sigil*=m-promo-jewel-header]",
),
ADS("article[data-xt*=sponsor]", "article[data-store*=sponsor]", "article[data-ft*=sponsor]"),
PEOPLE_YOU_MAY_KNOW("article._d2r"),
SUGGESTED_GROUPS("article[data-ft*=\"ei\":]"),
// Is it really this simple?
SUGGESTED_POSTS("article[data-store*=recommendation]", "article[data-ft*=recommendation]"),
COMPOSER("#MComposer"),
MESSENGER("._s15", "[data-testid=info_panel]", "js_i"),
NON_RECENT("article:not([data-store*=actor_name])"),
STORIES(
"#MStoriesTray",
// Sub element with just the tray; title is not a part of this
"[data-testid=story_tray]",
),
POST_ACTIONS("footer [data-sigil=\"ufi-inline-actions\"]"),
POST_REACTIONS("footer [data-sigil=\"reactions-bling-bar\"]");
private val injector: JsInjector =
JsBuilder()
.css("${items.joinToString(separator = ",")}{display:none !important}")
.single("css-hider-$name")
.build()
override fun inject(webView: WebView) {
injector.inject(webView)
}
}

View File

@ -0,0 +1,55 @@
/*
* 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.injection.assets
import android.webkit.WebView
import com.pitchedapps.frost.facebook.FB_URL_BASE
import com.pitchedapps.frost.webview.injection.JsInjector
/**
* Created by Allan Wang on 2017-05-31.
*
* Collection of short js functions that are embedded directly
*/
enum class JsActions(body: String) : JsInjector {
/**
* Redirects to login activity if create account is found see
* [com.pitchedapps.frost.web.FrostJSI.loadLogin]
*/
LOGIN_CHECK("document.getElementById('signup-button')&&Frost.loadLogin();"),
BASE_HREF("""document.write("<base href='$FB_URL_BASE'/>");"""),
FETCH_BODY(
"""setTimeout(function(){var e=document.querySelector("main");e||(e=document.querySelector("body")),Frost.handleHtml(e.outerHTML)},1e2);""",
),
RETURN_BODY("return(document.getElementsByTagName('html')[0].innerHTML);"),
CREATE_POST(clickBySelector("#MComposer [onclick]")),
// CREATE_MSG(clickBySelector("a[rel=dialog]")),
/** Used as a pseudoinjector for maybe functions */
EMPTY("");
val function = "(function(){$body})();"
private val injector: JsInjector = JsInjector(function)
override fun inject(webView: WebView) {
injector.inject(webView)
}
}
@Suppress("NOTHING_TO_INLINE")
private inline fun clickBySelector(selector: String): String =
"""document.querySelector("$selector").click()"""

View File

@ -0,0 +1,81 @@
/*
* 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.injection.assets
import android.content.Context
import android.webkit.WebView
import androidx.annotation.VisibleForTesting
import com.google.common.flogger.FluentLogger
import com.pitchedapps.frost.webview.injection.JsBuilder
import com.pitchedapps.frost.webview.injection.JsInjector
import java.io.BufferedReader
import java.io.FileNotFoundException
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Created by Allan Wang on 2017-05-31. Mapping of the available assets The enum name must match the
* js file name
*/
enum class JsAssets(private val singleLoad: Boolean = true) : JsInjector {
CLICK_A,
CONTEXT_A,
MEDIA,
HEADER_BADGES,
TEXTAREA_LISTENER,
NOTIF_MSG,
DOCUMENT_WATCHER,
HORIZONTAL_SCROLLING,
AUTO_RESIZE_TEXTAREA(singleLoad = false),
SCROLL_STOP,
;
@VisibleForTesting internal val file = "${name.lowercase(Locale.CANADA)}.js"
private fun injectorBlocking(context: Context): JsInjector {
return try {
val content =
context.assets.open("frost/js/$file").bufferedReader().use(BufferedReader::readText)
JsBuilder().js(content).run { if (singleLoad) single(name) else this }.build()
} catch (e: FileNotFoundException) {
logger.atWarning().withCause(e).log("JsAssets file not found")
JsInjector.EMPTY
}
}
private var injector: JsInjector = JsInjector.EMPTY
override fun inject(webView: WebView) {
injector.inject(webView)
}
private suspend fun load(context: Context) {
withContext(Dispatchers.IO) { injector = injectorBlocking(context) }
}
companion object {
private val logger = FluentLogger.forEnclosingClass()
suspend fun load(context: Context) =
withContext(Dispatchers.IO) { JsAssets.values().forEach { it.load(context) } }
}
}
fun List<JsInjector>.inject(webView: WebView) {
forEach { it.inject(webView) }
}

View File

@ -0,0 +1,9 @@
syntax = "proto3";
option java_package = "com.pitchedapps.frost.proto";
option java_multiple_files = true;
message Account {
// Current account id
optional int64 account_id = 1;
}

View File

@ -0,0 +1,18 @@
syntax = "proto3";
option java_package = "com.pitchedapps.frost.proto.settings";
option java_multiple_files = true;
message Appearance {
// Tab identifiers for main screen
repeated string main_tabs = 1;
enum MainTabLayout {
TOP = 0;
BOTTOM = 1;
// Bare bones layout without custom tabs
COMPACT = 2;
}
MainTabLayout main_tab_layout = 2;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<corners android:radius="10dp" />
<solid android:color="@color/facebook_blue" />
</shape>
</item>
</layer-list>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportHeight="177.16534"
android:viewportWidth="177.16534">
<path
android:pathData="M88.9405 31.61857l41.06143 23.88663M88.3467 31.59584l-31.358 18.2953M56.87005
51.09542v94.26222M57.20726 87.2077l40.97656 23.86424"
android:strokeColor="#fff"
android:strokeLineCap="round"
android:strokeWidth="10" />
</vector>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="177.16534"
android:viewportWidth="177.16534">
<path
android:pathData="M88.9405 31.61857l41.06143 23.88663M88.3467 31.59584l-31.358 18.2953M56.87005
51.09542v94.26222M57.20726 87.2077l40.97656 23.86424"
android:strokeColor="#fff"
android:strokeLineCap="round"
android:strokeWidth="10" />
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/frost_splash_background" />
<item android:bottom="@dimen/splash_logo">
<bitmap
android:gravity="center"
android:src="@drawable/splash_logo" />
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="frost_splash_background">@color/facebook_blue</color>
<color name="facebook_blue">#3b5998</color>
<color name="facebook_blue_light">#6183C8</color>
<color name="facebook_blue_dark">#2e4b86</color>
<color name="frost_notification_accent">@color/facebook_blue</color>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<dimen name="splash_logo">16dp</dimen>
</resources>

View File

@ -0,0 +1,82 @@
<resources>
<string name="feed">Feed</string>
<string name="most_recent">Most Recent</string>
<string name="top_stories">Top Stories</string>
<string name="profile">Profile</string>
<string name="bookmarks">Bookmarks</string>
<string name="events">Events</string>
<string name="friends">Friends</string>
<string name="messages">Messages</string>
<string name="messenger">Messenger</string>
<string name="notifications">Notifications</string>
<string name="activity_log">Activity Log</string>
<string name="pages">Pages</string>
<string name="groups">Groups</string>
<string name="saved">Saved</string>
<string name="birthdays">Birthdays</string>
<string name="chat">Chat</string>
<string name="photos">Photos</string>
<string name="marketplace">Marketplace</string>
<string name="menu">Menu</string>
<string name="settings">Settings</string>
<string name="notes">Notes</string>
<string name="on_this_day">On This Day</string>
<string name="loading_account">Getting everything ready…</string>
<string name="welcome">Welcome %s</string>
<string name="select_facebook_account">Select Facebook Account</string>
<string name="account_not_found">Current account is not in the database</string>
<string name="frost_notifications">Frost Notifications</string>
<string name="requires_custom_theme">Requires custom theme</string>
<string name="share">Share</string>
<string name="web_overlay_swipe_hint">Swipe right to go back to the previous window.</string>
<string name="profile_picture">Profile Picture</string>
<string name="new_message">New Message</string>
<string name="no_text">No text</string>
<string name="show_all_results">Show All Results</string>
<string name="frost_description">Frost is a fully themable,
fully functional alternative to the official Facebook app, made from scratch and proudly open sourced.</string>
<string name="faq_title">Frost FAQ</string>
<string name="open">Open</string>
<string name="close">Close</string>
<string name="html_extraction_error">An error occurred in the html extraction.</string>
<string name="html_extraction_cancelled">The request has been cancelled.</string>
<string name="html_extraction_timeout">The request has timed out.</string>
<string name="file_chooser_not_found">File chooser not found</string>
<string name="top_bar">Top Bar</string>
<string name="bottom_bar">Bottom Bar</string>
<string name="pip" translatable="false">PIP</string>
<string name="preview">Preview</string>
<string name="options">Options</string>
<string name="tab_customizer_instructions">Long press and drag to rearrange the top icons.</string>
<string name="no_new_notifications">No new notifications found</string>
<!--Biometrics-->
<string name="biometrics_prompt_title">Authenticate Frost</string>
<string name="today">Today</string>
<string name="yesterday">Yesterday</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
<string name="time_template">%1$s at %2$s</string>
<string name="disclaimer">Disclaimer</string>
</resources>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pick_image">Pick Image</string>
<string name="download">Download</string>
<string name="downloading">Downloading…</string>
<string name="image_download_success">Image downloaded</string>
<string name="image_download_fail">Image failed to download</string>
<string name="image_share_failed">Failed to share image</string>
<string name="downloading_video">Downloading Video</string>
<string name="downloaded_video">Video Downloaded</string>
<string name="downloading_file">Downloading File</string>
<string name="downloaded_file">File Downloaded</string>
<string name="error_invalid_download">Invalid download attempt</string>
</resources>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="null_url_overlay">Empty URL given to overlay; exiting</string>
<string name="invalid_share_url">Invalid Share URL</string>
<string name="invalid_share_url_desc">You have shared a block of text that is not a URL. The text has been copied to your clipboard, so you may share it manually yourself.</string>
<string name="no_download_manager">No Download Manager</string>
<string name="no_download_manager_desc">The download manager is not enabled. Would you like to enable it to allow downloads?</string>
<string name="error_generic">An error occurred.</string>
<string name="video_load_failed">Failed to load video</string>
<string name="error_notification">An error occurred when fetching notifications</string>
<string name="error_sdk">Your device\'s SDK (%d) is incompatible. Frost only supports Lollipop (SDK 21) and above</string>
<string name="error_webview">Your device does not seem to have a webview. Please add or enable one.</string>
<string name="image_not_found">Image not found.</string>
</resources>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="skip">Skip</string>
<string name="intro_welcome_to_frost">Welcome to Frost</string>
<string name="intro_slide_to_continue">Slide to continue</string>
<string name="intro_select_theme">Select a theme</string>
<string name="intro_multiple_accounts">Multiple Accounts</string>
<string name="intro_multiple_accounts_desc">Add and switch accounts directly from the navigation bar.\nTap the current avatar to jump to your profile.</string>
<string name="intro_easy_navigation">Easy Navigation</string>
<string name="intro_easy_navigation_desc">Slide between views with a swipe, and click the tab icon to go back to the top.\nClick the icon again to reload the page.</string>
<string name="intro_context_aware">Context Aware</string>
<string name="intro_context_aware_desc">Long press links to copy and share them.\nLong press images to zoom and download.\nLong press cards to scroll horizontally.</string>
<string name="intro_end">Let\'s Begin!</string>
<string name="intro_tap_to_exit">Tap anywhere to exit</string>
</resources>

View File

@ -0,0 +1,16 @@
<resources>
<string name="dev_name" translatable="false">Pitched Apps</string>
<string name="translation_url" translatable="false">https://crwd.in/frost-for-facebook</string>
<string name="github" translatable="false">GitHub</string>
<string name="github_url" translatable="false">https://github.com/AllanWang/Frost-for-Facebook</string>
<string name="github_downloads_url" translatable="false">https://github.com/AllanWang/Frost-for-Facebook/releases</string>
<string name="fdroid_url" translatable="false">https://f-droid.org/en/packages/com.pitchedapps.frost</string>
<string name="reddit_url" translatable="false">https://www.reddit.com/r/FrostForFacebook</string>
<string name="frost_prefix" translatable="false">Frost for Facebook:</string>
<string name="feedback" translatable="false">Feedback</string>
<string name="bug_report" translatable="false">Bug Report</string>
<string name="debug_report" translatable="false">Debug Report</string>
<string name="theme_issue" translatable="false">Theme Issue</string>
<string name="feature_request" translatable="false">Feature Request</string>
<string name="reset_notif_epoch" translatable="false">Reset Notif Epoch</string>
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<resources>
<string name="restoring_purchases">Restoring purchases…</string>
<string name="uh_oh">Uh Oh</string>
<string name="reload">Reload</string>
</resources>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="theme_customization">Theme Customization</string>
<string name="theme">Theme</string>
<string name="text_color">Text Color</string>
<string name="accent_color">Accent Color</string>
<string name="background_color">Background Color</string>
<string name="header_color">Header Color</string>
<string name="icon_color">Icon Color</string>
<string name="global_customization">Global Customization</string>
<string name="main_activity_layout">Main Activity Layout</string>
<string name="main_activity_layout_desc">Set Main Activity Layout</string>
<string name="main_tabs">Main Activity Tabs</string>
<string name="main_tabs_desc">Customize which tabs you\'ll see in your main activity</string>
<string name="tint_nav">Tint Nav Bar</string>
<string name="tint_nav_desc">Navigation bar will be the same color as the header</string>
<string name="web_text_scaling">Web Text Scaling</string>
<string name="web_text_scaling_desc">Text Scaling Example; Long press the percentage text to reset.</string>
<string name="enforce_black_media_bg">Enforce black media background</string>
<string name="enforce_black_media_bg_desc">Make media backgrounds black; default is the selected background above</string>
</resources>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="auto_refresh_feed">Auto Refresh Feed</string>
<string name="auto_refresh_feed_desc">Refresh the feed after 30 minutes of inactivity</string>
<string name="fancy_animations">Fancy Animations</string>
<string name="fancy_animations_desc">Reveal webviews using ripples and animate transitions</string>
<string name="overlay_swipe">Enable Overlays</string>
<string name="overlay_swipe_desc">Pressing most links will launch in a new overlay so you can easily swipe back to the original page. Note that this does result in slightly longer loads given that the whole page is reloaded.</string>
<string name="overlay_full_screen_swipe">Overlay Full Screen Swipe to Dismiss</string>
<string name="overlay_full_screen_swipe_desc">Swipe right from anywhere on the overlaying web to close the browser. If disabled, only swiping from the left edge will move it.</string>
<string name="open_links_in_default">Open Links in Default App</string>
<string name="open_links_in_default_desc">When possible, open links in the default app rather than through the Frost web overlay</string>
<string name="viewpager_swipe">Viewpager Swipe</string>
<string name="viewpager_swipe_desc">Allow swiping between the pages in the main view to switch tabs. By default, the swiping automatically stops when you long press on an item, such as the like button. Disabling this will prevent page swiping altogether.</string>
<string name="swipe_to_refresh">Swipe to Refresh</string>
<string name="swipe_to_refresh_desc">Allow swiping down to refresh</string>
<string name="search_bar">Search Bar</string>
<string name="search_bar_desc">Enable the search bar instead of a search overlay</string>
<string name="force_message_bottom">Force Message Bottom</string>
<string name="force_message_bottom_desc">When loading a message thread, trigger a scroll to the bottom of the page rather than loading the page as is.</string>
<string name="enable_pip">Enable PIP</string>
<string name="enable_pip_desc">Enable picture in picture videos</string>
<string name="autoplay_settings">Autoplay Settings</string>
<string name="autoplay_settings_desc">Open Facebook\'s auto play settings. Note that it must be disabled for PIP to work.</string>
<string name="exit_confirmation">Exit Confirmation</string>
<string name="exit_confirmation_desc">Show confirmation dialog before exiting the app</string>
<string name="auto_expand_text_box">Auto expand text box</string>
<string name="auto_expand_text_box_desc">Increase text box height while typing. Disable if there are scroll issues.</string>
</resources>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="debug_toast_enabled">Debugging section is enabled! Go back to settings.</string>
<string name="debug_toast_already_enabled">Debugging section is already enabled. Go back to settings.</string>
<string name="debug_disclaimer_info">Though most private content is automatically removed in the report, some sensitive info may still be visible.
\nPlease have a look at the debug report before sending it.
\n\nClicking one of the options below will prepare an email response with the web page data.
</string>
<string name="debug_incomplete">Incomplete report</string>
<string name="debug_report_email_title" translatable="false">Frost for Facebook: Debug Report</string>
<string name="debug_web">Debug from the Web</string>
<string name="debug_web_desc">Navigate to the page with an issue and send the resources for debugging.</string>
<string name="parsing_data">Parsing Data</string>
<string name="debug_parsers">Debug Parsers</string>
<string name="debug_parsers_desc">Launch one of the available parsers to debug its response data</string>
</resources>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="experimental_disclaimer_info">Experimental features may be unstable and may never make it to production. Use at your own risk, send feedback, and feel free to disable them if they don\'t work well.</string>
<string name="verbose_logging">Verbose Logging</string>
<string name="verbose_logging_desc">Enable verbose logging to help with crash reports. Logging will only be sent once an error is encountered, so repeat the issue to notify the dev. This will automatically be disabled if the app restarts.</string>
<string name="restart_frost">Restart Frost</string>
<string name="restart_frost_desc">Launch a cold restart for the application.</string>
</resources>

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="newsfeed_sort">Newsfeed Order</string>
<string name="newsfeed_sort_desc">Defines the order in which the posts are shown</string>
<string name="aggressive_recents">Aggressive Recents</string>
<string name="aggressive_recents_desc">Filter out additional old posts from Facebook\'s original most recents feed. Disable this if your feed is empty.</string>
<string name="composer">Status Composer</string>
<string name="composer_desc">Show status composer in the feed</string>
<string name="create_fab">Create FAB</string>
<string name="create_fab_desc">Show FAB in feed to create new post</string>
<string name="suggested_friends">Suggested Friends</string>
<string name="suggested_friends_desc">Show \"People You May Know\" in the feed</string>
<string name="suggested_groups">Suggested Groups</string>
<string name="suggested_groups_desc">Show \"Suggested Groups\" in the feed</string>
<string name="suggested_posts">Suggested Posts</string>
<string name="suggested_posts_desc">Show \"Suggested for you\" in the feed</string>
<string name="show_stories">Show Stories</string>
<string name="show_stories_desc">Show stories in the feed</string>
<string name="show_post_actions">Show Post Actions</string>
<string name="show_post_actions_desc">Show Like, Comment, and Share options</string>
<string name="show_post_reactions">Show Post Reactions</string>
<string name="show_post_reactions_desc">Show reaction counts to post</string>
<string name="facebook_ads">Facebook Ads</string>
<string name="facebook_ads_desc">Show native Facebook ads</string>
<string name="full_size_image">Full Size Images</string>
<string name="full_size_image_desc">Force news feed images to be full width</string>
</resources>

View File

@ -0,0 +1,4 @@
<resources>
<string name="network_media_on_metered">Disable images on metered network.</string>
<string name="network_media_on_metered_desc">If a metered network is detected, Frost will automatically stop all images and videos from loading.</string>
</resources>

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="notification_frequency">Notification Frequency</string>
<string name="no_notifications">No Notifications</string>
<string name="notification_keywords">Keywords</string>
<string name="notification_keywords_desc">Does not notify when notification contains any of these keys.</string>
<string name="add_keyword">Add Keyword</string>
<string name="hint_keyword">Type keyword and press +</string>
<string name="empty_keyword">Empty Keyword</string>
<string name="notification_general">Enable general notifications</string>
<string name="notification_general_desc">Get general notifications for your current account.</string>
<string name="notification_general_all_accounts">Notify from all accounts</string>
<string name="notification_general_all_accounts_desc">Get general notifications for every account that is logged in.</string>
<string name="notification_messages">Enable message notifications</string>
<string name="notification_messages_desc">Get instant message notifications for your current account.</string>
<string name="notification_messages_all_accounts">Notify messages from all accounts</string>
<string name="notification_messages_all_accounts_desc">Get instant message notifications from all accounts</string>
<string name="notification_fetch_now">Fetch Notifications Now</string>
<string name="notification_fetch_now_desc">Trigger the notification fetcher once.</string>
<string name="notification_fetch_success">Fetching Notifications…</string>
<string name="notification_fetch_fail">Couldn\'t fetch notifications</string>
<string name="notification_sound">Notification sound</string>
<string name="notification_channel">Customize notification channels</string>
<string name="notification_channel_desc">Modify sound, vibration, priority, etc</string>
<string name="notification_ringtone">Notification Ringtone</string>
<string name="message_ringtone">Message Ringtone</string>
<string name="select_ringtone">Select Ringtone</string>
<string name="notification_vibrate">Notification vibration</string>
<string name="notification_lights">Notification lights</string>
</resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="security_disclaimer_info">Security preferences help protect access to Frost from the UI. However, note that local data is not encrypted, and can still be accessed by rooted users.</string>
<string name="enable_biometrics">Enable biometrics</string>
<string name="enable_biometrics_desc">Require biometric authentication after inactivity</string>
</resources>

View File

@ -0,0 +1,32 @@
<resources>
<string name="appearance">Appearance</string>
<string name="appearance_desc">Theme, Items to display, etc</string>
<string name="notifications_desc">Frequency, filters, ringtones, etc</string>
<string name="newsfeed">News Feed</string>
<string name="newsfeed_desc">Define what items appear in the newsfeed</string>
<string name="behaviour">Behaviour</string>
<string name="behaviour_desc">Define how the app interacts in certain settings</string>
<string name="security">Security</string>
<string name="security_desc">Lock screen, biometrics, etc</string>
<string name="network">Network</string>
<string name="network_desc">Define options that affect metered networks</string>
<string name="experimental">Experimental</string>
<string name="experimental_desc">Enable early access to potentially unstable features</string>
<string name="about_frost">About Frost for Facebook</string>
<string name="about_frost_desc">Version, Credits, and FAQs</string>
<string name="help_translate">Help Translate</string>
<string name="help_translate_desc">Frost is translated through crowdin. Contribute if you want it in your language!</string>
<string name="debug_frost">Frost Debugger</string>
<string name="debug_frost_desc">Send html data to help with debugging.</string>
<string name="replay_intro">Replay Introduction</string>
</resources>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="refresh">Refresh</string>
<string name="share_link">Share Link</string>
<string name="local_service_name" translatable="false">Local Frost Service</string>
<string name="open_link">Open Link</string>
<string name="copy_link">Copy Link</string>
<string name="copy_text">Copy Text</string>
<string name="debug_image_link_subject" translatable="false">Frost for Facebook: Image Link Debug</string>
<string name="open_in_browser">Open in browser</string>
</resources>

View File

@ -0,0 +1,29 @@
<resources>
<style name="FrostTheme" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/facebook_blue</item>
<item name="colorPrimaryDark">@color/facebook_blue_dark</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<style name="FrostTheme.Transparent">
<item name="android:windowShowWallpaper">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
</style>
<style name="FrostTheme.Overlay">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">false</item>
<item name="android:colorBackgroundCacheHint">@null</item>
<item name="android:windowIsTranslucent">true</item>
</style>
<style name="FrostTheme.Splash" parent="Theme.Material3.Dark.NoActionBar">
<item name="android:windowBackground">@drawable/splash_screen</item>
<item name="android:navigationBarColor">@color/frost_splash_background</item>
<item name="colorPrimaryDark">@color/frost_splash_background</item>
<item name="colorAccent">@color/frost_splash_background</item>
</style>
</resources>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path
name="external"
path="/" />
<cache-path
name="cache"
path="/" />
<files-path
name="files"
path="/" />
</paths>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude
domain="database"
path="Cookies.db" />
<exclude
domain="database"
path="Cookies.db-journal" />
</full-backup-content>

View File

@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS account (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT,
avatar TEXT,
facebook_cookie TEXT,
messenger_cookie TEXT
);
count:
SELECT COUNT(*) FROM account;
insertNew:
INSERT INTO account DEFAULT VALUES;
insert:
INSERT OR REPLACE INTO account (id, name, avatar, facebook_cookie, messenger_cookie) VALUES (?, ?, ?, ?, ?);
delete {
DELETE FROM account WHERE id == (:id);
DELETE FROM notifications WHERE id == (:id);
}
select:
SELECT *
FROM account WHERE id == ?;
selectAll:
SELECT *
FROM account;
selectAllDisplay:
SELECT id, name, avatar
FROM account;

View File

@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER NOT NULL PRIMARY KEY,
facebook_notifications INTEGER,
facebook_messages INTEGER,
facebook_friends INTEGER,
messenger_notifications INTEGER
);
insertNew:
INSERT OR IGNORE INTO notifications (id) VALUES (?);
insert:
INSERT OR REPLACE INTO notifications (id,facebook_notifications, facebook_messages, facebook_friends, messenger_notifications) VALUES (?,?,?,?,?);
select:
SELECT *
FROM notifications WHERE id == ?;
selectAll:
SELECT *
FROM notifications;

28
app-compose/src/web/.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
node_modules/
.sass-cache/
*.js
*.css
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
.idea/
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml

Some files were not shown because too many files have changed in this diff Show More