Merge pull request #57 from jiongxuan/feat/platform-android-offline

Major Android Update: Introducing Offline Support and Resolving CORS Issues
This commit is contained in:
Johannes Millan 2024-10-11 13:59:21 +02:00 committed by GitHub
commit f6b9d1f306
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 919 additions and 257 deletions

95
.gitignore vendored
View file

@ -1,17 +1,92 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.gradle
/local.properties
/.idea/caches/build_file_checksums.ser
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/deploymentTargetSelector.xml
/.idea/deploymentTargetDropDown.xml
/.idea/caches/deviceStreaming.xml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
.idea/deploymentTargetSelector.xml
.idea/deploymentTargetDropDown.xml
.idea/caches/deviceStreaming.xml
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
# Platform and build
.DS_Store
/build
/captures
.externalNativeBuild
/app/release
/app/fdroid/release

3
.idea/gradle.xml generated
View file

@ -10,10 +10,11 @@
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/../node_modules/@capacitor/android/capacitor" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>
</project>

2
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.7.20" />
<option name="version" value="1.9.10" />
</component>
</project>

View file

@ -4,66 +4,50 @@ Android App for Super Productivity (https://super-productivity.com/).
I am not an Android developer, so help would be very welcome!!
## Building locally
## New Connectivity-Free Mode is Here!
*This feature was added on September 7, 2024. See [Pull Request #52](https://github.com/johannesjo/super-productivity-android/pull/52).*
*This feature was added on October 7, 2024. See [Pull Request #57](https://github.com/johannesjo/super-productivity-android/pull/57).*
To build this app locally, you can configure the URL that the web view loads by modifying the `app_config.properties` file. This allows you to easily switch between a local development server, the production server, or a self-hosted server without changing the source code directly.
You can now use the core features of the app without an internet connection, offering a smoother and more reliable experience. We've made several key updates to enhance usability:
> **IMPORTANT**: The `app_config.properties` file is intended for LOCAL MODIFICATIONS ONLY.
> **DO NOT COMMIT** this file unless you are absolutely sure of what you are doing.
- **Connectivity-Free Mode Support**: Enjoy uninterrupted access to the app's main features without needing a network connection. You can still sync with WebDAV, Dropbox, or choose to work entirely offline without any network access.
- **Online-Only Mode (Compatibility Mode)**: For users who prefer or need the traditional experience, the app still supports the original mode, which requires an internet connection for functionality.
- **CORS Issues Resolved**: Fixed cross-origin resource sharing (CORS) problems, especially for WebDAV sync, ensuring secure and smooth synchronization with local or hosted resources.
- **Enhanced Security**: Strengthened data protection to keep your information secure, even when offline.
- **Seamless Upgrade**: Existing users can continue using the app in Online-Only Mode (Compatibility Mode) without any disruptions, while new users can immediately enjoy the benefits of Connectivity-Free Mode. Future updates will also include a smooth migration plan for everyone.
### Configuration Options
Update now to enjoy these exciting new features and improvements!
1. **Use Production URL**:
- By default, the app points to the production URL `https://app.super-productivity.com`.
- To use this, ensure the `SERVICE_IS_LOCAL` setting in `app_config.properties` is set to `false`.
## Launch Modes
2. **Use Local Development Server**:
- If you're running the [super productivity](https://github.com/johannesjo/super-productivity) app locally, you can point the web view to your local server.
- Set the `SERVICE_IS_LOCAL` setting in `app_config.properties` to `true`.
- Start the local web app using the following command:
```bash
ng serve --disable-host-check --host 0.0.0.0 --port 4200 --live-reload --watch
```
- This makes the web app accessible from the Android Studio emulator at `http://10.0.2.2:4200`. The URL should also work in the emulator's Chrome browser.
The app supports two launch modes:
3. **Use a Self-Hosted Server**: `NEW FEATURE`
- If you prefer to self-host the web app, you can configure the app to point to your own server.
- Set the `SERVICE_IS_LOCAL` setting to `false` and update the `SERVICE_HOST` and `SERVICE_PROTOCOL` values in the `app_config.properties` file to point to your self-hosted environment.
1. **Connectivity-Free Mode** (Recommended) Use the app without an internet connection.
2. **Online-Only Mode (Compatibility Mode)** Requires an internet connection to connect to production, local development, or self-hosted servers.
### How to Modify the URL
### Configuring Launch Mode
You can edit the URL that the web view loads by modifying the `app_config.properties` file located in the project's root directory. This allows you to easily switch between a production server, a local development server, or a self-hosted server. Here's how the relevant settings work:
To configure the launch mode, adjust the `LAUNCH_MODE` setting in the `app_config.properties` file:
- `SERVICE_IS_LOCAL`:
- Set to `true` to load the web app from your local development server (`http://10.0.2.2:4200`).
- Set to `false` to load the production web app (`https://app.super-productivity.com`) or your self-hosted server.
- **0**: Default behavior (read from SharedPreferences)
- **1**: Force Online-Only Mode (Compatibility Mode)
- **2**: Force Connectivity-Free Mode (Recommended)
- `SERVICE_HOST`:
- Defines the server's address.
- If `SERVICE_IS_LOCAL` is `true`, this value is ignored, and the app uses `10.0.2.2:4200` instead.
- If `SERVICE_IS_LOCAL` is `false`, this value determines the server the app will connect to, making it possible to connect to a self-hosted server.
**Recommendation**: Set `LAUNCH_MODE` to `2` for Connectivity-Free Mode.
- `SERVICE_PROTOCOL`:
- Defines the protocol used (`http` or `https`).
- When `SERVICE_IS_LOCAL` is `true`, the default is `http`.
- When `SERVICE_IS_LOCAL` is `false`, the app uses the protocol specified in this property, which can be set for self-hosted environments.
### How to Adjust `LAUNCH_MODE`
### Example `app_config.properties` file:
1. Locate the `app_config.properties` file in the project's root directory.
2. Open the file in a text editor.
3. Find the `LAUNCH_MODE` setting and set it to your desired mode (`0`, `1`, or `2`).
```properties
# Use 'true' to point to the local server, 'false' to use the production server or self-hosted server
SERVICE_IS_LOCAL=true
# The server address (ignored if SERVICE_IS_LOCAL is true)
# Set this to your self-hosted server address if SERVICE_IS_LOCAL is false
SERVICE_HOST=app.super-productivity.com:1234
# The protocol to use (http or https)
SERVICE_PROTOCOL=https
LAUNCH_MODE=2
```
By configuring these properties, you can seamlessly switch between local development, production, and self-hosted environments without making direct changes to your Kotlin source files, improving your development workflow and offering flexibility in deployment.
**Important**: The `app_config.properties` file is intended for local modifications only. **DO NOT COMMIT** this file unless you are absolutely sure of what you are doing.
You can edit the properties in the `app_config.properties` file [here](https://github.com/johannesjo/super-productivity-android/blob/master/app/app_config.properties).
### Detailed Configuration Guides
- **[Connectivity-Free Mode Documentation (Recommended)](./README_OFFLINE.md)**: Step-by-step guide to setting up and building the app in Connectivity-Free Mode.
- **[Online-Only Mode (Compatibility) Documentation](./README_ONLINE.md)**: Step-by-step guide to setting up and building the app in Online-Only Mode.

80
README_OFFLINE.md Normal file
View file

@ -0,0 +1,80 @@
# Connectivity-Free Mode Configuration
**Connectivity-Free Mode** allows you to use the Super Productivity Android app without an internet connection. This mode is recommended for users who prefer local usage.
## Setting Launch Mode to Connectivity-Free
To enable Connectivity-Free Mode, set the `LAUNCH_MODE` to `0` (Default for new installation) or `2` in the `app_config.properties` file.
For users performing a **new installation**, setting `LAUNCH_MODE` to `2` ensures that the app starts in Connectivity-Free Mode by default. This avoids any attempts to connect to online services, providing a seamless offline experience from the outset.
**Important**: If you set `LAUNCH_MODE` to `0`, the app will use the default behavior, which may attempt to read from SharedPreferences and connect to online services if available. To maintain a purely offline experience, always set `LAUNCH_MODE` to `2` for new installations.
## Building and Running super-productivity-android Locally
### 1. Clone the Repository
To set up the project, clone the `super-productivity` repository instead of directly cloning the `super-productivity-android` repository. This ensures that all submodules, including the Android project, are properly initialized.
```bash
git clone https://github.com/super-productivity/super-productivity.git
cd super-productivity
git submodule init
git submodule update
```
### 2. Compile the Node.js Project
Ensure you have Node.js and npm installed. Navigate to the root directory of the `super-productivity` project and install the necessary dependencies.
```bash
npm install
```
### 3. Compile the Android Project
From the root directory, compile the Android project using the following commands:
- **For Testing Builds:**
```bash
npm run dist:android
```
- **For Production Builds:**
```bash
npm run dist:android:prod
```
### 4. Installation
You can install the compiled Android application using either Android Studio or npm scripts.
- **Using Android Studio:**
1. Open Android Studio.
2. Select `Open an existing project`.
3. Navigate to the `android` directory within the cloned repository.
4. Follow the prompts to build and run the application on your device or emulator.
- **Using NPM Scripts:**
- **For Testing Installation:**
```bash
npm run install:android
```
- **For Production Installation:**
```bash
npm run install:android:prod
```
## Additional Notes
- **Local Modifications**: The `app_config.properties` file is intended for local modifications only. **DO NOT COMMIT** this file unless you are absolutely sure of the changes.
- **No Additional Configuration**: Connectivity-Free Mode does not require further configuration beyond setting the `LAUNCH_MODE` to `0` or `2`.
For more information, refer to the [main README](./README.md).

93
README_ONLINE.md Normal file
View file

@ -0,0 +1,93 @@
# Online-Only Mode (Compatibility Mode) Configuration
**Online-Only Mode (Compatibility Mode)** allows the Super Productivity Android app to connect to the production server, a local development server, or a self-hosted server. This mode requires an internet connection and is compatible with various server setups.
**Note**: While Online-Only Mode offers connectivity to production, local development, or self-hosted servers, it is highly recommended to use the latest **Connectivity-Free Mode** for a more stable and reliable experience. Connectivity-Free Mode allows you to use the app without an internet connection, ensuring uninterrupted productivity, enhanced privacy, and reduced latency.
For more information, refer to the **[Connectivity-Free Mode Documentation (Recommended)](./README_OFFLINE.md)**.
If you require online features or need to connect to specific servers, proceed with the Online-Only Mode configuration below.
## Setting Launch Mode to Online
To enable Online-Only Mode, set the `LAUNCH_MODE` to `1` or `0` in the `app_config.properties` file.
- **1**: Force Online-Only Mode (Compatibility Mode)
- **0**: Default behavior (read from SharedPreferences)
**Recommendation**: Set `LAUNCH_MODE` to `0` for default behavior. The app will use the default behavior, which may attempt to read from SharedPreferences and connect to online services if available.
### Configuration Options
1. **Launch Mode (`LAUNCH_MODE`)**
```properties
LAUNCH_MODE=1
```
- **0**: Default behavior (read from SharedPreferences)
- **1**: Force Online-Only Mode (compatible mode)
- **2**: Force Connectivity-Free Mode (for offline configuration)
2. **Use Production URL**
- **Condition**: Applicable when `LAUNCH_MODE` is set to `1`, or set to `0` and the user has upgraded from a previous version.
- **Default**: `https://app.super-productivity.com`
- **Configuration**: Ensure `ONLINE_SERVICE_IS_LOCAL` is set to `false`.
```properties
ONLINE_SERVICE_IS_LOCAL=false
```
3. **Use Local Development Server**
- **Condition**: Applicable when `LAUNCH_MODE` is set to `1`, or set to `0` and the user has upgraded from a previous version.
- **Configuration**: Set `ONLINE_SERVICE_IS_LOCAL` to `true` and start the local server.
```properties
ONLINE_SERVICE_IS_LOCAL=true
```
- **Start Local Server**
```bash
ng serve --disable-host-check --host 0.0.0.0 --port 4200 --live-reload --watch
```
- **Access URL**: `http://10.0.2.2:4200` (accessible from the Android Studio emulator and emulator's Chrome browser).
4. **Use a Self-Hosted Server**
- **Condition**: Applicable when `LAUNCH_MODE` is set to `1`, or set to `0` and the user has upgraded from a previous version.
- **Configuration**: Set `ONLINE_SERVICE_IS_LOCAL` to `false` and update `ONLINE_SERVICE_HOST` and `ONLINE_SERVICE_PROTOCOL`.
```properties
ONLINE_SERVICE_IS_LOCAL=false
ONLINE_SERVICE_HOST=your.server.address
ONLINE_SERVICE_PROTOCOL=https
```
## How to Modify the URL
You can edit the URL that the web view loads by modifying the `app_config.properties` file located in the project's root directory. This allows you to easily switch between a production server, a local development server, or a self-hosted server.
### Relevant Settings
- **`LAUNCH_MODE`**:
- `0`: Default behavior (read from SharedPreferences)
- `1`: Force Online-Only Mode
- `2`: Force Connectivity-Free Mode
- **When `LAUNCH_MODE` is `1` or `0` (with upgrade)**:
- **`ONLINE_SERVICE_IS_LOCAL`**:
- `true`: Load from local development server (`http://10.0.2.2:4200`).
- `false`: Load from production or self-hosted server.
- **`ONLINE_SERVICE_HOST`**:
- Defines the server's address.
- **`ONLINE_SERVICE_PROTOCOL`**:
- `http` or `https`.
## Important Notes
- **Local Modifications**: The `app_config.properties` file is intended for local modifications only. **DO NOT COMMIT** this file unless you are absolutely sure of the changes.
- **Switching Servers**: By configuring these properties, you can seamlessly switch between default, online, and offline launch behaviors without making direct changes to your Kotlin source files, improving your development workflow and offering flexibility in deployment.

View file

@ -1,21 +1,28 @@
# IMPORTANT: The app_config.properties file is intended for LOCAL MODIFICATIONS ONLY.
# DO NOT COMMIT this file unless you are absolutely sure of what you are doing.
# SERVICE_IS_LOCAL determines if the app should use a local server during development.
# LAUNCH_MODE determines the launch behavior.
# - 0: Default behavior (read from SharedPreferences)
# - 1: Force Online-Only Mode (Compatibility Mode)
# - 2: Force Connectivity-Free Mode (all new offline mode)
# Default: 0
LAUNCH_MODE=0
# ONLINE_SERVICE_IS_LOCAL determines if the app should use a local server during development.
# - true: The app connects to a local server, typically used for development and testing.
# - false: The app connects to a remote server, useful for staging or production environments.
# Default: true
SERVICE_IS_LOCAL=false
ONLINE_SERVICE_IS_LOCAL=false
# SERVICE_HOST specifies the server's address. Set this to your [self-hosted] server address
# - When SERVICE_IS_LOCAL is true, this value is ignored in favor of "10.0.2.2:4200" (Android emulator's localhost).
# - When SERVICE_IS_LOCAL is false or in release builds, this value determines the server the app will connect to.
# ONLINE_SERVICE_HOST specifies the server's address. Set this to your [self-hosted] server address
# - When ONLINE_SERVICE_IS_LOCAL is true, this value is ignored in favor of "10.0.2.2:4200" (Android emulator's localhost).
# - When ONLINE_SERVICE_IS_LOCAL is false or in release builds, this value determines the server the app will connect to.
# Default: app.super-productivity.com
SERVICE_HOST=app.super-productivity.com
ONLINE_SERVICE_HOST=app.super-productivity.com
# SERVICE_PROTOCOL defines the protocol used to communicate with the server.
# ONLINE_SERVICE_PROTOCOL defines the protocol used to communicate with the server.
# - http: Used typically in local development or non-secure environments.
# - https: Used in secure, production environments.
# - In debug builds, when SERVICE_IS_LOCAL is true, the default is http. Otherwise, this value is used.
# - In debug builds, when ONLINE_SERVICE_IS_LOCAL is true, the default is http. Otherwise, this value is used.
# Default: https
SERVICE_PROTOCOL=https
ONLINE_SERVICE_PROTOCOL=https

View file

@ -20,8 +20,8 @@ android {
minSdkVersion 24
targetSdkVersion 34
compileSdk 34
versionCode 23
versionName "23.0"
versionCode 30
versionName "30.10.0.11"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [
hostName : "app.super-productivity.com",
@ -77,13 +77,19 @@ dependencies {
implementation "com.google.androidbrowserhelper:androidbrowserhelper:2.4.0"
implementation "com.google.code.gson:gson:2.10"
implementation "androidx.core:core-ktx:1.9.0"
implementation 'androidx.lifecycle:lifecycle-process:2.8.6'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation "com.anggrayudi:storage:1.5.4"
playImplementation "com.google.android.gms:play-services-auth:20.4.0"
implementation project(':capacitor-android')
testImplementation "junit:junit:4.13.2"
androidTestImplementation "androidx.test:rules:1.5.0"
androidTestImplementation "com.android.support.test:runner:1.0.2"
androidTestImplementation "com.android.support.test.espresso:espresso-core:3.0.2"
}
apply from: 'capacitor.build.gradle'

View file

@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

View file

@ -19,19 +19,23 @@ ext.loadAppConfig = {
// Determine the build configuration fields based on the build type (Debug or Release)
ext.getBuildConfigFields = { appConfig, isDebugBuild ->
// SERVICE_IS_LOCAL remains as a string for comparison
def serviceIsLocal = appConfig.getProperty("SERVICE_IS_LOCAL", "true")
// LAUNCH_MODE determines the launch behavior
def launchMode = appConfig.getProperty("LAUNCH_MODE", "0")
// ONLINE_SERVICE_IS_LOCAL remains as a string for comparison
def serviceIsLocal = appConfig.getProperty("ONLINE_SERVICE_IS_LOCAL", "true")
// Set the service host based on the build type and whether it's running locally
def serviceHost = (serviceIsLocal == "true" && isDebugBuild) ? "10.0.2.2:4200" : appConfig.getProperty("SERVICE_HOST", "app.super-productivity.com")
def serviceHost = (serviceIsLocal == "true" && isDebugBuild) ? "10.0.2.2:4200" : appConfig.getProperty("ONLINE_SERVICE_HOST", "app.super-productivity.com")
// Set the protocol based on the build type and whether it's running locally
def serviceProtocol = (serviceIsLocal == "true" && isDebugBuild) ? "http" : appConfig.getProperty("SERVICE_PROTOCOL", "https")
def serviceProtocol = (serviceIsLocal == "true" && isDebugBuild) ? "http" : appConfig.getProperty("ONLINE_SERVICE_PROTOCOL", "https")
// Return the configuration fields as a map
return [
SERVICE_IS_LOCAL: "\"${serviceIsLocal}\"", // Keeping SERVICE_IS_LOCAL as a string
SERVICE_HOST: "\"${serviceHost}\"",
SERVICE_PROTOCOL: "\"${serviceProtocol}\""
LAUNCH_MODE: "\"${launchMode}\"",
ONLINE_SERVICE_IS_LOCAL: "\"${serviceIsLocal}\"",
ONLINE_SERVICE_HOST: "\"${serviceHost}\"",
ONLINE_SERVICE_PROTOCOL: "\"${serviceProtocol}\""
]
}

View file

@ -22,7 +22,7 @@
android:theme="@style/AppTheme"
tools:ignore="UnusedAttribute">
<!-- App in WebFrame -->
<!-- Set FullscreenActivity as the default launcher -->
<activity
android:name=".FullscreenActivity"
android:allowTaskReparenting="true"
@ -45,5 +45,19 @@
<action android:name="com.superproductivity.superproductivity.PAUSE" />
</intent-filter>
</activity>
<!-- All new Super-Productivity main activity, based on Capacitor to support offline use -->
<activity
android:name=".CapacitorMainActivity"
android:allowTaskReparenting="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:label="@string/title_activity_fullscreen"
android:launchMode="singleTask"
android:lockTaskMode="never"
android:persistableMode="persistNever"
android:theme="@style/FullscreenTheme"
android:windowSoftInputMode="adjustResize"/>
</application>
</manifest>

View file

@ -1,7 +1,8 @@
package com.superproductivity.superproductivity
import android.app.Application
import android.webkit.WebView
import com.superproductivity.superproductivity.app.AppLifecycleObserver
import com.superproductivity.superproductivity.app.KeyValStore
class App : Application() {
@ -13,4 +14,11 @@ class App : Application() {
val keyValStore: KeyValStore by lazy {
KeyValStore(this)
}
override fun onCreate() {
super.onCreate()
// Initialize AppLifecycleObserver at app startup
AppLifecycleObserver.getInstance()
}
}

View file

@ -0,0 +1,166 @@
package com.superproductivity.superproductivity
import android.content.Intent
import android.graphics.Rect
import android.os.Bundle
import android.util.Log
import android.view.View
import android.webkit.ServiceWorkerClient
import android.webkit.ServiceWorkerController
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.widget.Toast
import androidx.activity.addCallback
import com.anggrayudi.storage.SimpleStorageHelper
import com.getcapacitor.BridgeActivity
import com.getcapacitor.BridgeWebViewClient
import com.superproductivity.superproductivity.webview.JavaScriptInterface
import com.superproductivity.superproductivity.webview.WebHelper
import com.superproductivity.superproductivity.webview.WebViewRequestHandler
/**
* All new Super-Productivity main activity, based on Capacitor to support offline use of the entire application
*/
class CapacitorMainActivity : BridgeActivity() {
private lateinit var javaScriptInterface: JavaScriptInterface
private var webViewRequestHandler = WebViewRequestHandler(this, "localhost")
private val storageHelper =
SimpleStorageHelper(this) // for scoped storage permission management on Android 10+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Register Plugin
// TODO: The changes to the compatible logic are too complex, so they will not be added for now
// (separate branch, there will be opportunities to add it later)
// DEBUG ONLY
if (BuildConfig.DEBUG) {
Toast.makeText(this, "DEBUG: Offline Mode", Toast.LENGTH_SHORT).show()
WebView.setWebContentsDebuggingEnabled(true)
}
// Hide the action bar
supportActionBar?.hide()
// Initialize JavaScriptInterface
javaScriptInterface = JavaScriptInterface(this, bridge.webView, storageHelper)
// Initialize WebView
WebHelper().setupView(bridge.webView, false)
// Inject JavaScriptInterface into Capacitor's WebView
bridge.webView.addJavascriptInterface(
javaScriptInterface,
WINDOW_INTERFACE_PROPERTY
)
if (BuildConfig.FLAVOR.equals("fdroid")) {
bridge.webView.addJavascriptInterface(
javaScriptInterface,
WINDOW_PROPERTY_F_DROID
)
// not ready in time, that's why we create a second JS interface just to fill the prop
// callJavaScriptFunction("window.$WINDOW_PROPERTY_F_DROID=true")
}
// Set custom SP WebViewClient & ServiceWorkerController
// No need to set up WebChromeClient, as most of the processes have been implemented in Bridge
bridge.webViewClient = object : BridgeWebViewClient(bridge) {
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
return webViewRequestHandler.handleUrlLoading(view, url)
}
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
val interceptedResponse = webViewRequestHandler.interceptWebRequest(request)
return interceptedResponse ?: super.shouldInterceptRequest(view, request)
}
}
val swController = ServiceWorkerController.getInstance()
swController.setServiceWorkerClient(
object : ServiceWorkerClient() {
override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? {
return bridge.webViewClient.shouldInterceptRequest(bridge.webView, request)
}
})
// Register OnBackPressedCallback to handle back button press
onBackPressedDispatcher.addCallback(this) {
Log.v("TW", "onBackPressed ${bridge.webView.canGoBack()}")
if (bridge.webView.canGoBack()) {
bridge.webView.goBack()
} else {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
// Handle keyboard visibility changes
val rootView = findViewById<View>(android.R.id.content)
rootView.viewTreeObserver.addOnGlobalLayoutListener {
val rect = Rect()
rootView.getWindowVisibleDisplayFrame(rect)
val screenHeight = rootView.rootView.height
val keypadHeight = screenHeight - rect.bottom
if (keypadHeight > screenHeight * 0.15) {
// keyboard is opened
callJSInterfaceFunctionIfExists("next", "isKeyboardShown$", "true")
} else {
// keyboard is closed
callJSInterfaceFunctionIfExists("next", "isKeyboardShown$", "false")
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
// Save scoped storage permission on Android 10+
storageHelper.onSaveInstanceState(outState)
bridge.webView.saveState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
// Restore scoped storage permission on Android 10+
storageHelper.onRestoreInstanceState(savedInstanceState)
bridge.webView.restoreState(savedInstanceState)
}
override fun onPause() {
super.onPause()
Log.v("TW", "CapacitorFullscreenActivity: onPause")
callJSInterfaceFunctionIfExists("next", "onPause$")
}
override fun onResume() {
super.onResume()
Log.v("TW", "CapacitorFullscreenActivity: onResume")
callJSInterfaceFunctionIfExists("next", "onResume$")
}
private fun callJSInterfaceFunctionIfExists(fnName: String, objectPath: String, fnParam: String = "") {
val fnFullName = "window.${FullscreenActivity.WINDOW_INTERFACE_PROPERTY}.$objectPath.$fnName"
val fullObjectPath = "window.${FullscreenActivity.WINDOW_INTERFACE_PROPERTY}.$objectPath"
javaScriptInterface.callJavaScriptFunction("if($fullObjectPath && $fnFullName)$fnFullName($fnParam)")
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (data != null) {
javaScriptInterface.onActivityResult(requestCode, resultCode, data)
}
}
companion object {
const val WINDOW_INTERFACE_PROPERTY: String = "SUPAndroid"
const val WINDOW_PROPERTY_F_DROID: String = "SUPFDroid"
}
}

View file

@ -1,9 +1,9 @@
package com.superproductivity.superproductivity
import android.app.AlertDialog
import android.content.ComponentName
import android.content.Intent
import android.graphics.Rect
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
@ -20,12 +20,11 @@ import android.widget.FrameLayout
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.anggrayudi.storage.SimpleStorageHelper
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import com.superproductivity.superproductivity.app.LaunchDecider
import com.superproductivity.superproductivity.webview.JavaScriptInterface
import com.superproductivity.superproductivity.webview.WebHelper
import com.superproductivity.superproductivity.webview.WebViewRequestHandler
/**
@ -36,18 +35,28 @@ class FullscreenActivity : AppCompatActivity() {
private lateinit var javaScriptInterface: JavaScriptInterface
private lateinit var webView: WebView
private lateinit var wvContainer: FrameLayout
var isInForeground: Boolean = false
private var webViewRequestHandler = WebViewRequestHandler(this, BuildConfig.ONLINE_SERVICE_HOST)
val storageHelper =
SimpleStorageHelper(this) // for scoped storage permission management on Android 10+
val appUrl =
// if (BuildConfig.DEBUG) "https://test-app.super-productivity.com" else "https://app.super-productivity.com"
"${BuildConfig.SERVICE_PROTOCOL}://${BuildConfig.SERVICE_HOST}"
"${BuildConfig.ONLINE_SERVICE_PROTOCOL}://${BuildConfig.ONLINE_SERVICE_HOST}"
@Suppress("ReplaceCallWithBinaryOperator")
override fun onCreate(savedInstanceState: Bundle?) {
Log.v("TW", "FullScreenActivity: onCreate")
super.onCreate(savedInstanceState)
// Determines which launch mode to use. (Online-old or Offline-new)
val launchDecider = LaunchDecider(this)
if (launchDecider.shouldSwitchToNewActivity()) {
// Switch to CapacitorMainActivity
val intent = intent.setComponent(ComponentName(this, CapacitorMainActivity::class.java))
startActivity(intent)
finish()
return
}
initWebView()
// FOR TESTING HTML INPUTS QUICKLY
@ -106,14 +115,12 @@ class FullscreenActivity : AppCompatActivity() {
override fun onPause() {
super.onPause()
isInForeground = false
Log.v("TW", "FullScreenActivity: onPause")
callJSInterfaceFunctionIfExists("next", "onPause$")
}
override fun onResume() {
super.onResume()
isInForeground = true
Log.v("TW", "FullScreenActivity: onResume")
callJSInterfaceFunctionIfExists("next", "onResume$")
}
@ -140,7 +147,7 @@ class FullscreenActivity : AppCompatActivity() {
webView.loadUrl(appUrl)
supportActionBar?.hide()
javaScriptInterface = JavaScriptInterface(this)
javaScriptInterface = JavaScriptInterface(this, webView, storageHelper)
webView.addJavascriptInterface(javaScriptInterface, WINDOW_INTERFACE_PROPERTY)
if (BuildConfig.FLAVOR.equals("fdroid")) {
webView.addJavascriptInterface(javaScriptInterface, WINDOW_PROPERTY_F_DROID)
@ -152,34 +159,21 @@ class FullscreenActivity : AppCompatActivity() {
swController.setServiceWorkerClient(@RequiresApi(Build.VERSION_CODES.N)
object : ServiceWorkerClient() {
override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? {
return interceptRequest(request)
return webViewRequestHandler.interceptWebRequest(request)
}
})
webView.webViewClient = object : WebViewClient() {
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
Log.v("TW", url)
return if (url.startsWith("http://") || url.startsWith("https://")) {
if (url.contains("super-productivity.com") || url.contains("localhost") || url.contains(
BuildConfig.SERVICE_HOST
)
) {
false
} else {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
true
}
} else {
false
}
return webViewRequestHandler.handleUrlLoading(view, url)
}
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
return interceptRequest(request)
return webViewRequestHandler.interceptWebRequest(request)
}
}
@ -230,11 +224,7 @@ class FullscreenActivity : AppCompatActivity() {
private fun callJSInterfaceFunctionIfExists(fnName: String, objectPath: String, fnParam: String = "") {
val fnFullName = "window.$WINDOW_INTERFACE_PROPERTY.$objectPath.$fnName"
val fullObjectPath = "window.$WINDOW_INTERFACE_PROPERTY.$objectPath"
callJavaScriptFunction("if($fullObjectPath && $fnFullName)$fnFullName($fnParam)")
}
fun callJavaScriptFunction(script: String) {
webView.post { webView.evaluateJavascript(script) { } }
javaScriptInterface.callJavaScriptFunction("if($fullObjectPath && $fnFullName)$fnFullName($fnParam)")
}
@Deprecated("Deprecated in Java")
@ -248,7 +238,10 @@ class FullscreenActivity : AppCompatActivity() {
}
override fun onDestroy() {
wvContainer.removeView(webView)
// Ensure wvContainer is initialized before removing the view
if (::wvContainer.isInitialized) {
wvContainer.removeView(webView)
}
super.onDestroy()
}
@ -268,134 +261,4 @@ class FullscreenActivity : AppCompatActivity() {
// Mandatory for Activity, but not for Fragment & ComponentActivity
//storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
private fun interceptRequest(request: WebResourceRequest?): WebResourceResponse? {
if (request == null || request.isForMainFrame) {
return null
}
if (request.url?.path?.contains("assets/icons/favicon") == true) {
try {
return WebResourceResponse("image/png", null, null)
} catch (e: Exception) {
e.printStackTrace()
}
}
if (request.url.toString().contains(BuildConfig.SERVICE_HOST)) {
return null
}
Log.v(
"TW",
"interceptRequest mf:${request?.isForMainFrame.toString()} ${request.method} ${request?.url}"
)
// since we currently don't have a way to also post the body, we only handle GET, HEAD and OPTIONS requests
// see https://github.com/KonstantinSchubert/request_data_webviewclient for a possible solution
if (request.method.uppercase() != "GET" && request.method.uppercase() != "OPTIONS" && request.method.uppercase() !="HEAD") {
return null
}
// remove user agent header in the hopes that we're treated better by the remotes :D
val keysToRemove =
request.requestHeaders.keys.filter { it.equals("User-Agent", ignoreCase = true) }
for (key in keysToRemove) {
request.requestHeaders.remove(key)
}
val client = OkHttpClient()
val newRequestBuilder = Request.Builder()
.url(request.url.toString())
.method(request.method, null)
// Add each header from the original request to the new request
for ((key, value) in request.requestHeaders) {
newRequestBuilder.addHeader(key, value)
}
val newRequest = newRequestBuilder.build()
// currently we can't handle POST requests since everything
if (request.method.uppercase() == "OPTIONS") {
Log.v("TW", "OPTIONS request triggered")
client.newCall(newRequest).execute().use { response ->
Log.v(
"TW",
"OPTIONS original response: ${response.code} ${response.message} ${response.body?.string()}"
)
if (response.code != 200) {
Log.v("TW", "OPTIONS overwrite")
return OptionsAllowResponse.build()
}
}
}
// Handle HEAD requests
if (request.method.uppercase() == "HEAD") {
Log.v("TW", "HEAD request triggered")
client.newCall(newRequest).execute().use { response ->
Log.v("TW", "HEAD response ${response.code} ${response.message}")
val responseHeaders = response.headers.names()
.associateWith { response.headers(it)?.joinToString() }
.toMutableMap()
val keysToRemoveI = responseHeaders.keys.filter {
it.equals("Access-Control-Allow-Origin", ignoreCase = true)
}
for (key in keysToRemoveI) {
responseHeaders.remove(key)
}
responseHeaders["Access-Control-Allow-Origin"] = "*"
val contentType = response.header("Content-Type", "text/plain")
val contentEncoding = response.header("Content-Encoding", "utf-8")
val reasonPhrase = response.message.ifEmpty { "OK" }
return WebResourceResponse(
contentType,
contentEncoding,
response.code,
reasonPhrase,
responseHeaders,
null
)
}
}
Log.v("TW", "exec request ${request.url}")
client.newCall(newRequest).execute().use { response ->
Log.v("TW", "response ${response.code} ${response.message}")
val responseHeaders = response.headers.names()
.associateWith { response.headers(it)?.joinToString() }
.toMutableMap()
val keysToRemoveI =
responseHeaders.keys.filter {
it.equals(
"Access-Control-Allow-Origin",
ignoreCase = true
)
}
for (key in keysToRemoveI) {
responseHeaders.remove(key)
}
responseHeaders["Access-Control-Allow-Origin"] = "*"
val contentType = response.header("Content-Type", "text/plain")
val contentEncoding = response.header("Content-Encoding", "utf-8")
val inputStream = ByteArrayInputStream(response.body?.bytes())
val reasonPhrase =
response.message.ifEmpty { "OK" } // provide a default value if the message is null or empty
return WebResourceResponse(
contentType,
contentEncoding,
response.code,
reasonPhrase,
responseHeaders,
inputStream
)
}
}
}

View file

@ -0,0 +1,35 @@
package com.superproductivity.superproductivity.app
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
// AppLifecycleObserver as a Singleton
class AppLifecycleObserver private constructor() : DefaultLifecycleObserver {
private var _isInForeground: Boolean = false
val isInForeground: Boolean
get() = _isInForeground
override fun onStart(owner: LifecycleOwner) {
_isInForeground = true
}
override fun onStop(owner: LifecycleOwner) {
_isInForeground = false
}
companion object {
private var instance: AppLifecycleObserver? = null
fun getInstance(): AppLifecycleObserver {
if (instance == null) {
instance = AppLifecycleObserver()
ProcessLifecycleOwner.get().lifecycle.addObserver(instance!!)
}
return instance!!
}
}
}

View file

@ -1,4 +1,4 @@
package com.superproductivity.superproductivity
package com.superproductivity.superproductivity.app
import android.content.ContentValues
import android.content.Context
@ -6,6 +6,7 @@ import android.database.DatabaseUtils
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.util.Log
import com.superproductivity.superproductivity.App
class KeyValStore(private val context: Context) :
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {

View file

@ -0,0 +1,106 @@
package com.superproductivity.superproductivity.app
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import androidx.preference.PreferenceManager
import com.superproductivity.superproductivity.BuildConfig
import java.io.File
/**
* Determines which launch mode to use. (Online-old or Offline-new)
*/
class LaunchDecider(private val context: Context) {
companion object {
// Enum values represented as integers
private const val LAUNCH_MODE_KEY = "launch_mode"
private const val MODE_DEFAULT = 0 // Need to determine
private const val MODE_ONLINE = 1 // Use FullscreenActivity (old activity)
private const val MODE_OFFLINE = 2 // Use CapacitorMainActivity (new activity)
}
private val sharedPrefs: SharedPreferences =
PreferenceManager.getDefaultSharedPreferences(context)
/**
* Determines which launch mode to use.
* If the mode is MODE_DEFAULT, it will perform checks to decide between MODE_ONLINE and MODE_OFFLINE.
* If LAUNCH_MODE is set to 1 or 2, it will force the corresponding mode.
* The result is saved in SharedPreferences for future launches.
*/
private fun getLaunchMode(): Int {
val launchMode = BuildConfig.LAUNCH_MODE.toIntOrNull() ?: 0
return when (launchMode) {
1 -> MODE_ONLINE
2 -> MODE_OFFLINE
else -> {
val currentMode = sharedPrefs.getInt(LAUNCH_MODE_KEY, MODE_DEFAULT)
if (currentMode != MODE_DEFAULT) {
// If mode is already determined, return it
currentMode
} else {
// Mode is MODE_DEFAULT, need to determine
val newMode = determineLaunchMode()
// Save the new mode for future launches
sharedPrefs.edit().putInt(LAUNCH_MODE_KEY, newMode).apply()
newMode
}
}
}
}
/**
* Determines whether to use MODE_ONLINE or MODE_OFFLINE.
* Logic:
* - If firstInstallTime == lastUpdateTime, it's a new installation -> MODE_OFFLINE
* - If firstInstallTime < lastUpdateTime, it's an upgrade:
* - If databases/SupKeyValStore file exists, user has data -> MODE_ONLINE
* - If not, user might not have data -> MODE_OFFLINE
*/
private fun determineLaunchMode(): Int {
val packageManager = context.packageManager
val packageName = context.packageName
try {
val packageInfo = packageManager.getPackageInfo(packageName, 0)
val firstInstallTime = packageInfo.firstInstallTime
val lastUpdateTime = packageInfo.lastUpdateTime
return if (firstInstallTime == lastUpdateTime) {
// New installation
MODE_OFFLINE
} else {
// Upgrade installation
if (hasLegacyData()) {
// User has existing data, use online mode
MODE_ONLINE
} else {
// No existing data, use offline mode
MODE_OFFLINE
}
}
} catch (e: PackageManager.NameNotFoundException) {
e.printStackTrace()
// In case of error, default to online mode to be safe
return MODE_ONLINE
}
}
/**
* Checks if the legacy data file exists.
* Returns true if databases/SupKeyValStore file exists.
*/
private fun hasLegacyData(): Boolean {
val dataDir = context.filesDir.parentFile
val dbFile = File(dataDir, "databases/SupKeyValStore")
return dbFile.exists()
}
/**
* Helper method to check if we should switch to the new activity.
* Returns true if MODE_OFFLINE (value 2), indicating we should switch to CapacitorMainActivity.
*/
fun shouldSwitchToNewActivity(): Boolean {
return getLaunchMode() == MODE_OFFLINE
}
}

View file

@ -1,4 +1,4 @@
package com.superproductivity.superproductivity
package com.superproductivity.superproductivity.app
data class SpTask(
val id: String,

View file

@ -1,6 +1,7 @@
package com.superproductivity.superproductivity
package com.superproductivity.superproductivity.webview
import android.Manifest
import android.app.Activity
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@ -13,11 +14,17 @@ import android.os.Build
import android.util.Base64
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.anggrayudi.storage.SimpleStorageHelper
import com.anggrayudi.storage.file.*
import com.superproductivity.superproductivity.App
import com.superproductivity.superproductivity.app.AppLifecycleObserver
import com.superproductivity.superproductivity.FullscreenActivity
import com.superproductivity.superproductivity.FullscreenActivity.Companion.WINDOW_INTERFACE_PROPERTY
import com.superproductivity.superproductivity.R
import org.json.JSONException
import org.json.JSONObject
import java.io.BufferedOutputStream
@ -37,7 +44,9 @@ import java.util.Locale
import javax.net.ssl.SSLHandshakeException
class JavaScriptInterface(
private val activity: FullscreenActivity,
private val activity: Activity,
private val webView: WebView,
private val storageHelper: SimpleStorageHelper
) {
/**
@ -47,7 +56,7 @@ class JavaScriptInterface(
// Additional callback for scoped storage permission management on Android 10+
// Mandatory for Activity, but not for Fragment & ComponentActivity
Log.d("SuperProductivity", "onActivityResult")
activity.storageHelper.storage.onActivityResult(requestCode, resultCode, data)
storageHelper.storage.onActivityResult(requestCode, resultCode, data)
}
@Suppress("unused")
@ -133,7 +142,7 @@ class JavaScriptInterface(
@Suppress("unused")
@JavascriptInterface
fun showNotificationIfAppIsNotOpen(title: String, body: String) {
if (!activity.isInForeground) {
if (!AppLifecycleObserver.getInstance().isInForeground) {
showNotification(title, body)
}
}
@ -512,7 +521,7 @@ class JavaScriptInterface(
// Note that SimpleStorage takes care of all the gritty technical details, including whether the user must pick a root path BEFORE selecting the folder they want to store in, everything is explained to the user
Log.d("SuperProductivity", "Before SimpleStorageHelper callback func def")
// Register a callback with SimpleStorage when a folder is picked
activity.storageHelper.onFolderSelected =
storageHelper.onFolderSelected =
{ requestCode, root -> // could also use simpleStorageHelper.onStorageAccessGranted()
Log.d("SuperProductivity", "Success Folder Pick! Now saving...")
// Get absolute path to folder
@ -528,7 +537,7 @@ class JavaScriptInterface(
// Open folder picker via SimpleStorage, this will request the necessary scoped storage permission
// Note that even though we get permissions, we need to only write DocumentFile files, not MediaStore files, because the latter are not meant to be reopened in the future so we can lose permission at anytime once they are written once, see: https://github.com/anggrayudi/SimpleStorage/issues/103
Log.d("SuperProductivity", "Get Storage Access permission")
activity.storageHelper.openFolderPicker(
storageHelper.openFolderPicker(
// We could also use simpleStorageHelper.requestStorageAccess()
initialPath = FileFullPath(
activity,
@ -541,9 +550,9 @@ class JavaScriptInterface(
)
}
protected fun callJavaScriptFunction(script: String) {
activity.callJavaScriptFunction(script)
}
fun callJavaScriptFunction(script: String) {
webView.post { webView.evaluateJavascript(script) { } }
}
companion object {
// TODO rename to WINDOW_PROPERTY

View file

@ -1,6 +1,5 @@
package com.superproductivity.superproductivity
package com.superproductivity.superproductivity.webview
import android.annotation.TargetApi
import android.util.Log
import android.webkit.WebResourceResponse
import java.text.SimpleDateFormat

View file

@ -1,4 +1,4 @@
package com.superproductivity.superproductivity
package com.superproductivity.superproductivity.webview
import android.annotation.SuppressLint
import android.content.Context
@ -11,10 +11,16 @@ import android.widget.LinearLayout
class WebHelper {
@SuppressLint("SetJavaScriptEnabled")
fun instanceView(context: Context) : WebView {
fun instanceView(context: Context, modifyUA: Boolean = true) : WebView {
val wv = WebView(context)
wv.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT)
return setupView(wv, modifyUA)
}
@SuppressLint("SetJavaScriptEnabled")
fun setupView(wv: WebView, modifyUA: Boolean) : WebView {
wv.setLayerType(View.LAYER_TYPE_HARDWARE, null)
wv.isFocusableInTouchMode = true
@ -37,7 +43,13 @@ class WebHelper {
// @see https://stackoverflow.com/questions/45863004/how-some-apps-are-able-to-perform-google-login-successfully-in-android-webview
// Force links and redirects to open in the WebView instead of in a browser
wSettings.javaScriptCanOpenWindowsAutomatically = true
wSettings.userAgentString = "Mozilla/5.0 (Linux Android 5.0 SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.125 Mobile Safari/537.36"
if (modifyUA) {
wSettings.userAgentString =
"Mozilla/5.0 (Linux Android 5.0 SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.125 Mobile Safari/537.36"
} else {
// IMPORTANT: Do not remove or modify "; wv" in the User-Agent string.
// Removing "; wv" prevents Service Workers from registering and functioning correctly in Capacitor.
}
return wv
}
}

View file

@ -0,0 +1,165 @@
package com.superproductivity.superproductivity.webview
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
/**
* Strip the original WebViewClient logic to ensure that both types share the same logic
*/
class WebViewRequestHandler(private val activity: Activity, private val serviceHost: String){
@Deprecated("Deprecated in Java")
fun handleUrlLoading(view: WebView, url: String): Boolean {
Log.v("TW", url)
return if (url.startsWith("http://") || url.startsWith("https://")) {
if (url.contains("super-productivity.com") || url.contains("localhost") || url.contains(
serviceHost
)
) {
false
} else {
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
true
}
} else {
false
}
}
fun interceptWebRequest(request: WebResourceRequest?): WebResourceResponse? {
if (request == null || request.isForMainFrame) {
return null
}
if (request.url?.path?.contains("assets/icons/favicon") == true) {
try {
return WebResourceResponse("image/png", null, null)
} catch (e: Exception) {
e.printStackTrace()
}
}
if (request.url.toString().contains(serviceHost)) {
return null
}
Log.v(
"TW",
"interceptRequest mf:${request?.isForMainFrame.toString()} ${request.method} ${request?.url}"
)
// since we currently don't have a way to also post the body, we only handle GET, HEAD and OPTIONS requests
// see https://github.com/KonstantinSchubert/request_data_webviewclient for a possible solution
if (request.method.uppercase() != "GET" && request.method.uppercase() != "OPTIONS" && request.method.uppercase() !="HEAD") {
return null
}
// remove user agent header in the hopes that we're treated better by the remotes :D
val keysToRemove =
request.requestHeaders.keys.filter { it.equals("User-Agent", ignoreCase = true) }
for (key in keysToRemove) {
request.requestHeaders.remove(key)
}
val client = OkHttpClient()
val newRequestBuilder = Request.Builder()
.url(request.url.toString())
.method(request.method, null)
// Add each header from the original request to the new request
for ((key, value) in request.requestHeaders) {
newRequestBuilder.addHeader(key, value)
}
val newRequest = newRequestBuilder.build()
// currently we can't handle POST requests since everything
if (request.method.uppercase() == "OPTIONS") {
Log.v("TW", "OPTIONS request triggered")
client.newCall(newRequest).execute().use { response ->
Log.v(
"TW",
"OPTIONS original response: ${response.code} ${response.message} ${response.body?.string()}"
)
if (response.code != 200) {
Log.v("TW", "OPTIONS overwrite")
return OptionsAllowResponse.build()
}
}
}
// Handle HEAD requests
if (request.method.uppercase() == "HEAD") {
Log.v("TW", "HEAD request triggered")
client.newCall(newRequest).execute().use { response ->
Log.v("TW", "HEAD response ${response.code} ${response.message}")
val responseHeaders = response.headers.names()
.associateWith { response.headers(it)?.joinToString() }
.toMutableMap()
val keysToRemoveI = responseHeaders.keys.filter {
it.equals("Access-Control-Allow-Origin", ignoreCase = true)
}
for (key in keysToRemoveI) {
responseHeaders.remove(key)
}
responseHeaders["Access-Control-Allow-Origin"] = "*"
val contentType = response.header("Content-Type", "text/plain")
val contentEncoding = response.header("Content-Encoding", "utf-8")
val reasonPhrase = response.message.ifEmpty { "OK" }
return WebResourceResponse(
contentType,
contentEncoding,
response.code,
reasonPhrase,
responseHeaders,
null
)
}
}
Log.v("TW", "exec request ${request.url}")
client.newCall(newRequest).execute().use { response ->
Log.v("TW", "response ${response.code} ${response.message}")
val responseHeaders = response.headers.names()
.associateWith { response.headers(it)?.joinToString() }
.toMutableMap()
val keysToRemoveI =
responseHeaders.keys.filter {
it.equals(
"Access-Control-Allow-Origin",
ignoreCase = true
)
}
for (key in keysToRemoveI) {
responseHeaders.remove(key)
}
responseHeaders["Access-Control-Allow-Origin"] = "*"
val contentType = response.header("Content-Type", "text/plain")
val contentEncoding = response.header("Content-Encoding", "utf-8")
val inputStream = ByteArrayInputStream(response.body?.bytes())
val reasonPhrase =
response.message.ifEmpty { "OK" } // provide a default value if the message is null or empty
return WebResourceResponse(
contentType,
contentEncoding,
response.code,
reasonPhrase,
responseHeaders,
inputStream
)
}
}
}

View file

@ -1,5 +1,6 @@
buildscript {
ext.kotlin_version = "1.9.10"
repositories {
google()
mavenCentral()
@ -7,7 +8,7 @@ buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:8.6.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.20"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}

View file

@ -0,0 +1,3 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')

View file

@ -0,0 +1,10 @@
New Connectivity-Free Mode is Here!
You can now use the core features of the app without an internet connection, offering a smoother and more reliable experience. We've made several key updates to enhance usability:
· Connectivity-Free Mode Support: Enjoy uninterrupted access to the app's main features without needing a network connection. You can still sync with WebDAV, Dropbox, or choose to work entirely offline without any network access.
· Seamless Transition: Existing users will continue using the app in Online-Only Mode (Compatibility Mode) with no disruptions, while new users can immediately benefit from the Connectivity-Free Mode. Future updates will include a smooth migration plan for everyone.
· CORS Issues Resolved: Fixed cross-origin resource sharing (CORS) problems, especially for WebDAV sync, ensuring secure and smooth synchronization with local or hosted resources.
· Enhanced Security: Strengthened data protection to keep your information secure, even when offline.
Update now to enjoy these exciting new features and improvements!

View file

@ -1 +1,2 @@
include ':app'
apply from: 'capacitor.settings.gradle'