Compare commits

...

32 Commits

Author SHA1 Message Date
dependabot[bot] f4c167da22 chore(deps): bump androidGradlePlugin from 8.9.1 to 8.9.2
Bumps `androidGradlePlugin` from 8.9.1 to 8.9.2.

Updates `com.android.application` from 8.9.1 to 8.9.2

Updates `com.android.library` from 8.9.1 to 8.9.2

---
updated-dependencies:
- dependency-name: com.android.application
  dependency-version: 8.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.android.library
  dependency-version: 8.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-22 13:51:13 +00:00
Zane Schepke 4707d3eb95 fix: app versioning 2025-04-22 03:03:24 -04:00
Zane Schepke cedc2db326 feat: add app licenses screen 2025-04-21 15:33:14 -04:00
Zane Schepke 256e3f7951 fix: version changed while testing 2025-04-21 11:52:40 -04:00
Zane Schepke 9e797b24d6 feat: add in-app updater for release versions
closes #636
2025-04-21 11:51:18 -04:00
Zane Schepke f2b9eb526e fix: amnezia compatibility action
closes #711
2025-04-21 06:56:16 -04:00
Zane Schepke abb29607d3 refactor: ui section divider padding 2025-04-21 06:45:26 -04:00
Zane Schepke f6d7cbc032 fix: recomposition bug, improve cancel scenario
#704
2025-04-20 22:00:26 -04:00
Zane Schepke 9304d79775 feat: variable number tunnel export with file explorer support
feat: listen for user present AndroidTV
#606
closes #704
2025-04-20 21:30:20 -04:00
Zane Schepke 4d18decbf7 fix: simplify bottom nav
closes #716
closes #705
2025-04-19 18:01:09 -04:00
Zane Schepke 76186c092f feat: export variable number of tunnels 2025-04-18 22:41:45 -04:00
Zane Schepke c90a7bbaf5 feat: add multi-select support
closes #332
2025-04-18 18:32:12 -04:00
Zane Schepke d3d70ab2e7 build: change from debug signing 2025-04-17 05:04:29 -04:00
Zane Schepke 9b2d4a3fb5 build: fix release building 2025-04-17 05:02:43 -04:00
Zane Schepke d7741c37c5 chore: bump version, release notes 2025-04-17 04:43:31 -04:00
Zane Schepke 6046e4131f fix: android tv sleep restore
#606
2025-04-17 04:38:15 -04:00
Zane Schepke 4b2d2d20db fix: split tunnel search and select
closes #696
2025-04-17 04:28:20 -04:00
Zane Schepke a09501aaf5 feat(lang): weblate langauge updates (#701)
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Languages add-on <noreply-addon-languages@weblate.org>
Co-authored-by: solokot <solokot@gmail.com>
Co-authored-by: Kachelkaiser <kachelkaiser@htpst.de>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: mak7im01 <mak7im02@gmail.com>
Co-authored-by: nware-lab <nware.labs@gmail.com>
Co-authored-by: 翻譯得真好下次別翻了 <x86_64-pc-linux-gnu@proton.me>
Co-authored-by: Faisal Gull <mail.faisalrehman.345@gmail.com>
2025-04-17 04:08:19 -04:00
Zane Schepke d46a0653f1 ci: remove old workflows 2025-04-17 01:37:32 -04:00
Zane Schepke 49ee2431c2 refactor: revert banner for the google monopoly 2025-04-16 07:26:50 -04:00
Zane Schepke dfcc022257 refactor: minor change 2025-04-16 07:18:21 -04:00
Zane Schepke bc08069a64 ci: fix release token 2025-04-15 16:19:36 -04:00
Zane Schepke fb97adca4f fix: latest tagging 2025-04-15 16:05:55 -04:00
Zane Schepke 41540db9b7 ci: fix org changes (#698) 2025-04-15 15:44:01 -04:00
Zane Schepke a1c663233d fix: space 2025-04-15 06:00:06 -04:00
Zane Schepke c520fa5ed2 fix: ci 2025-04-15 05:59:41 -04:00
Zane Schepke 120bde2939 ci: remove old r8 rules 2025-04-15 05:20:52 -04:00
Zane Schepke 58fcc358ce build: gradle checksum update 2025-04-15 04:57:43 -04:00
Zane Schepke 72722a0be5 fix: android dns issue
closes #687
2025-04-15 03:53:17 -04:00
Zane Schepke 29aba65690 ci: change to org ci vars 2025-04-14 15:52:14 -04:00
Tobias Wienkoop 5d9a534e1c feat: add missing monochrome app icon (#689) 2025-04-12 11:56:56 -04:00
Zane Schepke f5dafa6bf7 ci: fix fastlane 2025-04-11 22:28:59 -04:00
130 changed files with 2353 additions and 1600 deletions
+4 -4
View File
@@ -48,9 +48,9 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} SIGNING_KEY_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} SIGNING_STORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_STORE_PASSWORD }}
KEY_STORE_FILE: 'android_keystore.jks' KEY_STORE_FILE: 'android_keystore.jks'
KEY_STORE_LOCATION: ${{ github.workspace }}/app/keystore/ KEY_STORE_LOCATION: ${{ github.workspace }}/app/keystore/
outputs: outputs:
@@ -74,7 +74,7 @@ jobs:
with: with:
fileName: ${{ env.KEY_STORE_FILE }} fileName: ${{ env.KEY_STORE_FILE }}
fileDir: ${{ env.KEY_STORE_LOCATION }} fileDir: ${{ env.KEY_STORE_LOCATION }}
encodedString: ${{ secrets.KEYSTORE }} encodedString: ${{ secrets.ANDROID_KEYSTORE }}
# create keystore path for gradle to read # create keystore path for gradle to read
- name: Create keystore path env var - name: Create keystore path env var
-36
View File
@@ -1,36 +0,0 @@
name: on-issue
on:
issues:
types: [ opened, closed, reopened ]
jobs:
on-issue:
name: On new issue
runs-on: ubuntu-latest
steps:
- name: Send Telegram Message
run: |
msg_text='${{ github.actor }} updated an issue:
status: ${{ github.event.issue.state }} - #${{ github.event.issue.number }} ${{ github.event.issue.title }}
https://github.com/zaneschepke/wgtunnel/issues/${{ github.event.issue.number }}'
curl -s -X POST 'https://api.telegram.org/bot${{ secrets.TELEGRAM_TOKEN }}/sendMessage' \
-d "chat_id=${{ secrets.TELEGRAM_TO }}&text=${msg_text}&message_thread_id=${{ vars.TELEGRAM_ACTIVITY_TOPIC }}"
- name: Send Matrix Message
run: |
msg_text='${{ github.actor }} updated an issue:
status: ${{ github.event.issue.state }} - #${{ github.event.issue.number }} ${{ github.event.issue.title }}
https://github.com/zaneschepke/wgtunnel/issues/${{ github.event.issue.number }}'
# Escape newlines and quotes for JSON
formatted_msg=$(echo -n "$msg_text" | sed ':a;N;$ba;s/\n/\\n/g' | sed 's/"/\\"/g')
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.MATRIX_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{
"msgtype": "m.text",
"body": "'"$formatted_msg"'"
}' \
"https://matrix.org/_matrix/client/v3/rooms/${{ vars.MATRIX_ACTIVITY_TOPIC }}/send/m.room.message/$(date +%s)"
-37
View File
@@ -1,37 +0,0 @@
name: on-publish
on:
release:
types: [ published ]
jobs:
on-publish:
name: On publish
runs-on: ubuntu-latest
steps:
- name: Send Telegram Message
run: |
msg_text='${{ github.actor }} published a new release:
Release: ${{ github.event.release.tag_name }}
${{ github.event.release.body }}
https://github.com/zaneschepke/wgtunnel/releases/tag/${{ github.event.release.tag_name }}'
curl -s -X POST 'https://api.telegram.org/bot${{ secrets.TELEGRAM_TOKEN }}/sendMessage' \
-d "chat_id=${{ secrets.TELEGRAM_TO }}&text=${msg_text}&message_thread_id=${{ vars.TELEGRAM_RELEASE_TOPIC }}"
- name: Send Matrix Message
run: |
msg_text='${{ github.actor }} published a new release:
Release: ${{ github.event.release.tag_name }}
${{ github.event.release.body }}
https://github.com/zaneschepke/wgtunnel/releases/tag/${{ github.event.release.tag_name }}'
# Escape newlines and quotes for JSON
formatted_msg=$(echo -n "$msg_text" | sed ':a;N;$ba;s/\n/\\n/g' | sed 's/"/\\"/g')
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.MATRIX_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{
"msgtype": "m.text",
"body": "'"$formatted_msg"'"
}' \
"https://matrix.org/_matrix/client/v3/rooms/${{ vars.MATRIX_RELEASE_TOPIC }}/send/m.room.message/$(date +%s)"
+15 -19
View File
@@ -34,6 +34,9 @@ on:
env: env:
UPLOAD_DIR_ANDROID: android_artifacts UPLOAD_DIR_ANDROID: android_artifacts
permissions:
contents: write
jobs: jobs:
check_commits: check_commits:
name: Check for New Commits name: Check for New Commits
@@ -50,7 +53,7 @@ jobs:
- name: Check for new commits - name: Check for new commits
id: check id: check
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.PAT }}
run: | run: |
# This script checks for commits newer than 23 hours ago # This script checks for commits newer than 23 hours ago
NEW_COMMITS=$(git rev-list --count --after="$(date -Iseconds -d '23 hours ago')" ${{ github.sha }}) NEW_COMMITS=$(git rev-list --count --after="$(date -Iseconds -d '23 hours ago')" ${{ github.sha }})
@@ -71,9 +74,9 @@ jobs:
name: publish-github name: publish-github
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
GH_USER: ${{ secrets.GH_USER }} GH_USER: ${{ secrets.PAT_USERNAME }}
# GH needed for gh cli # GH needed for gh cli
GH_TOKEN: ${{ secrets.GH_TOKEN }} GH_TOKEN: ${{ secrets.PAT }}
GH_REPO: ${{ github.repository }} GH_REPO: ${{ github.repository }}
steps: steps:
@@ -90,6 +93,7 @@ jobs:
tag: "latest" # or any tag name you wish to use tag: "latest" # or any tag name you wish to use
message: "Automated tag for HEAD commit" message: "Automated tag for HEAD commit"
force_push_tag: true force_push_tag: true
github_token: ${{ secrets.GITHUB_TOKEN }}
tag_exists_error: false tag_exists_error: false
- name: Get latest release - name: Get latest release
@@ -118,7 +122,7 @@ jobs:
if: ${{ inputs.release_type == '' || inputs.release_type == 'nightly' || inputs.release_type == 'prerelease' }} if: ${{ inputs.release_type == '' || inputs.release_type == 'nightly' || inputs.release_type == 'prerelease' }}
uses: ad-m/github-push-action@master uses: ad-m/github-push-action@master
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.PAT }}
branch: ${{ github.ref }} branch: ${{ github.ref }}
- name: Make download dir - name: Make download dir
@@ -168,7 +172,7 @@ jobs:
id: create_release id: create_release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
env: env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
body: | body: |
${{ env.RELEASE_NOTES }} ${{ env.RELEASE_NOTES }}
@@ -193,14 +197,6 @@ jobs:
files: | files: |
${{ github.workspace }}/temp/* ${{ github.workspace }}/temp/*
# notify socials
- name: Trigger on-publish workflow
if: ${{ inputs.release_type == 'release' }}
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.PAT }}
event-type: publish-release
publish-fdroid: publish-fdroid:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
@@ -220,13 +216,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} SIGNING_KEY_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} SIGNING_STORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_STORE_PASSWORD }}
KEY_STORE_FILE: 'android_keystore.jks' KEY_STORE_FILE: 'android_keystore.jks'
KEY_STORE_LOCATION: ${{ github.workspace }}/app/keystore/ KEY_STORE_LOCATION: ${{ github.workspace }}/app/keystore/
GH_USER: ${{ secrets.GH_USER }} GH_USER: ${{ secrets.PAT_USERNAME }}
GH_TOKEN: ${{ secrets.GH_TOKEN }} GH_TOKEN: ${{ secrets.PAT }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -248,7 +244,7 @@ jobs:
with: with:
fileName: ${{ env.KEY_STORE_FILE }} fileName: ${{ env.KEY_STORE_FILE }}
fileDir: ${{ env.KEY_STORE_LOCATION }} fileDir: ${{ env.KEY_STORE_LOCATION }}
encodedString: ${{ secrets.KEYSTORE }} encodedString: ${{ secrets.ANDROID_KEYSTORE }}
# create keystore path for gradle to read # create keystore path for gradle to read
- name: Create keystore path env var - name: Create keystore path env var
+1
View File
@@ -1,2 +1,3 @@
/build /build
/release /release
/src/main/assets/licenses.json
+34
View File
@@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
alias(libs.plugins.grgit) alias(libs.plugins.grgit)
alias(libs.plugins.licensee)
} }
val versionFile = file("$rootDir/versionCode.txt") val versionFile = file("$rootDir/versionCode.txt")
@@ -139,6 +140,14 @@ android {
buildConfig = true buildConfig = true
} }
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
licensee {
Constants.allowedLicenses.forEach { allow(it) }
allowUrl(Constants.XZING_LICENSE_URL)
// Fix for qrcode-kotlin (MIT, custom URL)
allowUrl("https://rafaellins.mit-license.org/2021/")
}
} }
dependencies { dependencies {
@@ -223,6 +232,17 @@ dependencies {
// worker // worker
implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.runtime)
implementation(libs.androidx.hilt.work) implementation(libs.androidx.hilt.work)
// util
implementation(libs.qrcode.kotlin)
implementation(libs.semver4j)
// Ktor
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
} }
fun determineVersionName(): String { fun determineVersionName(): String {
@@ -251,3 +271,17 @@ tasks.whenTaskAdded {
dependsOn(incrementVersionCode) dependsOn(incrementVersionCode)
} }
} }
tasks.register<Copy>("copyLicenseeJsonToAssets") {
dependsOn("licensee")
val outputAssets = layout.projectDirectory.dir("src/main/assets")
from(layout.buildDirectory.file("reports/licensee/androidFdroidRelease/artifacts.json")) {
rename("artifacts.json", "licenses.json")
}
into(outputAssets)
}
tasks.named("preBuild") { dependsOn("copyLicenseeJsonToAssets") }
-42
View File
@@ -1,42 +0,0 @@
-dontwarn com.google.errorprone.annotations.**
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
# Keep all classes in the org.xbill.DNS package and subpackages
-keep class org.xbill.DNS.** { *; }
-dontwarn org.xbill.DNS.**
# Preserve JNA classes if used (e.g., for IPHlpAPI on Windows)
-keep class com.sun.jna.** { *; }
-dontwarn com.sun.jna.**
# Keep DNS resolver configuration classes that might be loaded dynamically
-keep class org.xbill.DNS.config.** { *; }
-dontwarn org.xbill.DNS.config.**
-keep class org.xbill.DNS.** { *; }
# Prevent optimization issues with native or reflection-based calls
-dontoptimize
-dontshrink
# Uncomment the above if errors persist, but use sparingly as theyre broad
# Suppress warnings about missing classes if not all features are used
-dontwarn java.lang.management.**
-dontwarn sun.nio.ch.**
-dontwarn com.google.api.client.http.GenericUrl
-dontwarn com.google.api.client.http.HttpHeaders
-dontwarn com.google.api.client.http.HttpRequest
-dontwarn com.google.api.client.http.HttpRequestFactory
-dontwarn com.google.api.client.http.HttpResponse
-dontwarn com.google.api.client.http.HttpTransport
-dontwarn com.google.api.client.http.javanet.NetHttpTransport$Builder
-dontwarn com.google.api.client.http.javanet.NetHttpTransport
-dontwarn javax.lang.model.element.Modifier
-dontwarn org.joda.time.Instant
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn org.slf4j.impl.StaticMDCBinder
-dontwarn org.slf4j.impl.StaticMarkerBinder
-61
View File
@@ -1,61 +0,0 @@
# 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
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
# Keep all classes in the org.xbill.DNS package and subpackages
-keep class org.xbill.DNS.** { *; }
-dontwarn org.xbill.DNS.**
# Preserve JNA classes if used (e.g., for IPHlpAPI on Windows)
-keep class com.sun.jna.** { *; }
-dontwarn com.sun.jna.**
# Keep DNS resolver configuration classes that might be loaded dynamically
-keep class org.xbill.DNS.config.** { *; }
-dontwarn org.xbill.DNS.config.**
-keep class org.xbill.DNS.** { *; }
# Prevent optimization issues with native or reflection-based calls
-dontoptimize
-dontshrink
# Uncomment the above if errors persist, but use sparingly as theyre broad
# Suppress warnings about missing classes if not all features are used
-dontwarn java.lang.management.**
-dontwarn sun.nio.ch.**
-dontwarn com.google.api.client.http.GenericUrl
-dontwarn com.google.api.client.http.HttpHeaders
-dontwarn com.google.api.client.http.HttpRequest
-dontwarn com.google.api.client.http.HttpRequestFactory
-dontwarn com.google.api.client.http.HttpResponse
-dontwarn com.google.api.client.http.HttpTransport
-dontwarn com.google.api.client.http.javanet.NetHttpTransport$Builder
-dontwarn com.google.api.client.http.javanet.NetHttpTransport
-dontwarn javax.lang.model.element.Modifier
-dontwarn org.joda.time.Instant
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn org.slf4j.impl.StaticMDCBinder
-dontwarn org.slf4j.impl.StaticMarkerBinder
+6 -3
View File
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<!--updater-->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -16,7 +18,6 @@
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!--start service on boot permission--> <!--start service on boot permission-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!--android tv support-->
<permission <permission
android:name="${applicationId}.permission.CONTROL_TUNNELS" android:name="${applicationId}.permission.CONTROL_TUNNELS"
@@ -53,7 +54,7 @@
<application <application
android:name=".WireGuardAutoTunnel" android:name=".WireGuardAutoTunnel"
android:allowBackup="false" android:allowBackup="false"
android:banner="@mipmap/ic_banner" android:banner="@drawable/ic_banner"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
@@ -66,6 +67,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:banner="@mipmap/ic_banner"
android:windowSoftInputMode="adjustNothing" android:windowSoftInputMode="adjustNothing"
android:theme="@style/Theme.WireguardAutoTunnel" android:theme="@style/Theme.WireguardAutoTunnel"
android:configChanges="orientation|screenSize|keyboardHidden" android:configChanges="orientation|screenSize|keyboardHidden"
@@ -170,7 +172,8 @@
android:exported="false" android:exported="false"
android:directBootAware="true"> android:directBootAware="true">
<intent-filter> <intent-filter>
<category android:name="android.intent.category.DEFAULT" /> <action android:name="android.intent.action.SCREEN_ON" />
<action android:name="android.intent.action.USER_PRESENT" />
<action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" /> <action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" /> <action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
View File
@@ -5,7 +5,6 @@ import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Color import android.graphics.Color
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
@@ -22,33 +21,15 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.*
import androidx.compose.material3.Scaffold import androidx.compose.runtime.*
import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.CompositionLocalProvider
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.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@@ -62,14 +43,15 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.VpnDeniedDialog import com.zaneschepke.wireguardautotunnel.ui.common.dialog.VpnDeniedDialog
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.CustomBottomNavbar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.DynamicTopAppBar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.currentNavBackStackEntryAsNavBarState
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.CustomSnackBar import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.navigation.components.BottomNavbar
import com.zaneschepke.wireguardautotunnel.ui.navigation.components.DynamicTopAppBar
import com.zaneschepke.wireguardautotunnel.ui.navigation.components.currentNavBackStackEntryAsNavBarState
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.AutoTunnelScreen import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.AutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced.AutoTunnelAdvancedScreen import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced.AutoTunnelAdvancedScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.LocationDisclosureScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.TunnelAutoTunnelScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.TunnelAutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigScreen
@@ -82,12 +64,12 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.advanced.Settings
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.AppearanceScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.AppearanceScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.display.DisplayScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.display.DisplayScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.LanguageScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.LanguageScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure.LocationDisclosureScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.KillSwitchScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.KillSwitchScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.logs.LogsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.logs.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.license.LicenseScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@@ -125,6 +107,7 @@ class MainActivity : AppCompatActivity() {
} }
setContent { setContent {
val isTv = isRunningOnTv()
val appUiState by viewModel.uiState.collectAsStateWithLifecycle() val appUiState by viewModel.uiState.collectAsStateWithLifecycle()
val appViewState by viewModel.appViewState.collectAsStateWithLifecycle() val appViewState by viewModel.appViewState.collectAsStateWithLifecycle()
@@ -136,6 +119,7 @@ class MainActivity : AppCompatActivity() {
backStackEntry, backStackEntry,
viewModel, viewModel,
appUiState, appUiState,
appViewState,
) )
val snackbar = remember { SnackbarHostState() } val snackbar = remember { SnackbarHostState() }
var showVpnPermissionDialog by remember { mutableStateOf(false) } var showVpnPermissionDialog by remember { mutableStateOf(false) }
@@ -218,13 +202,14 @@ class MainActivity : AppCompatActivity() {
batteryActivity.launch( batteryActivity.launch(
Intent().apply { Intent().apply {
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${this@MainActivity.packageName}") data = "package:${this@MainActivity.packageName}".toUri()
} }
) )
} }
} }
} }
CompositionLocalProvider(LocalIsAndroidTV provides isTv) {
CompositionLocalProvider(LocalNavController provides navController) { CompositionLocalProvider(LocalNavController provides navController) {
WireguardAutoTunnelTheme(theme = appUiState.appState.theme) { WireguardAutoTunnelTheme(theme = appUiState.appState.theme) {
VpnDeniedDialog( VpnDeniedDialog(
@@ -236,7 +221,7 @@ class MainActivity : AppCompatActivity() {
modifier = modifier =
Modifier.pointerInput(Unit) { Modifier.pointerInput(Unit) {
detectTapGestures { detectTapGestures {
viewModel.handleEvent(AppEvent.SetSelectedTunnel(null)) viewModel.handleEvent(AppEvent.ClearSelectedTunnels)
} }
}, },
snackbarHost = { snackbarHost = {
@@ -256,45 +241,7 @@ class MainActivity : AppCompatActivity() {
enter = slideInVertically(initialOffsetY = { it }), enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }), exit = slideOutVertically(targetOffsetY = { it }),
) { ) {
CustomBottomNavbar( BottomNavbar(appUiState = appUiState)
listOf(
BottomNavItem(
name = stringResource(R.string.tunnels),
route = Route.Main,
icon = Icons.Rounded.Home,
onClick = { navController.goFromRoot(Route.Main) },
),
BottomNavItem(
name = stringResource(R.string.auto_tunnel),
route = Route.AutoTunnel,
icon = Icons.Rounded.Bolt,
onClick = {
val route =
if (
appUiState.appState
.isLocationDisclosureShown
)
Route.AutoTunnel
else Route.LocationDisclosure
navController.goFromRoot(route)
},
active = appUiState.isAutoTunnelActive,
),
BottomNavItem(
name = stringResource(R.string.settings),
route = Route.Settings,
icon = Icons.Rounded.Settings,
onClick = { navController.goFromRoot(Route.Settings) },
),
BottomNavItem(
name = stringResource(R.string.support),
route = Route.Support,
icon = Icons.Rounded.QuestionMark,
onClick = { navController.goFromRoot(Route.Support) },
),
),
navBarState = navBarState,
)
} }
}, },
) { padding -> ) { padding ->
@@ -316,7 +263,7 @@ class MainActivity : AppCompatActivity() {
MainScreen(appUiState, appViewState, viewModel) MainScreen(appUiState, appViewState, viewModel)
} }
composable<Route.Settings> { composable<Route.Settings> {
SettingsScreen(appUiState, appViewState, viewModel) SettingsScreen(appUiState, viewModel)
} }
composable<Route.SettingsAdvanced> { composable<Route.SettingsAdvanced> {
SettingsAdvancedScreen(appUiState, viewModel) SettingsAdvancedScreen(appUiState, viewModel)
@@ -328,16 +275,24 @@ class MainActivity : AppCompatActivity() {
AutoTunnelScreen(appUiState, viewModel) AutoTunnelScreen(appUiState, viewModel)
} }
composable<Route.Appearance> { AppearanceScreen() } composable<Route.Appearance> { AppearanceScreen() }
composable<Route.Language> { LanguageScreen(appUiState, viewModel) } composable<Route.Language> {
composable<Route.Display> { DisplayScreen(appUiState, viewModel) } LanguageScreen(appUiState, viewModel)
composable<Route.Support> { SupportScreen() } }
composable<Route.Display> {
DisplayScreen(appUiState, viewModel)
}
composable<Route.Support> {
SupportScreen(appViewModel = viewModel)
}
composable<Route.License> { LicenseScreen() }
composable<Route.AutoTunnelAdvanced> { composable<Route.AutoTunnelAdvanced> {
AutoTunnelAdvancedScreen(appUiState, viewModel) AutoTunnelAdvancedScreen(appUiState, viewModel)
} }
composable<Route.Logs> { LogsScreen(appViewState, viewModel) } composable<Route.Logs> { LogsScreen(appViewState, viewModel) }
composable<Route.Config> { backStack -> composable<Route.Config> { backStack ->
val args = backStack.toRoute<Route.Config>() val args = backStack.toRoute<Route.Config>()
val config = appUiState.tunnels.firstOrNull { it.id == args.id } val config =
appUiState.tunnels.firstOrNull { it.id == args.id }
ConfigScreen(config, viewModel) ConfigScreen(config, viewModel)
} }
composable<Route.TunnelOptions> { backStack -> composable<Route.TunnelOptions> { backStack ->
@@ -345,7 +300,7 @@ class MainActivity : AppCompatActivity() {
appUiState.tunnels appUiState.tunnels
.firstOrNull { it.id == args.id } .firstOrNull { it.id == args.id }
?.let { config -> ?.let { config ->
TunnelOptionsScreen(config, appUiState, viewModel) TunnelOptionsScreen(config, viewModel)
} }
} }
composable<Route.Lock> { PinLockScreen(viewModel) } composable<Route.Lock> { PinLockScreen(viewModel) }
@@ -373,6 +328,7 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
@@ -8,6 +8,7 @@ import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -29,6 +30,10 @@ class RestartReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
Timber.d("RestartReceiver triggered with action: ${intent.action}") Timber.d("RestartReceiver triggered with action: ${intent.action}")
// screen on for Android TV only to help with sleep shutdowns
val isTv = context.isRunningOnTv()
if (intent.action == Intent.ACTION_SCREEN_ON && !isTv) return
if (intent.action == Intent.ACTION_USER_PRESENT && !isTv) return
serviceManager.updateTunnelTile() serviceManager.updateTunnelTile()
serviceManager.updateAutoTunnelTile() serviceManager.updateAutoTunnelTile()
applicationScope.launch(ioDispatcher) { applicationScope.launch(ioDispatcher) {
@@ -148,11 +148,12 @@ class TunnelControlTile : TileService(), LifecycleOwner {
private fun setTileDescription(description: String) { private fun setTileDescription(description: String) {
runCatching { runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (qsTile == null) return@runCatching
qsTile.subtitle = description
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
qsTile.subtitle = description
qsTile.stateDescription = description qsTile.stateDescription = description
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
qsTile.subtitle = description
} }
qsTile.updateTile() qsTile.updateTile()
} }
@@ -25,7 +25,7 @@ class DataStoreManager(
val locationDisclosureShown = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN") val locationDisclosureShown = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN")
val batteryDisableShown = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN") val batteryDisableShown = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN")
val pinLockEnabled = booleanPreferencesKey("PIN_LOCK_ENABLED") val pinLockEnabled = booleanPreferencesKey("PIN_LOCK_ENABLED")
val tunnelStatsExpanded = booleanPreferencesKey("TUNNEL_STATS_EXPANDED") val expandedTunnelIds = stringPreferencesKey("EXPANDED_TUNNEL_IDS")
val isLocalLogsEnabled = booleanPreferencesKey("LOCAL_LOGS_ENABLED") val isLocalLogsEnabled = booleanPreferencesKey("LOCAL_LOGS_ENABLED")
val locale = stringPreferencesKey("LOCALE") val locale = stringPreferencesKey("LOCALE")
val theme = stringPreferencesKey("THEME") val theme = stringPreferencesKey("THEME")
@@ -7,7 +7,6 @@ import timber.log.Timber
class DatabaseCallback : RoomDatabase.Callback() { class DatabaseCallback : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) = override fun onCreate(db: SupportSQLiteDatabase) =
db.run { db.run {
// Notice non-ui thread is here
beginTransaction() beginTransaction()
try { try {
execSQL(Queries.createDefaultSettings()) execSQL(Queries.createDefaultSettings())
@@ -0,0 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Asset(
val name: String,
@SerialName("browser_download_url") val browserDownloadUrl: String,
)
@@ -7,7 +7,7 @@ data class GeneralState(
val isLocationDisclosureShown: Boolean = LOCATION_DISCLOSURE_SHOWN_DEFAULT, val isLocationDisclosureShown: Boolean = LOCATION_DISCLOSURE_SHOWN_DEFAULT,
val isBatteryOptimizationDisableShown: Boolean = BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT, val isBatteryOptimizationDisableShown: Boolean = BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT,
val isPinLockEnabled: Boolean = PIN_LOCK_ENABLED_DEFAULT, val isPinLockEnabled: Boolean = PIN_LOCK_ENABLED_DEFAULT,
val isTunnelStatsExpanded: Boolean = IS_TUNNEL_STATS_EXPANDED, val expandedTunnelIds: List<Int> = emptyList(),
val isLocalLogsEnabled: Boolean = IS_LOGS_ENABLED_DEFAULT, val isLocalLogsEnabled: Boolean = IS_LOGS_ENABLED_DEFAULT,
val isRemoteControlEnabled: Boolean = IS_REMOTE_CONTROL_ENABLED, val isRemoteControlEnabled: Boolean = IS_REMOTE_CONTROL_ENABLED,
val remoteKey: String? = null, val remoteKey: String? = null,
@@ -20,7 +20,7 @@ data class GeneralState(
isLocationDisclosureShown, isLocationDisclosureShown,
isBatteryOptimizationDisableShown, isBatteryOptimizationDisableShown,
isPinLockEnabled, isPinLockEnabled,
isTunnelStatsExpanded, expandedTunnelIds,
isLocalLogsEnabled, isLocalLogsEnabled,
isRemoteControlEnabled, isRemoteControlEnabled,
remoteKey, remoteKey,
@@ -35,7 +35,7 @@ data class GeneralState(
isLocationDisclosureShown, isLocationDisclosureShown,
isBatteryOptimizationDisableShown, isBatteryOptimizationDisableShown,
isPinLockEnabled, isPinLockEnabled,
isTunnelStatsExpanded, expandedTunnelIds,
isLocalLogsEnabled, isLocalLogsEnabled,
isRemoteControlEnabled, isRemoteControlEnabled,
remoteKey, remoteKey,
@@ -48,7 +48,6 @@ data class GeneralState(
const val LOCATION_DISCLOSURE_SHOWN_DEFAULT = false const val LOCATION_DISCLOSURE_SHOWN_DEFAULT = false
const val BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT = false const val BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT = false
const val PIN_LOCK_ENABLED_DEFAULT = false const val PIN_LOCK_ENABLED_DEFAULT = false
const val IS_TUNNEL_STATS_EXPANDED = false
const val IS_LOGS_ENABLED_DEFAULT = false const val IS_LOGS_ENABLED_DEFAULT = false
const val IS_REMOTE_CONTROL_ENABLED = false const val IS_REMOTE_CONTROL_ENABLED = false
} }
@@ -0,0 +1,24 @@
package com.zaneschepke.wireguardautotunnel.data.model
import com.zaneschepke.wireguardautotunnel.domain.entity.AppUpdate
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GitHubRelease(
@SerialName("tag_name") val tagName: String,
val name: String?,
val body: String?,
val assets: List<Asset>,
) {
fun toAppUpdate(): AppUpdate {
val apkAsset = assets.firstOrNull { it.name.endsWith(".apk") }
return AppUpdate(
version = tagName.removePrefix("v"),
title = name ?: "Update $tagName",
releaseNotes = body ?: "No release notes provided",
apkUrl = apkAsset?.browserDownloadUrl,
apkFileName = apkAsset?.name,
)
}
}
@@ -0,0 +1,7 @@
package com.zaneschepke.wireguardautotunnel.data.network
import com.zaneschepke.wireguardautotunnel.data.model.GitHubRelease
interface GitHubApi {
suspend fun getLatestRelease(owner: String, repo: String): Result<GitHubRelease>
}
@@ -0,0 +1,28 @@
package com.zaneschepke.wireguardautotunnel.data.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
object KtorClient {
fun create(): HttpClient {
return HttpClient(OkHttp) {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
isLenient = true
}
)
}
install(HttpTimeout) {
requestTimeoutMillis = 15000
connectTimeoutMillis = 15000
socketTimeoutMillis = 15000
}
}
}
}
@@ -0,0 +1,27 @@
package com.zaneschepke.wireguardautotunnel.data.network
import com.zaneschepke.wireguardautotunnel.data.model.GitHubRelease
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.request.get
import io.ktor.http.HttpStatusCode
class KtorGitHubApi(private val client: HttpClient) : GitHubApi {
override suspend fun getLatestRelease(owner: String, repo: String): Result<GitHubRelease> {
return try {
val response: GitHubRelease =
client.get("https://api.github.com/repos/$owner/$repo/releases/latest").body()
Result.success(response)
} catch (e: ClientRequestException) {
when (e.response.status) {
HttpStatusCode.Forbidden -> Result.failure(Exception("Rate limit exceeded"))
HttpStatusCode.NotFound ->
Result.failure(Exception("Repository or release not found"))
else -> Result.failure(e)
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -38,13 +38,36 @@ class DataStoreAppStateRepository(private val dataStoreManager: DataStoreManager
dataStoreManager.saveToDataStore(DataStoreManager.batteryDisableShown, shown) dataStoreManager.saveToDataStore(DataStoreManager.batteryDisableShown, shown)
} }
override suspend fun isTunnelStatsExpanded(): Boolean { override suspend fun setTunnelExpanded(id: Int) {
return dataStoreManager.getFromStore(DataStoreManager.tunnelStatsExpanded) val ids =
?: GeneralState.IS_TUNNEL_STATS_EXPANDED dataStoreManager
.getFromStore(DataStoreManager.expandedTunnelIds)
?.split(",")
?.mapNotNull { it.toIntOrNull() } ?: emptyList()
if (ids.contains(id)) return
val updatedList = ids.toMutableList().apply { add(id) }
dataStoreManager.saveToDataStore(
DataStoreManager.expandedTunnelIds,
updatedList.joinToString(","),
)
} }
override suspend fun setTunnelStatsExpanded(expanded: Boolean) { override suspend fun removeTunnelExpanded(id: Int) {
dataStoreManager.saveToDataStore(DataStoreManager.tunnelStatsExpanded, expanded) val ids =
dataStoreManager
.getFromStore(DataStoreManager.expandedTunnelIds)
?.split(",")
?.mapNotNull { it.toIntOrNull() } ?: emptyList()
if (ids.isEmpty() || !ids.contains(id)) return
val updatedList = ids.toMutableList().apply { remove(id) }
dataStoreManager.saveToDataStore(
DataStoreManager.expandedTunnelIds,
updatedList.joinToString(","),
)
} }
override suspend fun setTheme(theme: Theme) { override suspend fun setTheme(theme: Theme) {
@@ -110,9 +133,10 @@ class DataStoreAppStateRepository(private val dataStoreManager: DataStoreManager
isPinLockEnabled = isPinLockEnabled =
pref[DataStoreManager.pinLockEnabled] pref[DataStoreManager.pinLockEnabled]
?: GeneralState.PIN_LOCK_ENABLED_DEFAULT, ?: GeneralState.PIN_LOCK_ENABLED_DEFAULT,
isTunnelStatsExpanded = expandedTunnelIds =
pref[DataStoreManager.tunnelStatsExpanded] pref[DataStoreManager.expandedTunnelIds]?.split(",")?.mapNotNull {
?: GeneralState.IS_TUNNEL_STATS_EXPANDED, it.toIntOrNull()
} ?: emptyList(),
isLocalLogsEnabled = isLocalLogsEnabled =
pref[DataStoreManager.isLocalLogsEnabled] pref[DataStoreManager.isLocalLogsEnabled]
?: GeneralState.IS_LOGS_ENABLED_DEFAULT, ?: GeneralState.IS_LOGS_ENABLED_DEFAULT,
@@ -0,0 +1,83 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import android.content.Context
import com.zaneschepke.wireguardautotunnel.data.network.GitHubApi
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.AppUpdate
import com.zaneschepke.wireguardautotunnel.domain.repository.UpdateRepository
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.contentLength
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readAvailable
import java.io.File
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
class GitHubUpdateRepository(
private val gitHubApi: GitHubApi,
private val httpClient: HttpClient,
private val githubOwner: String,
private val githubRepo: String,
private val context: Context,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : UpdateRepository {
override suspend fun checkForUpdate(currentVersion: String): Result<AppUpdate?> =
withContext(ioDispatcher) {
gitHubApi.getLatestRelease(githubOwner, githubRepo).map { release ->
if (
NumberUtils.compareVersions(release.tagName.removePrefix("v"), currentVersion) >
0
) {
release.toAppUpdate()
} else {
null
}
}
}
override suspend fun downloadApk(
apkUrl: String,
fileName: String,
onProgress: (Float) -> Unit,
): Result<File> =
withContext(ioDispatcher) {
try {
// clean up old files
context.getExternalFilesDir(null)?.listFiles()?.forEach { file ->
if (file.extension == "apk") file.delete()
}
val response: HttpResponse = httpClient.get(apkUrl)
val apkFile = File(context.getExternalFilesDir(null), fileName)
val channel: ByteReadChannel = response.bodyAsChannel()
val totalBytes: Long = response.contentLength() ?: -1L
var bytesCopied = 0L
apkFile.outputStream().use { output ->
val buffer = ByteArray(8 * 1024)
while (!channel.isClosedForRead) {
val bytesRead = channel.readAvailable(buffer)
if (bytesRead <= 0) break
output.write(buffer, 0, bytesRead)
bytesCopied += bytesRead
if (totalBytes > 0) {
val progress = bytesCopied.toFloat() / totalBytes
onProgress(progress.coerceIn(0f, 1f))
}
}
}
Result.success(apkFile)
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -8,19 +8,25 @@ import com.zaneschepke.wireguardautotunnel.data.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.DatabaseCallback import com.zaneschepke.wireguardautotunnel.data.DatabaseCallback
import com.zaneschepke.wireguardautotunnel.data.dao.SettingsDao import com.zaneschepke.wireguardautotunnel.data.dao.SettingsDao
import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.network.GitHubApi
import com.zaneschepke.wireguardautotunnel.data.network.KtorClient
import com.zaneschepke.wireguardautotunnel.data.network.KtorGitHubApi
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRoomRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRoomRepository
import com.zaneschepke.wireguardautotunnel.data.repository.DataStoreAppStateRepository import com.zaneschepke.wireguardautotunnel.data.repository.DataStoreAppStateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.GitHubUpdateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.RoomSettingsRepository import com.zaneschepke.wireguardautotunnel.data.repository.RoomSettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.RoomTunnelRepository import com.zaneschepke.wireguardautotunnel.data.repository.RoomTunnelRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.UpdateRepository
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import io.ktor.client.HttpClient
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -35,7 +41,7 @@ class RepositoryModule {
AppDatabase::class.java, AppDatabase::class.java,
context.getString(R.string.db_name), context.getString(R.string.db_name),
) )
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration(true)
.addCallback(DatabaseCallback()) .addCallback(DatabaseCallback())
.build() .build()
} }
@@ -94,4 +100,34 @@ class RepositoryModule {
): AppDataRepository { ): AppDataRepository {
return AppDataRoomRepository(settingsRepository, tunnelRepository, appStateRepository) return AppDataRoomRepository(settingsRepository, tunnelRepository, appStateRepository)
} }
@Provides
@Singleton
fun provideHttpClient(): HttpClient {
return KtorClient.create()
}
@Provides
@Singleton
fun provideGitHubApi(client: HttpClient): GitHubApi {
return KtorGitHubApi(client)
}
@Provides
@Singleton
fun provideUpdateRepository(
gitHubApi: GitHubApi,
client: HttpClient,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
@ApplicationContext context: Context,
): UpdateRepository {
return GitHubUpdateRepository(
gitHubApi,
client,
"wgtunnel",
"wgtunnel",
context,
ioDispatcher,
)
}
} }
@@ -6,7 +6,7 @@ data class AppState(
val isLocationDisclosureShown: Boolean, val isLocationDisclosureShown: Boolean,
val isBatteryOptimizationDisableShown: Boolean, val isBatteryOptimizationDisableShown: Boolean,
val isPinLockEnabled: Boolean, val isPinLockEnabled: Boolean,
val isTunnelStatsExpanded: Boolean, val expandedTunnelIds: List<Int>,
val isLocalLogsEnabled: Boolean, val isLocalLogsEnabled: Boolean,
val isRemoteControlEnabled: Boolean, val isRemoteControlEnabled: Boolean,
val remoteKey: String?, val remoteKey: String?,
@@ -0,0 +1,9 @@
package com.zaneschepke.wireguardautotunnel.domain.entity
data class AppUpdate(
val version: String,
val title: String,
val releaseNotes: String,
val apkUrl: String?,
val apkFileName: String?,
)
@@ -3,11 +3,7 @@ package com.zaneschepke.wireguardautotunnel.domain.entity
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.defaultName import com.zaneschepke.wireguardautotunnel.util.extensions.*
import com.zaneschepke.wireguardautotunnel.util.extensions.extractNameAndNumber
import com.zaneschepke.wireguardautotunnel.util.extensions.hasNumberInParentheses
import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
import java.io.InputStream import java.io.InputStream
import java.net.InetAddress import java.net.InetAddress
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@@ -30,6 +26,7 @@ data class TunnelConf(
val pingIp: String? = null, val pingIp: String? = null,
val isEthernetTunnel: Boolean = false, val isEthernetTunnel: Boolean = false,
val isIpv4Preferred: Boolean = true, val isIpv4Preferred: Boolean = true,
val useCache: Boolean = false,
@Transient private var stateChangeCallback: ((Any) -> Unit)? = null, @Transient private var stateChangeCallback: ((Any) -> Unit)? = null,
) : Tunnel, org.amnezia.awg.backend.Tunnel { ) : Tunnel, org.amnezia.awg.backend.Tunnel {
@@ -95,20 +92,8 @@ data class TunnelConf(
isEthernetTunnel, isEthernetTunnel,
isIpv4Preferred, isIpv4Preferred,
) )
.apply { .apply { stateChangeCallback = this@TunnelConf.stateChangeCallback }
stateChangeCallback = this@TunnelConf.stateChangeCallback
// tunnelStatsCallback = this@TunnelConf.tunnelStatsCallback
// bounceTunnelCallback = this@TunnelConf.bounceTunnelCallback
} }
}
// fun onUpdateStatistics() {
// tunnelStatsCallback?.invoke()
// }
//
// fun bounceTunnel(tunnelConf: TunnelConf, reason: TunnelStatus.StopReason) {
// bounceTunnelCallback?.invoke(tunnelConf, reason)
// }
fun toAmConfig(): org.amnezia.awg.config.Config { fun toAmConfig(): org.amnezia.awg.config.Config {
return configFromAmQuick(amQuick.ifBlank { wgQuick }) return configFromAmQuick(amQuick.ifBlank { wgQuick })
@@ -122,6 +107,8 @@ data class TunnelConf(
override fun isIpv4ResolutionPreferred(): Boolean = isIpv4Preferred override fun isIpv4ResolutionPreferred(): Boolean = isIpv4Preferred
override fun useCache(): Boolean = useCache
override fun onStateChange(newState: org.amnezia.awg.backend.Tunnel.State) { override fun onStateChange(newState: org.amnezia.awg.backend.Tunnel.State) {
stateChangeCallback?.invoke(newState) stateChangeCallback?.invoke(newState)
} }
@@ -130,12 +117,6 @@ data class TunnelConf(
stateChangeCallback?.invoke(newState) stateChangeCallback?.invoke(newState)
} }
fun isTunnelConfigChanged(updatedConf: TunnelConf): Boolean {
return updatedConf.wgQuick != wgQuick ||
updatedConf.amQuick != amQuick ||
updatedConf.name != name
}
fun generateUniqueName(tunnelNames: List<String>): String { fun generateUniqueName(tunnelNames: List<String>): String {
var tunnelName = this.tunName var tunnelName = this.tunName
var num = 1 var num = 1
@@ -17,9 +17,9 @@ interface AppStateRepository {
suspend fun setBatteryOptimizationDisableShown(shown: Boolean) suspend fun setBatteryOptimizationDisableShown(shown: Boolean)
suspend fun isTunnelStatsExpanded(): Boolean suspend fun setTunnelExpanded(id: Int)
suspend fun setTunnelStatsExpanded(expanded: Boolean) suspend fun removeTunnelExpanded(id: Int)
suspend fun setTheme(theme: Theme) suspend fun setTheme(theme: Theme)
@@ -0,0 +1,14 @@
package com.zaneschepke.wireguardautotunnel.domain.repository
import com.zaneschepke.wireguardautotunnel.domain.entity.AppUpdate
import java.io.File
interface UpdateRepository {
suspend fun checkForUpdate(currentVersion: String): Result<AppUpdate?>
suspend fun downloadApk(
apkUrl: String,
fileName: String,
onProgress: (Float) -> Unit,
): Result<File>
}
@@ -31,6 +31,8 @@ sealed class Route {
@Serializable data object Scanner : Route() @Serializable data object Scanner : Route()
@Serializable data object License : Route()
@Serializable data class Config(val id: Int) : Route() @Serializable data class Config(val id: Int) : Route()
@Serializable @Serializable
@@ -3,39 +3,73 @@ package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.indication
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ExpandingRowListItem( fun ExpandingRowListItem(
leading: @Composable () -> Unit, leading: @Composable () -> Unit,
text: String, text: String,
onHold: () -> Unit = {}, onHold: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
onDoubleClick: () -> Unit,
trailing: @Composable () -> Unit, trailing: @Composable () -> Unit,
isExpanded: Boolean, isSelected: Boolean,
expanded: @Composable () -> Unit = {}, expanded: (@Composable () -> Unit)?,
) { ) {
val isTv = LocalIsAndroidTV.current
val haptic = LocalHapticFeedback.current
val interactionSource = remember { MutableInteractionSource() }
Box( Box(
modifier = modifier =
Modifier.animateContentSize() Modifier.animateContentSize()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.combinedClickable(onClick = { onClick() }, onLongClick = { onHold() }) .then(
if (!isTv) {
Modifier.combinedClickable(
onClick = onClick,
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onHold()
},
onDoubleClick = onDoubleClick,
)
.indication(
interactionSource = interactionSource,
indication = ripple(),
)
} else Modifier
)
) { ) {
LaunchedEffect(isSelected) {
if (isSelected) {
interactionSource.emit(PressInteraction.Press(Offset.Zero))
} else {
interactionSource.emit(
PressInteraction.Release(PressInteraction.Press(Offset.Zero))
)
}
}
Column { Column {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
@@ -58,7 +92,7 @@ fun ExpandingRowListItem(
} }
trailing() trailing()
} }
if (isExpanded) expanded() expanded?.invoke()
} }
} }
} }
@@ -0,0 +1,16 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun SectionDivider() {
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(0.30f),
modifier = Modifier.padding(horizontal = 12.dp),
)
}
@@ -3,21 +3,25 @@ package com.zaneschepke.wireguardautotunnel.ui.common.button.surface
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.Modifier
@Composable @Composable
fun SelectionItemLabel( fun SelectionItemLabel(text: String, labelType: SelectionLabelType, modifier: Modifier = Modifier) {
textResId: Int,
style: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.bodyMedium, val style =
isDescription: Boolean = false, when (labelType) {
) { SelectionLabelType.DESCRIPTION ->
Text( MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)
text = stringResource(textResId), SelectionLabelType.TITLE ->
style = MaterialTheme.typography.bodyMedium.copy(
style.copy( color = MaterialTheme.colorScheme.onSurface
color =
if (isDescription) MaterialTheme.colorScheme.outline
else MaterialTheme.colorScheme.onSurface
),
) )
} }
Text(text = text, style = style, modifier = modifier)
}
enum class SelectionLabelType {
DESCRIPTION,
TITLE,
}
@@ -9,21 +9,23 @@ import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import timber.log.Timber
@Composable @Composable
fun rememberFileImportLauncherForResult( fun rememberFileImportLauncherForResult(
onNoFileExplorer: () -> Unit, onNoFileExplorer: () -> Unit,
onData: (data: Uri) -> Unit, onData: (data: Uri) -> Unit,
): ManagedActivityResultLauncher<String, Uri?> { ): ManagedActivityResultLauncher<String, Uri?> {
val isTv = LocalIsAndroidTV.current
return rememberLauncherForActivityResult( return rememberLauncherForActivityResult(
object : ActivityResultContracts.GetContent() { object : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent { override fun createIntent(context: Context, input: String): Intent {
val intent = val intent =
super.createIntent(context, input).apply { super.createIntent(context, input).apply {
type = type =
if (context.isRunningOnTv()) { if (isTv) {
Constants.ALLOWED_TV_FILE_TYPES Constants.ALLOWED_TV_FILE_TYPES
} else { } else {
Constants.ALL_FILE_TYPES Constants.ALL_FILE_TYPES
@@ -63,3 +65,43 @@ fun rememberFileImportLauncherForResult(
onData(data) onData(data)
} }
} }
@Composable
fun rememberFileExportLauncherForResult(
mimeType: String = Constants.ZIP_FILE_MIME_TYPE,
onResult: (Uri?) -> Unit,
): ManagedActivityResultLauncher<String, Uri?> {
val isTv = LocalIsAndroidTV.current
return rememberLauncherForActivityResult(
contract =
object : ActivityResultContracts.CreateDocument(mimeType) {
override fun createIntent(context: Context, input: String): Intent {
super.createIntent(context, input)
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type =
if (isTv) {
Constants.ALLOWED_TV_FILE_TYPES
} else {
mimeType
}
putExtra(Intent.EXTRA_TITLE, input)
}
Timber.d("Returning SAF intent for launch")
return intent
}
}
) { uri ->
Timber.d("SAF onResult called with Uri: $uri")
if (uri != null) {
Timber.d(
"Uri details: scheme=${uri.scheme}, authority=${uri.authority}, path=${uri.path}"
)
} else {
Timber.d("SAF picker canceled or failed to return a Uri")
}
onResult(uri)
}
}
@@ -1,115 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
@Composable
fun BottomBarTabs(
tabs: List<BottomNavItem>,
selectedTabIndex: Int,
isChildRoute: Boolean,
onTabSelected: (BottomNavItem) -> Unit,
) {
val context = LocalContext.current
val isRunningOnTv = remember { context.isRunningOnTv() }
Row(
modifier =
Modifier.fillMaxWidth().height(64.dp).padding(horizontal = 8.dp).padding(top = 12.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
tabs.forEachIndexed { index, tab ->
Column(
modifier =
Modifier.weight(1f)
.fillMaxHeight()
.background(Color.Transparent)
.then(
if (isRunningOnTv) {
Modifier.clickable {
if (index == selectedTabIndex && !isChildRoute) return@clickable
tab.onClick.invoke()
onTabSelected(tab)
}
} else {
Modifier
}
)
.pointerInput(Unit) {
detectTapGestures {
if (index == selectedTabIndex && !isChildRoute)
return@detectTapGestures
tab.onClick.invoke()
onTabSelected(tab)
}
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
val animatedColor by
animateColorAsState(
targetValue = MaterialTheme.colorScheme.primary,
animationSpec = spring(stiffness = Spring.StiffnessLow),
label = "animatedColor",
)
val color =
if (selectedTabIndex == index) animatedColor
else MaterialTheme.colorScheme.onSurface
if (tab.active) {
BadgedBox(
badge = {
Badge(
modifier = Modifier.offset(x = 8.dp, y = ((-8).dp)).size(6.dp),
containerColor = SilverTree,
)
}
) {
Icon(
imageVector = tab.icon,
contentDescription = tab.name,
tint = color,
modifier = Modifier.size(24.dp),
)
}
} else {
Icon(
imageVector = tab.icon,
contentDescription = tab.name,
tint = color,
modifier = Modifier.size(24.dp),
)
}
}
}
}
}
@@ -1,121 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.Route
@Composable
fun CustomBottomNavbar(tabs: List<BottomNavItem>, navBarState: NavBarState) {
var selectedTabIndex by remember { mutableIntStateOf(0) }
var isChildRoute by remember { mutableStateOf(false) }
LaunchedEffect(tabs) {}
when (navBarState.route) {
Route.Main -> {
selectedTabIndex = 0
isChildRoute = false
}
Route.AutoTunnel -> {
selectedTabIndex = 1
isChildRoute = false
}
Route.Settings -> {
selectedTabIndex = 2
isChildRoute = false
}
Route.Support -> {
selectedTabIndex = 3
isChildRoute = false
}
else -> isChildRoute = true
}
val systemBars = WindowInsets.systemBars
val bottomPadding = with(LocalDensity.current) { systemBars.getBottom(this).toDp() }
val navHeight = 64.dp + bottomPadding
Box(modifier = Modifier.fillMaxWidth().height(navHeight).background(Color.Transparent)) {
BottomBarTabs(
tabs = tabs,
selectedTabIndex = selectedTabIndex,
isChildRoute = isChildRoute,
onTabSelected = { selectedTabIndex = tabs.indexOf(it) },
)
val animatedSelectedTabIndex by
animateFloatAsState(
targetValue = selectedTabIndex.toFloat(),
label = "animatedSelectedTabIndex",
animationSpec =
spring(
stiffness = Spring.StiffnessLow,
dampingRatio = Spring.DampingRatioLowBouncy,
),
)
val animatedColor by
animateColorAsState(
targetValue = MaterialTheme.colorScheme.primary,
label = "animatedColor",
animationSpec = spring(stiffness = Spring.StiffnessLow),
)
Canvas(modifier = Modifier.fillMaxWidth().height(navHeight)) {
val path =
Path().apply { addRoundRect(RoundRect(size.toRect(), CornerRadius(size.height))) }
val length = PathMeasure().apply { setPath(path, false) }.length
val tabWidth = size.width / tabs.size
drawPath(
path,
brush =
Brush.horizontalGradient(
colors =
listOf(
animatedColor.copy(alpha = 0f),
animatedColor.copy(alpha = 1f),
animatedColor.copy(alpha = 1f),
animatedColor.copy(alpha = 0f),
),
startX = tabWidth * animatedSelectedTabIndex,
endX = tabWidth * (animatedSelectedTabIndex + 1),
),
style =
Stroke(
width = 4f,
pathEffect =
PathEffect.dashPathEffect(intervals = floatArrayOf(length / 2, length)),
),
)
}
}
}
@@ -1,7 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.runtime.compositionLocalOf
import androidx.navigation.NavHostController
val LocalNavController =
compositionLocalOf<NavHostController> { error("NavController was not provided") }
@@ -19,11 +19,10 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
@Composable @Composable
fun CustomSnackBar( fun CustomSnackBar(
@@ -31,12 +30,10 @@ fun CustomSnackBar(
isRtl: Boolean = true, isRtl: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.surface, containerColor: Color = MaterialTheme.colorScheme.surface,
) { ) {
val context = LocalContext.current val isTv = LocalIsAndroidTV.current
Snackbar( Snackbar(
containerColor = containerColor, containerColor = containerColor,
modifier = modifier = Modifier.fillMaxWidth(if (isTv) 1 / 3f else 2 / 3f).padding(bottom = 100.dp),
Modifier.fillMaxWidth(if (context.isRunningOnTv()) 1 / 3f else 2 / 3f)
.padding(bottom = 100.dp),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
@@ -1,7 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.enums
enum class InterfaceActions {
TOGGLE_AMNEZIA_VALUES,
SET_AMNEZIA_COMPATIBILITY,
TOGGLE_SHOW_SCRIPTS,
}
@@ -1,5 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.enums
enum class PeerActions {
EXCLUDE_LAN
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation package com.zaneschepke.wireguardautotunnel.ui.navigation
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
@@ -1,9 +1,12 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation package com.zaneschepke.wireguardautotunnel.ui.navigation
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavHostController
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import kotlin.reflect.KClass import kotlin.reflect.KClass
@@ -11,3 +14,7 @@ import kotlin.reflect.KClass
fun <T : Route> NavBackStackEntry?.isCurrentRoute(cls: KClass<T>): Boolean { fun <T : Route> NavBackStackEntry?.isCurrentRoute(cls: KClass<T>): Boolean {
return this?.destination?.hierarchy?.any { it.hasRoute(route = cls) } == true return this?.destination?.hierarchy?.any { it.hasRoute(route = cls) } == true
} }
val LocalNavController =
compositionLocalOf<NavHostController> { error("NavController was not provided") }
val LocalIsAndroidTV = staticCompositionLocalOf { false }
@@ -0,0 +1,120 @@
package com.zaneschepke.wireguardautotunnel.ui.navigation.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.currentBackStackEntryAsState
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.navigation.BottomNavItem
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.navigation.isCurrentRoute
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BottomNavbar(appUiState: AppUiState) {
val navController = LocalNavController.current
val isTv = LocalIsAndroidTV.current
val navBackStackEntry by navController.currentBackStackEntryAsState()
val items =
listOf(
BottomNavItem(
name = stringResource(R.string.tunnels),
route = Route.Main,
icon = Icons.Rounded.Home,
onClick = { navController.goFromRoot(Route.Main) },
),
BottomNavItem(
name = stringResource(R.string.auto_tunnel),
route = Route.AutoTunnel,
icon = Icons.Rounded.Bolt,
onClick = {
val route =
if (appUiState.appState.isLocationDisclosureShown) Route.AutoTunnel
else Route.LocationDisclosure
navController.goFromRoot(route)
},
active = appUiState.isAutoTunnelActive,
),
BottomNavItem(
name = stringResource(R.string.settings),
route = Route.Settings,
icon = Icons.Rounded.Settings,
onClick = { navController.goFromRoot(Route.Settings) },
),
BottomNavItem(
name = stringResource(R.string.support),
route = Route.Support,
icon = Icons.Rounded.QuestionMark,
onClick = { navController.goFromRoot(Route.Support) },
),
)
// Define ripple configuration based on platform
val rippleConfiguration =
if (isTv) {
RippleConfiguration()
} else {
null
}
// Apply ripple configuration only if needed
CompositionLocalProvider(LocalRippleConfiguration provides rippleConfiguration) {
NavigationBar(containerColor = MaterialTheme.colorScheme.surface) {
items.forEach { item ->
val isSelected = navBackStackEntry.isCurrentRoute(item.route::class)
val interactionSource = remember { MutableInteractionSource() }
NavigationBarItem(
icon = {
if (item.active) {
BadgedBox(
badge = {
Badge(
modifier =
Modifier.offset(x = 8.dp, y = (-8).dp).size(6.dp),
containerColor = SilverTree,
)
}
) {
Icon(imageVector = item.icon, contentDescription = item.name)
}
} else {
Icon(imageVector = item.icon, contentDescription = item.name)
}
},
onClick = { navController.goFromRoot(item.route) },
selected = isSelected,
enabled = true,
label = null,
alwaysShowLabel = false,
colors =
NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onBackground,
indicatorColor = Color.Transparent,
),
interactionSource = interactionSource,
)
}
}
}
}
@@ -1,10 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation package com.zaneschepke.wireguardautotunnel.ui.navigation.components
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.*
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -14,6 +10,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.state.NavBarState
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -1,12 +1,18 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation package com.zaneschepke.wireguardautotunnel.ui.navigation.components
import android.os.Build
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.CopyAll
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Save import androidx.compose.material.icons.rounded.Save
import androidx.compose.material.icons.rounded.SelectAll
import androidx.compose.material.icons.rounded.Stop import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -15,80 +21,126 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.navigation.isCurrentRoute
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState
import com.zaneschepke.wireguardautotunnel.ui.state.NavBarState
import com.zaneschepke.wireguardautotunnel.ui.theme.Brick import com.zaneschepke.wireguardautotunnel.ui.theme.Brick
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
data class NavBarState(
val showTop: Boolean = true,
val showBottom: Boolean = true,
val topTitle: @Composable (() -> Unit)? = null,
val topTrailing: @Composable (() -> Unit)? = null,
val route: Route? = null,
)
@Composable @Composable
fun currentNavBackStackEntryAsNavBarState( fun currentNavBackStackEntryAsNavBarState(
navController: NavController, navController: NavController,
backStackEntry: NavBackStackEntry?, backStackEntry: NavBackStackEntry?,
viewModel: AppViewModel, viewModel: AppViewModel,
uiState: AppUiState, uiState: AppUiState,
appViewState: AppViewState,
): State<NavBarState> { ): State<NavBarState> {
return produceState(initialValue = NavBarState(), key1 = backStackEntry, key2 = uiState) { fun isActiveSelected() =
uiState.activeTunnels.any { active ->
appViewState.selectedTunnels.any { it.id == active.key.id }
}
@Composable
fun ActionIconButton(icon: ImageVector, labelRes: Int, onClick: () -> Unit) {
IconButton(onClick = onClick) {
Icon(
icon,
contentDescription = stringResource(labelRes),
modifier = Modifier.size(iconSize),
)
}
}
@Composable
fun TunnelActionBar() {
val selectedCount = appViewState.selectedTunnels.size
val showDelete = !isActiveSelected()
Row {
if (selectedCount == 0) {
ActionIconButton(Icons.Rounded.Add, R.string.add_tunnel) {
viewModel.handleEvent(
AppEvent.SetBottomSheet(AppViewState.BottomSheet.IMPORT_TUNNELS)
)
}
} else {
ActionIconButton(Icons.Rounded.SelectAll, R.string.select_all) {
viewModel.handleEvent(AppEvent.ToggleSelectAllTunnels)
}
// due to permissions, and SAF issues on TV, not support less than Android 10 on
// Android TV for file exports
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ActionIconButton(Icons.Rounded.Download, R.string.download) {
viewModel.handleEvent(
AppEvent.SetBottomSheet(AppViewState.BottomSheet.EXPORT_TUNNELS)
)
}
}
if (selectedCount == 1) {
ActionIconButton(Icons.Rounded.CopyAll, R.string.copy) {
viewModel.handleEvent(AppEvent.CopySelectedTunnel)
}
}
if (showDelete) {
ActionIconButton(Icons.Rounded.Delete, R.string.delete_tunnel) {
viewModel.handleEvent(AppEvent.SetShowModal(AppViewState.ModalType.DELETE))
}
}
}
}
}
return produceState(
initialValue = NavBarState(),
key1 = backStackEntry,
key2 = uiState,
key3 = appViewState,
) {
value = value =
when { when {
backStackEntry.isCurrentRoute(Route.Main::class) -> { backStackEntry.isCurrentRoute(Route.Main::class) -> {
NavBarState( NavBarState(
showTop = true, showTop = true,
showBottom = true, showBottom = true,
{ Text(stringResource(R.string.tunnels)) }, topTitle = { Text(stringResource(R.string.tunnels)) },
{ topTrailing = { TunnelActionBar() },
IconButton(
onClick = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) }
) {
val icon = Icons.Rounded.Add
Icon(
icon,
stringResource(R.string.add_tunnel),
modifier = Modifier.size(iconSize),
)
}
},
route = Route.Main, route = Route.Main,
) )
} }
backStackEntry.isCurrentRoute(Route.AutoTunnel::class) -> { backStackEntry.isCurrentRoute(Route.AutoTunnel::class) -> {
NavBarState( val (icon, label, tint) =
showTop = true,
showBottom = true,
{ Text(stringResource(R.string.auto_tunnel)) },
{
IconButton(
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnel) }
) {
val (icon, description, color) =
if (uiState.appSettings.isAutoTunnelEnabled) { if (uiState.appSettings.isAutoTunnelEnabled) {
Triple(Icons.Rounded.Stop, R.string.stop_auto, Brick) Triple(Icons.Rounded.Stop, R.string.stop_auto, Brick)
} else { } else {
Triple( Triple(Icons.Rounded.PlayArrow, R.string.start_auto, SilverTree)
Icons.Rounded.PlayArrow,
R.string.start_auto,
SilverTree,
)
} }
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.auto_tunnel)) },
topTrailing = {
IconButton(
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnel) }
) {
Icon( Icon(
icon, icon,
stringResource(description), stringResource(label),
tint = color, tint = tint,
modifier = Modifier.size(iconSize), modifier = Modifier.size(iconSize),
) )
} }
@@ -96,175 +148,152 @@ fun currentNavBackStackEntryAsNavBarState(
route = Route.AutoTunnel, route = Route.AutoTunnel,
) )
} }
backStackEntry.isCurrentRoute(Route.AutoTunnelAdvanced::class) ||
backStackEntry.isCurrentRoute(Route.SettingsAdvanced::class) -> {
NavBarState(
showTop = true,
showBottom = true,
{ Text(stringResource(R.string.advanced_settings)) },
route = Route.AutoTunnelAdvanced,
)
}
backStackEntry.isCurrentRoute(Route.Settings::class) -> {
NavBarState(
showTop = true,
showBottom = true,
{ Text(stringResource(R.string.settings)) },
{
IconButton(
onClick = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) }
) {
val icon = Icons.Rounded.Menu
Icon(
icon,
stringResource(R.string.quick_actions),
modifier = Modifier.size(iconSize),
)
}
},
route = Route.Settings,
)
}
backStackEntry.isCurrentRoute(Route.KillSwitch::class) -> {
NavBarState(
showTop = true,
showBottom = true,
{ Text(stringResource(R.string.kill_switch)) },
route = Route.KillSwitch,
)
}
backStackEntry.isCurrentRoute(Route.Appearance::class) -> {
NavBarState(
showTop = true,
showBottom = true,
{ Text(stringResource(R.string.appearance)) },
route = Route.Appearance,
)
}
backStackEntry.isCurrentRoute(Route.Language::class) -> {
NavBarState(
showTop = true,
showBottom = true,
{ Text(stringResource(R.string.language)) },
route = Route.Language,
)
}
backStackEntry.isCurrentRoute(Route.Display::class) -> {
NavBarState(
showTop = true,
showBottom = true,
{ Text(stringResource(R.string.display_theme)) },
route = Route.Display,
)
}
backStackEntry.isCurrentRoute(Route.Logs::class) -> { backStackEntry.isCurrentRoute(Route.Logs::class) -> {
NavBarState( NavBarState(
showTop = true, showTop = true,
showBottom = false, showBottom = false,
{ Text(stringResource(R.string.logs)) }, topTitle = { Text(stringResource(R.string.logs)) },
{ topTrailing = {
IconButton( ActionIconButton(Icons.Rounded.Menu, R.string.quick_actions) {
onClick = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) } viewModel.handleEvent(
) { AppEvent.SetBottomSheet(AppViewState.BottomSheet.LOGS)
val icon = Icons.Rounded.Menu
Icon(
icon,
stringResource(R.string.quick_actions),
modifier = Modifier.size(iconSize),
) )
} }
}, },
route = Route.Logs, route = Route.Logs,
) )
} }
backStackEntry.isCurrentRoute(Route.TunnelOptions::class) -> {
val args = backStackEntry?.toRoute<Route.TunnelOptions>() backStackEntry.isCurrentRoute(Route.Settings::class) ->
val tunnel = uiState.tunnels.find { it.id == args?.id }
NavBarState( NavBarState(
showTop = true, showTop = true,
showBottom = true, showBottom = true,
{ tunnel?.name?.let { Text(it) } }, topTitle = { Text(stringResource(R.string.settings)) },
{ route = Route.Settings,
IconButton(
onClick = {
tunnel?.id?.let {
navController.navigate(Route.Config(id = it))
}
}
) {
val icon = Icons.Rounded.Edit
Icon(
icon,
stringResource(R.string.edit_tunnel),
modifier = Modifier.size(iconSize),
) )
backStackEntry.isCurrentRoute(Route.Appearance::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.appearance)) },
route = Route.Appearance,
)
backStackEntry.isCurrentRoute(Route.Language::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.language)) },
route = Route.Language,
)
backStackEntry.isCurrentRoute(Route.Display::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.display_theme)) },
route = Route.Display,
)
backStackEntry.isCurrentRoute(Route.KillSwitch::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.kill_switch)) },
route = Route.KillSwitch,
)
backStackEntry.isCurrentRoute(Route.Support::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.support)) },
route = Route.Support,
)
backStackEntry.isCurrentRoute(Route.License::class) -> {
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.licenses)) },
route = Route.License,
)
}
backStackEntry.isCurrentRoute(Route.AutoTunnelAdvanced::class) ||
backStackEntry.isCurrentRoute(Route.SettingsAdvanced::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.advanced_settings)) },
route = Route.AutoTunnelAdvanced,
)
backStackEntry.isCurrentRoute(Route.TunnelOptions::class) -> {
val args = backStackEntry?.toRoute<Route.TunnelOptions>()
val tunnel = uiState.tunnels.find { it.id == args?.id }
NavBarState(
showTop = true,
showBottom = true,
topTitle = { tunnel?.name?.let { Text(it) } },
topTrailing = {
ActionIconButton(Icons.Rounded.Edit, R.string.edit_tunnel) {
tunnel?.id?.let { navController.navigate(Route.Config(it)) }
} }
}, },
route = args?.let { Route.TunnelOptions(it.id) }, route = args?.let { Route.TunnelOptions(it.id) },
) )
} }
backStackEntry.isCurrentRoute(Route.SplitTunnel::class) -> { backStackEntry.isCurrentRoute(Route.SplitTunnel::class) -> {
val args = backStackEntry?.toRoute<Route.SplitTunnel>() val args = backStackEntry?.toRoute<Route.SplitTunnel>()
val name = uiState.tunnels.find { it.id == args?.id }?.name val name = uiState.tunnels.find { it.id == args?.id }?.name
NavBarState( NavBarState(
showTop = true, showTop = true,
showBottom = true, showBottom = true,
{ name?.let { Text(it) } }, topTitle = { name?.let { Text(it) } },
{ topTrailing = {
IconButton( ActionIconButton(Icons.Rounded.Save, R.string.save) {
onClick = { viewModel.handleEvent(AppEvent.InvokeScreenAction) } viewModel.handleEvent(AppEvent.InvokeScreenAction)
) {
val icon = Icons.Rounded.Save
Icon(
icon,
stringResource(R.string.save),
modifier = Modifier.size(iconSize),
)
} }
}, },
route = args?.let { Route.SplitTunnel(it.id) }, route = args?.let { Route.SplitTunnel(it.id) },
) )
} }
backStackEntry.isCurrentRoute(Route.Config::class) -> { backStackEntry.isCurrentRoute(Route.Config::class) -> {
val args = backStackEntry?.toRoute<Route.Config>() val args = backStackEntry?.toRoute<Route.Config>()
val name = uiState.tunnels.find { it.id == args?.id }?.name val name = uiState.tunnels.find { it.id == args?.id }?.name
NavBarState( NavBarState(
showTop = true, showTop = true,
showBottom = true, showBottom = true,
{ name?.let { Text(it) } }, topTitle = { name?.let { Text(it) } },
{ topTrailing = {
IconButton( ActionIconButton(Icons.Rounded.Save, R.string.save) {
onClick = { viewModel.handleEvent(AppEvent.InvokeScreenAction) } viewModel.handleEvent(AppEvent.InvokeScreenAction)
) {
val icon = Icons.Rounded.Save
Icon(
icon,
stringResource(R.string.save),
modifier = Modifier.size(iconSize),
)
} }
}, },
route = args?.let { Route.Config(it.id) }, route = args?.let { Route.Config(it.id) },
) )
} }
backStackEntry.isCurrentRoute(Route.TunnelAutoTunnel::class) -> { backStackEntry.isCurrentRoute(Route.TunnelAutoTunnel::class) -> {
val args = backStackEntry?.toRoute<Route.TunnelAutoTunnel>() val args = backStackEntry?.toRoute<Route.TunnelAutoTunnel>()
val name = uiState.tunnels.find { it.id == args?.id }?.name val name = uiState.tunnels.find { it.id == args?.id }?.name
NavBarState( NavBarState(
showTop = true, showTop = true,
showBottom = true, showBottom = true,
{ name?.let { Text(it) } }, topTitle = { name?.let { Text(it) } },
route = args?.let { Route.TunnelAutoTunnel(it.id) }, route = args?.let { Route.TunnelAutoTunnel(it.id) },
) )
} }
backStackEntry.isCurrentRoute(Route.Support::class) -> {
NavBarState(
showTop = true,
showBottom = true,
{ Text(stringResource(R.string.support)) },
route = Route.Support,
)
}
else -> NavBarState(showTop = false, showBottom = false) else -> NavBarState(showTop = false, showBottom = false)
} }
} }
@@ -8,8 +8,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -24,8 +22,10 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.AdvancedSettingsItem import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.AdvancedSettingsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.NetworkTunnelingItems import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.NetworkTunnelingItems
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.WifiTunnelingItems import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.WifiTunnelingItems
@@ -33,7 +33,6 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.Backgr
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocationServicesDialog import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocationServicesDialog
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.isLocationServicesEnabled import com.zaneschepke.wireguardautotunnel.util.extensions.isLocationServicesEnabled
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@@ -41,6 +40,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
fun AutoTunnelScreen(uiState: AppUiState, viewModel: AppViewModel) { fun AutoTunnelScreen(uiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current val context = LocalContext.current
val navController = LocalNavController.current val navController = LocalNavController.current
val isTv = LocalIsAndroidTV.current
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") } var currentText by remember { mutableStateOf("") }
var isBackgroundLocationGranted by remember { mutableStateOf(true) } var isBackgroundLocationGranted by remember { mutableStateOf(true) }
@@ -67,12 +67,11 @@ fun AutoTunnelScreen(uiState: AppUiState, viewModel: AppViewModel) {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) checkFineLocationGranted() if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) checkFineLocationGranted()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (context.isRunningOnTv() && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { if (isTv && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
checkFineLocationGranted() checkFineLocationGranted()
} else { } else {
val backgroundLocationState = val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION) rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
isBackgroundLocationGranted = backgroundLocationState.status.isGranted
} }
} }
@@ -109,9 +108,9 @@ fun AutoTunnelScreen(uiState: AppUiState, viewModel: AppViewModel) {
{ isWifiNameReadable() }, { isWifiNameReadable() },
) )
) )
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.30f)) SectionDivider()
SurfaceSelectionGroupButton(items = NetworkTunnelingItems(uiState, viewModel)) SurfaceSelectionGroupButton(items = NetworkTunnelingItems(uiState, viewModel))
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.30f)) SectionDivider()
SurfaceSelectionGroupButton( SurfaceSelectionGroupButton(
items = items =
listOf( listOf(
@@ -20,7 +20,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -28,7 +27,7 @@ import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.ClickableIconButton import com.zaneschepke.wireguardautotunnel.ui.common.button.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@@ -40,7 +39,7 @@ fun TrustedNetworkTextBox(
onValueChange: (network: String) -> Unit, onValueChange: (network: String) -> Unit,
supporting: @Composable () -> Unit, supporting: @Composable () -> Unit,
) { ) {
val context = LocalContext.current val isTv = LocalIsAndroidTV.current
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
FlowRow( FlowRow(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -49,7 +48,7 @@ fun TrustedNetworkTextBox(
trustedNetworks.forEach { ssid -> trustedNetworks.forEach { ssid ->
ClickableIconButton( ClickableIconButton(
onClick = { onClick = {
if (context.isRunningOnTv()) { if (isTv) {
onDelete(ssid) onDelete(ssid)
} }
}, },
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -11,10 +11,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure.components.AppSettingsItem import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.AppSettingsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure.components.LocationDisclosureHeader import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.LocationDisclosureHeader
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure.components.SkipItem import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.SkipItem
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure.components package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.LocationOn
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure.components package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure.components package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -5,11 +5,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
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.Modifier
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -18,7 +14,8 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ExportTunnelsBottomSheet
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelList import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelList
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.UrlImportDialog import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.UrlImportDialog
@@ -34,7 +31,6 @@ fun MainScreen(appUiState: AppUiState, appViewState: AppViewState, viewModel: Ap
val navController = LocalNavController.current val navController = LocalNavController.current
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) }
var showUrlImportDialog by remember { mutableStateOf(false) } var showUrlImportDialog by remember { mutableStateOf(false) }
val tunnelFileImportResultLauncher = val tunnelFileImportResultLauncher =
@@ -63,13 +59,14 @@ fun MainScreen(appUiState: AppUiState, appViewState: AppViewState, viewModel: Ap
navController.navigate(Route.Scanner) navController.navigate(Route.Scanner)
} }
if (showDeleteTunnelAlertDialog && appViewState.selectedTunnel != null) { if (appViewState.showModal == AppViewState.ModalType.DELETE) {
InfoDialog( InfoDialog(
onDismiss = { showDeleteTunnelAlertDialog = false }, onDismiss = {
viewModel.handleEvent(AppEvent.SetShowModal(AppViewState.ModalType.NONE))
},
onAttest = { onAttest = {
appViewState.selectedTunnel.let { viewModel.handleEvent(AppEvent.DeleteTunnel(it)) } viewModel.handleEvent(AppEvent.DeleteSelectedTunnels)
showDeleteTunnelAlertDialog = false viewModel.handleEvent(AppEvent.SetShowModal(AppViewState.ModalType.NONE))
viewModel.handleEvent(AppEvent.SetSelectedTunnel(null))
}, },
title = { Text(text = stringResource(R.string.delete_tunnel)) }, title = { Text(text = stringResource(R.string.delete_tunnel)) },
body = { Text(text = stringResource(R.string.delete_tunnel_message)) }, body = { Text(text = stringResource(R.string.delete_tunnel_message)) },
@@ -77,11 +74,21 @@ fun MainScreen(appUiState: AppUiState, appViewState: AppViewState, viewModel: Ap
) )
} }
when (appViewState.bottomSheet) {
AppViewState.BottomSheet.EXPORT_TUNNELS -> {
ExportTunnelsBottomSheet(viewModel)
}
AppViewState.BottomSheet.IMPORT_TUNNELS -> {
TunnelImportSheet( TunnelImportSheet(
appViewState.showBottomSheet, onDismiss = {
onDismiss = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) }, viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE))
onFileClick = { tunnelFileImportResultLauncher.launch(Constants.ALLOWED_TV_FILE_TYPES) }, },
onQrClick = { requestPermissionLauncher.launch(android.Manifest.permission.CAMERA) }, onFileClick = {
tunnelFileImportResultLauncher.launch(Constants.ALLOWED_TV_FILE_TYPES)
},
onQrClick = {
requestPermissionLauncher.launch(android.Manifest.permission.CAMERA)
},
onClipboardClick = { onClipboardClick = {
clipboard.getText()?.text?.let { clipboard.getText()?.text?.let {
viewModel.handleEvent(AppEvent.ImportTunnelFromClipboard(it)) viewModel.handleEvent(AppEvent.ImportTunnelFromClipboard(it))
@@ -92,6 +99,9 @@ fun MainScreen(appUiState: AppUiState, appViewState: AppViewState, viewModel: Ap
}, },
onUrlClick = { showUrlImportDialog = true }, onUrlClick = { showUrlImportDialog = true },
) )
}
else -> Unit
}
if (showUrlImportDialog) { if (showUrlImportDialog) {
UrlImportDialog( UrlImportDialog(
@@ -105,22 +115,11 @@ fun MainScreen(appUiState: AppUiState, appViewState: AppViewState, viewModel: Ap
TunnelList( TunnelList(
appUiState = appUiState, appUiState = appUiState,
activeTunnels = appUiState.activeTunnels, selectedTunnels = appViewState.selectedTunnels,
selectedTunnel = appViewState.selectedTunnel,
onSetSelectedTunnel = { viewModel.handleEvent(AppEvent.SetSelectedTunnel(it)) },
onDeleteTunnel = {
viewModel.handleEvent(AppEvent.SetSelectedTunnel(it))
showDeleteTunnelAlertDialog = true
},
onToggleTunnel = { tunnel, checked -> onToggleTunnel = { tunnel, checked ->
if (checked) viewModel.handleEvent(AppEvent.StartTunnel(tunnel)) if (checked) viewModel.handleEvent(AppEvent.StartTunnel(tunnel))
else viewModel.handleEvent(AppEvent.StopTunnel(tunnel)) else viewModel.handleEvent(AppEvent.StopTunnel(tunnel))
}, },
onExpandStats = { viewModel.handleEvent(AppEvent.ToggleTunnelStatsExpanded) },
onCopyTunnel = {
viewModel.handleEvent(AppEvent.CopyTunnel(it))
viewModel.handleEvent(AppEvent.SetSelectedTunnel(null))
},
modifier = Modifier.fillMaxSize().padding(vertical = 24.dp).padding(horizontal = 12.dp), modifier = Modifier.fillMaxSize().padding(vertical = 24.dp).padding(horizontal = 12.dp),
viewModel = viewModel, viewModel = viewModel,
) )
@@ -0,0 +1,130 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderZip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
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.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType
import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileExportLauncherForResult
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AuthorizationPromptWrapper
import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.hasSAFSupport
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExportTunnelsBottomSheet(viewModel: AppViewModel) {
val context = LocalContext.current
val isTv = LocalIsAndroidTV.current
var exportConfigType by remember { mutableStateOf(ConfigType.WG) }
var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthorized by remember { mutableStateOf(false) }
var shouldExport by remember { mutableStateOf(false) }
val selectedTunnelsExportLauncher =
rememberFileExportLauncherForResult(
mimeType = Constants.ZIP_FILE_MIME_TYPE,
onResult = { file ->
if (file != null) {
viewModel.handleEvent(AppEvent.ExportSelectedTunnels(exportConfigType, file))
} else {
viewModel.handleEvent(AppEvent.ClearSelectedTunnels)
viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE))
}
},
)
fun handleFileExport() {
if (context.hasSAFSupport(Constants.ZIP_FILE_MIME_TYPE)) {
selectedTunnelsExportLauncher.launch(Constants.DEFAULT_EXPORT_FILE_NAME)
} else {
viewModel.handleEvent(AppEvent.ExportSelectedTunnels(exportConfigType, null))
}
}
LaunchedEffect(shouldExport) {
if (shouldExport) {
handleFileExport()
shouldExport = false
}
}
if (showAuthPrompt) {
AuthorizationPromptWrapper(
onDismiss = { showAuthPrompt = false },
onSuccess = {
showAuthPrompt = false
isAuthorized = true
shouldExport = true
},
viewModel = viewModel,
)
}
ModalBottomSheet(
containerColor = MaterialTheme.colorScheme.surface,
onDismissRequest = {
viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE))
},
) {
ExportOptionRow(
label = stringResource(R.string.export_tunnels_amnezia),
onClick = {
exportConfigType = ConfigType.AMNEZIA
if (!isAuthorized && !isTv) {
showAuthPrompt = true
} else {
shouldExport = true
}
},
)
HorizontalDivider()
ExportOptionRow(
label = stringResource(R.string.export_tunnels_wireguard),
onClick = {
exportConfigType = ConfigType.WG
if (!isAuthorized && !isTv) {
showAuthPrompt = true
} else {
shouldExport = true
}
},
)
}
}
@Composable
private fun ExportOptionRow(label: String, onClick: () -> Unit) {
Row(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(10.dp)) {
Icon(
imageVector = Icons.Filled.FolderZip,
contentDescription = label,
modifier = Modifier.padding(10.dp),
)
Text(text = label, modifier = Modifier.padding(10.dp))
}
}
@@ -23,13 +23,12 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
// TODO refactor this component // TODO refactor this component
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TunnelImportSheet( fun TunnelImportSheet(
show: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onFileClick: () -> Unit, onFileClick: () -> Unit,
onQrClick: () -> Unit, onQrClick: () -> Unit,
@@ -37,10 +36,11 @@ fun TunnelImportSheet(
onClipboardClick: () -> Unit, onClipboardClick: () -> Unit,
onUrlClick: () -> Unit, onUrlClick: () -> Unit,
) { ) {
val isTv = LocalIsAndroidTV.current
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
val context = LocalContext.current val context = LocalContext.current
if (show) {
ModalBottomSheet( ModalBottomSheet(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
onDismissRequest = { onDismiss() }, onDismissRequest = { onDismiss() },
@@ -60,12 +60,9 @@ fun TunnelImportSheet(
contentDescription = stringResource(id = R.string.open_file), contentDescription = stringResource(id = R.string.open_file),
modifier = Modifier.padding(10.dp), modifier = Modifier.padding(10.dp),
) )
Text( Text(stringResource(id = R.string.add_tunnels_text), modifier = Modifier.padding(10.dp))
stringResource(id = R.string.add_tunnels_text),
modifier = Modifier.padding(10.dp),
)
} }
if (!context.isRunningOnTv()) { if (!isTv) {
HorizontalDivider() HorizontalDivider()
Row( Row(
modifier = modifier =
@@ -81,10 +78,7 @@ fun TunnelImportSheet(
contentDescription = stringResource(id = R.string.qr_scan), contentDescription = stringResource(id = R.string.qr_scan),
modifier = Modifier.padding(10.dp), modifier = Modifier.padding(10.dp),
) )
Text( Text(stringResource(id = R.string.add_from_qr), modifier = Modifier.padding(10.dp))
stringResource(id = R.string.add_from_qr),
modifier = Modifier.padding(10.dp),
)
} }
HorizontalDivider() HorizontalDivider()
Row( Row(
@@ -136,11 +130,7 @@ fun TunnelImportSheet(
contentDescription = stringResource(id = R.string.create_import), contentDescription = stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp), modifier = Modifier.padding(10.dp),
) )
Text( Text(stringResource(id = R.string.create_import), modifier = Modifier.padding(10.dp))
stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp),
)
}
} }
} }
} }
@@ -17,9 +17,12 @@ import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.core.tunnel.getValueById import com.zaneschepke.wireguardautotunnel.core.tunnel.getValueById
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import java.text.Collator import java.text.Collator
import java.util.* import java.util.*
@@ -27,17 +30,13 @@ import java.util.*
@Composable @Composable
fun TunnelList( fun TunnelList(
appUiState: AppUiState, appUiState: AppUiState,
activeTunnels: Map<TunnelConf, TunnelState>, selectedTunnels: List<TunnelConf>,
selectedTunnel: TunnelConf?,
onSetSelectedTunnel: (TunnelConf?) -> Unit,
onDeleteTunnel: (TunnelConf) -> Unit,
onToggleTunnel: (TunnelConf, Boolean) -> Unit,
onExpandStats: () -> Unit,
onCopyTunnel: (TunnelConf) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onToggleTunnel: (TunnelConf, Boolean) -> Unit,
viewModel: AppViewModel, viewModel: AppViewModel,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val navController = LocalNavController.current
val collator = Collator.getInstance(Locale.getDefault()) val collator = Collator.getInstance(Locale.getDefault())
val sortedTunnels = val sortedTunnels =
remember(appUiState.tunnels) { remember(appUiState.tunnels) {
@@ -60,19 +59,28 @@ fun TunnelList(
item { GettingStartedLabel(onClick = { context.openWebUrl(it) }) } item { GettingStartedLabel(onClick = { context.openWebUrl(it) }) }
} }
items(sortedTunnels, key = { it.id }) { tunnel -> items(sortedTunnels, key = { it.id }) { tunnel ->
val tunnelState = activeTunnels.getValueById(tunnel.id) ?: TunnelState() val tunnelState =
remember(appUiState.activeTunnels) {
appUiState.activeTunnels.getValueById(tunnel.id) ?: TunnelState()
}
val selected = remember(selectedTunnels) { selectedTunnels.any { it.id == tunnel.id } }
TunnelRowItem( TunnelRowItem(
isActive = tunnelState.status.isUpOrStarting(), state = tunnelState,
expanded = appUiState.appState.isTunnelStatsExpanded, expanded = appUiState.appState.expandedTunnelIds.contains(tunnel.id),
isSelected = selectedTunnel?.id == tunnel.id, isSelected = selected,
tunnel = tunnel, tunnel = tunnel,
tunnelState = tunnelState, tunnelState = tunnelState,
onSetSelectedTunnel = { onSetSelectedTunnel(it) }, onClick = {
onClick = onExpandStats, navController.navigate(Route.TunnelOptions(tunnel.id))
onCopy = { onCopyTunnel(tunnel) }, viewModel.handleEvent(AppEvent.ClearSelectedTunnels)
onDelete = { onDeleteTunnel(tunnel) }, },
onDoubleClick = {
viewModel.handleEvent(AppEvent.ToggleTunnelStatsExpanded(tunnel.id))
},
onToggleSelectedTunnel = {
viewModel.handleEvent(AppEvent.ToggleSelectedTunnel(it))
},
onSwitchClick = { checked -> onToggleTunnel(tunnel, checked) }, onSwitchClick = { checked -> onToggleTunnel(tunnel, checked) },
viewModel = viewModel,
) )
} }
} }
@@ -2,221 +2,114 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.CopyAll import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.SettingsEthernet import androidx.compose.material.icons.rounded.SettingsEthernet
import androidx.compose.material.icons.rounded.Smartphone import androidx.compose.material.icons.rounded.Smartphone
import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.asColor import com.zaneschepke.wireguardautotunnel.util.extensions.asColor
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable @Composable
fun TunnelRowItem( fun TunnelRowItem(
isActive: Boolean, state: TunnelState,
expanded: Boolean,
isSelected: Boolean, isSelected: Boolean,
expanded: Boolean,
tunnel: TunnelConf, tunnel: TunnelConf,
tunnelState: TunnelState, tunnelState: TunnelState,
onSetSelectedTunnel: (TunnelConf?) -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
onCopy: () -> Unit, onDoubleClick: () -> Unit,
onDelete: () -> Unit, onToggleSelectedTunnel: (TunnelConf) -> Unit,
onSwitchClick: (Boolean) -> Unit, onSwitchClick: (Boolean) -> Unit,
viewModel: AppViewModel,
) { ) {
val context = LocalContext.current val isTv = LocalIsAndroidTV.current
val navController = LocalNavController.current
val haptic = LocalHapticFeedback.current
val itemFocusRequester = remember { FocusRequester() }
val isTv = context.isRunningOnTv()
val leadingIconColor = if (!isActive) Color.Gray else tunnelState.statistics.asColor() val leadingIconColor =
remember(state) {
if (state.status.isUp()) tunnelState.statistics.asColor() else Color.Gray
}
val (leadingIcon, size) = val (leadingIcon, size) =
remember(tunnel) {
when { when {
tunnel.isPrimaryTunnel -> Pair(Icons.Rounded.Star, 16.dp) tunnel.isPrimaryTunnel -> Pair(Icons.Rounded.Star, 16.dp)
tunnel.isMobileDataTunnel -> Pair(Icons.Rounded.Smartphone, 16.dp) tunnel.isMobileDataTunnel -> Pair(Icons.Rounded.Smartphone, 16.dp)
tunnel.isEthernetTunnel -> Pair(Icons.Rounded.SettingsEthernet, 16.dp) tunnel.isEthernetTunnel -> Pair(Icons.Rounded.SettingsEthernet, 16.dp)
else -> Pair(Icons.Rounded.Circle, 14.dp) else -> Pair(Icons.Rounded.Circle, 14.dp)
} }
}
ExpandingRowListItem( ExpandingRowListItem(
leading = { leading = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.Start),
) {
if (isTv) {
Checkbox(
isSelected,
onCheckedChange = { onToggleSelectedTunnel(tunnel) },
modifier = Modifier.minimumInteractiveComponentSize().size(12.dp),
)
}
Icon( Icon(
leadingIcon, leadingIcon,
stringResource(R.string.status), stringResource(R.string.status),
tint = leadingIconColor, tint = leadingIconColor,
modifier = Modifier.size(size), modifier = Modifier.size(size),
) )
}
}, },
text = tunnel.tunName, text = tunnel.tunName,
onHold = { onHold = { if (!isTv) onToggleSelectedTunnel(tunnel) },
haptic.performHapticFeedback(HapticFeedbackType.LongPress) onClick = { if (!isTv) onClick() },
onSetSelectedTunnel(tunnel) onDoubleClick = { if (!isTv) onDoubleClick() },
},
onClick = {
if (!isTv) {
if (isActive) onClick()
} else {
onSetSelectedTunnel(tunnel)
itemFocusRequester.requestFocus()
}
},
isExpanded = expanded && isActive,
expanded = { expanded = {
if (isActive && expanded) TunnelStatisticsRow(tunnelState.statistics, tunnel) if (expanded) {
TunnelStatisticsRow(tunnelState.statistics, tunnel)
} else null
}, },
trailing = { trailing = {
if (isSelected && !isTv) {
Row( Row(
modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
IconButton(
modifier = Modifier.weight(1f),
onClick = {
onSetSelectedTunnel(null)
navController.navigate(Route.TunnelOptions(tunnel.id))
},
) { ) {
if (isTv) {
IconButton(onClick = onDoubleClick) {
Icon(
Icons.Rounded.KeyboardArrowDown,
contentDescription = stringResource(R.string.info),
)
}
IconButton(onClick = onClick) {
Icon( Icon(
Icons.Rounded.Settings, Icons.Rounded.Settings,
stringResource(id = R.string.settings), contentDescription = stringResource(R.string.settings),
modifier = Modifier.size(24.dp),
)
}
IconButton(
modifier = Modifier.weight(1f),
onClick = {
onCopy()
onSetSelectedTunnel(null)
},
) {
Icon(
Icons.Rounded.CopyAll,
stringResource(R.string.copy),
modifier = Modifier.size(24.dp),
)
}
IconButton(
modifier = Modifier.weight(1f),
enabled = !isActive,
onClick = { onDelete() },
) {
Icon(
Icons.Rounded.Delete,
stringResource(R.string.delete_tunnel),
modifier = Modifier.size(24.dp),
) )
} }
} }
} else if (isTv) { ScaledSwitch(checked = state.status.isUpOrStarting(), onClick = onSwitchClick)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
IconButton(
modifier = Modifier.weight(1f),
onClick = {
navController.navigate(Route.TunnelOptions(tunnel.id))
onSetSelectedTunnel(null)
},
) {
Icon(
Icons.Rounded.Settings,
stringResource(id = R.string.settings),
modifier = Modifier.size(24.dp),
)
}
IconButton(
modifier = Modifier.weight(1f),
onClick = {
if (isActive) {
onClick()
} else {
viewModel.handleEvent(
AppEvent.ShowMessage(
StringValue.StringResource(R.string.turn_on_tunnel)
)
)
}
},
) {
Icon(
Icons.Rounded.Info,
stringResource(R.string.info),
modifier = Modifier.size(24.dp),
)
}
IconButton(modifier = Modifier.weight(1f), onClick = onCopy) {
Icon(
Icons.Rounded.CopyAll,
stringResource(R.string.copy),
modifier = Modifier.size(24.dp),
)
}
IconButton(
modifier = Modifier.weight(1f),
onClick = {
if (isActive) {
viewModel.handleEvent(
AppEvent.ShowMessage(
StringValue.StringResource(R.string.turn_off_tunnel)
)
)
} else {
onDelete()
}
},
) {
Icon(
Icons.Rounded.Delete,
stringResource(R.string.delete_tunnel),
modifier = Modifier.size(24.dp),
)
}
ScaledSwitch(
modifier = Modifier.focusRequester(itemFocusRequester).weight(1f),
checked = isActive,
onClick = onSwitchClick,
)
}
} else {
ScaledSwitch(
modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive,
onClick = onSwitchClick,
)
} }
}, },
isSelected = isSelected,
) )
} }
@@ -27,6 +27,10 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state.ConfigUi
@Composable @Composable
fun InterfaceSection(uiState: ConfigUiState, viewModel: ConfigViewModel) { fun InterfaceSection(uiState: ConfigUiState, viewModel: ConfigViewModel) {
var isDropDownExpanded by remember { mutableStateOf(false) } var isDropDownExpanded by remember { mutableStateOf(false) }
val isAmneziaCompatibilitySet =
remember(uiState.configProxy.`interface`) {
uiState.configProxy.`interface`.isAmneziaCompatibilityModeSet()
}
Surface(shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surface) { Surface(shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surface) {
Column( Column(
@@ -44,8 +48,7 @@ fun InterfaceSection(uiState: ConfigUiState, viewModel: ConfigViewModel) {
onExpandedChange = { isDropDownExpanded = it }, onExpandedChange = { isDropDownExpanded = it },
showScripts = uiState.showScripts, showScripts = uiState.showScripts,
showAmneziaValues = uiState.showAmneziaValues, showAmneziaValues = uiState.showAmneziaValues,
isAmneziaCompatibilitySet = isAmneziaCompatibilitySet = isAmneziaCompatibilitySet,
uiState.configProxy.`interface`.isAmneziaCompatibilityModeSet(),
onToggleScripts = viewModel::toggleScripts, onToggleScripts = viewModel::toggleScripts,
onToggleAmneziaValues = viewModel::toggleAmneziaValues, onToggleAmneziaValues = viewModel::toggleAmneziaValues,
onToggleAmneziaCompatibility = viewModel::toggleAmneziaCompatibility, onToggleAmneziaCompatibility = viewModel::toggleAmneziaCompatibility,
@@ -50,6 +50,7 @@ constructor(
tunnelId?.let { loadInitialState(it) } tunnelId?.let { loadInitialState(it) }
} }
// TODO improve this loading experience
private fun loadInitialState(tunnelId: Int) = private fun loadInitialState(tunnelId: Int) =
viewModelScope.launch { viewModelScope.launch {
val tunnel = tunnelRepository.getById(tunnelId) ?: return@launch val tunnel = tunnelRepository.getById(tunnelId) ?: return@launch
@@ -108,6 +109,7 @@ constructor(
loading = false, loading = false,
tunnelConf = tunnel, tunnelConf = tunnel,
tunneledApps = tunneledApps, tunneledApps = tunneledApps,
queriedApps = tunneledApps,
splitOption = splitOption, splitOption = splitOption,
) )
} }
@@ -116,14 +118,14 @@ constructor(
fun onSearchQuery(query: String) { fun onSearchQuery(query: String) {
val filteredApps = val filteredApps =
if (query.isBlank()) { if (query.isBlank()) {
allTunneledApps _uiState.value.tunneledApps
} else { } else {
allTunneledApps.filter { _uiState.value.tunneledApps.filter {
it.first.name.contains(query, ignoreCase = true) || it.first.name.contains(query, ignoreCase = true) ||
it.first.`package`.contains(query, ignoreCase = true) it.first.`package`.contains(query, ignoreCase = true)
} }
} }
_uiState.update { it.copy(searchQuery = query, tunneledApps = filteredApps) } _uiState.update { it.copy(searchQuery = query, queriedApps = filteredApps) }
} }
fun updateSplitOption(newOption: SplitOption) { fun updateSplitOption(newOption: SplitOption) {
@@ -136,7 +138,12 @@ constructor(
currentState.tunneledApps.map { (app, selected) -> currentState.tunneledApps.map { (app, selected) ->
if (app.`package` == packageName) Pair(app, !selected) else Pair(app, selected) if (app.`package` == packageName) Pair(app, !selected) else Pair(app, selected)
} }
_uiState.value = currentState.copy(tunneledApps = updatedApps) val updatedQueryApps =
currentState.queriedApps.map { (app, selected) ->
if (app.`package` == packageName) Pair(app, !selected) else Pair(app, selected)
}
_uiState.value =
currentState.copy(tunneledApps = updatedApps, queriedApps = updatedQueryApps)
} }
fun saveChanges() = fun saveChanges() =
@@ -29,7 +29,7 @@ fun SplitTunnelContent(
) )
if (uiState.splitOption != SplitOption.ALL) { if (uiState.splitOption != SplitOption.ALL) {
AppListSection( AppListSection(
apps = uiState.tunneledApps, apps = uiState.queriedApps,
onAppSelectionToggle = onAppSelectionToggle, onAppSelectionToggle = onAppSelectionToggle,
onQueryChange = onQueryChange, onQueryChange = onQueryChange,
uiState.searchQuery, uiState.searchQuery,
@@ -6,6 +6,7 @@ data class SplitTunnelUiState(
val loading: Boolean = true, val loading: Boolean = true,
val tunnelConf: TunnelConf? = null, val tunnelConf: TunnelConf? = null,
val tunneledApps: SplitTunnelApps = emptyList(), val tunneledApps: SplitTunnelApps = emptyList(),
val queriedApps: SplitTunnelApps = emptyList(),
val splitOption: SplitOption = SplitOption.ALL, val splitOption: SplitOption = SplitOption.ALL,
val searchQuery: String = "", val searchQuery: String = "",
val success: Boolean? = null, val success: Boolean? = null,
@@ -6,33 +6,18 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components.AutoTunnelingItem import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components.*
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components.PingConfigItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components.PingRestartItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components.PrimaryTunnelItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components.ServerIpv4Item
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components.SplitTunnelingItem
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@Composable @Composable
fun TunnelOptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: AppViewModel) { fun TunnelOptionsScreen(tunnelConf: TunnelConf, viewModel: AppViewModel) {
var currentText by remember { mutableStateOf("") }
LaunchedEffect(tunnelConf.tunnelNetworks) { currentText = "" }
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
@@ -52,7 +37,7 @@ fun TunnelOptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewMode
SplitTunnelingItem(tunnelConf), SplitTunnelingItem(tunnelConf),
) )
) )
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.30f)) SectionDivider()
SurfaceSelectionGroupButton( SurfaceSelectionGroupButton(
items = items =
buildList { buildList {
@@ -11,7 +11,7 @@ import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
@Composable @Composable
fun AutoTunnelingItem(tunnelConf: TunnelConf): SelectionItem { fun AutoTunnelingItem(tunnelConf: TunnelConf): SelectionItem {
@@ -11,7 +11,7 @@ import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
@Composable @Composable
fun SplitTunnelingItem(tunnelConf: TunnelConf): SelectionItem { fun SplitTunnelingItem(tunnelConf: TunnelConf): SelectionItem {
@@ -3,21 +3,20 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.pin
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import xyz.teamgravity.pin_lock_compose.PinLock import xyz.teamgravity.pin_lock_compose.PinLock
@Composable @Composable
fun PinLockScreen(viewModel: AppViewModel) { fun PinLockScreen(viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current val navController = LocalNavController.current
val isTv = LocalIsAndroidTV.current
PinLock( PinLock(
title = { pinExists -> title = { pinExists ->
Text( Text(
@@ -34,7 +33,7 @@ fun PinLockScreen(viewModel: AppViewModel) {
textColor = MaterialTheme.colorScheme.onSurface, textColor = MaterialTheme.colorScheme.onSurface,
onPinCorrect = { onPinCorrect = {
// pin is correct, navigate or hide pin lock // pin is correct, navigate or hide pin lock
if (context.isRunningOnTv()) { if (isTv) {
navController.navigate(Route.Main) navController.navigate(Route.Main)
} else { } else {
val isPopped = navController.popBackStack() val isPopped = navController.popBackStack()
@@ -8,23 +8,21 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.AdvancedSettingsItem import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.AdvancedSettingsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AlwaysOnVpnItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AlwaysOnVpnItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AppShortcutsItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AppShortcutsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AppearanceItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AppearanceItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ExportTunnelsBottomSheet
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.KernelModeItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.KernelModeItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.KillSwitchItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.KillSwitchItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocalLoggingItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocalLoggingItem
@@ -32,21 +30,15 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.PinLoc
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ReadLogsItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ReadLogsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.RestartAtBootItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.RestartAtBootItem
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@Composable @Composable
fun SettingsScreen(uiState: AppUiState, appViewState: AppViewState, viewModel: AppViewModel) { fun SettingsScreen(uiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current val isTv = LocalIsAndroidTV.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val navController = LocalNavController.current val navController = LocalNavController.current
val isRunningOnTv = remember { context.isRunningOnTv() }
val interactionSource = remember { MutableInteractionSource() }
if (appViewState.showBottomSheet) { val interactionSource = remember { MutableInteractionSource() }
ExportTunnelsBottomSheet(viewModel, isRunningOnTv)
}
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
@@ -57,7 +49,7 @@ fun SettingsScreen(uiState: AppUiState, appViewState: AppViewState, viewModel: A
.padding(vertical = 24.dp) .padding(vertical = 24.dp)
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
.then( .then(
if (!isRunningOnTv) { if (!isTv) {
Modifier.clickable( Modifier.clickable(
indication = null, indication = null,
interactionSource = interactionSource, interactionSource = interactionSource,
@@ -72,12 +64,12 @@ fun SettingsScreen(uiState: AppUiState, appViewState: AppViewState, viewModel: A
items = items =
buildList { buildList {
add(AppShortcutsItem(uiState, viewModel)) add(AppShortcutsItem(uiState, viewModel))
if (!isRunningOnTv) add(AlwaysOnVpnItem(uiState, viewModel)) if (!isTv) add(AlwaysOnVpnItem(uiState, viewModel))
add(KillSwitchItem()) add(KillSwitchItem())
add(RestartAtBootItem(uiState, viewModel)) add(RestartAtBootItem(uiState, viewModel))
} }
) )
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.30f)) SectionDivider()
SurfaceSelectionGroupButton( SurfaceSelectionGroupButton(
items = items =
buildList { buildList {
@@ -87,10 +79,10 @@ fun SettingsScreen(uiState: AppUiState, appViewState: AppViewState, viewModel: A
add(PinLockItem(uiState, viewModel)) add(PinLockItem(uiState, viewModel))
} }
) )
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.30f)) SectionDivider()
if (!isRunningOnTv) { if (!isTv) {
SurfaceSelectionGroupButton(items = listOf(KernelModeItem(uiState, viewModel))) SurfaceSelectionGroupButton(items = listOf(KernelModeItem(uiState, viewModel)))
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.30f)) SectionDivider()
} }
SurfaceSelectionGroupButton( SurfaceSelectionGroupButton(
items = items =
@@ -4,12 +4,11 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components.DisplayThemeItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components.DisplayThemeItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components.LanguageItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components.LanguageItem
@@ -23,9 +22,9 @@ fun AppearanceScreen() {
modifier = Modifier.fillMaxSize().padding(vertical = 24.dp).padding(horizontal = 12.dp), modifier = Modifier.fillMaxSize().padding(vertical = 24.dp).padding(horizontal = 12.dp),
) { ) {
SurfaceSelectionGroupButton(items = listOf(LanguageItem())) SurfaceSelectionGroupButton(items = listOf(LanguageItem()))
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.30f)) SectionDivider()
SurfaceSelectionGroupButton(items = listOf(NotificationsItem())) SurfaceSelectionGroupButton(items = listOf(NotificationsItem()))
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.30f)) SectionDivider()
SurfaceSelectionGroupButton(items = listOf(DisplayThemeItem())) SurfaceSelectionGroupButton(items = listOf(DisplayThemeItem()))
} }
} }
@@ -10,7 +10,7 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
@Composable @Composable
fun DisplayThemeItem(): SelectionItem { fun DisplayThemeItem(): SelectionItem {
@@ -10,7 +10,7 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
@Composable @Composable
fun LanguageItem(): SelectionItem { fun LanguageItem(): SelectionItem {
@@ -8,13 +8,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.components.AutomaticLanguageItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.components.AutomaticLanguageItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.components.LanguageItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.components.LanguageItem
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import java.text.Collator import java.text.Collator
import java.util.* import java.util.*
@@ -22,9 +21,7 @@ import java.util.*
@Composable @Composable
fun LanguageScreen(appUiState: AppUiState, viewModel: AppViewModel) { fun LanguageScreen(appUiState: AppUiState, viewModel: AppViewModel) {
val collator = Collator.getInstance(Locale.getDefault()) val collator = Collator.getInstance(Locale.getDefault())
val isTv = LocalIsAndroidTV.current
val context = LocalContext.current
val isAndroidTv = remember { context.isRunningOnTv() }
val locales = val locales =
LocaleUtil.supportedLocales.map { LocaleUtil.supportedLocales.map {
@@ -42,9 +39,9 @@ fun LanguageScreen(appUiState: AppUiState, viewModel: AppViewModel) {
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(horizontal = 12.dp), modifier = Modifier.padding(horizontal = 12.dp),
) { ) {
item { AutomaticLanguageItem(appUiState, viewModel, isAndroidTv) } item { AutomaticLanguageItem(appUiState, viewModel, isTv) }
items(sortedLocales, key = { it }) { locale -> items(sortedLocales, key = { it }) { locale ->
LanguageItem(locale, appUiState, viewModel, isAndroidTv) LanguageItem(locale, appUiState, viewModel, isTv)
} }
} }
} }
@@ -10,7 +10,7 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
@Composable @Composable
fun AppearanceItem(): SelectionItem { fun AppearanceItem(): SelectionItem {
@@ -1,104 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderZip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExportTunnelsBottomSheet(viewModel: AppViewModel, isRunningOnTv: Boolean) {
var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthorized by remember { mutableStateOf(false) }
var exportType by remember { mutableStateOf(ConfigType.WG) }
fun handleExport() {
viewModel.handleEvent(AppEvent.ToggleBottomSheet)
viewModel.handleEvent(AppEvent.ExportTunnels(exportType))
}
if (showAuthPrompt) {
AuthorizationPromptWrapper(
onDismiss = { showAuthPrompt = false },
onSuccess = {
showAuthPrompt = false
isAuthorized = true
handleExport()
},
viewModel = viewModel,
)
}
ModalBottomSheet(
containerColor = MaterialTheme.colorScheme.surface,
onDismissRequest = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) },
) {
Row(
modifier =
Modifier.fillMaxWidth()
.clickable {
exportType = ConfigType.AMNEZIA
if (!isAuthorized && !isRunningOnTv) {
showAuthPrompt = true
return@clickable
}
handleExport()
}
.padding(10.dp)
) {
Icon(
Icons.Filled.FolderZip,
contentDescription = stringResource(R.string.export_tunnels_amnezia),
modifier = Modifier.padding(10.dp),
)
Text(
stringResource(R.string.export_tunnels_amnezia),
modifier = Modifier.padding(10.dp),
)
}
HorizontalDivider()
Row(
modifier =
Modifier.fillMaxWidth()
.clickable {
exportType = ConfigType.WG
if (!isAuthorized && !isRunningOnTv) {
showAuthPrompt = true
return@clickable
}
handleExport()
}
.padding(10.dp)
) {
Icon(
Icons.Filled.FolderZip,
contentDescription = stringResource(R.string.export_tunnels_wireguard),
modifier = Modifier.padding(10.dp),
)
Text(
stringResource(R.string.export_tunnels_wireguard),
modifier = Modifier.padding(10.dp),
)
}
}
}
@@ -10,7 +10,7 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
@Composable @Composable
fun KillSwitchItem(): SelectionItem { fun KillSwitchItem(): SelectionItem {
@@ -3,10 +3,12 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ViewHeadline import androidx.compose.material.icons.outlined.ViewHeadline
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLabelType
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@@ -15,8 +17,15 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
fun LocalLoggingItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { fun LocalLoggingItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem {
return SelectionItem( return SelectionItem(
leadingIcon = Icons.Outlined.ViewHeadline, leadingIcon = Icons.Outlined.ViewHeadline,
title = { SelectionItemLabel(R.string.local_logging) }, title = {
description = { SelectionItemLabel(R.string.enable_local_logging, isDescription = true) }, SelectionItemLabel(stringResource(R.string.local_logging), SelectionLabelType.TITLE)
},
description = {
SelectionItemLabel(
stringResource(R.string.enable_local_logging),
SelectionLabelType.DESCRIPTION,
)
},
trailing = { trailing = {
ScaledSwitch( ScaledSwitch(
checked = uiState.appState.isLocalLogsEnabled, checked = uiState.appState.isLocalLogsEnabled,
@@ -11,7 +11,7 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@@ -3,19 +3,23 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ViewTimeline import androidx.compose.material.icons.filled.ViewTimeline
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLabelType
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
@Composable @Composable
fun ReadLogsItem(): SelectionItem { fun ReadLogsItem(): SelectionItem {
val navController = LocalNavController.current val navController = LocalNavController.current
return SelectionItem( return SelectionItem(
leadingIcon = Icons.Filled.ViewTimeline, leadingIcon = Icons.Filled.ViewTimeline,
title = { SelectionItemLabel(R.string.read_logs) }, title = {
SelectionItemLabel(stringResource(R.string.read_logs), SelectionLabelType.TITLE)
},
trailing = { ForwardButton { navController.navigate(Route.Logs) } }, trailing = { ForwardButton { navController.navigate(Route.Logs) } },
onClick = { navController.navigate(Route.Logs) }, onClick = { navController.navigate(Route.Logs) },
) )
@@ -4,32 +4,30 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.components.VpnKillSwitchItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.components.VpnKillSwitchItem
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable @Composable
fun KillSwitchScreen(uiState: AppUiState, viewModel: AppViewModel) { fun KillSwitchScreen(uiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current val isTv = LocalIsAndroidTV.current
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top),
modifier = Modifier.fillMaxSize().padding(vertical = 24.dp).padding(horizontal = 12.dp), modifier = Modifier.fillMaxSize().padding(vertical = 24.dp).padding(horizontal = 12.dp),
) { ) {
if (!context.isRunningOnTv()) { if (!isTv) {
SurfaceSelectionGroupButton(items = listOf(NativeKillSwitchItem())) SurfaceSelectionGroupButton(items = listOf(NativeKillSwitchItem()))
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.30f)) SectionDivider()
} }
SurfaceSelectionGroupButton( SurfaceSelectionGroupButton(
items = items =
@@ -65,9 +65,12 @@ fun LogsScreen(appViewState: AppViewState, viewModel: AppViewModel) {
} }
} }
if (appViewState.showBottomSheet) { when (appViewState.bottomSheet) {
AppViewState.BottomSheet.LOGS -> {
LogsBottomSheet(viewModel) LogsBottomSheet(viewModel)
} }
else -> Unit
}
if (logs.isEmpty()) { if (logs.isEmpty()) {
return Box( return Box(
@@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@@ -26,13 +27,17 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
fun LogsBottomSheet(viewModel: AppViewModel) { fun LogsBottomSheet(viewModel: AppViewModel) {
ModalBottomSheet( ModalBottomSheet(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
onDismissRequest = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) }, onDismissRequest = {
viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE))
},
) { ) {
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.clickable { .clickable {
viewModel.handleEvent(AppEvent.ToggleBottomSheet) viewModel.handleEvent(
AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)
)
viewModel.handleEvent(AppEvent.ExportLogs) viewModel.handleEvent(AppEvent.ExportLogs)
} }
.padding(10.dp) .padding(10.dp)
@@ -49,7 +54,9 @@ fun LogsBottomSheet(viewModel: AppViewModel) {
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.clickable { .clickable {
viewModel.handleEvent(AppEvent.ToggleBottomSheet) viewModel.handleEvent(
AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)
)
viewModel.handleEvent(AppEvent.DeleteLogs) viewModel.handleEvent(AppEvent.DeleteLogs)
} }
.padding(10.dp) .padding(10.dp)
@@ -1,29 +1,102 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support package com.zaneschepke.wireguardautotunnel.ui.screens.support
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.zxing.client.android.BuildConfig
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel
import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.ContactSupportOptions import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.ContactSupportOptions
import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.GeneralSupportOptions import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.GeneralSupportOptions
import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.VersionLabel import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.UpdateSection
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.canInstallPackages
import com.zaneschepke.wireguardautotunnel.util.extensions.requestInstallPackagesPermission
import com.zaneschepke.wireguardautotunnel.util.extensions.showToast
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable @Composable
fun SupportScreen() { fun SupportScreen(viewModel: SupportViewModel = hiltViewModel(), appViewModel: AppViewModel) {
val context = LocalContext.current val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showPermissionDialog by remember { mutableStateOf(false) }
LaunchedEffect(uiState.error) {
uiState.error?.let {
viewModel.handleErrorShown()
appViewModel.handleEvent(AppEvent.ShowMessage(it))
viewModel.handleErrorShown()
}
}
LaunchedEffect(uiState.isUptoDate) {
if (uiState.isUptoDate == true)
return@LaunchedEffect context.showToast(R.string.latest_installed)
}
if (uiState.appUpdate != null) {
InfoDialog(
onDismiss = { viewModel.handleUpdateShown() },
onAttest = {
if (context.canInstallPackages()) {
viewModel.handleDownloadAndInstallApk()
} else {
showPermissionDialog = true
}
},
title = { Text(stringResource(R.string.update_available)) },
body = {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top),
modifier = Modifier.fillMaxWidth(),
) {
Text(uiState.appUpdate?.version ?: "")
Text(uiState.appUpdate?.releaseNotes ?: "")
if (uiState.isLoading) {
LinearProgressIndicator(
progress = { uiState.downloadProgress },
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
color = MaterialTheme.colorScheme.primary,
strokeCap = StrokeCap.Round,
)
}
}
},
confirmText = { Text(stringResource(R.string.download_and_install)) },
)
}
if (showPermissionDialog) {
InfoDialog(
onDismiss = { showPermissionDialog = false },
onAttest = {
context.requestInstallPackagesPermission()
showPermissionDialog = false
},
title = { Text(stringResource(R.string.permission_required)) },
body = { Text(stringResource(R.string.install_updated_permission)) },
confirmText = { Text(stringResource(R.string.allow)) },
)
}
Column( Column(
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
@@ -37,9 +110,17 @@ fun SupportScreen() {
stringResource(R.string.thank_you), stringResource(R.string.thank_you),
modifier = Modifier.padding(horizontal = 12.dp), modifier = Modifier.padding(horizontal = 12.dp),
) )
if (BuildConfig.BUILD_TYPE == Constants.RELEASE) {
UpdateSection(
onUpdateCheck = {
context.showToast(R.string.checking_for_update)
viewModel.handleUpdateCheck()
}
)
SectionDivider()
}
GeneralSupportOptions(context) GeneralSupportOptions(context)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.30f)) SectionDivider()
ContactSupportOptions(context) ContactSupportOptions(context)
VersionLabel()
} }
} }
@@ -0,0 +1,12 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support
import com.zaneschepke.wireguardautotunnel.domain.entity.AppUpdate
import com.zaneschepke.wireguardautotunnel.util.StringValue
data class SupportUiState(
val appUpdate: AppUpdate? = null,
val isLoading: Boolean = false,
val error: StringValue? = null,
val isUptoDate: Boolean? = null,
val downloadProgress: Float = 0f,
)
@@ -0,0 +1,86 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.AppUpdate
import com.zaneschepke.wireguardautotunnel.domain.repository.UpdateRepository
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue
import dagger.hilt.android.lifecycle.HiltViewModel
import jakarta.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@HiltViewModel
class SupportViewModel
@Inject
constructor(private val updateRepository: UpdateRepository, private val fileUtils: FileUtils) :
ViewModel() {
private val _uiState = MutableStateFlow(SupportUiState())
val uiState = _uiState.asStateFlow()
fun handleUpdateCheck() =
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
updateRepository
.checkForUpdate(BuildConfig.VERSION_NAME)
.onSuccess { appUpdate ->
_uiState.update {
it.copy(
appUpdate = appUpdate.sanitized(),
error = null,
isUptoDate = appUpdate == null,
)
}
}
.onFailure {
_uiState.update {
it.copy(error = StringValue.StringResource(R.string.update_check_failed))
}
}
_uiState.update { it.copy(isLoading = false) }
}
private fun AppUpdate?.sanitized(): AppUpdate? {
return this?.copy(releaseNotes = releaseNotes.substringBefore(CHANGELOG_START))
}
fun handleErrorShown() = _uiState.update { it.copy(error = null) }
fun handleUpdateShown() = _uiState.update { it.copy(appUpdate = null) }
fun handleDownloadAndInstallApk() =
viewModelScope.launch {
with(uiState.value) {
if (appUpdate == null) return@launch
if (appUpdate.apkUrl == null || appUpdate.apkFileName == null) return@launch
_uiState.update { it.copy(isLoading = true) }
updateRepository
.downloadApk(appUpdate.apkUrl, appUpdate.apkFileName) { progress ->
_uiState.update { it.copy(downloadProgress = progress) }
}
.onSuccess { apk ->
_uiState.update { it.copy(isLoading = false) }
fileUtils.installApk(apk)
}
.onFailure {
_uiState.update {
it.copy(
isLoading = false,
error = StringValue.StringResource(R.string.update_download_failed),
)
}
}
}
}
companion object {
private const val CHANGELOG_START =
"SHA-256 fingerprint for the 4096-bit signing certificate:"
}
}
@@ -5,12 +5,14 @@ import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Mail import androidx.compose.material.icons.filled.Mail
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import com.zaneschepke.wireguardautotunnel.BuildConfig import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLabelType
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.launchSupportEmail import com.zaneschepke.wireguardautotunnel.util.extensions.launchSupportEmail
@@ -25,7 +27,12 @@ fun ContactSupportOptions(context: android.content.Context) {
listOf( listOf(
SelectionItem( SelectionItem(
leadingIcon = ImageVector.vectorResource(R.drawable.matrix), leadingIcon = ImageVector.vectorResource(R.drawable.matrix),
title = { SelectionItemLabel(R.string.join_matrix) }, title = {
SelectionItemLabel(
stringResource(R.string.join_matrix),
SelectionLabelType.TITLE,
)
},
trailing = { trailing = {
ForwardButton { ForwardButton {
context.openWebUrl(context.getString(R.string.matrix_url)) context.openWebUrl(context.getString(R.string.matrix_url))
@@ -35,7 +42,12 @@ fun ContactSupportOptions(context: android.content.Context) {
), ),
SelectionItem( SelectionItem(
leadingIcon = ImageVector.vectorResource(R.drawable.telegram), leadingIcon = ImageVector.vectorResource(R.drawable.telegram),
title = { SelectionItemLabel(R.string.join_telegram) }, title = {
SelectionItemLabel(
stringResource(R.string.join_telegram),
SelectionLabelType.TITLE,
)
},
trailing = { trailing = {
ForwardButton { ForwardButton {
context.openWebUrl(context.getString(R.string.telegram_url)) context.openWebUrl(context.getString(R.string.telegram_url))
@@ -47,7 +59,12 @@ fun ContactSupportOptions(context: android.content.Context) {
), ),
SelectionItem( SelectionItem(
leadingIcon = ImageVector.vectorResource(R.drawable.github), leadingIcon = ImageVector.vectorResource(R.drawable.github),
title = { SelectionItemLabel(R.string.open_issue) }, title = {
SelectionItemLabel(
stringResource(R.string.open_issue),
SelectionLabelType.TITLE,
)
},
trailing = { trailing = {
ForwardButton { ForwardButton {
context.openWebUrl(context.getString(R.string.github_url)) context.openWebUrl(context.getString(R.string.github_url))
@@ -57,7 +74,12 @@ fun ContactSupportOptions(context: android.content.Context) {
), ),
SelectionItem( SelectionItem(
leadingIcon = Icons.Filled.Mail, leadingIcon = Icons.Filled.Mail,
title = { SelectionItemLabel(R.string.email_description) }, title = {
SelectionItemLabel(
stringResource(R.string.email_description),
SelectionLabelType.TITLE,
)
},
trailing = { ForwardButton { context.launchSupportEmail() } }, trailing = { ForwardButton { context.launchSupportEmail() } },
onClick = { context.launchSupportEmail() }, onClick = { context.launchSupportEmail() },
), ),
@@ -67,7 +89,12 @@ fun ContactSupportOptions(context: android.content.Context) {
add( add(
SelectionItem( SelectionItem(
leadingIcon = Icons.Filled.Favorite, leadingIcon = Icons.Filled.Favorite,
title = { SelectionItemLabel(R.string.donate) }, title = {
SelectionItemLabel(
stringResource(R.string.donate),
SelectionLabelType.TITLE,
)
},
trailing = { trailing = {
ForwardButton { ForwardButton {
context.openWebUrl(context.getString(R.string.donate_url)) context.openWebUrl(context.getString(R.string.donate_url))
@@ -1,25 +1,36 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support.components package com.zaneschepke.wireguardautotunnel.ui.screens.support.components
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Balance
import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.filled.Book
import androidx.compose.material.icons.filled.Policy import androidx.compose.material.icons.filled.Policy
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLabelType
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
@Composable @Composable
fun GeneralSupportOptions(context: android.content.Context) { fun GeneralSupportOptions(context: android.content.Context) {
val navController = LocalNavController.current
SurfaceSelectionGroupButton( SurfaceSelectionGroupButton(
items = items =
buildList { buildList {
add( add(
SelectionItem( SelectionItem(
leadingIcon = Icons.Filled.Book, leadingIcon = Icons.Filled.Book,
title = { SelectionItemLabel(R.string.docs_description) }, title = {
SelectionItemLabel(
stringResource(R.string.docs_description),
SelectionLabelType.TITLE,
)
},
trailing = { trailing = {
ForwardButton { ForwardButton {
context.openWebUrl(context.getString(R.string.docs_url)) context.openWebUrl(context.getString(R.string.docs_url))
@@ -31,7 +42,12 @@ fun GeneralSupportOptions(context: android.content.Context) {
add( add(
SelectionItem( SelectionItem(
leadingIcon = Icons.Filled.Policy, leadingIcon = Icons.Filled.Policy,
title = { SelectionItemLabel(R.string.privacy_policy) }, title = {
SelectionItemLabel(
stringResource(R.string.privacy_policy),
SelectionLabelType.TITLE,
)
},
trailing = { trailing = {
ForwardButton { ForwardButton {
context.openWebUrl(context.getString(R.string.privacy_policy_url)) context.openWebUrl(context.getString(R.string.privacy_policy_url))
@@ -42,6 +58,19 @@ fun GeneralSupportOptions(context: android.content.Context) {
}, },
) )
) )
add(
SelectionItem(
leadingIcon = Icons.Filled.Balance,
title = {
SelectionItemLabel(
stringResource(R.string.licenses),
SelectionLabelType.TITLE,
)
},
trailing = { ForwardButton { navController.navigate(Route.License) } },
onClick = { navController.navigate(Route.License) },
)
)
} }
) )
} }
@@ -0,0 +1,44 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLabelType
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.util.Constants
@Composable
fun UpdateSection(onUpdateCheck: () -> Unit = {}) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
leadingIcon = Icons.Filled.CloudDownload,
title = {
SelectionItemLabel(
stringResource(R.string.check_for_update),
SelectionLabelType.TITLE,
)
},
description = {
val versionName =
if (BuildConfig.BUILD_TYPE == Constants.RELEASE) {
"v${BuildConfig.VERSION_NAME}"
} else {
"v${BuildConfig.VERSION_NAME}-${BuildConfig.BUILD_TYPE}"
}
SelectionItemLabel(
stringResource(R.string.version_template, versionName),
SelectionLabelType.DESCRIPTION,
)
},
onClick = onUpdateCheck,
)
)
)
}
@@ -1,38 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.Constants
@Composable
fun VersionLabel() {
val clipboardManager = LocalClipboardManager.current
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
val versionText =
if (BuildConfig.BUILD_TYPE == Constants.RELEASE) {
BuildConfig.VERSION_NAME
} else {
"${BuildConfig.VERSION_NAME}-${BuildConfig.BUILD_TYPE}"
}
Text(
"${stringResource(R.string.version)}: $versionText",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline,
modifier =
Modifier.clickable {
clipboardManager.setText(AnnotatedString(BuildConfig.VERSION_NAME))
},
)
}
}
@@ -0,0 +1,15 @@
import kotlinx.serialization.Serializable
@Serializable
data class LicenseFileEntry(
val groupId: String,
val artifactId: String,
val version: String,
val name: String,
val spdxLicenses: List<SpdxLicense> = emptyList(),
val scm: Scm? = null,
)
@Serializable data class SpdxLicense(val identifier: String, val name: String, val url: String)
@Serializable data class Scm(val url: String)
@@ -0,0 +1,45 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support.license
import LicenseFileEntry
import android.content.Context
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.zaneschepke.wireguardautotunnel.ui.screens.support.license.components.LicenseList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@Composable
fun LicenseScreen() {
val context = LocalContext.current
var licenses by remember { mutableStateOf<List<LicenseFileEntry>>(emptyList()) }
LaunchedEffect(Unit) { licenses = loadLicenseeJson(context) }
if (licenses.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
LicenseList(licenses)
}
}
suspend fun loadLicenseeJson(context: Context): List<LicenseFileEntry> {
return withContext(Dispatchers.IO) {
val json = Json { ignoreUnknownKeys = true }
val jsonResult = context.assets.open("licenses.json").bufferedReader().use { it.readText() }
json.decodeFromString(jsonResult)
}
}
@@ -0,0 +1,44 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support.license.components
import LicenseFileEntry
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun LicenseList(licenses: List<LicenseFileEntry>) {
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
items(licenses) { entry ->
Column(modifier = Modifier.padding(bottom = 12.dp)) {
Text(
text = "${entry.name} (${entry.version})",
style = MaterialTheme.typography.titleSmall,
)
entry.spdxLicenses.forEach { license ->
Text(
text = license.name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
entry.scm?.url?.let { scmUrl ->
Text(
text = scmUrl,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 4.dp),
color = MaterialTheme.colorScheme.secondary,
)
}
}
}
}
}
@@ -15,6 +15,5 @@ data class AppUiState(
val isAutoTunnelActive: Boolean = false, val isAutoTunnelActive: Boolean = false,
val appConfigurationChange: Boolean = false, val appConfigurationChange: Boolean = false,
val isAppLoaded: Boolean = false, val isAppLoaded: Boolean = false,
val selectedTunnel: TunnelConf? = null,
val networkStatus: NetworkStatus? = null, val networkStatus: NetworkStatus? = null,
) )
@@ -8,8 +8,22 @@ data class AppViewState(
val errorMessage: StringValue? = null, val errorMessage: StringValue? = null,
val popBackStack: Boolean = false, val popBackStack: Boolean = false,
val isAppReady: Boolean = false, val isAppReady: Boolean = false,
val showBottomSheet: Boolean = false, val bottomSheet: BottomSheet = BottomSheet.NONE,
val selectedTunnel: TunnelConf? = null, val selectedTunnels: List<TunnelConf> = emptyList(),
val requestVpnPermission: Boolean = false, val requestVpnPermission: Boolean = false,
val requestBatteryPermission: Boolean = false, val requestBatteryPermission: Boolean = false,
) val showModal: ModalType = ModalType.NONE,
) {
enum class ModalType {
NONE,
DELETE,
INFO,
}
enum class BottomSheet {
EXPORT_TUNNELS,
IMPORT_TUNNELS,
LOGS,
NONE,
}
}
@@ -75,10 +75,7 @@ data class InterfaceProxy(
} }
fun isAmneziaCompatibilityModeSet(): Boolean { fun isAmneziaCompatibilityModeSet(): Boolean {
return junkPacketCount.toIntOrNull() in 3..<5 && return with(initPacketJunkSize.toIntOrNull()) { this == 0 || this == null } &&
junkPacketMinSize.toIntOrNull() == 40 &&
junkPacketMaxSize.toIntOrNull() == 70 &&
with(initPacketJunkSize.toIntOrNull()) { this == 0 || this == null } &&
with(responsePacketJunkSize.toIntOrNull()) { this == 0 || this == null } && with(responsePacketJunkSize.toIntOrNull()) { this == 0 || this == null } &&
initPacketMagicHeader.toLongOrNull() == 1L && initPacketMagicHeader.toLongOrNull() == 1L &&
responsePacketMagicHeader.toLongOrNull() == 2L && responsePacketMagicHeader.toLongOrNull() == 2L &&
@@ -0,0 +1,12 @@
package com.zaneschepke.wireguardautotunnel.ui.state
import androidx.compose.runtime.Composable
import com.zaneschepke.wireguardautotunnel.ui.Route
data class NavBarState(
val showTop: Boolean = true,
val showBottom: Boolean = true,
val topTitle: @Composable (() -> Unit)? = null,
val topTrailing: @Composable (() -> Unit)? = null,
val route: Route? = null,
)
@@ -78,6 +78,7 @@ fun WireguardAutoTunnelTheme(theme: Theme = Theme.AUTOMATIC, content: @Composabl
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
@Suppress("DEPRECATION")
SideEffect { SideEffect {
val window = (view.context as Activity).window val window = (view.context as Activity).window
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -14,7 +14,7 @@ object Constants {
const val ZIP_FILE_EXTENSION = ".zip" const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content" const val URI_CONTENT_SCHEME = "content"
private const val TEXT_MIME_TYPE = "text/plain" private const val TEXT_MIME_TYPE = "text/plain"
private const val ZIP_FILE_MIME_TYPE = "application/zip" const val ZIP_FILE_MIME_TYPE = "application/zip"
const val ALLOWED_TV_FILE_TYPES = "${TEXT_MIME_TYPE}|${ZIP_FILE_MIME_TYPE}" const val ALLOWED_TV_FILE_TYPES = "${TEXT_MIME_TYPE}|${ZIP_FILE_MIME_TYPE}"
const val ALL_FILE_TYPES = "*/*" const val ALL_FILE_TYPES = "*/*"
const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs" const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
@@ -22,6 +22,8 @@ object Constants {
const val VPN_SETTINGS_PACKAGE = "android.net.vpn.SETTINGS" const val VPN_SETTINGS_PACKAGE = "android.net.vpn.SETTINGS"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024 const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
const val DEFAULT_EXPORT_FILE_NAME = "wgtunnel-export.zip"
const val SUBSCRIPTION_TIMEOUT = 5_000L const val SUBSCRIPTION_TIMEOUT = 5_000L
const val DEFAULT_PING_IP = "1.1.1.1" const val DEFAULT_PING_IP = "1.1.1.1"
@@ -1,19 +1,22 @@
package com.zaneschepke.wireguardautotunnel.util package com.zaneschepke.wireguardautotunnel.util
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.annotation.RequiresApi
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.util.extensions.getInputStreamFromUri import com.zaneschepke.wireguardautotunnel.util.extensions.getInputStreamFromUri
import com.zaneschepke.wireguardautotunnel.util.extensions.installApk
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
import java.io.BufferedOutputStream import java.io.*
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
@@ -26,53 +29,118 @@ class FileUtils(private val context: Context, private val ioDispatcher: Coroutin
suspend fun createWgFiles(tunnels: List<TunnelConf>): List<File> = suspend fun createWgFiles(tunnels: List<TunnelConf>): List<File> =
withContext(ioDispatcher) { withContext(ioDispatcher) {
tunnels.map { config -> tunnels.mapNotNull { config ->
if (config.wgQuick.isBlank()) {
Timber.w("Skipping tunnel ${config.tunName}: empty wgQuick config")
return@mapNotNull null
}
val file = File(context.cacheDir, "${config.tunName}-wg.conf") val file = File(context.cacheDir, "${config.tunName}-wg.conf")
file.outputStream().use { it.write(config.wgQuick.toByteArray()) } file.outputStream().use { it.write(config.wgQuick.toByteArray()) }
Timber.d("Created WG file: ${file.path}, size: ${file.length()} bytes")
if (file.length() == 0L) {
Timber.w("WG file ${file.path} is empty")
null
} else {
file file
} }
} }
}
suspend fun createAmFiles(tunnels: List<TunnelConf>): List<File> = suspend fun createAmFiles(tunnels: List<TunnelConf>): List<File> =
withContext(ioDispatcher) { withContext(ioDispatcher) {
tunnels tunnels
.filter { it.amQuick != TunnelConfig.AM_QUICK_DEFAULT } .filter { it.amQuick != TunnelConfig.AM_QUICK_DEFAULT && it.amQuick.isNotBlank() }
.map { config -> .mapNotNull { config ->
val file = File(context.cacheDir, "${config.tunName}-am.conf") val file = File(context.cacheDir, "${config.tunName}-am.conf")
file.outputStream().use { it.write(config.amQuick.toByteArray()) } file.outputStream().use { it.write(config.amQuick.toByteArray()) }
Timber.d("Created AM file: ${file.path}, size: ${file.length()} bytes")
if (file.length() == 0L) {
Timber.w("AM file ${file.path} is empty")
null
} else {
file file
} }
} }
}
suspend fun zipAll(zipFile: File, files: List<File>) = suspend fun zipAll(zipFile: File, files: List<File>) =
withContext(ioDispatcher) { withContext(ioDispatcher) {
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos -> ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
files.forEach { file -> files.forEach { file ->
val zipFileName = if (!file.exists() || file.length() == 0L) {
(file.parentFile?.let { parent -> Timber.w("Skipping file ${file.path}: does not exist or empty")
file.absolutePath.removePrefix(parent.absolutePath) return@forEach
} ?: file.absolutePath) }
.removePrefix("/") val entryName = file.name // Use file name only, avoid complex path logic
val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}") val entry = ZipEntry(entryName)
zos.putNextEntry(entry) zos.putNextEntry(entry)
if (file.isFile) { FileInputStream(file).use { fis ->
file.inputStream().use { it.copyTo(zos) } fis.copyTo(zos)
Timber.d(
"Added ${file.path} to zip as $entryName, size: ${file.length()} bytes"
)
} }
zos.closeEntry()
} }
zos.flush()
Timber.d("Finished zipping: ${zipFile.path}, size: ${zipFile.length()} bytes")
} }
} }
suspend fun createNewShareFile(name: String): File = suspend fun createNewShareFile(name: String): File =
withContext(ioDispatcher) { withContext(ioDispatcher) {
val sharePath = File(context.filesDir, "external_files") val sharePath = File(context.filesDir, "external_files")
if (sharePath.exists()) sharePath.delete() if (sharePath.exists()) sharePath.deleteRecursively()
sharePath.mkdir() sharePath.mkdirs()
val file = File("${sharePath.path}/$name") val file = File(sharePath, name)
if (file.exists()) file.delete() if (file.exists()) file.delete()
file.createNewFile() file.createNewFile()
Timber.d("Created share file: ${file.path}")
file file
} }
suspend fun copyFileToUri(sourceFile: File, destinationUri: Uri): Result<Unit> =
withContext(ioDispatcher) {
try {
if (!sourceFile.exists()) {
Timber.e("Source file does not exist: ${sourceFile.path}")
return@withContext Result.failure(IOException("Source file does not exist"))
}
if (!sourceFile.canRead()) {
Timber.e("Source file is not readable: ${sourceFile.path}")
return@withContext Result.failure(IOException("Source file is not readable"))
}
if (sourceFile.length() == 0L) {
Timber.e("Source file is empty: ${sourceFile.path}")
return@withContext Result.failure(IOException("Source file is empty"))
}
Timber.d("Copying file: ${sourceFile.path}, size: ${sourceFile.length()} bytes")
var bytesCopied = 0L
FileInputStream(sourceFile).use { inputStream ->
context.contentResolver.openOutputStream(destinationUri)?.use { outputStream ->
val buffer = ByteArray(1024 * 1024) // 1MB buffer
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
bytesCopied += bytesRead
Timber.d("Copied $bytesCopied bytes so far")
}
outputStream.flush()
Timber.d("Total bytes copied: $bytesCopied")
Result.success(Unit)
}
?: run {
Timber.e("Failed to open OutputStream for Uri: $destinationUri")
Result.failure(IOException("Failed to open OutputStream for Uri"))
}
}
} catch (e: IOException) {
Timber.e(e, "Error copying file to Uri: ${e.message}")
Result.failure(e)
}
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? { private fun getDisplayNameColumnIndex(cursor: Cursor): Int? {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (columnIndex == -1) return null if (columnIndex == -1) return null
@@ -161,4 +229,33 @@ class FileUtils(private val context: Context, private val ioDispatcher: Coroutin
FileProvider.getUriForFile(context, context.getString(R.string.provider), shareFile) FileProvider.getUriForFile(context, context.getString(R.string.provider), shareFile)
context.launchShareFile(uri) context.launchShareFile(uri)
} }
@RequiresApi(Build.VERSION_CODES.Q)
suspend fun saveToDownloadsWithMediaStore(file: File, mimeType: String): Uri? =
withContext(ioDispatcher) {
val contentValues =
ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, file.name)
put(MediaStore.Downloads.MIME_TYPE, mimeType)
put(MediaStore.Downloads.IS_PENDING, 1)
}
val resolver = context.contentResolver
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val itemUri = resolver.insert(collection, contentValues) ?: return@withContext null
resolver.openOutputStream(itemUri)?.use { output ->
file.inputStream().use { input -> input.copyTo(output) }
}
// Mark as finished
contentValues.clear()
contentValues.put(MediaStore.Downloads.IS_PENDING, 0)
resolver.update(itemUri, contentValues, null, null)
return@withContext itemUri
}
fun installApk(file: File) {
context.installApk(file)
}
} }
@@ -1,5 +1,6 @@
package com.zaneschepke.wireguardautotunnel.util package com.zaneschepke.wireguardautotunnel.util
import com.vdurmont.semver4j.Semver
import java.math.BigDecimal import java.math.BigDecimal
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
@@ -38,4 +39,10 @@ object NumberUtils {
null null
} }
} }
fun compareVersions(newVersion: String, currentVersion: String): Int {
val newSemver = Semver(newVersion, Semver.SemverType.LOOSE)
val currentSemver = Semver(currentVersion, Semver.SemverType.LOOSE)
return newSemver.compareTo(currentSemver)
}
} }
@@ -14,24 +14,21 @@ import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import android.widget.Toast import android.widget.Toast
import androidx.compose.ui.unit.Dp import androidx.core.content.FileProvider
import androidx.compose.ui.unit.TextUnit
import androidx.core.location.LocationManagerCompat import androidx.core.location.LocationManagerCompat
import androidx.core.net.toUri
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.service.tile.AutoTunnelControlTile import com.zaneschepke.wireguardautotunnel.core.service.tile.AutoTunnelControlTile
import com.zaneschepke.wireguardautotunnel.core.service.tile.TunnelControlTile import com.zaneschepke.wireguardautotunnel.core.service.tile.TunnelControlTile
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import java.io.File
import java.io.InputStream import java.io.InputStream
import timber.log.Timber
private const val BASELINE_HEIGHT = 2201
private const val BASELINE_WIDTH = 1080
private const val BASELINE_DENSITY = 2.625
private const val ANDROID_TV_SIZE_MULTIPLIER = 1.5f
fun Context.openWebUrl(url: String): Result<Unit> { fun Context.openWebUrl(url: String): Result<Unit> {
return kotlin return kotlin
.runCatching { .runCatching {
val webpage: Uri = Uri.parse(url) val webpage: Uri = url.toUri()
val intent = val intent =
Intent(Intent.ACTION_VIEW, webpage).apply { Intent(Intent.ACTION_VIEW, webpage).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -52,37 +49,6 @@ val Context.actionBarSize
attrs.getDimension(0, 0F).toInt().also { attrs.recycle() } attrs.getDimension(0, 0F).toInt().also { attrs.recycle() }
} }
fun Context.resizeHeight(dp: Dp): Dp {
val displayMetrics = resources.displayMetrics
val density = displayMetrics.density
val height =
(displayMetrics.heightPixels - this.actionBarSize) *
(if (isRunningOnTv()) ANDROID_TV_SIZE_MULTIPLIER else 1f)
val resizeHeightPercentage =
(height.toFloat() / BASELINE_HEIGHT) * (BASELINE_DENSITY.toFloat() / density)
return dp * resizeHeightPercentage
}
fun Context.resizeHeight(textUnit: TextUnit): TextUnit {
val displayMetrics = resources.displayMetrics
val density = displayMetrics.density
val height =
(displayMetrics.heightPixels - actionBarSize) *
(if (isRunningOnTv()) ANDROID_TV_SIZE_MULTIPLIER else 1f)
val resizeHeightPercentage =
(height.toFloat() / BASELINE_HEIGHT) * (BASELINE_DENSITY.toFloat() / density)
return textUnit * resizeHeightPercentage * 1.1
}
fun Context.resizeWidth(dp: Dp): Dp {
val displayMetrics = resources.displayMetrics
val density = displayMetrics.density
val width = displayMetrics.widthPixels
val resizeWidthPercentage =
(width.toFloat() / BASELINE_WIDTH) * (BASELINE_DENSITY.toFloat() / density)
return dp * resizeWidthPercentage
}
fun Context.launchNotificationSettings() { fun Context.launchNotificationSettings() {
if (isRunningOnTv()) return launchAppSettings() if (isRunningOnTv()) return launchAppSettings()
val settingsIntent: Intent = val settingsIntent: Intent =
@@ -92,6 +58,43 @@ fun Context.launchNotificationSettings() {
this.startActivity(settingsIntent) this.startActivity(settingsIntent)
} }
fun Context.hasSAFSupport(mimeType: String): Boolean {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = mimeType
addCategory(Intent.CATEGORY_OPENABLE)
}
val activitiesToResolveIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.queryIntentActivities(
intent,
PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()),
)
} else {
packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
}
Timber.d(
"Found ${activitiesToResolveIntent.size} activities: ${activitiesToResolveIntent.map { it.activityInfo.packageName }}"
)
return if (activitiesToResolveIntent.isEmpty()) {
Timber.w("No activities found to handle SAF intent")
false
} else if (
isRunningOnTv() &&
activitiesToResolveIntent.all {
val name = it.activityInfo.packageName
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) ||
name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB)
}
) {
Timber.w("Only stub file explorers found on TV")
false
} else {
true
}
}
fun Context.launchShareFile(file: Uri) { fun Context.launchShareFile(file: Uri) {
val shareIntent = val shareIntent =
Intent().apply { Intent().apply {
@@ -99,6 +102,7 @@ fun Context.launchShareFile(file: Uri) {
type = Constants.ALL_FILE_TYPES type = Constants.ALL_FILE_TYPES
putExtra(Intent.EXTRA_STREAM, file) putExtra(Intent.EXTRA_STREAM, file)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
val chooserIntent = val chooserIntent =
Intent.createChooser(shareIntent, "").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } Intent.createChooser(shareIntent, "").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
@@ -117,7 +121,7 @@ fun Context.showToast(resId: Int) {
fun Context.launchSupportEmail() { fun Context.launchSupportEmail() {
val intent = val intent =
Intent(Intent.ACTION_SENDTO).apply { Intent(Intent.ACTION_SENDTO).apply {
data = Uri.parse("mailto:") data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf(getString(R.string.my_email))) putExtra(Intent.EXTRA_EMAIL, arrayOf(getString(R.string.my_email)))
putExtra(Intent.EXTRA_SUBJECT, getString(R.string.email_subject)) putExtra(Intent.EXTRA_SUBJECT, getString(R.string.email_subject))
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -197,3 +201,27 @@ fun Context.getAllInternetCapablePackages(): List<PackageInfo> {
packageManager.getPackagesHoldingPermissions(permissions, 0) packageManager.getPackagesHoldingPermissions(permissions, 0)
} }
} }
fun Context.canInstallPackages(): Boolean {
return packageManager.canRequestPackageInstalls()
}
fun Context.requestInstallPackagesPermission() {
val intent =
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${packageName}".toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(intent)
}
fun Context.installApk(apkFile: File) {
val apkUri = FileProvider.getUriForFile(this, getString(R.string.provider), apkFile)
val intent =
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(apkUri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(intent)
}
@@ -20,3 +20,7 @@ typealias Tunnels = List<TunnelConf>
typealias TunnelConfigs = List<TunnelConfig> typealias TunnelConfigs = List<TunnelConfig>
typealias Packages = List<PackageInfo> typealias Packages = List<PackageInfo>
fun <T> MutableList<T>.addAllUnique(elements: Collection<T>, comparator: (T, T) -> Boolean) {
addAll(elements.filterNot { new -> this.any { existing -> comparator(existing, new) } })
}
@@ -2,7 +2,7 @@ package com.zaneschepke.wireguardautotunnel.util.extensions
import androidx.navigation.NavController import androidx.navigation.NavController
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.isCurrentRoute import com.zaneschepke.wireguardautotunnel.ui.navigation.isCurrentRoute
fun NavController.goFromRoot(route: Route) { fun NavController.goFromRoot(route: Route) {
if (currentBackStackEntry?.isCurrentRoute(route::class) == true) return if (currentBackStackEntry?.isCurrentRoute(route::class) == true) return

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