Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 7a5bbf81a7 build(deps): bump compose from 1.7.0 to 1.7.1
Bumps `compose` from 1.7.0 to 1.7.1.

Updates `androidx.compose.ui:ui-test-junit4` from 1.7.0 to 1.7.1

Updates `androidx.compose.ui:ui-tooling` from 1.7.0 to 1.7.1

Updates `androidx.compose.ui:ui-test-manifest` from 1.7.0 to 1.7.1

Updates `androidx.compose.ui:ui-graphics` from 1.7.0 to 1.7.1

Updates `androidx.compose.ui:ui-tooling-preview` from 1.7.0 to 1.7.1

Updates `androidx.compose.ui:ui` from 1.7.0 to 1.7.1

Updates `androidx.compose.material:material-icons-extended` from 1.7.0 to 1.7.1

---
updated-dependencies:
- dependency-name: androidx.compose.ui:ui-test-junit4
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-tooling
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-test-manifest
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-graphics
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-tooling-preview
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.material:material-icons-extended
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-15 04:32:05 +00:00
152 changed files with 2417 additions and 2839 deletions
-1
View File
@@ -1,3 +1,2 @@
ko_fi: zaneschepke ko_fi: zaneschepke
liberapay: zaneschepke liberapay: zaneschepke
github: zaneschepke
-1
View File
@@ -15,7 +15,6 @@ A clear and concise description of what the bug is.
- Device: [e.g. Pixel 4a] - Device: [e.g. Pixel 4a]
- Android Version: [e.g. Android 13] - Android Version: [e.g. Android 13]
- App Version [e.g. 3.3.3] - App Version [e.g. 3.3.3]
- Backend: [e.g. Kernel, Userspace]
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
@@ -1,4 +1,4 @@
name: on-issue name: Issue Updates Workflow
on: on:
issues: issues:
@@ -7,8 +7,8 @@ on:
jobs: jobs:
on-issue: build:
name: On new issue name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Send Telegram Message - name: Send Telegram Message
@@ -1,4 +1,4 @@
name: on-publish name: Release Updates Workflow
on: on:
release: release:
@@ -7,8 +7,8 @@ on:
jobs: jobs:
on-publish: build:
name: On publish name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Send Telegram Message - name: Send Telegram Message
+5 -41
View File
@@ -2,7 +2,7 @@ name: release-android
on: on:
schedule: schedule:
- cron: "4 3 * * *" - cron: "4 3 * * *"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
track: track:
@@ -14,7 +14,7 @@ on:
- alpha - alpha
- beta - beta
- production - production
default: none default: alpha
required: true required: true
release_type: release_type:
type: choice type: choice
@@ -30,34 +30,13 @@ on:
description: "Tag name for release" description: "Tag name for release"
required: false required: false
default: nightly default: nightly
workflow_call:
jobs: jobs:
check_commits:
runs-on: ubuntu-latest
outputs:
new_commits: ${{ steps.check_last_commit.outputs.new_commits }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Check for new commits in the last 23 hours
id: check_last_commit
run: |
if git log --since="23 hours ago" --oneline | grep -q .; then
echo "New commits found in the last 23 hours."
echo "new_commits=true" >> $GITHUB_OUTPUT
else
echo "No new commits in the last 23 hours."
echo "new_commits=false" >> $GITHUB_OUTPUT
fi
build: build:
needs: check_commits
if: needs.check_commits.outputs.new_commits == 'true'
name: Build Signed APK name: Build Signed APK
if: ${{ inputs.release_type != 'none' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
@@ -131,24 +110,9 @@ jobs:
version_code=$(grep "VERSION_CODE" buildSrc/src/main/kotlin/Constants.kt | awk '{print $5}' | tr -d '\n') version_code=$(grep "VERSION_CODE" buildSrc/src/main/kotlin/Constants.kt | awk '{print $5}' | tr -d '\n')
echo "VERSION_CODE=$version_code" >> $GITHUB_ENV echo "VERSION_CODE=$version_code" >> $GITHUB_ENV
- name: Commit and push versionCode changes
if: ${{ inputs.release_type == '' || inputs.release_type == 'nightly' || inputs.release_type == 'prerelease' }}
run: |
git config --global user.name 'GitHub Actions'
git config --global user.email 'actions@github.com'
git add versionCode.txt
git commit -m "Automated build update"
- name: Push changes
if: ${{ inputs.release_type == '' || inputs.release_type == 'nightly' || inputs.release_type == 'prerelease' }}
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.ref }}
# Save the APK after the Build job is complete to publish it as a Github release in the next job # Save the APK after the Build job is complete to publish it as a Github release in the next job
- name: Upload APK - name: Upload APK
uses: actions/upload-artifact@v4.4.3 uses: actions/upload-artifact@v4.3.6
with: with:
name: wgtunnel name: wgtunnel
path: ${{ env.APK_PATH }} path: ${{ env.APK_PATH }}
+15 -36
View File
@@ -8,21 +8,6 @@ plugins {
alias(libs.plugins.grgit) alias(libs.plugins.grgit)
} }
val versionFile = file("$rootDir/versionCode.txt")
val versionCodeIncrement = with(getBuildTaskName().lowercase()) {
when {
this.contains(Constants.NIGHTLY) || this.contains(Constants.PRERELEASE) -> {
if (versionFile.exists()) {
versionFile.readText().toInt() + 1
} else {
1
}
}
else -> 0
}
}
android { android {
namespace = Constants.APP_ID namespace = Constants.APP_ID
compileSdk = Constants.TARGET_SDK compileSdk = Constants.TARGET_SDK
@@ -35,7 +20,7 @@ android {
applicationId = Constants.APP_ID applicationId = Constants.APP_ID
minSdk = Constants.MIN_SDK minSdk = Constants.MIN_SDK
targetSdk = Constants.TARGET_SDK targetSdk = Constants.TARGET_SDK
versionCode = Constants.VERSION_CODE + versionCodeIncrement versionCode = determineVersionCode()
versionName = determineVersionName() versionName = determineVersionName()
ksp { arg("room.schemaLocation", "$projectDir/schemas") } ksp { arg("room.schemaLocation", "$projectDir/schemas") }
@@ -72,14 +57,12 @@ android {
"proguard-rules.pro", "proguard-rules.pro",
) )
signingConfig = signingConfigs.getByName(Constants.RELEASE) signingConfig = signingConfigs.getByName(Constants.RELEASE)
resValue("string", "provider", "\"${Constants.APP_NAME}.provider\"")
} }
debug { debug {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
versionNameSuffix = "-debug" versionNameSuffix = "-debug"
resValue("string", "app_name", "WG Tunnel - Debug") resValue("string", "app_name", "WG Tunnel - Debug")
isDebuggable = true isDebuggable = true
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.debug\"")
} }
create(Constants.PRERELEASE) { create(Constants.PRERELEASE) {
@@ -87,7 +70,6 @@ android {
applicationIdSuffix = ".prerelease" applicationIdSuffix = ".prerelease"
versionNameSuffix = "-pre" versionNameSuffix = "-pre"
resValue("string", "app_name", "WG Tunnel - Pre") resValue("string", "app_name", "WG Tunnel - Pre")
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.pre\"")
} }
create(Constants.NIGHTLY) { create(Constants.NIGHTLY) {
@@ -95,7 +77,6 @@ android {
applicationIdSuffix = ".nightly" applicationIdSuffix = ".nightly"
versionNameSuffix = "-nightly" versionNameSuffix = "-nightly"
resValue("string", "app_name", "WG Tunnel - Nightly") resValue("string", "app_name", "WG Tunnel - Nightly")
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.nightly\"")
} }
applicationVariants.all { applicationVariants.all {
@@ -133,6 +114,8 @@ android {
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
} }
val generalImplementation by configurations
dependencies { dependencies {
implementation(project(":logcatter")) implementation(project(":logcatter"))
@@ -174,6 +157,8 @@ dependencies {
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.zaneschepke.multifab)
// hilt // hilt
implementation(libs.hilt.android) implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)
@@ -214,6 +199,16 @@ dependencies {
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
} }
fun determineVersionCode(): Int {
return with(getBuildTaskName().lowercase()) {
when {
contains(Constants.NIGHTLY) -> Constants.VERSION_CODE + Constants.NIGHTLY_CODE
contains(Constants.PRERELEASE) -> Constants.VERSION_CODE + Constants.PRERELEASE_CODE
else -> Constants.VERSION_CODE
}
}
}
fun determineVersionName(): String { fun determineVersionName(): String {
return with(getBuildTaskName().lowercase()) { return with(getBuildTaskName().lowercase()) {
when { when {
@@ -224,19 +219,3 @@ fun determineVersionName(): String {
} }
} }
} }
val incrementVersionCode by tasks.registering {
doLast {
val versionFile = file("$rootDir/versionCode.txt")
if (versionFile.exists()) {
versionFile.writeText(versionCodeIncrement.toString())
println("Incremented versionCode to $versionCodeIncrement")
}
}
}
tasks.whenTaskAdded {
if (name.startsWith("assemble") && !name.lowercase().contains("debug")) {
dependsOn(incrementVersionCode)
}
}
+12 -14
View File
@@ -10,7 +10,10 @@
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" android:maxSdkVersion="32"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission
android:name="android.permission.ACCESS_WIFI_STATE"
android:maxSdkVersion="30"
tools:ignore="LeanbackUsesWifi" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
@@ -63,12 +66,12 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.App.Start" android:theme="@style/Theme.AppSplashScreen"
tools:targetApi="tiramisu"> tools:targetApi="tiramisu">
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.SplashActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.WireguardAutoTunnel"> android:theme="@style/Theme.AppSplashScreen">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -80,6 +83,11 @@
android:name="android.app.shortcuts" android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" /> android:resource="@xml/shortcuts" />
</activity> </activity>
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.WireguardAutoTunnel">
</activity>
<activity <activity
android:name="com.journeyapps.barcodescanner.CaptureActivity" android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
@@ -95,16 +103,6 @@
android:launchMode="singleInstance" android:launchMode="singleInstance"
android:theme="@android:style/Theme.NoDisplay" /> android:theme="@android:style/Theme.NoDisplay" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="@string/provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service <service
android:name=".service.tile.TunnelControlTile" android:name=".service.tile.TunnelControlTile"
android:exported="true" android:exported="true"
@@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel
import android.app.Application import android.app.Application
import android.os.StrictMode import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy import android.os.StrictMode.ThreadPolicy
import com.zaneschepke.logcatter.LogReader import com.zaneschepke.logcatter.LocalLogCollector
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
@@ -23,7 +23,7 @@ class WireGuardAutoTunnel : Application() {
lateinit var applicationScope: CoroutineScope lateinit var applicationScope: CoroutineScope
@Inject @Inject
lateinit var logReader: LogReader lateinit var localLogCollector: LocalLogCollector
@Inject @Inject
@IoDispatcher @IoDispatcher
@@ -47,7 +47,7 @@ class WireGuardAutoTunnel : Application() {
} }
if (!isRunningOnTv()) { if (!isRunningOnTv()) {
applicationScope.launch(ioDispatcher) { applicationScope.launch(ioDispatcher) {
logReader.start() localLogCollector.start()
} }
} }
} }
@@ -25,7 +25,6 @@ class DataStoreManager(
val BATTERY_OPTIMIZE_DISABLE_SHOWN = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN") val BATTERY_OPTIMIZE_DISABLE_SHOWN = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN")
val CURRENT_SSID = stringPreferencesKey("CURRENT_SSID") val CURRENT_SSID = stringPreferencesKey("CURRENT_SSID")
val IS_PIN_LOCK_ENABLED = booleanPreferencesKey("PIN_LOCK_ENABLED") val IS_PIN_LOCK_ENABLED = booleanPreferencesKey("PIN_LOCK_ENABLED")
val IS_TUNNEL_STATS_EXPANDED = booleanPreferencesKey("TUNNEL_STATS_EXPANDED")
} }
// preferences // preferences
@@ -4,12 +4,11 @@ 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 lastActiveTunnelId: Int? = null,
) { ) {
companion object { companion object {
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
} }
} }
@@ -58,11 +58,6 @@ data class TunnelConfig(
) )
var pingIp: String? = null, var pingIp: String? = null,
) { ) {
fun toAmConfig(): org.amnezia.awg.config.Config {
return configFromAmQuick(if (amQuick != "") amQuick else wgQuick)
}
companion object { companion object {
fun configFromWgQuick(wgQuick: String): Config { fun configFromWgQuick(wgQuick: String): Config {
@@ -20,9 +20,5 @@ interface AppStateRepository {
suspend fun setCurrentSsid(ssid: String) suspend fun setCurrentSsid(ssid: String)
suspend fun isTunnelStatsExpanded(): Boolean
suspend fun setTunnelStatsExpanded(expanded: Boolean)
val generalStateFlow: Flow<GeneralState> val generalStateFlow: Flow<GeneralState>
} }
@@ -2,56 +2,57 @@ package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.domain.GeneralState import com.zaneschepke.wireguardautotunnel.data.domain.GeneralState
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
class DataStoreAppStateRepository( class DataStoreAppStateRepository(
private val dataStoreManager: DataStoreManager, private val dataStoreManager: DataStoreManager,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ) :
AppStateRepository { AppStateRepository {
override suspend fun isLocationDisclosureShown(): Boolean { override suspend fun isLocationDisclosureShown(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN) return withContext(ioDispatcher) {
?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN)
?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT
}
} }
override suspend fun setLocationDisclosureShown(shown: Boolean) { override suspend fun setLocationDisclosureShown(shown: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, shown) withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, shown) }
} }
override suspend fun isPinLockEnabled(): Boolean { override suspend fun isPinLockEnabled(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.IS_PIN_LOCK_ENABLED) return withContext(ioDispatcher) {
?: GeneralState.PIN_LOCK_ENABLED_DEFAULT dataStoreManager.getFromStore(DataStoreManager.IS_PIN_LOCK_ENABLED)
?: GeneralState.PIN_LOCK_ENABLED_DEFAULT
}
} }
override suspend fun setPinLockEnabled(enabled: Boolean) { override suspend fun setPinLockEnabled(enabled: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.IS_PIN_LOCK_ENABLED, enabled) withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.IS_PIN_LOCK_ENABLED, enabled) }
} }
override suspend fun isBatteryOptimizationDisableShown(): Boolean { override suspend fun isBatteryOptimizationDisableShown(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN) return withContext(ioDispatcher) {
?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT dataStoreManager.getFromStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN)
?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT
}
} }
override suspend fun setBatteryOptimizationDisableShown(shown: Boolean) { override suspend fun setBatteryOptimizationDisableShown(shown: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, shown) withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, shown) }
} }
override suspend fun getCurrentSsid(): String? { override suspend fun getCurrentSsid(): String? {
return dataStoreManager.getFromStore(DataStoreManager.CURRENT_SSID) return withContext(ioDispatcher) { dataStoreManager.getFromStore(DataStoreManager.CURRENT_SSID) }
} }
override suspend fun setCurrentSsid(ssid: String) { override suspend fun setCurrentSsid(ssid: String) {
dataStoreManager.saveToDataStore(DataStoreManager.CURRENT_SSID, ssid) withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.CURRENT_SSID, ssid) }
}
override suspend fun isTunnelStatsExpanded(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.IS_TUNNEL_STATS_EXPANDED)
?: GeneralState.IS_TUNNEL_STATS_EXPANDED
}
override suspend fun setTunnelStatsExpanded(expanded: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.IS_TUNNEL_STATS_EXPANDED, expanded)
} }
override val generalStateFlow: Flow<GeneralState> = override val generalStateFlow: Flow<GeneralState> =
@@ -68,7 +69,6 @@ class DataStoreAppStateRepository(
isPinLockEnabled = isPinLockEnabled =
pref[DataStoreManager.IS_PIN_LOCK_ENABLED] pref[DataStoreManager.IS_PIN_LOCK_ENABLED]
?: GeneralState.PIN_LOCK_ENABLED_DEFAULT, ?: GeneralState.PIN_LOCK_ENABLED_DEFAULT,
isTunnelStatsExpanded = pref[DataStoreManager.IS_TUNNEL_STATS_EXPANDED] ?: GeneralState.IS_TUNNEL_STATS_EXPANDED,
) )
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
Timber.e(e) Timber.e(e)
@@ -1,8 +1,8 @@
package com.zaneschepke.wireguardautotunnel.module package com.zaneschepke.wireguardautotunnel.module
import android.content.Context import android.content.Context
import com.zaneschepke.logcatter.LogReader import com.zaneschepke.logcatter.LocalLogCollector
import com.zaneschepke.logcatter.LogcatCollector import com.zaneschepke.logcatter.LogcatUtil
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -24,7 +24,7 @@ class AppModule {
@Singleton @Singleton
@Provides @Provides
fun provideLogCollect(@ApplicationContext context: Context): LogReader { fun provideLogCollect(@ApplicationContext context: Context): LocalLogCollector {
return LogcatCollector.init(context = context) return LogcatUtil.init(context = context)
} }
} }
@@ -0,0 +1,22 @@
package com.zaneschepke.wireguardautotunnel.module
import android.content.Context
import androidx.navigation.NavHostController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.NavigationService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityRetainedComponent
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ActivityRetainedScoped
@Module
@InstallIn(ActivityRetainedComponent::class)
object NavigationModule {
@Provides
@ActivityRetainedScoped
fun provideNestedNavController(@ApplicationContext context: Context): NavHostController {
return NavigationService(context).navController
}
}
@@ -72,8 +72,8 @@ class RepositoryModule {
@Provides @Provides
@Singleton @Singleton
fun provideGeneralStateRepository(dataStoreManager: DataStoreManager): AppStateRepository { fun provideGeneralStateRepository(dataStoreManager: DataStoreManager, @IoDispatcher ioDispatcher: CoroutineDispatcher): AppStateRepository {
return DataStoreAppStateRepository(dataStoreManager) return DataStoreAppStateRepository(dataStoreManager, ioDispatcher)
} }
@Provides @Provides
@@ -4,11 +4,8 @@ import android.content.Context
import com.wireguard.android.backend.Backend import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.RootTunnelActionHandler import com.wireguard.android.backend.RootTunnelActionHandler
import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.util.RootShell import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel
import dagger.Module import dagger.Module
@@ -46,8 +43,8 @@ class TunnelModule {
@Provides @Provides
@Singleton @Singleton
@Kernel @Kernel
fun provideKernelBackend(@ApplicationContext context: Context, rootShell: RootShell): Backend { fun provideKernelBackend(@ApplicationContext context: Context, rootShell: org.amnezia.awg.util.RootShell): org.amnezia.awg.backend.Backend {
return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell), RootTunnelActionHandler(rootShell)) return org.amnezia.awg.backend.AwgQuickBackend(context, rootShell, org.amnezia.awg.util.ToolsInstaller(context, rootShell))
} }
@Provides @Provides
@@ -60,15 +57,15 @@ class TunnelModule {
@Singleton @Singleton
fun provideVpnService( fun provideVpnService(
amneziaBackend: Provider<org.amnezia.awg.backend.Backend>, amneziaBackend: Provider<org.amnezia.awg.backend.Backend>,
@Kernel kernelBackend: Provider<Backend>, @Userspace userspaceBackend: Provider<Backend>,
@Kernel kernelBackend: Provider<org.amnezia.awg.backend.Backend>,
appDataRepository: AppDataRepository, appDataRepository: AppDataRepository,
tunnelConfigRepository: TunnelConfigRepository,
@ApplicationScope applicationScope: CoroutineScope, @ApplicationScope applicationScope: CoroutineScope,
@IoDispatcher ioDispatcher: CoroutineDispatcher, @IoDispatcher ioDispatcher: CoroutineDispatcher,
): TunnelService { ): TunnelService {
return WireGuardTunnel( return WireGuardTunnel(
amneziaBackend, amneziaBackend,
tunnelConfigRepository, userspaceBackend,
kernelBackend, kernelBackend,
appDataRepository, appDataRepository,
applicationScope, applicationScope,
@@ -7,7 +7,6 @@ import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -34,9 +33,7 @@ class BootReceiver : BroadcastReceiver() {
with(appDataRepository.settings.getSettings()) { with(appDataRepository.settings.getSettings()) {
if (isRestoreOnBootEnabled) { if (isRestoreOnBootEnabled) {
val activeTunnels = appDataRepository.tunnels.getActive() val activeTunnels = appDataRepository.tunnels.getActive()
val tunState = tunnelService.get().vpnState.value.status if (activeTunnels.isNotEmpty()) {
if (activeTunnels.isNotEmpty() && tunState != TunnelState.UP) {
Timber.i("Starting previously active tunnel")
context.startTunnelBackground(activeTunnels.first().id) context.startTunnelBackground(activeTunnels.first().id)
} }
if (isAutoTunnelEnabled) { if (isAutoTunnelEnabled) {
@@ -2,13 +2,11 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.NetworkCapabilities
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.wireguard.android.util.RootShell
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.Settings import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
@@ -25,7 +23,6 @@ import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.getCurrentWifiName
import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList
import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable
import com.zaneschepke.wireguardautotunnel.util.extensions.onNotRunning import com.zaneschepke.wireguardautotunnel.util.extensions.onNotRunning
@@ -36,7 +33,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -49,9 +45,6 @@ import javax.inject.Provider
class AutoTunnelService : LifecycleService() { class AutoTunnelService : LifecycleService() {
private val foregroundId = 122 private val foregroundId = 122
@Inject
lateinit var rootShell: Provider<RootShell>
@Inject @Inject
lateinit var wifiService: NetworkService<WifiService> lateinit var wifiService: NetworkService<WifiService>
@@ -78,7 +71,7 @@ class AutoTunnelService : LifecycleService() {
@MainImmediateDispatcher @MainImmediateDispatcher
lateinit var mainImmediateDispatcher: CoroutineDispatcher lateinit var mainImmediateDispatcher: CoroutineDispatcher
private val autoTunnelStateFlow = MutableStateFlow(AutoTunnelState()) private val networkEventsFlow = MutableStateFlow(AutoTunnelState())
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
@@ -139,7 +132,6 @@ class AutoTunnelService : LifecycleService() {
initWakeLock() initWakeLock()
} }
startSettingsJob() startSettingsJob()
startVpnStateJob()
}.onFailure { }.onFailure {
Timber.e(it) Timber.e(it)
} }
@@ -199,10 +191,6 @@ class AutoTunnelService : LifecycleService() {
watchForSettingsChanges() watchForSettingsChanges()
} }
private fun startVpnStateJob() = lifecycleScope.launch {
watchForVpnStateChanges()
}
private fun startWifiJob() = lifecycleScope.launch { private fun startWifiJob() = lifecycleScope.launch {
watchForWifiConnectivityChanges() watchForWifiConnectivityChanges()
} }
@@ -230,7 +218,7 @@ class AutoTunnelService : LifecycleService() {
when (status) { when (status) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.i("Gained Mobile data connection") Timber.i("Gained Mobile data connection")
autoTunnelStateFlow.update { networkEventsFlow.update {
it.copy( it.copy(
isMobileDataConnected = true, isMobileDataConnected = true,
) )
@@ -238,7 +226,7 @@ class AutoTunnelService : LifecycleService() {
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
autoTunnelStateFlow.update { networkEventsFlow.update {
it.copy( it.copy(
isMobileDataConnected = true, isMobileDataConnected = true,
) )
@@ -247,7 +235,7 @@ class AutoTunnelService : LifecycleService() {
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
autoTunnelStateFlow.update { networkEventsFlow.update {
it.copy( it.copy(
isMobileDataConnected = false, isMobileDataConnected = false,
) )
@@ -265,8 +253,7 @@ class AutoTunnelService : LifecycleService() {
runCatching { runCatching {
do { do {
val vpnState = tunnelService.get().vpnState.value val vpnState = tunnelService.get().vpnState.value
val settings = appDataRepository.settings.getSettings() if (vpnState.status == TunnelState.UP) {
if (vpnState.status == TunnelState.UP && !settings.isAutoTunnelPaused) {
if (vpnState.tunnelConfig != null) { if (vpnState.tunnelConfig != null) {
val config = TunnelConfig.configFromWgQuick(vpnState.tunnelConfig.wgQuick) val config = TunnelConfig.configFromWgQuick(vpnState.tunnelConfig.wgQuick)
val results = if (vpnState.tunnelConfig.pingIp != null) { val results = if (vpnState.tunnelConfig.pingIp != null) {
@@ -296,8 +283,16 @@ class AutoTunnelService : LifecycleService() {
} }
} }
private fun updateSettings(settings: Settings) {
networkEventsFlow.update {
it.copy(
settings = settings,
)
}
}
private fun onAutoTunnelPause(paused: Boolean) { private fun onAutoTunnelPause(paused: Boolean) {
if (autoTunnelStateFlow.value.settings.isAutoTunnelPaused if (networkEventsFlow.value.settings.isAutoTunnelPaused
!= paused != paused
) { ) {
when (paused) { when (paused) {
@@ -311,36 +306,19 @@ class AutoTunnelService : LifecycleService() {
Timber.i("Starting settings watcher") Timber.i("Starting settings watcher")
withContext(ioDispatcher) { withContext(ioDispatcher) {
appDataRepository.settings.getSettingsFlow().combine( appDataRepository.settings.getSettingsFlow().combine(
// ignore isActive changes to allow manual tunnel overrides appDataRepository.tunnels.getTunnelConfigsFlow(),
appDataRepository.tunnels.getTunnelConfigsFlow().distinctUntilChanged { old, new ->
old.map { it.isActive } != new.map { it.isActive }
},
) { settings, tunnels -> ) { settings, tunnels ->
autoTunnelStateFlow.value.copy( val activeTunnel = tunnels.firstOrNull { it.isActive }
settings = settings, if (!settings.isPingEnabled) {
tunnels = tunnels, settings.copy(isPingEnabled = activeTunnel?.isPingEnabled ?: false)
) } else {
}.collect { settings
onAutoTunnelPause(it.settings.isAutoTunnelPaused)
manageJobsBySettings(it.settings)
autoTunnelStateFlow.emit(it)
}
}
}
private suspend fun watchForVpnStateChanges() {
Timber.i("Starting vpn state watcher")
withContext(ioDispatcher) {
tunnelService.get().vpnState.collect { state ->
state.tunnelConfig?.let {
val settings = appDataRepository.settings.getSettings()
if (it.isPingEnabled && !settings.isPingEnabled) {
pingJob.onNotRunning { pingJob = startPingJob() }
}
if (!it.isPingEnabled && !settings.isPingEnabled) {
cancelAndResetPingJob()
}
} }
}.collect {
Timber.d("Settings change: $it")
onAutoTunnelPause(it.isAutoTunnelPaused)
updateSettings(it)
manageJobsBySettings(it)
} }
} }
} }
@@ -396,21 +374,13 @@ class AutoTunnelService : LifecycleService() {
} }
private fun updateEthernet(connected: Boolean) { private fun updateEthernet(connected: Boolean) {
autoTunnelStateFlow.update { networkEventsFlow.update {
it.copy( it.copy(
isEthernetConnected = connected, isEthernetConnected = connected,
) )
} }
} }
private fun updateWifi(connected: Boolean) {
autoTunnelStateFlow.update {
it.copy(
isWifiConnected = connected,
)
}
}
private suspend fun watchForEthernetConnectivityChanges() { private suspend fun watchForEthernetConnectivityChanges() {
withContext(ioDispatcher) { withContext(ioDispatcher) {
Timber.i("Starting ethernet data watcher") Timber.i("Starting ethernet data watcher")
@@ -442,13 +412,21 @@ class AutoTunnelService : LifecycleService() {
when (status) { when (status) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.i("Gained Wi-Fi connection") Timber.i("Gained Wi-Fi connection")
updateWifi(true) networkEventsFlow.update {
it.copy(
isWifiConnected = true,
)
}
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
Timber.i("Wifi capabilities changed") Timber.i("Wifi capabilities changed")
updateWifi(true) networkEventsFlow.update {
val ssid = getWifiSSID(status.networkCapabilities) it.copy(
isWifiConnected = true,
)
}
val ssid = wifiService.getNetworkName(status.networkCapabilities)
ssid?.let { name -> ssid?.let { name ->
if (name.contains(Constants.UNREADABLE_SSID)) { if (name.contains(Constants.UNREADABLE_SSID)) {
Timber.w("SSID unreadable: missing permissions") Timber.w("SSID unreadable: missing permissions")
@@ -456,7 +434,7 @@ class AutoTunnelService : LifecycleService() {
Timber.i("Detected valid SSID") Timber.i("Detected valid SSID")
} }
appDataRepository.appState.setCurrentSsid(name) appDataRepository.appState.setCurrentSsid(name)
autoTunnelStateFlow.update { networkEventsFlow.update {
it.copy( it.copy(
currentNetworkSSID = name, currentNetworkSSID = name,
) )
@@ -465,7 +443,11 @@ class AutoTunnelService : LifecycleService() {
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
updateWifi(false) networkEventsFlow.update {
it.copy(
isWifiConnected = false,
)
}
Timber.i("Lost Wi-Fi connection") Timber.i("Lost Wi-Fi connection")
} }
} }
@@ -473,20 +455,14 @@ class AutoTunnelService : LifecycleService() {
} }
} }
private suspend fun getWifiSSID(networkCapabilities: NetworkCapabilities): String? {
return withContext(ioDispatcher) {
try {
rootShell.get().getCurrentWifiName()
} catch (_: Exception) {
wifiService.getNetworkName(networkCapabilities)
}
}
}
private suspend fun getMobileDataTunnel(): TunnelConfig? { private suspend fun getMobileDataTunnel(): TunnelConfig? {
return appDataRepository.tunnels.findByMobileDataTunnel().firstOrNull() return appDataRepository.tunnels.findByMobileDataTunnel().firstOrNull()
} }
private suspend fun getSsidTunnel(ssid: String): TunnelConfig? {
return appDataRepository.tunnels.findByTunnelNetworksName(ssid).firstOrNull()
}
private fun isTunnelDown(): Boolean { private fun isTunnelDown(): Boolean {
return tunnelService.get().vpnState.value.status == TunnelState.DOWN return tunnelService.get().vpnState.value.status == TunnelState.DOWN
} }
@@ -494,7 +470,7 @@ class AutoTunnelService : LifecycleService() {
private suspend fun handleNetworkEventChanges() { private suspend fun handleNetworkEventChanges() {
withContext(ioDispatcher) { withContext(ioDispatcher) {
Timber.i("Starting network event watcher") Timber.i("Starting network event watcher")
autoTunnelStateFlow.collectLatest { watcherState -> networkEventsFlow.collectLatest { watcherState ->
val autoTunnel = "Auto-tunnel watcher" val autoTunnel = "Auto-tunnel watcher"
if (!watcherState.settings.isAutoTunnelPaused) { if (!watcherState.settings.isAutoTunnelPaused) {
// delay for rapid network state changes and then collect latest // delay for rapid network state changes and then collect latest
@@ -540,7 +516,7 @@ class AutoTunnelService : LifecycleService() {
Timber.i( Timber.i(
"$autoTunnel - tunnel on ssid not associated with current tunnel condition met", "$autoTunnel - tunnel on ssid not associated with current tunnel condition met",
) )
watcherState.tunnels.firstOrNull { it.tunnelNetworks.isMatchingToWildcardList(watcherState.currentNetworkSSID) }?.let { getSsidTunnel(watcherState.currentNetworkSSID)?.let {
Timber.i("Found tunnel associated with this SSID, bringing tunnel up: ${it.name}") Timber.i("Found tunnel associated with this SSID, bringing tunnel up: ${it.name}")
if (isTunnelDown() || activeTunnel?.id != it.id) { if (isTunnelDown() || activeTunnel?.id != it.id) {
tunnelService.get().startTunnel(it) tunnelService.get().startTunnel(it)
@@ -1,7 +1,6 @@
package com.zaneschepke.wireguardautotunnel.service.foreground package com.zaneschepke.wireguardautotunnel.service.foreground
import com.zaneschepke.wireguardautotunnel.data.domain.Settings import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList
data class AutoTunnelState( data class AutoTunnelState(
@@ -10,7 +9,6 @@ data class AutoTunnelState(
val isMobileDataConnected: Boolean = false, val isMobileDataConnected: Boolean = false,
val currentNetworkSSID: String = "", val currentNetworkSSID: String = "",
val settings: Settings = Settings(), val settings: Settings = Settings(),
val tunnels: TunnelConfigs = emptyList(),
) { ) {
fun isEthernetConditionMet(): Boolean { fun isEthernetConditionMet(): Boolean {
return ( return (
@@ -5,6 +5,8 @@ import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.net.wifi.SupplicantState
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import android.os.Build import android.os.Build
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
@@ -19,7 +21,7 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
private val connectivityManager = private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val wifiManager = private val wifiManager =
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
override val networkStatus = override val networkStatus =
@@ -81,6 +83,30 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
awaitClose { connectivityManager.unregisterNetworkCallback(networkStatusCallback) } awaitClose { connectivityManager.unregisterNetworkCallback(networkStatusCallback) }
} }
override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? {
var ssid: String? = getWifiNameFromCapabilities(networkCapabilities)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
val info = wifiManager.connectionInfo
if (info.supplicantState === SupplicantState.COMPLETED) {
ssid = info.ssid
}
}
return ssid?.trim('"')
}
companion object {
private fun getWifiNameFromCapabilities(networkCapabilities: NetworkCapabilities): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val info: WifiInfo
if (networkCapabilities.transportInfo is WifiInfo) {
info = networkCapabilities.transportInfo as WifiInfo
return info.ssid
}
}
return null
}
}
} }
inline fun <Result> Flow<NetworkStatus>.map( inline fun <Result> Flow<NetworkStatus>.map(
@@ -10,9 +10,4 @@ class EthernetService
constructor( constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
) : ) :
BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET) { BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET)
override fun isNetworkSecure(): Boolean {
return true
}
}
@@ -10,8 +10,4 @@ class MobileDataService
constructor( constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
) : ) :
BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR) { BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR)
override fun isNetworkSecure(): Boolean {
return false
}
}
@@ -1,16 +0,0 @@
package com.zaneschepke.wireguardautotunnel.service.network
import android.net.NetworkCapabilities
import android.net.wifi.WifiInfo
import android.os.Build
fun NetworkCapabilities.getWifiName(): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val info: WifiInfo
if (transportInfo is WifiInfo) {
info = transportInfo as WifiInfo
return info.ssid
}
}
return null
}
@@ -4,11 +4,7 @@ import android.net.NetworkCapabilities
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface NetworkService<T> { interface NetworkService<T> {
fun getNetworkName(networkCapabilities: NetworkCapabilities): String? { fun getNetworkName(networkCapabilities: NetworkCapabilities): String?
return null
}
fun isNetworkSecure(): Boolean
val networkStatus: Flow<NetworkStatus> val networkStatus: Flow<NetworkStatus>
} }
@@ -2,8 +2,6 @@ package com.zaneschepke.wireguardautotunnel.service.network
import android.content.Context import android.content.Context
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.wifi.SupplicantState
import android.os.Build
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
@@ -12,21 +10,4 @@ class WifiService
constructor( constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
) : ) :
BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI) { BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI)
override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? {
var ssid = networkCapabilities.getWifiName()
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) {
val info = wifiManager.connectionInfo
if (info.supplicantState === SupplicantState.COMPLETED) {
ssid = info.ssid
}
}
return ssid?.trim('"')
}
override fun isNetworkSecure(): Boolean {
// TODO
return false
}
}
@@ -9,7 +9,7 @@ import android.content.Intent
import android.graphics.Color import android.graphics.Color
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.MainActivity import com.zaneschepke.wireguardautotunnel.ui.SplashActivity
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
@@ -63,7 +63,7 @@ constructor(
} }
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
val pendingIntent: PendingIntent = val pendingIntent: PendingIntent =
Intent(context, MainActivity::class.java).let { notificationIntent -> Intent(context, SplashActivity::class.java).let { notificationIntent ->
PendingIntent.getActivity( PendingIntent.getActivity(
context, context,
0, 0,
@@ -2,24 +2,25 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Backend import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.Tunnel.State import com.wireguard.android.backend.Tunnel.State
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.module.Kernel import com.zaneschepke.wireguardautotunnel.module.Kernel
import com.zaneschepke.wireguardautotunnel.module.Userspace
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.AmneziaStatistics import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.AmneziaStatistics
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.WireGuardStatistics import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.WireGuardStatistics
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.amnezia.awg.backend.Tunnel import org.amnezia.awg.backend.Tunnel
@@ -31,30 +32,15 @@ class WireGuardTunnel
@Inject @Inject
constructor( constructor(
private val amneziaBackend: Provider<org.amnezia.awg.backend.Backend>, private val amneziaBackend: Provider<org.amnezia.awg.backend.Backend>,
tunnelConfigRepository: TunnelConfigRepository, @Userspace private val userspaceBackend: Provider<Backend>,
@Kernel private val kernelBackend: Provider<Backend>, @Kernel private val kernelBackend: Provider<org.amnezia.awg.backend.Backend>,
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
@ApplicationScope private val applicationScope: CoroutineScope, @ApplicationScope private val applicationScope: CoroutineScope,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : TunnelService { ) : TunnelService {
private val _vpnState = MutableStateFlow(VpnState()) private val _vpnState = MutableStateFlow(VpnState())
override val vpnState: StateFlow<VpnState> = _vpnState.combine( override val vpnState: StateFlow<VpnState> = _vpnState.asStateFlow()
tunnelConfigRepository.getTunnelConfigsFlow(),
) {
vpnState, tunnels ->
vpnState.copy(
tunnelConfig = tunnels.firstOrNull { it.id == vpnState.tunnelConfig?.id },
)
}.stateIn(applicationScope, SharingStarted.Lazily, VpnState())
private var statsJob: Job? = null
private suspend fun backend(): Any {
val settings = appDataRepository.settings.getSettings()
if (settings.isKernelEnabled) return kernelBackend.get()
return amneziaBackend.get()
}
override suspend fun runningTunnelNames(): Set<String> { override suspend fun runningTunnelNames(): Set<String> {
return when (val backend = backend()) { return when (val backend = backend()) {
@@ -64,6 +50,8 @@ constructor(
} }
} }
private var statsJob: Job? = null
private suspend fun setState(tunnelConfig: TunnelConfig, tunnelState: TunnelState): Result<TunnelState> { private suspend fun setState(tunnelConfig: TunnelConfig, tunnelState: TunnelState): Result<TunnelState> {
return runCatching { return runCatching {
when (val backend = backend()) { when (val backend = backend()) {
@@ -87,26 +75,34 @@ constructor(
} }
} }
private suspend fun backend(): Any {
val settings = appDataRepository.settings.getSettings()
if (settings.isKernelEnabled) return kernelBackend.get()
if (settings.isAmneziaEnabled) return amneziaBackend.get()
return userspaceBackend.get()
}
override suspend fun startTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> { override suspend fun startTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> {
return withContext(ioDispatcher) { return withContext(ioDispatcher) {
onBeforeStart(tunnelConfig) if (_vpnState.value.status == TunnelState.UP) vpnState.value.tunnelConfig?.let { stopTunnel(it) }
emitTunnelConfig(tunnelConfig)
setState(tunnelConfig, TunnelState.UP).onSuccess { setState(tunnelConfig, TunnelState.UP).onSuccess {
emitTunnelState(it) emitTunnelState(it)
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true))
}.onFailure { }.onFailure {
Timber.e(it) Timber.e(it)
onStartFailed()
} }
} }
} }
override suspend fun stopTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> { override suspend fun stopTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> {
return withContext(ioDispatcher) { return withContext(ioDispatcher) {
onBeforeStop(tunnelConfig)
setState(tunnelConfig, TunnelState.DOWN).onSuccess { setState(tunnelConfig, TunnelState.DOWN).onSuccess {
emitTunnelState(it) emitTunnelState(it)
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = false))
resetBackendStatistics()
}.onFailure { }.onFailure {
Timber.e(it) Timber.e(it)
onStopFailed()
} }
} }
} }
@@ -114,7 +110,7 @@ constructor(
// use this when we just want to bounce tunnel and not change tunnelConfig active state // use this when we just want to bounce tunnel and not change tunnelConfig active state
override suspend fun bounceTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> { override suspend fun bounceTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> {
toggleTunnel(tunnelConfig) toggleTunnel(tunnelConfig)
delay(VPN_RESTART_DELAY) delay(Constants.VPN_RESTART_DELAY)
return toggleTunnel(tunnelConfig) return toggleTunnel(tunnelConfig)
} }
@@ -129,34 +125,6 @@ constructor(
} }
} }
private suspend fun onStopFailed() {
_vpnState.value.tunnelConfig?.let {
appDataRepository.tunnels.save(it.copy(isActive = true))
}
}
private suspend fun onStartFailed() {
_vpnState.value.tunnelConfig?.let {
appDataRepository.tunnels.save(it.copy(isActive = false))
}
cancelStatsJob()
resetBackendStatistics()
}
private suspend fun onBeforeStart(tunnelConfig: TunnelConfig) {
if (_vpnState.value.status == TunnelState.UP) vpnState.value.tunnelConfig?.let { stopTunnel(it) }
resetBackendStatistics()
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true))
emitVpnStateConfig(tunnelConfig)
startStatsJob()
}
private suspend fun onBeforeStop(tunnelConfig: TunnelConfig) {
cancelStatsJob()
resetBackendStatistics()
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = false))
}
private fun emitTunnelState(state: TunnelState) { private fun emitTunnelState(state: TunnelState) {
_vpnState.tryEmit( _vpnState.tryEmit(
_vpnState.value.copy( _vpnState.value.copy(
@@ -173,7 +141,7 @@ constructor(
) )
} }
private fun emitVpnStateConfig(tunnelConfig: TunnelConfig) { private fun emitTunnelConfig(tunnelConfig: TunnelConfig?) {
_vpnState.tryEmit( _vpnState.tryEmit(
_vpnState.value.copy( _vpnState.value.copy(
tunnelConfig = tunnelConfig, tunnelConfig = tunnelConfig,
@@ -209,9 +177,21 @@ constructor(
return _vpnState.value.tunnelConfig?.name ?: "" return _vpnState.value.tunnelConfig?.name ?: ""
} }
override fun onStateChange(newState: Tunnel.State) {
handleStateChange(TunnelState.from(newState))
}
private fun handleStateChange(state: TunnelState) {
emitTunnelState(state)
WireGuardAutoTunnel.instance.requestTunnelTileServiceStateUpdate()
when (state) {
TunnelState.UP -> startStatsJob()
else -> cancelStatsJob()
}
}
private fun startTunnelStatisticsJob() = applicationScope.launch(ioDispatcher) { private fun startTunnelStatisticsJob() = applicationScope.launch(ioDispatcher) {
val backend = backend() val backend = backend()
delay(STATS_START_DELAY)
while (true) { while (true) {
when (backend) { when (backend) {
is Backend -> emitBackendStatistics( is Backend -> emitBackendStatistics(
@@ -225,21 +205,11 @@ constructor(
) )
} }
} }
delay(VPN_STATISTIC_CHECK_INTERVAL) delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
} }
} }
override fun onStateChange(newState: Tunnel.State) {
emitTunnelState(TunnelState.from(newState))
}
override fun onStateChange(state: State) { override fun onStateChange(state: State) {
emitTunnelState(TunnelState.from(state)) handleStateChange(TunnelState.from(state))
}
companion object {
const val STATS_START_DELAY = 5_000L
const val VPN_STATISTIC_CHECK_INTERVAL = 1_000L
const val VPN_RESTART_DELAY = 1_000L
} }
} }
@@ -2,37 +2,27 @@ package com.zaneschepke.wireguardautotunnel.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import androidx.navigation.NavHostController
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import xyz.teamgravity.pin_lock_compose.PinManager import xyz.teamgravity.pin_lock_compose.PinManager
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider
@HiltViewModel @HiltViewModel
class AppViewModel class AppViewModel
@Inject @Inject
constructor( constructor(
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
private val tunnelService: Provider<TunnelService>, private val tunnelService: TunnelService,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, val navHostController: NavHostController,
) : ViewModel() { ) : ViewModel() {
private val _appUiState = MutableStateFlow(AppUiState()) private val _appUiState = MutableStateFlow(AppUiState())
@@ -41,7 +31,7 @@ constructor(
combine( combine(
appDataRepository.settings.getSettingsFlow(), appDataRepository.settings.getSettingsFlow(),
appDataRepository.tunnels.getTunnelConfigsFlow(), appDataRepository.tunnels.getTunnelConfigsFlow(),
tunnelService.get().vpnState, tunnelService.vpnState,
appDataRepository.appState.generalStateFlow, appDataRepository.appState.generalStateFlow,
) { settings, tunnels, tunnelState, generalState -> ) { settings, tunnels, tunnelState, generalState ->
AppUiState( AppUiState(
@@ -50,53 +40,14 @@ constructor(
tunnelState, tunnelState,
generalState, generalState,
) )
}.stateIn(
viewModelScope + ioDispatcher,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
_appUiState.value,
)
private val _isAppReady = MutableStateFlow<Boolean>(false)
val isAppReady = _isAppReady.asStateFlow()
init {
viewModelScope.launch {
initPin()
initAutoTunnel()
initTunnel()
appReadyCheck()
} }
} .stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
_appUiState.value,
)
private suspend fun appReadyCheck() { fun setTunnels(tunnels: TunnelConfigs) = viewModelScope.launch {
val tunnelCount = appDataRepository.tunnels.count()
uiState.takeWhile { it.tunnels.size != tunnelCount }.onCompletion {
_isAppReady.emit(true)
}.collect()
}
private suspend fun initTunnel() {
if (tunnelService.get().getState() == TunnelState.UP) tunnelService.get().startStatsJob()
val activeTunnels = appDataRepository.tunnels.getActive()
if (activeTunnels.isNotEmpty() &&
tunnelService.get().getState() == TunnelState.DOWN
) {
tunnelService.get().startTunnel(activeTunnels.first())
}
}
private suspend fun initPin() {
val isPinEnabled = appDataRepository.appState.isPinLockEnabled()
if (isPinEnabled) PinManager.initialize(WireGuardAutoTunnel.instance)
}
private suspend fun initAutoTunnel() {
val settings = appDataRepository.settings.getSettings()
if (settings.isAutoTunnelEnabled) ServiceManager.startWatcherService(WireGuardAutoTunnel.instance)
}
fun setTunnels(tunnels: TunnelConfigs) = viewModelScope.launch(ioDispatcher) {
_appUiState.emit( _appUiState.emit(
_appUiState.value.copy( _appUiState.value.copy(
tunnels = tunnels, tunnels = tunnels,
@@ -104,7 +55,7 @@ constructor(
) )
} }
fun onPinLockDisabled() = viewModelScope.launch(ioDispatcher) { fun onPinLockDisabled() = viewModelScope.launch {
PinManager.clearPin() PinManager.clearPin()
appDataRepository.appState.setPinLockEnabled(false) appDataRepository.appState.setPinLockEnabled(false)
} }
@@ -4,25 +4,19 @@ import android.os.Bundle
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
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.material.icons.Icons
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.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarData import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -31,37 +25,31 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument
import androidx.navigation.toRoute
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.isCurrentRoute
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.options.OptionsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.options.OptionsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.pinlock.PinLockScreen import com.zaneschepke.wireguardautotunnel.ui.screens.pinlock.PinLockScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.scanner.ScannerScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
@@ -74,10 +62,11 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var tunnelService: TunnelService lateinit var tunnelService: TunnelService
private val viewModel by viewModels<AppViewModel>()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val isPinLockEnabled = intent.extras?.getBoolean(SplashActivity.IS_PIN_LOCK_ENABLED_KEY)
enableEdgeToEdge( enableEdgeToEdge(
navigationBarStyle = SystemBarStyle.auto( navigationBarStyle = SystemBarStyle.auto(
lightScrim = Color.Transparent.toArgb(), lightScrim = Color.Transparent.toArgb(),
@@ -85,15 +74,10 @@ class MainActivity : AppCompatActivity() {
), ),
) )
installSplashScreen().apply {
setKeepOnScreenCondition {
!viewModel.isAppReady.value
}
}
setContent { setContent {
val appUiState by viewModel.uiState.collectAsStateWithLifecycle(lifecycle = this.lifecycle) val appViewModel = hiltViewModel<AppViewModel>()
val navController = rememberNavController() val appUiState by appViewModel.uiState.collectAsStateWithLifecycle(lifecycle = this.lifecycle)
val navController = appViewModel.navHostController
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
LaunchedEffect(appUiState.vpnState.status) { LaunchedEffect(appUiState.vpnState.status) {
@@ -105,114 +89,125 @@ class MainActivity : AppCompatActivity() {
context.requestTunnelTileServiceStateUpdate() context.requestTunnelTileServiceStateUpdate()
} }
with(appUiState.settings) { SnackbarControllerProvider { host ->
LaunchedEffect(isAutoTunnelPaused, isAutoTunnelEnabled) { WireguardAutoTunnelTheme {
this@MainActivity.requestAutoTunnelTileServiceUpdate() val focusRequester = remember { FocusRequester() }
} Scaffold(
} snackbarHost = {
SnackbarHost(host) { snackbarData: SnackbarData ->
CompositionLocalProvider(LocalNavController provides navController) { CustomSnackBar(
SnackbarControllerProvider { host -> snackbarData.visuals.message,
WireguardAutoTunnelTheme { isRtl = false,
val focusRequester = remember { FocusRequester() } containerColor =
Scaffold( MaterialTheme.colorScheme.surfaceColorAtElevation(
snackbarHost = { 2.dp,
SnackbarHost(host) { snackbarData: SnackbarData ->
CustomSnackBar(
snackbarData.visuals.message,
isRtl = false,
containerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp,
),
)
}
},
modifier =
Modifier
.focusable()
.focusProperties {
if (navBackStackEntry?.isCurrentRoute(Route.Lock) == true) {
Unit
} else {
up = focusRequester
}
},
bottomBar = {
BottomNavBar(
navController,
listOf(
BottomNavItem(
name = stringResource(R.string.tunnels),
route = Route.Main,
icon = Icons.Rounded.Home,
),
BottomNavItem(
name = stringResource(R.string.settings),
route = Route.Settings,
icon = Icons.Rounded.Settings,
),
BottomNavItem(
name = stringResource(R.string.support),
route = Route.Support,
icon = Icons.Rounded.QuestionMark,
),
), ),
) )
}
},
containerColor = MaterialTheme.colorScheme.background,
modifier =
Modifier
.focusable()
.focusProperties {
when (navBackStackEntry?.destination?.route) {
Screen.Lock.route -> Unit
else -> up = focusRequester
}
}, },
) { padding -> bottomBar = {
Box(modifier = Modifier.fillMaxSize().padding(padding)) { BottomNavBar(
NavHost( navController,
navController, listOf(
enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) }, Screen.Main.navItem,
exitTransition = { fadeOut(tween(Constants.TRANSITION_ANIMATION_TIME)) }, Screen.Settings.navItem,
startDestination = (if (appUiState.generalState.isPinLockEnabled == true) Route.Lock else Route.Main), Screen.Support.navItem,
),
)
},
) { padding ->
Surface(modifier = Modifier.fillMaxSize().padding(padding)) {
NavHost(
navController,
enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) },
exitTransition = { fadeOut(tween(Constants.TRANSITION_ANIMATION_TIME)) },
startDestination = (if (isPinLockEnabled == true) Screen.Lock.route else Screen.Main.route),
) {
composable(
Screen.Main.route,
) { ) {
composable<Route.Main> { MainScreen(
MainScreen( focusRequester = focusRequester,
focusRequester = focusRequester, uiState = appUiState,
uiState = appUiState, navController = navController,
)
}
composable(
Screen.Settings.route,
) {
SettingsScreen(
appViewModel = appViewModel,
uiState = appUiState,
navController = navController,
focusRequester = focusRequester,
)
}
composable(
Screen.Support.route,
) {
SupportScreen(
focusRequester = focusRequester,
navController = navController,
appUiState = appUiState,
)
}
composable(Screen.Support.Logs.route) {
LogsScreen()
}
composable(
"${Screen.Config.route}/{id}?configType={configType}",
arguments =
listOf(
navArgument("id") {
type = NavType.StringType
defaultValue = "0"
},
navArgument("configType") {
type = NavType.StringType
defaultValue = ConfigType.WIREGUARD.name
},
),
) {
val id = it.arguments?.getString("id")
val configType =
ConfigType.valueOf(
it.arguments?.getString("configType") ?: ConfigType.WIREGUARD.name,
) )
} if (!id.isNullOrBlank()) {
composable<Route.Settings> {
SettingsScreen(
appViewModel = viewModel,
uiState = appUiState,
focusRequester = focusRequester,
)
}
composable<Route.Support> {
SupportScreen(
focusRequester = focusRequester,
appUiState = appUiState,
)
}
composable<Route.Logs> {
LogsScreen()
}
composable<Route.Config> {
val args = it.toRoute<Route.Config>()
ConfigScreen( ConfigScreen(
navController = navController,
tunnelId = id,
focusRequester = focusRequester, focusRequester = focusRequester,
tunnelId = args.id, configType = configType,
) )
} }
composable<Route.Option> { }
val args = it.toRoute<Route.Option>() composable("${Screen.Option.route}/{id}") {
val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) {
OptionsScreen( OptionsScreen(
tunnelId = args.id, navController = navController,
tunnelId = id.toInt(),
focusRequester = focusRequester, focusRequester = focusRequester,
appUiState = appUiState, appUiState = appUiState,
) )
} }
composable<Route.Lock> { }
PinLockScreen( composable(Screen.Lock.route) {
appViewModel = viewModel, PinLockScreen(
) navController = navController,
} appViewModel = appViewModel,
composable<Route.Scanner> { )
ScannerScreen()
}
} }
} }
} }
@@ -1,33 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui
import kotlinx.serialization.Serializable
sealed class Route {
@Serializable
data object Support : Route()
@Serializable
data object Settings : Route()
@Serializable
data object Main : Route()
@Serializable
data class Option(
val id: Int,
) : Route()
@Serializable
data object Lock : Route()
@Serializable
data object Scanner : Route()
@Serializable
data class Config(
val id: Int,
) : Route()
@Serializable
data object Logs : Route()
}
@@ -0,0 +1,46 @@
package com.zaneschepke.wireguardautotunnel.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
sealed class Screen(val route: String) {
data object Main : Screen("main") {
val navItem =
BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.tunnels),
route = route,
icon = Icons.Rounded.Home,
)
}
data object Settings : Screen("settings") {
val navItem =
BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.settings),
route = route,
icon = Icons.Rounded.Settings,
)
}
data object Support : Screen("support") {
val navItem =
BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.support),
route = route,
icon = Icons.Rounded.QuestionMark,
)
data object Logs : Screen("support/logs")
}
data object Config : Screen("config")
data object Lock : Screen("lock")
data object Option : Screen("option")
}
@@ -0,0 +1,87 @@
package com.zaneschepke.wireguardautotunnel.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import xyz.teamgravity.pin_lock_compose.PinManager
import javax.inject.Inject
import javax.inject.Provider
@SuppressLint("CustomSplashScreen")
@AndroidEntryPoint
class SplashActivity : ComponentActivity() {
@Inject
lateinit var appStateRepository: AppStateRepository
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject
lateinit var tunnelService: Provider<TunnelService>
private val appViewModel: AppViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition { true }
}
super.onCreate(savedInstanceState)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
val pinLockEnabled = async {
appStateRepository.isPinLockEnabled().also {
if (it) PinManager.initialize(WireGuardAutoTunnel.instance)
}
}.await()
async {
val settings = appDataRepository.settings.getSettings()
if (settings.isAutoTunnelEnabled) ServiceManager.startWatcherService(application.applicationContext)
if (tunnelService.get().getState() == TunnelState.UP) tunnelService.get().startStatsJob()
val activeTunnels = appDataRepository.tunnels.getActive()
if (activeTunnels.isNotEmpty() &&
tunnelService.get().getState() == TunnelState.DOWN
) {
tunnelService.get().startTunnel(activeTunnels.first())
}
}.await()
async {
val tunnels = appDataRepository.tunnels.getAll()
appViewModel.setTunnels(tunnels)
}.await()
requestAutoTunnelTileServiceUpdate()
val intent =
Intent(this@SplashActivity, MainActivity::class.java).apply {
putExtra(IS_PIN_LOCK_ENABLED_KEY, pinLockEnabled)
}
startActivity(intent)
finish()
}
}
}
companion object {
const val IS_PIN_LOCK_ENABLED_KEY = "is_pin_lock_enabled"
}
}
@@ -1,69 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ExpandingRowListItem(
leading: @Composable () -> Unit,
text: String,
onHold: () -> Unit = {},
onClick: () -> Unit,
trailing: @Composable () -> Unit,
isExpanded: Boolean,
expanded: @Composable () -> Unit = {},
focusRequester: FocusRequester,
) {
Box(
modifier =
Modifier
.focusRequester(focusRequester)
.animateContentSize()
.clip(RoundedCornerShape(30.dp))
.combinedClickable(
onClick = { onClick() },
onLongClick = { onHold() },
),
) {
Column {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(15.dp),
modifier = Modifier.fillMaxWidth(13 / 20f),
) {
leading()
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelLarge)
}
trailing()
}
if (isExpanded) expanded()
}
}
}
@@ -1,13 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
class NestedScrollListener(val onUp: () -> Unit, val onDown: () -> Unit) : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y < -1) onDown()
if (available.y > 1) onUp()
return Offset.Zero
}
}
@@ -0,0 +1,99 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.extensions.toThreeDecimalPlaceString
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RowListItem(
icon: @Composable () -> Unit,
text: String,
onHold: () -> Unit,
onClick: () -> Unit,
rowButton: @Composable () -> Unit,
expanded: Boolean,
statistics: TunnelStatistics?,
focusRequester: FocusRequester,
) {
Box(
modifier =
Modifier
.focusRequester(focusRequester)
.animateContentSize()
.clip(RoundedCornerShape(30.dp))
.combinedClickable(
onClick = { onClick() },
onLongClick = { onHold() },
),
) {
Column {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 15.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(13 / 20f),
) {
icon()
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
rowButton()
}
if (expanded) {
statistics?.getPeers()?.forEach {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(end = 10.dp, bottom = 10.dp, start = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
// TODO change these to string resources
val handshakeEpoch = statistics.peerStats(it)!!.latestHandshakeEpochMillis
val peerTx = statistics.peerStats(it)!!.txBytes
val peerRx = statistics.peerStats(it)!!.rxBytes
val peerId = it.toBase64().subSequence(0, 3).toString() + "***"
val handshakeSec =
NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch)
val handshake =
if (handshakeSec == null) "never" else "$handshakeSec secs ago"
val peerTxMB = NumberUtils.bytesToMB(peerTx).toThreeDecimalPlaceString()
val peerRxMB = NumberUtils.bytesToMB(peerRx).toThreeDecimalPlaceString()
val fontSize = 9.sp
Text("peer: $peerId", fontSize = fontSize)
Text("handshake: $handshake", fontSize = fontSize)
Text("tx: $peerTxMB MB", fontSize = fontSize)
Text("rx: $peerRxMB MB", fontSize = fontSize)
}
}
}
}
}
}
@@ -4,7 +4,6 @@ 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -19,7 +18,7 @@ fun ConfigurationToggle(
enabled: Boolean = true, enabled: Boolean = true,
checked: Boolean, checked: Boolean,
padding: Dp, padding: Dp,
onCheckChanged: (checked: Boolean) -> Unit, onCheckChanged: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@@ -33,7 +32,6 @@ fun ConfigurationToggle(
Text( Text(
label, label,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
style = MaterialTheme.typography.labelLarge,
modifier = modifier =
Modifier Modifier
.weight( .weight(
@@ -46,7 +44,7 @@ fun ConfigurationToggle(
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
checked = checked, checked = checked,
onCheckedChange = { onCheckChanged(it) }, onCheckedChange = { onCheckChanged() },
) )
} }
} }
@@ -3,15 +3,12 @@ package com.zaneschepke.wireguardautotunnel.ui.common.config
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -45,31 +42,25 @@ fun SubmitConfigurationTextBox(
val isFocused by interactionSource.collectIsFocusedAsState() val isFocused by interactionSource.collectIsFocusedAsState()
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
var stateValue by remember { mutableStateOf(value ?: "") } var stateValue by remember { mutableStateOf(value) }
OutlinedTextField( ConfigurationTextBox(
isError = isErrorValue(stateValue), isError = isErrorValue(stateValue),
interactionSource = interactionSource,
value = stateValue ?: "",
onValueChange = {
stateValue = it
},
keyboardOptions = keyboardOptions,
label = label,
hint = hint,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(focusRequester), .focusRequester(focusRequester),
value = stateValue, trailing = {
singleLine = true, if (!stateValue.isNullOrBlank() && !isErrorValue(stateValue) && isFocused) {
interactionSource = interactionSource,
onValueChange = { stateValue = it },
label = { Text(label) },
maxLines = 1,
placeholder = { Text(hint) },
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(
onDone = {
onSubmit(stateValue)
keyboardController?.hide()
},
),
trailingIcon = {
if (!isErrorValue(stateValue) && isFocused) {
IconButton(onClick = { IconButton(onClick = {
onSubmit(stateValue) onSubmit(stateValue!!)
keyboardController?.hide() keyboardController?.hide()
focusManager.clearFocus() focusManager.clearFocus()
}) { }) {
@@ -10,20 +10,13 @@ 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.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
@Composable @Composable
fun rememberFileImportLauncherForResult(onNoFileExplorer: () -> Unit, onData: (data: Uri) -> Unit): ManagedActivityResultLauncher<String, Uri?> { fun rememberFileImportLauncherForResult(onNoFileExplorer: () -> Unit, onData: (data: Uri) -> Unit): ManagedActivityResultLauncher<String, Uri?> {
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 = super.createIntent(context, input).apply { val intent = super.createIntent(context, input)
type = if (context.isRunningOnTv()) {
Constants.ALLOWED_TV_FILE_TYPES
} else {
Constants.ALL_FILE_TYPES
}
}
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than /* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
* what we can do, so detect this and throw an exception that we can catch later. */ * what we can do, so detect this and throw an exception that we can catch later. */
@@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.text.font.FontWeight
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@@ -19,20 +20,19 @@ fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavIte
var showBottomBar by rememberSaveable { mutableStateOf(true) } var showBottomBar by rememberSaveable { mutableStateOf(true) }
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
showBottomBar = bottomNavItems.any { showBottomBar = bottomNavItems.firstOrNull { navBackStackEntry?.destination?.route?.contains(it.route) == true } != null
navBackStackEntry?.isCurrentRoute(it.route) == true
}
if (showBottomBar) { if (showBottomBar) {
NavigationBar( NavigationBar(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
) { ) {
bottomNavItems.forEach { item -> bottomNavItems.forEach { item ->
val selected = navBackStackEntry.isCurrentRoute(item.route) val selected = navBackStackEntry?.destination?.route?.contains(item.route) == true
NavigationBarItem( NavigationBarItem(
selected = selected, selected = selected,
onClick = { onClick = {
if (selected) return@NavigationBarItem if (navBackStackEntry?.destination?.route == item.route) return@NavigationBarItem
navController.navigate(item.route) { navController.navigate(item.route) {
// Pop up to the start destination of the graph to // Pop up to the start destination of the graph to
// avoid building up a large stack of destinations // avoid building up a large stack of destinations
@@ -48,7 +48,7 @@ fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavIte
label = { label = {
Text( Text(
text = item.name, text = item.name,
style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold,
) )
}, },
icon = { icon = {
@@ -1,10 +1,9 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import com.zaneschepke.wireguardautotunnel.ui.Route
data class BottomNavItem( data class BottomNavItem(
val name: String, val name: String,
val route: Route, val route: String,
val icon: ImageVector, val icon: ImageVector,
) )
@@ -1,14 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import android.annotation.SuppressLint
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import com.zaneschepke.wireguardautotunnel.ui.Route
@SuppressLint("RestrictedApi")
fun NavBackStackEntry?.isCurrentRoute(route: Route): Boolean {
return this?.destination?.hierarchy?.any {
it.hasRoute(route = route::class)
} == true
}
@@ -1,8 +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")
}
@@ -0,0 +1,15 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import android.content.Context
import androidx.navigation.NavHostController
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.compose.DialogNavigator
class NavigationService constructor(
context: Context,
) {
val navController = NavHostController(context).apply {
navigatorProvider.addNavigator(ComposeNavigator())
navigatorProvider.addNavigator(DialogNavigator())
}
}
@@ -1,33 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopNavBar(title: String, trailing: @Composable () -> Unit = {}) {
val navController = LocalNavController.current
CenterAlignedTopAppBar(
title = {
Text(title)
},
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
val icon = Icons.AutoMirrored.Outlined.ArrowBack
Icon(
imageVector = icon,
contentDescription = icon.name,
)
}
},
actions = {
trailing()
},
)
}
@@ -1,20 +1,22 @@
package com.zaneschepke.wireguardautotunnel.ui.common.text package com.zaneschepke.wireguardautotunnel.ui.common.text
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
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.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable @Composable
fun SectionTitle(title: String, padding: Dp) { fun SectionTitle(title: String, padding: Dp) {
Text( Text(
title, title,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
style = MaterialTheme.typography.titleMedium, style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.ExtraBold),
modifier = Modifier.padding(padding, bottom = 5.dp, top = 5.dp), modifier = Modifier.padding(padding, bottom = 5.dp, top = 5.dp),
) )
} }
@@ -1,27 +1,36 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config package com.zaneschepke.wireguardautotunnel.ui.screens.config
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Android
import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Save import androidx.compose.material.icons.rounded.Save
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -30,11 +39,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -43,6 +53,7 @@ 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -56,53 +67,47 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.drawablepainter.DrawablePainter
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.ui.screens.config.components.ApplicationSelectionDialog
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.getMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(
ExperimentalMaterial3Api::class,
)
@Composable @Composable
fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) { fun ConfigScreen(
val viewModel = hiltViewModel<ConfigViewModel, ConfigViewModel.ConfigViewModelFactory> { factory -> viewModel: ConfigViewModel = hiltViewModel(),
factory.create(tunnelId) focusRequester: FocusRequester,
} navController: NavController,
tunnelId: String,
configType: ConfigType,
) {
val context = LocalContext.current val context = LocalContext.current
val snackbar = SnackbarController.current val snackbar = SnackbarController.current
val clipboardManager: ClipboardManager = LocalClipboardManager.current val clipboardManager: ClipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val navController = LocalNavController.current
var showApplicationsDialog by remember { mutableStateOf(false) } var showApplicationsDialog by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) } var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthenticated by remember { mutableStateOf(false) } var isAuthenticated by remember { mutableStateOf(false) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var configType by remember { mutableStateOf<ConfigType?>(null) }
val derivedConfigType = remember {
derivedStateOf<ConfigType> {
configType ?: if (!uiState.hasAmneziaProperties()) ConfigType.WIREGUARD else ConfigType.AMNEZIA
}
}
val saved by viewModel.saved.collectAsStateWithLifecycle(null)
LaunchedEffect(saved) { LaunchedEffect(Unit) { viewModel.init(tunnelId) }
if (saved == true) {
navController.navigate(Route.Main)
}
}
LaunchedEffect(Unit) { LaunchedEffect(uiState.loading) {
if (!uiState.loading && context.isRunningOnTv()) { if (!uiState.loading && context.isRunningOnTv()) {
delay(Constants.FOCUS_REQUEST_DELAY) delay(Constants.FOCUS_REQUEST_DELAY)
kotlin.runCatching { kotlin.runCatching {
@@ -114,12 +119,13 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
} }
} }
LaunchedEffect(Unit) { if (uiState.loading) {
delay(2_000L) LoadingScreen()
viewModel.cleanUpUninstalledApps() return
} }
val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
val fillMaxHeight = .85f val fillMaxHeight = .85f
@@ -168,19 +174,182 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
} }
if (showApplicationsDialog) { if (showApplicationsDialog) {
ApplicationSelectionDialog(viewModel, uiState) { val sortedPackages =
showApplicationsDialog = false remember(uiState.packages) {
uiState.packages.sortedBy { viewModel.getPackageLabel(it) }
}
BasicAlertDialog(onDismissRequest = { showApplicationsDialog = false }) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight(if (uiState.isAllApplicationsEnabled) 1 / 5f else 4 / 5f),
) {
Column(
modifier =
Modifier
.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(id = R.string.tunnel_all))
Switch(
checked = uiState.isAllApplicationsEnabled,
onCheckedChange = { viewModel.onAllApplicationsChange(it) },
)
}
if (!uiState.isAllApplicationsEnabled) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(id = R.string.include))
Checkbox(
checked = uiState.include,
onCheckedChange = {
viewModel.onIncludeChange(!uiState.include)
},
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(id = R.string.exclude))
Checkbox(
checked = !uiState.include,
onCheckedChange = {
viewModel.onIncludeChange(!uiState.include)
},
)
}
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
SearchBar(viewModel::emitQueriedPackages)
}
Spacer(Modifier.padding(5.dp))
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxHeight(4 / 5f),
) {
items(sortedPackages, key = { it.packageName }) { pack ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxSize()
.padding(5.dp),
) {
Row(modifier = Modifier.fillMaxWidth(fillMaxWidth)) {
val drawable =
pack.applicationInfo?.loadIcon(context.packageManager)
if (drawable != null) {
Image(
painter = DrawablePainter(drawable),
stringResource(id = R.string.icon),
modifier = Modifier.size(50.dp, 50.dp),
)
} else {
val icon = Icons.Rounded.Android
Icon(
icon,
icon.name,
modifier = Modifier.size(50.dp, 50.dp),
)
}
Text(
viewModel.getPackageLabel(pack),
modifier = Modifier.padding(5.dp),
)
}
Checkbox(
modifier = Modifier.fillMaxSize(),
checked =
(
uiState.checkedPackageNames.contains(
pack.packageName,
)
),
onCheckedChange = {
if (it) {
viewModel.onAddCheckedPackage(pack.packageName)
} else {
viewModel.onRemoveCheckedPackage(pack.packageName)
}
},
)
}
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = { showApplicationsDialog = false }) {
Text(stringResource(R.string.done))
}
}
}
}
} }
} }
Scaffold( Scaffold(
floatingActionButtonPosition = FabPosition.End, floatingActionButtonPosition = FabPosition.End,
floatingActionButton = { floatingActionButton = {
val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton( FloatingActionButton(
onClick = { modifier =
viewModel.onSaveAllChanges() Modifier.onFocusChanged {
if (context.isRunningOnTv()) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
}
}, },
containerColor = MaterialTheme.colorScheme.primary, onClick = {
viewModel.onSaveAllChanges(configType).onSuccess {
snackbar.showMessage(
context.getString(R.string.config_changes_saved),
)
navController.navigate(Screen.Main.route)
}.onFailure {
snackbar.showMessage(it.getMessage(context))
}
},
containerColor = fobColor,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
) { ) {
Icon( Icon(
@@ -230,22 +399,16 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
stringResource(R.string.interface_), stringResource(R.string.interface_),
padding = screenPadding, padding = screenPadding,
) )
ConfigurationToggle(
stringResource(id = R.string.show_amnezia_properties),
checked = derivedConfigType.value == ConfigType.AMNEZIA,
padding = screenPadding,
onCheckChanged = { configType = if (it) ConfigType.AMNEZIA else ConfigType.WIREGUARD },
modifier = Modifier.focusRequester(focusRequester),
)
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.tunnelName, value = uiState.tunnelName,
onValueChange = viewModel::onTunnelNameChange, onValueChange = { value -> viewModel.onTunnelNameChange(value) },
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.name), label = stringResource(R.string.name),
hint = stringResource(R.string.tunnel_name).lowercase(), hint = stringResource(R.string.tunnel_name).lowercase(),
modifier = modifier =
Modifier Modifier
.fillMaxWidth(), .fillMaxWidth()
.focusRequester(focusRequester),
) )
OutlinedTextField( OutlinedTextField(
modifier = modifier =
@@ -254,12 +417,12 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
.clickable { showAuthPrompt = true }, .clickable { showAuthPrompt = true },
value = uiState.interfaceProxy.privateKey, value = uiState.interfaceProxy.privateKey,
visualTransformation = visualTransformation =
if ((tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID.toInt()) || isAuthenticated) { if ((tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) {
VisualTransformation.None VisualTransformation.None
} else { } else {
PasswordVisualTransformation() PasswordVisualTransformation()
}, },
enabled = (tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID.toInt()) || isAuthenticated, enabled = (tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated,
onValueChange = { value -> viewModel.onPrivateKeyChange(value) }, onValueChange = { value -> viewModel.onPrivateKeyChange(value) },
trailingIcon = { trailingIcon = {
IconButton( IconButton(
@@ -309,29 +472,31 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
) )
ConfigurationTextBox( Row(modifier = Modifier.fillMaxWidth()) {
value = uiState.interfaceProxy.addresses, ConfigurationTextBox(
onValueChange = viewModel::onAddressesChanged, value = uiState.interfaceProxy.addresses,
keyboardActions = keyboardActions, onValueChange = { value -> viewModel.onAddressesChanged(value) },
label = stringResource(R.string.addresses), keyboardActions = keyboardActions,
hint = stringResource(R.string.comma_separated_list), label = stringResource(R.string.addresses),
modifier = hint = stringResource(R.string.comma_separated_list),
Modifier modifier =
.fillMaxWidth() Modifier
.padding(end = 5.dp), .fillMaxWidth(3 / 5f)
) .padding(end = 5.dp),
ConfigurationTextBox( )
value = uiState.interfaceProxy.listenPort, ConfigurationTextBox(
onValueChange = viewModel::onListenPortChanged, value = uiState.interfaceProxy.listenPort,
keyboardActions = keyboardActions, onValueChange = { value -> viewModel.onListenPortChanged(value) },
label = stringResource(R.string.listen_port), keyboardActions = keyboardActions,
hint = stringResource(R.string.random), label = stringResource(R.string.listen_port),
modifier = Modifier.fillMaxWidth(), hint = stringResource(R.string.random),
) modifier = Modifier.width(IntrinsicSize.Min),
)
}
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.dnsServers, value = uiState.interfaceProxy.dnsServers,
onValueChange = viewModel::onDnsServersChanged, onValueChange = { value -> viewModel.onDnsServersChanged(value) },
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.dns_servers), label = stringResource(R.string.dns_servers),
hint = stringResource(R.string.comma_separated_list), hint = stringResource(R.string.comma_separated_list),
@@ -342,17 +507,20 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.mtu, value = uiState.interfaceProxy.mtu,
onValueChange = viewModel::onMtuChanged, onValueChange = { value -> viewModel.onMtuChanged(value) },
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.mtu), label = stringResource(R.string.mtu),
hint = stringResource(R.string.auto), hint = stringResource(R.string.auto),
modifier = Modifier.width(IntrinsicSize.Min), modifier = Modifier.width(IntrinsicSize.Min),
) )
} }
if (derivedConfigType.value == ConfigType.AMNEZIA) { if (configType == ConfigType.AMNEZIA) {
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.junkPacketCount, value = uiState.interfaceProxy.junkPacketCount,
onValueChange = viewModel::onJunkPacketCountChanged, onValueChange = {
value ->
viewModel.onJunkPacketCountChanged(value)
},
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.junk_packet_count), label = stringResource(R.string.junk_packet_count),
hint = stringResource(R.string.junk_packet_count).lowercase(), hint = stringResource(R.string.junk_packet_count).lowercase(),
@@ -363,7 +531,11 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.junkPacketMinSize, value = uiState.interfaceProxy.junkPacketMinSize,
onValueChange = viewModel::onJunkPacketMinSizeChanged, onValueChange = { value ->
viewModel.onJunkPacketMinSizeChanged(
value,
)
},
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.junk_packet_minimum_size), label = stringResource(R.string.junk_packet_minimum_size),
hint = hint =
@@ -377,7 +549,11 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.junkPacketMaxSize, value = uiState.interfaceProxy.junkPacketMaxSize,
onValueChange = viewModel::onJunkPacketMaxSizeChanged, onValueChange = { value ->
viewModel.onJunkPacketMaxSizeChanged(
value,
)
},
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.junk_packet_maximum_size), label = stringResource(R.string.junk_packet_maximum_size),
hint = hint =
@@ -391,7 +567,11 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.initPacketJunkSize, value = uiState.interfaceProxy.initPacketJunkSize,
onValueChange = viewModel::onInitPacketJunkSizeChanged, onValueChange = { value ->
viewModel.onInitPacketJunkSizeChanged(
value,
)
},
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.init_packet_junk_size), label = stringResource(R.string.init_packet_junk_size),
hint = stringResource(R.string.init_packet_junk_size).lowercase(), hint = stringResource(R.string.init_packet_junk_size).lowercase(),
@@ -402,7 +582,10 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.responsePacketJunkSize, value = uiState.interfaceProxy.responsePacketJunkSize,
onValueChange = viewModel::onResponsePacketJunkSize, onValueChange = {
value ->
viewModel.onResponsePacketJunkSize(value)
},
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.response_packet_junk_size), label = stringResource(R.string.response_packet_junk_size),
hint = hint =
@@ -416,7 +599,10 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.initPacketMagicHeader, value = uiState.interfaceProxy.initPacketMagicHeader,
onValueChange = viewModel::onInitPacketMagicHeader, onValueChange = {
value ->
viewModel.onInitPacketMagicHeader(value)
},
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.init_packet_magic_header), label = stringResource(R.string.init_packet_magic_header),
hint = hint =
@@ -430,7 +616,11 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.responsePacketMagicHeader, value = uiState.interfaceProxy.responsePacketMagicHeader,
onValueChange = viewModel::onResponsePacketMagicHeader, onValueChange = { value ->
viewModel.onResponsePacketMagicHeader(
value,
)
},
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.response_packet_magic_header), label = stringResource(R.string.response_packet_magic_header),
hint = hint =
@@ -444,7 +634,11 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.underloadPacketMagicHeader, value = uiState.interfaceProxy.underloadPacketMagicHeader,
onValueChange = viewModel::onUnderloadPacketMagicHeader, onValueChange = { value ->
viewModel.onUnderloadPacketMagicHeader(
value,
)
},
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.underload_packet_magic_header), label = stringResource(R.string.underload_packet_magic_header),
hint = hint =
@@ -458,7 +652,11 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.transportPacketMagicHeader, value = uiState.interfaceProxy.transportPacketMagicHeader,
onValueChange = viewModel::onTransportPacketMagicHeader, onValueChange = { value ->
viewModel.onTransportPacketMagicHeader(
value,
)
},
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.transport_packet_magic_header), label = stringResource(R.string.transport_packet_magic_header),
hint = hint =
@@ -539,27 +737,15 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
hint = stringResource(R.string.base64_key), hint = stringResource(R.string.base64_key),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( ConfigurationTextBox(
modifier =
Modifier
.fillMaxWidth()
.clickable { showAuthPrompt = true },
value = peer.preSharedKey, value = peer.preSharedKey,
visualTransformation =
if ((tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID.toInt()) || isAuthenticated) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
enabled = (tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID.toInt()) || isAuthenticated || peer.preSharedKey.isEmpty(),
onValueChange = { value -> onValueChange = { value ->
viewModel.onPreSharedKeyChange(index, value) viewModel.onPreSharedKeyChange(index, value)
}, },
label = { Text(stringResource(R.string.preshared_key)) },
singleLine = true,
placeholder = { Text(stringResource(R.string.optional)) },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.preshared_key),
hint = stringResource(R.string.optional),
modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -628,6 +814,9 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
} }
} }
} }
if (context.isRunningOnTv()) {
Spacer(modifier = Modifier.weight(.17f))
}
} }
} }
} }
@@ -15,12 +15,9 @@ data class ConfigUiState(
val isAllApplicationsEnabled: Boolean = false, val isAllApplicationsEnabled: Boolean = false,
val loading: Boolean = true, val loading: Boolean = true,
val tunnel: TunnelConfig? = null, val tunnel: TunnelConfig? = null,
var tunnelName: String = "", val tunnelName: String = "",
val isAmneziaEnabled: Boolean = false, val isAmneziaEnabled: Boolean = false,
) { ) {
fun hasAmneziaProperties(): Boolean {
return this.interfaceProxy.junkPacketCount != ""
}
companion object { companion object {
fun from(config: Config): ConfigUiState { fun from(config: Config): ConfigUiState {
val proxyPeers = config.peers.map { PeerProxy.from(it) } val proxyPeers = config.peers.map { PeerProxy.from(it) }
@@ -48,6 +45,7 @@ data class ConfigUiState(
} }
fun from(config: org.amnezia.awg.config.Config): ConfigUiState { fun from(config: org.amnezia.awg.config.Config): ConfigUiState {
// TODO update with new values
val proxyPeers = config.peers.map { PeerProxy.from(it) } val proxyPeers = config.peers.map { PeerProxy.from(it) }
val proxyInterface = InterfaceProxy.from(config.`interface`) val proxyInterface = InterfaceProxy.from(config.`interface`)
var include = true var include = true
@@ -71,13 +69,5 @@ data class ConfigUiState(
isAllApplicationsEnabled, isAllApplicationsEnabled,
) )
} }
fun from(tunnel: TunnelConfig): ConfigUiState {
val config = tunnel.toAmConfig()
return from(config).copy(
tunnelName = tunnel.name,
tunnel = tunnel,
)
}
} }
} }
@@ -15,119 +15,100 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.config.model.PeerProxy import com.zaneschepke.wireguardautotunnel.ui.screens.config.model.PeerProxy
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import com.zaneschepke.wireguardautotunnel.util.extensions.removeAt import com.zaneschepke.wireguardautotunnel.util.extensions.removeAt
import com.zaneschepke.wireguardautotunnel.util.extensions.update import com.zaneschepke.wireguardautotunnel.util.extensions.update
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@HiltViewModel(assistedFactory = ConfigViewModel.ConfigViewModelFactory::class) @HiltViewModel
class ConfigViewModel class ConfigViewModel
@AssistedInject @Inject
constructor( constructor(
private val settingsRepository: SettingsRepository,
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
@Assisted val id: Int,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
private val packageManager = WireGuardAutoTunnel.instance.packageManager private val packageManager = WireGuardAutoTunnel.instance.packageManager
private val _saved = MutableSharedFlow<Boolean>()
val saved = _saved.asSharedFlow()
private val _uiState = MutableStateFlow(ConfigUiState()) private val _uiState = MutableStateFlow(ConfigUiState())
val uiState = _uiState.onStart { val uiState = _uiState.asStateFlow()
appDataRepository.tunnels.getById(id)?.let {
val packages = getQueriedPackages() fun init(tunnelId: String) = viewModelScope.launch(ioDispatcher) {
_uiState.value = ConfigUiState.from(it).copy( val packages = getQueriedPackages("")
packages = packages, val state =
) if (tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) {
} val tunnelConfig =
}.stateIn( appDataRepository.tunnels.getAll()
viewModelScope + ioDispatcher, .firstOrNull { it.id.toString() == tunnelId }
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), val isAmneziaEnabled = settingsRepository.getSettings().isAmneziaEnabled
ConfigUiState(), if (tunnelConfig != null) {
) (
if (isAmneziaEnabled) {
val amConfig =
if (tunnelConfig.amQuick == "") tunnelConfig.wgQuick else tunnelConfig.amQuick
ConfigUiState.from(TunnelConfig.configFromAmQuick(amConfig))
} else {
ConfigUiState.from(
TunnelConfig.configFromWgQuick(tunnelConfig.wgQuick),
)
}
).copy(
packages = packages,
loading = false,
tunnel = tunnelConfig,
tunnelName = tunnelConfig.name,
isAmneziaEnabled = isAmneziaEnabled,
)
} else {
ConfigUiState(loading = false, packages = packages)
}
} else {
ConfigUiState(loading = false, packages = packages)
}
_uiState.value = state
}
fun onTunnelNameChange(name: String) { fun onTunnelNameChange(name: String) {
_uiState.update { _uiState.value = _uiState.value.copy(tunnelName = name)
it.copy(tunnelName = name)
}
} }
fun onIncludeChange(include: Boolean) { fun onIncludeChange(include: Boolean) {
_uiState.update { _uiState.value = _uiState.value.copy(include = include)
it.copy(include = include)
}
}
fun cleanUpUninstalledApps() = viewModelScope.launch(ioDispatcher) {
uiState.value.tunnel?.let {
val config = it.toAmConfig()
val packages = getQueriedPackages()
val packageSet = packages.map { pack -> pack.packageName }.toSet()
val includedApps = config.`interface`.includedApplications.toMutableList()
val excludedApps = config.`interface`.excludedApplications.toMutableList()
if (includedApps.isEmpty() && excludedApps.isEmpty()) return@launch
if (includedApps.retainAll(packageSet) || excludedApps.retainAll(packageSet)) {
Timber.i("Removing split tunnel package name that no longer exists on the device")
_uiState.update { state ->
state.copy(
checkedPackageNames = if (_uiState.value.include) includedApps else excludedApps,
)
}
val wgQuick = buildConfig().toWgQuickString(true)
val amQuick = buildAmConfig().toAwgQuickString(true)
saveConfig(
it.copy(
amQuick = amQuick,
wgQuick = wgQuick,
),
)
}
}
} }
fun onAddCheckedPackage(packageName: String) { fun onAddCheckedPackage(packageName: String) {
_uiState.update { _uiState.value =
it.copy( _uiState.value.copy(
checkedPackageNames = it.checkedPackageNames + packageName, checkedPackageNames = _uiState.value.checkedPackageNames + packageName,
) )
}
} }
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) { fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_uiState.update { _uiState.value = _uiState.value.copy(isAllApplicationsEnabled = isAllApplicationsEnabled)
it.copy(isAllApplicationsEnabled = isAllApplicationsEnabled)
}
} }
fun onRemoveCheckedPackage(packageName: String) { fun onRemoveCheckedPackage(packageName: String) {
_uiState.update { _uiState.value =
it.copy( _uiState.value.copy(
checkedPackageNames = it.checkedPackageNames - packageName, checkedPackageNames = _uiState.value.checkedPackageNames - packageName,
) )
}
} }
private fun getQueriedPackages(query: String = ""): List<PackageInfo> { private fun getQueriedPackages(query: String): List<PackageInfo> {
return getAllInternetCapablePackages().filter { return getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase()) getPackageLabel(it).lowercase().contains(query.lowercase())
} }
@@ -156,9 +137,7 @@ constructor(
return _uiState.value.isAllApplicationsEnabled return _uiState.value.isAllApplicationsEnabled
} }
private fun saveConfig(tunnelConfig: TunnelConfig) = viewModelScope.launch { private fun saveConfig(tunnelConfig: TunnelConfig) = viewModelScope.launch { appDataRepository.tunnels.save(tunnelConfig) }
appDataRepository.tunnels.save(tunnelConfig)
}
private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) = viewModelScope.launch { private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) = viewModelScope.launch {
if (tunnelConfig != null) { if (tunnelConfig != null) {
@@ -195,113 +174,105 @@ constructor(
} }
private fun emptyCheckedPackagesList() { private fun emptyCheckedPackagesList() {
_uiState.update { _uiState.value = _uiState.value.copy(checkedPackageNames = emptyList())
it.copy(checkedPackageNames = emptyList())
}
} }
private fun buildInterfaceListFromProxyInterface(): Interface { private fun buildInterfaceListFromProxyInterface(): Interface {
val builder = Interface.Builder() val builder = Interface.Builder()
with(_uiState.value.interfaceProxy) { builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim())
builder.parsePrivateKey(this.privateKey.trim()) builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim())
builder.parseAddresses(this.addresses.trim()) if (_uiState.value.interfaceProxy.dnsServers.isNotEmpty()) {
if (this.dnsServers.isNotEmpty()) { builder.parseDnsServers(_uiState.value.interfaceProxy.dnsServers.trim())
builder.parseDnsServers(this.dnsServers.trim()) }
} if (_uiState.value.interfaceProxy.mtu.isNotEmpty()) {
if (this.mtu.isNotEmpty()) { builder.parseMtu(_uiState.value.interfaceProxy.mtu.trim())
builder.parseMtu(this.mtu.trim()) }
} if (_uiState.value.interfaceProxy.listenPort.isNotEmpty()) {
if (this.listenPort.isNotEmpty()) { builder.parseListenPort(_uiState.value.interfaceProxy.listenPort.trim())
builder.parseListenPort(this.listenPort.trim()) }
} if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (isAllApplicationsEnabled()) emptyCheckedPackagesList() if (_uiState.value.include) {
if (_uiState.value.include) { builder.includeApplications(
builder.includeApplications( _uiState.value.checkedPackageNames,
_uiState.value.checkedPackageNames, )
) }
} if (!_uiState.value.include) {
if (!_uiState.value.include) { builder.excludeApplications(
builder.excludeApplications( _uiState.value.checkedPackageNames,
_uiState.value.checkedPackageNames, )
)
}
} }
return builder.build() return builder.build()
} }
private fun buildAmInterfaceListFromProxyInterface(): org.amnezia.awg.config.Interface { private fun buildAmInterfaceListFromProxyInterface(): org.amnezia.awg.config.Interface {
val builder = org.amnezia.awg.config.Interface.Builder() val builder = org.amnezia.awg.config.Interface.Builder()
with(_uiState.value.interfaceProxy) { builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim())
builder.parsePrivateKey(this.privateKey.trim()) builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim())
builder.parseAddresses(this.addresses.trim()) if (_uiState.value.interfaceProxy.dnsServers.isNotEmpty()) {
if (this.dnsServers.isNotEmpty()) { builder.parseDnsServers(_uiState.value.interfaceProxy.dnsServers.trim())
builder.parseDnsServers(this.dnsServers.trim()) }
} if (_uiState.value.interfaceProxy.mtu.isNotEmpty()) {
if (this.mtu.isNotEmpty()) { builder.parseMtu(_uiState.value.interfaceProxy.mtu.trim())
builder.parseMtu(this.mtu.trim()) }
} if (_uiState.value.interfaceProxy.listenPort.isNotEmpty()) {
if (this.listenPort.isNotEmpty()) { builder.parseListenPort(_uiState.value.interfaceProxy.listenPort.trim())
builder.parseListenPort(this.listenPort.trim()) }
} if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (isAllApplicationsEnabled()) emptyCheckedPackagesList() if (_uiState.value.include) {
if (_uiState.value.include) { builder.includeApplications(
builder.includeApplications( _uiState.value.checkedPackageNames,
_uiState.value.checkedPackageNames, )
) }
} if (!_uiState.value.include) {
if (!_uiState.value.include) { builder.excludeApplications(
builder.excludeApplications( _uiState.value.checkedPackageNames,
_uiState.value.checkedPackageNames, )
) }
} if (_uiState.value.interfaceProxy.junkPacketCount.isNotEmpty()) {
if (this.junkPacketCount.isNotEmpty()) { builder.setJunkPacketCount(
builder.setJunkPacketCount( _uiState.value.interfaceProxy.junkPacketCount.trim().toInt(),
this.junkPacketCount.trim().toInt(), )
) }
} if (_uiState.value.interfaceProxy.junkPacketMinSize.isNotEmpty()) {
if (this.junkPacketMinSize.isNotEmpty()) { builder.setJunkPacketMinSize(
builder.setJunkPacketMinSize( _uiState.value.interfaceProxy.junkPacketMinSize.trim().toInt(),
this.junkPacketMinSize.trim().toInt(), )
) }
} if (_uiState.value.interfaceProxy.junkPacketMaxSize.isNotEmpty()) {
if (this.junkPacketMaxSize.isNotEmpty()) { builder.setJunkPacketMaxSize(
builder.setJunkPacketMaxSize( _uiState.value.interfaceProxy.junkPacketMaxSize.trim().toInt(),
this.junkPacketMaxSize.trim().toInt(), )
) }
} if (_uiState.value.interfaceProxy.initPacketJunkSize.isNotEmpty()) {
if (this.initPacketJunkSize.isNotEmpty()) { builder.setInitPacketJunkSize(
builder.setInitPacketJunkSize( _uiState.value.interfaceProxy.initPacketJunkSize.trim().toInt(),
this.initPacketJunkSize.trim().toInt(), )
) }
} if (_uiState.value.interfaceProxy.responsePacketJunkSize.isNotEmpty()) {
if (this.responsePacketJunkSize.isNotEmpty()) { builder.setResponsePacketJunkSize(
builder.setResponsePacketJunkSize( _uiState.value.interfaceProxy.responsePacketJunkSize.trim().toInt(),
this.responsePacketJunkSize.trim().toInt(), )
) }
} if (_uiState.value.interfaceProxy.initPacketMagicHeader.isNotEmpty()) {
if (this.initPacketMagicHeader.isNotEmpty()) { builder.setInitPacketMagicHeader(
builder.setInitPacketMagicHeader( _uiState.value.interfaceProxy.initPacketMagicHeader.trim().toLong(),
this.initPacketMagicHeader.trim().toLong(), )
) }
} if (_uiState.value.interfaceProxy.responsePacketMagicHeader.isNotEmpty()) {
if (this.responsePacketMagicHeader.isNotEmpty()) { builder.setResponsePacketMagicHeader(
builder.setResponsePacketMagicHeader( _uiState.value.interfaceProxy.responsePacketMagicHeader.trim().toLong(),
this.responsePacketMagicHeader.trim().toLong(), )
) }
} if (_uiState.value.interfaceProxy.transportPacketMagicHeader.isNotEmpty()) {
if (this.transportPacketMagicHeader.isNotEmpty()) { builder.setTransportPacketMagicHeader(
builder.setTransportPacketMagicHeader( _uiState.value.interfaceProxy.transportPacketMagicHeader.trim().toLong(),
this.transportPacketMagicHeader.trim().toLong(), )
) }
} if (_uiState.value.interfaceProxy.underloadPacketMagicHeader.isNotEmpty()) {
if (this.underloadPacketMagicHeader.isNotEmpty()) { builder.setUnderloadPacketMagicHeader(
builder.setUnderloadPacketMagicHeader( _uiState.value.interfaceProxy.underloadPacketMagicHeader.trim().toLong(),
this.underloadPacketMagicHeader.trim().toLong(), )
)
}
} }
return builder.build() return builder.build()
} }
@@ -320,33 +291,41 @@ constructor(
.build() .build()
} }
fun onSaveAllChanges() = viewModelScope.launch { fun onSaveAllChanges(configType: ConfigType): Result<Unit> {
kotlin.runCatching { return try {
val wgQuick = buildConfig().toWgQuickString(true) val wgQuick = buildConfig().toWgQuickString(true)
val amQuick = buildAmConfig().toAwgQuickString(true) val amQuick =
val tunnelConfig = uiState.value.tunnel?.copy( if (configType == ConfigType.AMNEZIA) {
name = _uiState.value.tunnelName, buildAmConfig().toAwgQuickString(true)
amQuick = amQuick, } else {
wgQuick = wgQuick, TunnelConfig.AM_QUICK_DEFAULT
) ?: TunnelConfig( }
name = _uiState.value.tunnelName, val tunnelConfig =
wgQuick = wgQuick, when (uiState.value.tunnel) {
amQuick = amQuick, null ->
) TunnelConfig(
name = _uiState.value.tunnelName,
wgQuick = wgQuick,
amQuick = amQuick,
)
else ->
uiState.value.tunnel!!.copy(
name = _uiState.value.tunnelName,
wgQuick = wgQuick,
amQuick = amQuick,
)
}
updateTunnelConfig(tunnelConfig) updateTunnelConfig(tunnelConfig)
SnackbarController.showMessage( Result.success(Unit)
StringValue.StringResource(R.string.config_changes_saved), } catch (e: Exception) {
) Timber.e(e)
_saved.emit(true) val message = e.message?.substringAfter(":", missingDelimiterValue = "")
}.onFailure { val stringValue =
Timber.e(it) message?.let {
val message = it.message?.substringAfter(":", missingDelimiterValue = "") StringValue.DynamicString(message)
val stringValue = if (message.isNullOrBlank()) { } ?: StringValue.StringResource(R.string.unknown_error)
StringValue.StringResource(R.string.unknown_error) Result.failure(WgTunnelExceptions.ConfigParseError(stringValue))
} else {
StringValue.DynamicString(message)
}
SnackbarController.showMessage(stringValue)
} }
} }
@@ -429,7 +408,7 @@ constructor(
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = interfaceProxy =
it.interfaceProxy.copy( _uiState.value.interfaceProxy.copy(
privateKey = keyPair.privateKey.toBase64(), privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64(), publicKey = keyPair.publicKey.toBase64(),
), ),
@@ -440,7 +419,7 @@ constructor(
fun onAddressesChanged(value: String) { fun onAddressesChanged(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = it.interfaceProxy.copy(addresses = value), interfaceProxy = _uiState.value.interfaceProxy.copy(addresses = value),
) )
} }
} }
@@ -448,7 +427,7 @@ constructor(
fun onListenPortChanged(value: String) { fun onListenPortChanged(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = it.interfaceProxy.copy(listenPort = value), interfaceProxy = _uiState.value.interfaceProxy.copy(listenPort = value),
) )
} }
} }
@@ -456,21 +435,21 @@ constructor(
fun onDnsServersChanged(value: String) { fun onDnsServersChanged(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = it.interfaceProxy.copy(dnsServers = value), interfaceProxy = _uiState.value.interfaceProxy.copy(dnsServers = value),
) )
} }
} }
fun onMtuChanged(value: String) { fun onMtuChanged(value: String) {
_uiState.update { _uiState.update {
it.copy(interfaceProxy = it.interfaceProxy.copy(mtu = value)) it.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(mtu = value))
} }
} }
private fun onInterfacePublicKeyChange(value: String) { private fun onInterfacePublicKeyChange(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = it.interfaceProxy.copy(publicKey = value), interfaceProxy = _uiState.value.interfaceProxy.copy(publicKey = value),
) )
} }
} }
@@ -478,7 +457,7 @@ constructor(
fun onPrivateKeyChange(value: String) { fun onPrivateKeyChange(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = it.interfaceProxy.copy(privateKey = value), interfaceProxy = _uiState.value.interfaceProxy.copy(privateKey = value),
) )
} }
if (NumberUtils.isValidKey(value)) { if (NumberUtils.isValidKey(value)) {
@@ -500,7 +479,7 @@ constructor(
fun onJunkPacketCountChanged(value: String) { fun onJunkPacketCountChanged(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = it.interfaceProxy.copy(junkPacketCount = value), interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketCount = value),
) )
} }
} }
@@ -508,7 +487,7 @@ constructor(
fun onJunkPacketMinSizeChanged(value: String) { fun onJunkPacketMinSizeChanged(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = it.interfaceProxy.copy(junkPacketMinSize = value), interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketMinSize = value),
) )
} }
} }
@@ -516,7 +495,7 @@ constructor(
fun onJunkPacketMaxSizeChanged(value: String) { fun onJunkPacketMaxSizeChanged(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = it.interfaceProxy.copy(junkPacketMaxSize = value), interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketMaxSize = value),
) )
} }
} }
@@ -524,7 +503,7 @@ constructor(
fun onInitPacketJunkSizeChanged(value: String) { fun onInitPacketJunkSizeChanged(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = it.interfaceProxy.copy(initPacketJunkSize = value), interfaceProxy = _uiState.value.interfaceProxy.copy(initPacketJunkSize = value),
) )
} }
} }
@@ -533,7 +512,7 @@ constructor(
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = interfaceProxy =
it.interfaceProxy.copy( _uiState.value.interfaceProxy.copy(
responsePacketJunkSize = value, responsePacketJunkSize = value,
), ),
) )
@@ -544,7 +523,7 @@ constructor(
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = interfaceProxy =
it.interfaceProxy.copy( _uiState.value.interfaceProxy.copy(
initPacketMagicHeader = value, initPacketMagicHeader = value,
), ),
) )
@@ -555,7 +534,7 @@ constructor(
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = interfaceProxy =
it.interfaceProxy.copy( _uiState.value.interfaceProxy.copy(
responsePacketMagicHeader = value, responsePacketMagicHeader = value,
), ),
) )
@@ -566,7 +545,7 @@ constructor(
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = interfaceProxy =
it.interfaceProxy.copy( _uiState.value.interfaceProxy.copy(
transportPacketMagicHeader = value, transportPacketMagicHeader = value,
), ),
) )
@@ -577,15 +556,10 @@ constructor(
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = interfaceProxy =
it.interfaceProxy.copy( _uiState.value.interfaceProxy.copy(
underloadPacketMagicHeader = value, underloadPacketMagicHeader = value,
), ),
) )
} }
} }
@AssistedFactory
interface ConfigViewModelFactory {
fun create(id: Int): ConfigViewModel
}
} }
@@ -1,199 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config.components
import android.content.pm.PackageInfo
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Android
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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.google.accompanist.drawablepainter.DrawablePainter
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigUiState
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ApplicationSelectionDialog(viewModel: ConfigViewModel, uiState: ConfigUiState, onDismiss: () -> Unit) {
val context = LocalContext.current
val licenseComparator = compareBy<PackageInfo> { viewModel.getPackageLabel(it) }
val sortedPackages = remember(uiState.packages, licenseComparator) {
uiState.packages.sortedWith(licenseComparator)
}
BasicAlertDialog(
onDismissRequest = { onDismiss() },
) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight(if (uiState.isAllApplicationsEnabled) 1 / 5f else 4 / 5f),
) {
Column(
modifier =
Modifier
.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(id = R.string.tunnel_all))
Switch(
checked = uiState.isAllApplicationsEnabled,
onCheckedChange = viewModel::onAllApplicationsChange,
)
}
if (!uiState.isAllApplicationsEnabled) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(id = R.string.include))
Checkbox(
checked = uiState.include,
onCheckedChange = {
viewModel.onIncludeChange(!uiState.include)
},
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(id = R.string.exclude))
Checkbox(
checked = !uiState.include,
onCheckedChange = {
viewModel.onIncludeChange(!uiState.include)
},
)
}
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
SearchBar(viewModel::emitQueriedPackages)
}
Spacer(Modifier.padding(5.dp))
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxHeight(19 / 22f),
) {
items(sortedPackages, key = { it.packageName }) { pack ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxSize()
.padding(5.dp).padding(end = 25.dp),
) {
Row(modifier = Modifier.fillMaxWidth().padding(start = 5.dp)) {
val drawable =
pack.applicationInfo?.loadIcon(context.packageManager)
val iconSize = 35.dp
if (drawable != null) {
Image(
painter = DrawablePainter(drawable),
stringResource(id = R.string.icon),
modifier = Modifier.size(iconSize),
)
} else {
val icon = Icons.Rounded.Android
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize),
)
}
Text(
viewModel.getPackageLabel(pack),
modifier = Modifier.padding(5.dp),
)
}
Checkbox(
modifier = Modifier.fillMaxSize(),
checked =
(
uiState.checkedPackageNames.contains(
pack.packageName,
)
),
onCheckedChange = {
if (it) {
viewModel.onAddCheckedPackage(pack.packageName)
} else {
viewModel.onRemoveCheckedPackage(pack.packageName)
}
},
)
}
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = { onDismiss() }) {
Text(stringResource(R.string.done))
}
}
}
}
}
}
@@ -44,7 +44,7 @@ data class InterfaceProxy(
publicKey = i.keyPair.publicKey.toBase64().trim(), publicKey = i.keyPair.publicKey.toBase64().trim(),
privateKey = i.keyPair.privateKey.toBase64().trim(), privateKey = i.keyPair.privateKey.toBase64().trim(),
addresses = i.addresses.joinToString(", ").trim(), addresses = i.addresses.joinToString(", ").trim(),
dnsServers = (i.dnsServers + i.dnsSearchDomains).joinToString(", ").replace("/", "").trim(), dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
listenPort = listenPort =
if (i.listenPort.isPresent) { if (i.listenPort.isPresent) {
i.listenPort.get().toString().trim() i.listenPort.get().toString().trim()
@@ -1,92 +1,144 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.net.VpnService
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll import androidx.compose.foundation.overscroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.CopyAll
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.Smartphone
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.FabPosition import androidx.compose.material3.FabPosition
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue 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.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext 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.text.buildAnnotatedString
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.wireguard.android.backend.GoBackend
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.AppUiState import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.common.NestedScrollListener import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
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.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.AutoTunnelRowItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingStartedLabel import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingStartedLabel
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissMultiFab
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.TunnelRowItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.VpnDeniedDialog import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.VpnDeniedDialog
import com.zaneschepke.wireguardautotunnel.ui.theme.corn
import com.zaneschepke.wireguardautotunnel.ui.theme.mint
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.getMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.handshakeStatus
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.mapPeerStats
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState, focusRequester: FocusRequester) { fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState, focusRequester: FocusRequester, navController: NavController) {
val haptic = LocalHapticFeedback.current
val context = LocalContext.current val context = LocalContext.current
val navController = LocalNavController.current
val snackbar = SnackbarController.current val snackbar = SnackbarController.current
val scope = rememberCoroutineScope()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var configType by remember { mutableStateOf(ConfigType.WIREGUARD) }
var showVpnPermissionDialog by remember { mutableStateOf(false) } var showVpnPermissionDialog by remember { mutableStateOf(false) }
var isFabVisible by rememberSaveable { mutableStateOf(true) } val isVisible = rememberSaveable { mutableStateOf(true) }
var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) } var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) }
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) } var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val nestedScrollConnection = remember { val nestedScrollConnection =
NestedScrollListener({ isFabVisible = false }, { isFabVisible = true }) remember {
} object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Hide FAB
if (available.y < -1) {
isVisible.value = false
}
// Show FAB
if (available.y > 1) {
isVisible.value = true
}
return Offset.Zero
}
}
}
val vpnActivityResultState = val vpnActivityResultState =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(), ActivityResultContracts.StartActivityForResult(),
onResult = { onResult = {
if (it.resultCode != RESULT_OK) showVpnPermissionDialog = true val accepted = (it.resultCode == RESULT_OK)
if (accepted) {
Timber.d("VPN permission granted")
} else {
showVpnPermissionDialog = true
}
}, },
) )
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (context.isRunningOnTv()) { if (context.isRunningOnTv()) {
delay(Constants.FOCUS_REQUEST_DELAY) delay(Constants.FOCUS_REQUEST_DELAY)
runCatching { kotlin.runCatching {
focusRequester.requestFocus() focusRequester.requestFocus()
}.onFailure { }.onFailure {
delay(Constants.FOCUS_REQUEST_DELAY) delay(Constants.FOCUS_REQUEST_DELAY)
@@ -100,15 +152,26 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState,
context.getString(R.string.error_no_file_explorer), context.getString(R.string.error_no_file_explorer),
) )
}, onData = { data -> }, onData = { data ->
viewModel.onTunnelFileSelected(data, context) scope.launch {
viewModel.onTunnelFileSelected(data, configType, context).onFailure {
snackbar.showMessage(it.getMessage(context))
}
}
}) })
val requestPermissionLauncher = rememberLauncherForActivityResult( val scanLauncher =
ActivityResultContracts.RequestPermission(), rememberLauncherForActivityResult(
) { isGranted -> contract = ScanContract(),
if (!isGranted) return@rememberLauncherForActivityResult snackbar.showMessage("Camera permission required") onResult = {
navController.navigate(Route.Scanner) if (it.contents != null) {
} scope.launch {
viewModel.onTunnelQrResult(it.contents, configType).onFailure { error ->
snackbar.showMessage(error.getMessage(context))
}
}
}
},
)
VpnDeniedDialog(showVpnPermissionDialog, onDismiss = { showVpnPermissionDialog = false }) VpnDeniedDialog(showVpnPermissionDialog, onDismiss = { showVpnPermissionDialog = false })
@@ -127,48 +190,57 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState,
} }
fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) { fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) {
val intent = if (uiState.settings.isKernelEnabled) null else VpnService.prepare(context) if (checked) {
if (intent != null) return vpnActivityResultState.launch(intent) if (uiState.settings.isKernelEnabled) {
if (!checked) viewModel.onTunnelStop(tunnel).also { return } context.startTunnelBackground(tunnel.id)
if (uiState.settings.isKernelEnabled) { } else {
context.startTunnelBackground(tunnel.id) viewModel.onTunnelStart(tunnel)
}
} else { } else {
viewModel.onTunnelStart(tunnel) viewModel.onTunnelStop(
tunnel,
)
} }
} }
fun launchQrScanner() {
val scanOptions = ScanOptions()
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.setOrientationLocked(true)
scanOptions.setPrompt(
context.getString(R.string.scanning_qr),
)
scanOptions.setBeepEnabled(false)
scanLauncher.launch(scanOptions)
}
Scaffold( Scaffold(
modifier = modifier =
Modifier.pointerInput(Unit) { Modifier.pointerInput(Unit) {
if (uiState.tunnels.isEmpty()) return@pointerInput if (uiState.tunnels.isNotEmpty()) {
detectTapGestures( detectTapGestures(
onTap = { onTap = {
selectedTunnel = null selectedTunnel = null
}, },
) )
}
}, },
floatingActionButtonPosition = FabPosition.End, floatingActionButtonPosition = FabPosition.End,
floatingActionButton = { floatingActionButton = {
ScrollDismissFab({ ScrollDismissMultiFab(R.drawable.add, focusRequester, isVisible = isVisible.value, onFabItemClicked = {
val icon = Icons.Filled.Add
Icon(
imageVector = icon,
contentDescription = icon.name,
tint = MaterialTheme.colorScheme.onPrimary,
)
}, focusRequester, isVisible = isFabVisible, onClick = {
showBottomSheet = true showBottomSheet = true
configType = ConfigType.valueOf(it.value)
}) })
}, },
) { ) {
TunnelImportSheet( TunnelImportSheet(
showBottomSheet, showBottomSheet,
onDismiss = { showBottomSheet = false }, onDismiss = { showBottomSheet = false },
onFileClick = { tunnelFileImportResultLauncher.launch(Constants.ALLOWED_TV_FILE_TYPES) }, onFileClick = { tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES) },
onQrClick = { requestPermissionLauncher.launch(android.Manifest.permission.CAMERA) }, onQrClick = { launchQrScanner() },
onManualImportClick = { onManualImportClick = {
navController.navigate( navController.navigate(
Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID), "${Screen.Config.route}/${Constants.MANUAL_TUNNEL_CONFIG_ID}?configType=$configType",
) )
}, },
) )
@@ -190,32 +262,285 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState,
GettingStartedLabel(onClick = { context.openWebUrl(it) }) GettingStartedLabel(onClick = { context.openWebUrl(it) })
} }
} }
if (uiState.settings.isAutoTunnelEnabled) { item {
item { if (uiState.settings.isAutoTunnelEnabled) {
AutoTunnelRowItem(uiState.settings, { viewModel.onToggleAutoTunnelingPause() }, focusRequester) val itemFocusRequester = remember { FocusRequester() }
val autoTunnelingLabel =
buildAnnotatedString {
append(stringResource(id = R.string.auto_tunneling))
append(": ")
if (uiState.settings.isAutoTunnelPaused) {
append(
stringResource(id = R.string.paused),
)
} else {
append(
stringResource(id = R.string.active),
)
}
}
RowListItem(
icon = {
val icon = Icons.Rounded.Bolt
Icon(
icon,
icon.name,
modifier =
Modifier
.padding(end = 8.5.dp)
.size(25.dp),
tint =
if (uiState.settings.isAutoTunnelPaused) {
Color.Gray
} else {
mint
},
)
},
text = autoTunnelingLabel.text,
rowButton = {
if (uiState.settings.isAutoTunnelPaused) {
TextButton(
modifier = Modifier.focusRequester(itemFocusRequester),
onClick = { viewModel.resumeAutoTunneling() },
) {
Text(stringResource(id = R.string.resume))
}
} else {
TextButton(
modifier = Modifier.focusRequester(itemFocusRequester),
onClick = { viewModel.pauseAutoTunneling() },
) {
Text(stringResource(id = R.string.pause))
}
}
},
onClick = {
if (context.isRunningOnTv()) {
itemFocusRequester.requestFocus()
}
},
onHold = {},
expanded = false,
statistics = null,
focusRequester = focusRequester,
)
} }
} }
items( items(
uiState.tunnels, uiState.tunnels,
key = { tunnel -> tunnel.id }, key = { tunnel -> tunnel.id },
) { tunnel -> ) { tunnel ->
val isActive = uiState.tunnels.any { val isActive = uiState.tunnels.any { it.id == tunnel.id && it.isActive }
it.id == tunnel.id && val leadingIconColor =
it.isActive (
} if (
val expanded = uiState.generalState.isTunnelStatsExpanded isActive
TunnelRowItem( ) {
isActive, uiState.vpnState.statistics
expanded, ?.mapPeerStats()
selectedTunnel?.id == tunnel.id, ?.map { it.value?.handshakeStatus() }
tunnel, .let { statuses ->
vpnState = uiState.vpnState, when {
{ selectedTunnel = tunnel }, statuses?.all { it == HandshakeStatus.HEALTHY } == true -> mint
{ viewModel.onExpandedChanged(!expanded) }, statuses?.any { it == HandshakeStatus.STALE } == true -> corn
onDelete = { showDeleteTunnelAlertDialog = true }, statuses?.all { it == HandshakeStatus.NOT_STARTED } == true ->
onCopy = { viewModel.onCopyTunnel(tunnel) }, Color.Gray
onSwitchClick = { onTunnelToggle(it, tunnel) },
else -> {
Color.Gray
}
}
}
} else {
Color.Gray
}
)
val itemFocusRequester = remember { FocusRequester() }
val expanded = remember { mutableStateOf(false) }
RowListItem(
icon = {
val circleIcon = Icons.Rounded.Circle
val icon =
if (tunnel.isPrimaryTunnel) {
Icons.Rounded.Star
} else if (tunnel.isMobileDataTunnel) {
Icons.Rounded.Smartphone
} else {
circleIcon
}
Icon(
icon,
icon.name,
tint = leadingIconColor,
modifier =
Modifier
.padding(
end = if (icon == circleIcon) 12.5.dp else 10.dp,
start = if (icon == circleIcon) 2.5.dp else 0.dp,
)
.size(if (icon == circleIcon) 15.dp else 20.dp),
)
},
text = tunnel.name,
onHold = {
if (
(uiState.vpnState.status == TunnelState.UP) &&
(tunnel.name == uiState.vpnState.tunnelConfig?.name)
) {
snackbar.showMessage(
context.getString(R.string.turn_off_tunnel),
)
return@RowListItem
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
selectedTunnel = tunnel
},
onClick = {
if (!context.isRunningOnTv()) {
if (
isActive
) {
expanded.value = !expanded.value
}
} else {
selectedTunnel = tunnel
itemFocusRequester.requestFocus()
}
},
statistics = uiState.vpnState.statistics,
expanded = expanded.value,
focusRequester = focusRequester, focusRequester = focusRequester,
rowButton = {
if (
tunnel.id == selectedTunnel?.id &&
!context.isRunningOnTv()
) {
Row {
IconButton(
onClick = {
if (
uiState.settings.isAutoTunnelEnabled &&
!uiState.settings.isAutoTunnelPaused
) {
snackbar.showMessage(
context.getString(R.string.turn_off_tunnel),
)
} else {
navController.navigate(
"${Screen.Option.route}/${selectedTunnel?.id}",
)
}
},
) {
val icon = Icons.Rounded.Settings
Icon(
icon,
icon.name,
)
}
IconButton(
modifier = Modifier.focusable(),
onClick = { viewModel.onCopyTunnel(selectedTunnel) },
) {
val icon = Icons.Rounded.CopyAll
Icon(icon, icon.name)
}
IconButton(
modifier = Modifier.focusable(),
onClick = { showDeleteTunnelAlertDialog = true },
) {
val icon = Icons.Rounded.Delete
Icon(icon, icon.name)
}
}
} else {
if (!isActive) expanded.value = false
@Composable
fun TunnelSwitch() = Switch(
modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive,
onCheckedChange = { checked ->
if (!checked) expanded.value = false
val intent = if (uiState.settings.isKernelEnabled) null else GoBackend.VpnService.prepare(context)
if (intent != null) return@Switch vpnActivityResultState.launch(intent)
onTunnelToggle(checked, tunnel)
},
)
if (context.isRunningOnTv()) {
Row {
IconButton(
onClick = {
if (uiState.settings.isAutoTunnelEnabled && !uiState.settings.isAutoTunnelPaused) {
snackbar.showMessage(
context.getString(R.string.turn_off_auto),
)
} else {
selectedTunnel = tunnel
navController.navigate(
"${Screen.Option.route}/${selectedTunnel?.id}",
)
}
},
) {
val icon = Icons.Rounded.Settings
Icon(
icon,
icon.name,
)
}
IconButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
if (
uiState.vpnState.status == TunnelState.UP &&
(uiState.vpnState.tunnelConfig?.name == tunnel.name)
) {
expanded.value = !expanded.value
} else {
snackbar.showMessage(
context.getString(R.string.turn_on_tunnel),
)
}
},
) {
val icon = Icons.Rounded.Info
Icon(icon, icon.name)
}
IconButton(
onClick = { viewModel.onCopyTunnel(tunnel) },
) {
val icon = Icons.Rounded.CopyAll
Icon(icon, icon.name)
}
IconButton(
onClick = {
if (
uiState.vpnState.status == TunnelState.UP &&
tunnel.name == uiState.vpnState.tunnelConfig?.name
) {
snackbar.showMessage(
context.getString(R.string.turn_off_tunnel),
)
} else {
selectedTunnel = tunnel
showDeleteTunnelAlertDialog = true
}
},
) {
val icon = Icons.Rounded.Delete
Icon(
icon,
icon.name,
)
}
TunnelSwitch()
}
} else {
TunnelSwitch()
}
}
},
) )
} }
} }
@@ -6,21 +6,16 @@ import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.data.domain.Settings import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileReadException
import com.zaneschepke.wireguardautotunnel.util.InvalidFileExtensionException
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import com.zaneschepke.wireguardautotunnel.util.extensions.extractNameAndNumber
import com.zaneschepke.wireguardautotunnel.util.extensions.hasNumberInParentheses
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -65,10 +60,6 @@ constructor(
) )
} }
fun onExpandedChanged(expanded: Boolean) = viewModelScope.launch {
appDataRepository.appState.setTunnelStatsExpanded(expanded)
}
fun onTunnelStart(tunnelConfig: TunnelConfig) = viewModelScope.launch { fun onTunnelStart(tunnelConfig: TunnelConfig) = viewModelScope.launch {
Timber.i("Starting tunnel ${tunnelConfig.name}") Timber.i("Starting tunnel ${tunnelConfig.name}")
tunnelService.startTunnel(tunnelConfig) tunnelService.startTunnel(tunnelConfig)
@@ -79,17 +70,32 @@ constructor(
tunnelService.stopTunnel(tunnel) tunnelService.stopTunnel(tunnel)
} }
private fun generateQrCodeDefaultName(config: String): String { private fun validateConfigString(config: String, configType: ConfigType) {
when (configType) {
ConfigType.AMNEZIA -> TunnelConfig.configFromAmQuick(config)
ConfigType.WIREGUARD -> TunnelConfig.configFromWgQuick(config)
}
}
private fun generateQrCodeDefaultName(config: String, configType: ConfigType): String {
return try { return try {
TunnelConfig.configFromAmQuick(config).peers[0].endpoint.get().host when (configType) {
ConfigType.AMNEZIA -> {
TunnelConfig.configFromAmQuick(config).peers[0].endpoint.get().host
}
ConfigType.WIREGUARD -> {
TunnelConfig.configFromWgQuick(config).peers[0].endpoint.get().host
}
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
NumberUtils.generateRandomTunnelName() NumberUtils.generateRandomTunnelName()
} }
} }
private fun generateQrCodeTunnelName(config: String): String { private fun generateQrCodeTunnelName(config: String, configType: ConfigType): String {
var defaultName = generateQrCodeDefaultName(config) var defaultName = generateQrCodeDefaultName(config, configType)
val lines = config.lines().toMutableList() val lines = config.lines().toMutableList()
val linesIterator = lines.iterator() val linesIterator = lines.iterator()
while (linesIterator.hasNext()) { while (linesIterator.hasNext()) {
@@ -102,94 +108,190 @@ constructor(
return defaultName return defaultName
} }
suspend fun onTunnelQrResult(result: String, configType: ConfigType): Result<Unit> {
return withContext(ioDispatcher) {
try {
validateConfigString(result, configType)
val tunnelName =
makeTunnelNameUnique(generateQrCodeTunnelName(result, configType))
val tunnelConfig =
when (configType) {
ConfigType.AMNEZIA -> {
TunnelConfig(
name = tunnelName,
amQuick = result,
wgQuick =
TunnelConfig.configFromAmQuick(
result,
).toWgQuickString(),
)
}
ConfigType.WIREGUARD ->
TunnelConfig(
name = tunnelName,
wgQuick = result,
)
}
addTunnel(tunnelConfig)
Result.success(Unit)
} catch (e: Exception) {
Timber.e(e)
Result.failure(WgTunnelExceptions.InvalidQrCode())
}
}
}
private suspend fun makeTunnelNameUnique(name: String): String { private suspend fun makeTunnelNameUnique(name: String): String {
return withContext(ioDispatcher) { return withContext(ioDispatcher) {
val tunnels = appDataRepository.tunnels.getAll() val tunnels = appDataRepository.tunnels.getAll()
var tunnelName = name var tunnelName = name
var num = 1 var num = 1
while (tunnels.any { it.name == tunnelName }) { while (tunnels.any { it.name == tunnelName }) {
tunnelName = if (!tunnelName.hasNumberInParentheses()) { tunnelName = "$name($num)"
"$name($num)"
} else {
val pair = tunnelName.extractNameAndNumber()
"${pair?.first}($num)"
}
num++ num++
} }
tunnelName tunnelName
} }
} }
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) { private fun saveTunnelConfigFromStream(stream: InputStream, fileName: String, type: ConfigType) {
val amConfig = stream.use { org.amnezia.awg.config.Config.parse(it) } var amQuick: String? = null
val tunnelName = makeTunnelNameUnique(getNameFromFileName(fileName)) val wgQuick =
saveTunnel( stream.use {
TunnelConfig( when (type) {
name = tunnelName, ConfigType.AMNEZIA -> {
wgQuick = amConfig.toWgQuickString(), val config = org.amnezia.awg.config.Config.parse(it)
amQuick = amConfig.toAwgQuickString(true), amQuick = config.toAwgQuickString(true)
), config.toWgQuickString()
) }
ConfigType.WIREGUARD -> {
Config.parse(it).toWgQuickString(true)
}
}
}
viewModelScope.launch {
val tunnelName = makeTunnelNameUnique(getNameFromFileName(fileName))
addTunnel(
TunnelConfig(
name = tunnelName,
wgQuick = wgQuick,
amQuick = amQuick ?: TunnelConfig.AM_QUICK_DEFAULT,
),
)
}
} }
private fun getInputStreamFromUri(uri: Uri, context: Context): InputStream? { private fun getInputStreamFromUri(uri: Uri, context: Context): InputStream? {
return context.applicationContext.contentResolver.openInputStream(uri) return context.applicationContext.contentResolver.openInputStream(uri)
} }
fun onTunnelFileSelected(uri: Uri, context: Context) = viewModelScope.launch(ioDispatcher) { suspend fun onTunnelFileSelected(uri: Uri, configType: ConfigType, context: Context): Result<Unit> {
kotlin.runCatching { return withContext(ioDispatcher) {
if (!isValidUriContentScheme(uri)) throw InvalidFileExtensionException try {
val fileName = getFileName(context, uri) if (isValidUriContentScheme(uri)) {
when (getFileExtensionFromFileName(fileName)) { val fileName = getFileName(context, uri)
Constants.CONF_FILE_EXTENSION -> return@withContext when (getFileExtensionFromFileName(fileName)) {
saveTunnelFromConfUri(fileName, uri, context) Constants.CONF_FILE_EXTENSION ->
Constants.ZIP_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri, configType, context)
saveTunnelsFromZipUri(
uri, Constants.ZIP_FILE_EXTENSION ->
context, saveTunnelsFromZipUri(
) uri,
else -> throw InvalidFileExtensionException configType,
context,
)
else -> Result.failure(WgTunnelExceptions.InvalidFileExtension())
}
} else {
Result.failure(WgTunnelExceptions.InvalidFileExtension())
}
} catch (e: Exception) {
Timber.e(e)
Result.failure(WgTunnelExceptions.FileReadFailed())
} }
}.onFailure { }
Timber.e(it) }
if (it is InvalidFileExtensionException) {
SnackbarController.showMessage(StringValue.StringResource(R.string.error_file_extension)) private suspend fun saveTunnelsFromZipUri(uri: Uri, configType: ConfigType, context: Context): Result<Unit> {
return withContext(ioDispatcher) {
ZipInputStream(getInputStreamFromUri(uri, context)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach {
val name = getNameFromFileName(it.name)
withContext(viewModelScope.coroutineContext) {
try {
var amQuick: String? = null
val wgQuick =
when (configType) {
ConfigType.AMNEZIA -> {
val config =
org.amnezia.awg.config.Config.parse(
zip,
)
amQuick = config.toAwgQuickString(true)
config.toWgQuickString()
}
ConfigType.WIREGUARD -> {
Config.parse(zip).toWgQuickString(true)
}
}
addTunnel(
TunnelConfig(
name = makeTunnelNameUnique(name),
wgQuick = wgQuick,
amQuick = amQuick ?: TunnelConfig.AM_QUICK_DEFAULT,
),
)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(WgTunnelExceptions.FileReadFailed())
}
}
}
Result.success(Unit)
}
}
}
private suspend fun saveTunnelFromConfUri(name: String, uri: Uri, configType: ConfigType, context: Context): Result<Unit> {
return withContext(ioDispatcher) {
val stream = getInputStreamFromUri(uri, context)
return@withContext if (stream != null) {
try {
saveTunnelConfigFromStream(stream, name, configType)
} catch (e: Exception) {
return@withContext Result.failure(WgTunnelExceptions.ConfigParseError())
}
Result.success(Unit)
} else { } else {
SnackbarController.showMessage(StringValue.StringResource(R.string.error_file_format)) Result.failure(WgTunnelExceptions.FileReadFailed())
} }
} }
} }
private suspend fun saveTunnelsFromZipUri(uri: Uri, context: Context) { private fun addTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch {
ZipInputStream(getInputStreamFromUri(uri, context)).use { zip -> saveTunnel(tunnelConfig)
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach { entry ->
val name = getNameFromFileName(entry.name)
val amConf = org.amnezia.awg.config.Config.parse(zip.bufferedReader())
saveTunnel(
TunnelConfig(
name = makeTunnelNameUnique(name),
wgQuick = amConf.toWgQuickString(),
amQuick = amConf.toAwgQuickString(true),
),
)
}
}
} }
private suspend fun saveTunnelFromConfUri(name: String, uri: Uri, context: Context) { fun pauseAutoTunneling() = viewModelScope.launch {
val stream = getInputStreamFromUri(uri, context) ?: throw FileReadException
saveTunnelConfigFromStream(stream, name)
}
fun onToggleAutoTunnelingPause() = viewModelScope.launch {
val settings = appDataRepository.settings.getSettings() val settings = appDataRepository.settings.getSettings()
appDataRepository.settings.save( appDataRepository.settings.save(
settings.copy(isAutoTunnelPaused = !settings.isAutoTunnelPaused), settings.copy(isAutoTunnelPaused = true),
)
}
fun resumeAutoTunneling() = viewModelScope.launch {
val settings = appDataRepository.settings.getSettings()
appDataRepository.settings.save(
settings.copy(isAutoTunnelPaused = false),
) )
} }
@@ -198,23 +300,32 @@ constructor(
} }
private fun getFileNameByCursor(context: Context, uri: Uri): String? { private fun getFileNameByCursor(context: Context, uri: Uri): String? {
return context.contentResolver.query(uri, null, null, null, null)?.use { context.contentResolver.query(uri, null, null, null, null)?.use {
getDisplayNameByCursor(it) return getDisplayNameByCursor(it)
} }
return null
} }
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 return if (columnIndex != -1) {
return columnIndex return columnIndex
} else {
null
}
} }
private fun getDisplayNameByCursor(cursor: Cursor): String? { private fun getDisplayNameByCursor(cursor: Cursor): String? {
val move = cursor.moveToFirst() return if (cursor.moveToFirst()) {
if (!move) return null val index = getDisplayNameColumnIndex(cursor)
val index = getDisplayNameColumnIndex(cursor) if (index != null) {
if (index == null) return index cursor.getString(index)
return cursor.getString(index) } else {
null
}
} else {
null
}
} }
private fun isValidUriContentScheme(uri: Uri): Boolean { private fun isValidUriContentScheme(uri: Uri): Boolean {
@@ -240,15 +351,14 @@ constructor(
private fun saveSettings(settings: Settings) = viewModelScope.launch { appDataRepository.settings.save(settings) } private fun saveSettings(settings: Settings) = viewModelScope.launch { appDataRepository.settings.save(settings) }
fun onCopyTunnel(tunnel: TunnelConfig) = viewModelScope.launch { fun onCopyTunnel(tunnel: TunnelConfig?) = viewModelScope.launch {
saveTunnel( tunnel?.let {
tunnel.copy( saveTunnel(
id = 0, TunnelConfig(
isPrimaryTunnel = false, name = it.name.plus(NumberUtils.randomThree()),
isMobileDataTunnel = false, wgQuick = it.wgQuick,
isActive = false, ),
name = makeTunnelNameUnique(tunnel.name), )
), }
)
} }
} }
@@ -1,78 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
@Composable
fun AutoTunnelRowItem(settings: Settings, onToggle: () -> Unit, focusRequester: FocusRequester) {
val context = LocalContext.current
val itemFocusRequester = remember { FocusRequester() }
val autoTunnelingLabel =
buildAnnotatedString {
append(stringResource(id = R.string.auto_tunneling))
append(": ")
if (settings.isAutoTunnelPaused) {
append(
stringResource(id = R.string.paused),
)
} else {
append(
stringResource(id = R.string.active),
)
}
}
ExpandingRowListItem(
leading = {
val icon = Icons.Rounded.Bolt
Icon(
icon,
icon.name,
modifier =
Modifier
.size(iconSize).scale(1.5f),
tint =
if (settings.isAutoTunnelPaused) {
Color.Gray
} else {
SilverTree
},
)
},
text = autoTunnelingLabel.text,
trailing = {
TextButton(
modifier = Modifier.focusRequester(itemFocusRequester),
onClick = { onToggle() },
) {
Text(stringResource(id = if (settings.isAutoTunnelPaused) R.string.resume else R.string.pause))
}
},
onClick = {
if (context.isRunningOnTv()) {
itemFocusRequester.requestFocus()
}
},
isExpanded = false,
focusRequester = focusRequester,
)
}
@@ -1,20 +1,41 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.iamageo.multifablibrary.FabIcon
import com.iamageo.multifablibrary.FabOption
import com.iamageo.multifablibrary.MultiFabItem
import com.iamageo.multifablibrary.MultiFloatingActionButton
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
@Composable @Composable
fun ScrollDismissFab(icon: @Composable () -> Unit, focusRequester: FocusRequester, isVisible: Boolean, onClick: () -> Unit) { fun ScrollDismissMultiFab(
@DrawableRes res: Int,
focusRequester: FocusRequester,
isVisible: Boolean,
onFabItemClicked: (fabItem: MultiFabItem) -> Unit,
) {
// Nested scroll for control FAB
val context = LocalContext.current
AnimatedVisibility( AnimatedVisibility(
visible = isVisible, visible = isVisible,
enter = slideInVertically(initialOffsetY = { it * 2 }), enter = slideInVertically(initialOffsetY = { it * 2 }),
@@ -24,14 +45,64 @@ fun ScrollDismissFab(icon: @Composable () -> Unit, focusRequester: FocusRequeste
.focusRequester(focusRequester) .focusRequester(focusRequester)
.focusGroup(), .focusGroup(),
) { ) {
FloatingActionButton( val fobColor = MaterialTheme.colorScheme.secondary
onClick = { val fobIconColor = MaterialTheme.colorScheme.background
onClick() MultiFloatingActionButton(
fabIcon =
FabIcon(
iconRes = res,
iconResAfterRotate = R.drawable.close,
iconRotate = 180f,
),
fabOption =
FabOption(
iconTint = fobIconColor,
backgroundTint = fobColor,
),
itemsMultiFab =
listOf(
MultiFabItem(
label = {
Text(
stringResource(id = R.string.amnezia),
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.padding(end = 10.dp),
)
},
modifier =
Modifier
.size(40.dp),
icon = res,
value = ConfigType.AMNEZIA.name,
miniFabOption =
FabOption(
backgroundTint = fobColor,
fobIconColor,
),
),
MultiFabItem(
label = {
Text(
stringResource(id = R.string.wireguard),
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.padding(end = 10.dp),
)
},
icon = res,
value = ConfigType.WIREGUARD.name,
miniFabOption =
FabOption(
backgroundTint = fobColor,
fobIconColor,
),
),
),
onFabItemClicked = {
onFabItemClicked(it)
}, },
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
containerColor = MaterialTheme.colorScheme.primary, )
) {
icon()
}
} }
} }
@@ -1,204 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.CopyAll
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.Smartphone
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.asColor
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
@Composable
fun TunnelRowItem(
isActive: Boolean,
expanded: Boolean,
isSelected: Boolean,
tunnel: TunnelConfig,
vpnState: VpnState,
onHold: () -> Unit,
onClick: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit,
onSwitchClick: (checked: Boolean) -> Unit,
focusRequester: FocusRequester,
) {
val leadingIconColor = if (!isActive) Color.Gray else vpnState.statistics.asColor()
val context = LocalContext.current
val snackbar = SnackbarController.current
val navController = LocalNavController.current
val haptic = LocalHapticFeedback.current
val itemFocusRequester = remember { FocusRequester() }
ExpandingRowListItem(
leading = {
val circleIcon = Icons.Rounded.Circle
val icon =
if (tunnel.isPrimaryTunnel) {
Icons.Rounded.Star
} else if (tunnel.isMobileDataTunnel) {
Icons.Rounded.Smartphone
} else {
circleIcon
}
Icon(
icon,
icon.name,
tint = leadingIconColor,
modifier = Modifier.size(iconSize),
)
},
text = tunnel.name,
onHold = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onHold()
},
onClick = {
if (!context.isRunningOnTv()) {
if (isActive) {
onClick()
}
} else {
onHold()
itemFocusRequester.requestFocus()
}
},
isExpanded = expanded && isActive,
expanded = { if (isActive && expanded) TunnelStatisticsRow(vpnState.statistics, tunnel) },
focusRequester = focusRequester,
trailing = {
if (
isSelected &&
!context.isRunningOnTv()
) {
Row {
IconButton(
onClick = {
navController.navigate(
Route.Option(tunnel.id),
)
},
) {
val icon = Icons.Rounded.Settings
Icon(
icon,
icon.name,
)
}
IconButton(
modifier = Modifier.focusable(),
onClick = { onCopy() },
) {
val icon = Icons.Rounded.CopyAll
Icon(icon, icon.name)
}
IconButton(
enabled = !isActive,
modifier = Modifier.focusable(),
onClick = { onDelete() },
) {
val icon = Icons.Rounded.Delete
Icon(icon, icon.name)
}
}
} else {
if (context.isRunningOnTv()) {
Row {
IconButton(
onClick = {
onHold()
navController.navigate(
Route.Option(tunnel.id),
)
},
) {
val icon = Icons.Rounded.Settings
Icon(
icon,
icon.name,
)
}
IconButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
if (isActive) {
onClick()
} else {
snackbar.showMessage(
context.getString(R.string.turn_on_tunnel),
)
}
},
) {
val icon = Icons.Rounded.Info
Icon(icon, icon.name)
}
IconButton(
onClick = { onCopy() },
) {
val icon = Icons.Rounded.CopyAll
Icon(icon, icon.name)
}
IconButton(
onClick = {
if (isActive) {
snackbar.showMessage(
context.getString(R.string.turn_off_tunnel),
)
} else {
onHold()
onDelete()
}
},
) {
val icon = Icons.Rounded.Delete
Icon(
icon,
icon.name,
)
}
Switch(
modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive,
onCheckedChange = { checked ->
onSwitchClick(checked)
},
)
}
} else {
Switch(
modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive,
onCheckedChange = { checked ->
onSwitchClick(checked)
},
)
}
}
},
)
}
@@ -1,60 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.extensions.toThreeDecimalPlaceString
@Composable
fun TunnelStatisticsRow(statistics: TunnelStatistics?, tunnelConfig: TunnelConfig) {
val config = TunnelConfig.configFromAmQuick(tunnelConfig.wgQuick)
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(end = 10.dp, bottom = 10.dp, start = 45.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(30.dp, Alignment.Start),
) {
config.peers.forEach {
val peerId = it.publicKey.toBase64().subSequence(0, 3).toString() + "***"
val peerRx = statistics?.peerStats(it.publicKey)?.rxBytes ?: 0
val peerTx = statistics?.peerStats(it.publicKey)?.txBytes ?: 0
val peerTxMB = NumberUtils.bytesToMB(peerTx).toThreeDecimalPlaceString()
val peerRxMB = NumberUtils.bytesToMB(peerRx).toThreeDecimalPlaceString()
val handshake = statistics?.peerStats(it.publicKey)?.latestHandshakeEpochMillis?.let {
if (it == 0L) {
stringResource(R.string.never)
} else {
"${NumberUtils.getSecondsBetweenTimestampAndNow(it)} ${stringResource(R.string.sec)}"
}
} ?: stringResource(R.string.never)
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(stringResource(R.string.peer).lowercase() + ": $peerId", style = MaterialTheme.typography.bodySmall)
Text("tx: $peerTxMB MB", style = MaterialTheme.typography.bodySmall)
}
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(stringResource(R.string.handshake) + ": $handshake", style = MaterialTheme.typography.bodySmall)
Text("rx: $peerRxMB MB", style = MaterialTheme.typography.bodySmall)
}
}
}
}
@@ -19,9 +19,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -48,29 +46,33 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppUiState import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.WildcardSupportingLabel import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissMultiFab
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), focusRequester: FocusRequester, appUiState: AppUiState, tunnelId: Int) { fun OptionsScreen(
optionsViewModel: OptionsViewModel = hiltViewModel(),
navController: NavController,
focusRequester: FocusRequester,
appUiState: AppUiState,
tunnelId: Int,
) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val context = LocalContext.current val context = LocalContext.current
val navController = LocalNavController.current
val config = appUiState.tunnels.first { it.id == tunnelId } val config = appUiState.tunnels.first { it.id == tunnelId }
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
@@ -101,16 +103,10 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), focusReq
Scaffold( Scaffold(
floatingActionButton = { floatingActionButton = {
ScrollDismissFab(icon = { ScrollDismissMultiFab(R.drawable.edit, focusRequester, isVisible = true, onFabItemClicked = {
val icon = Icons.Filled.Edit val configType = ConfigType.valueOf(it.value)
Icon(
imageVector = icon,
contentDescription = icon.name,
tint = MaterialTheme.colorScheme.onPrimary,
)
}, focusRequester, isVisible = true, onClick = {
navController.navigate( navController.navigate(
Route.Config(config.id), "${Screen.Config.route}/${config.id}?configType=${configType.name}",
) )
}) })
}, },
@@ -244,7 +240,6 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), focusReq
value = currentText, value = currentText,
onValueChange = { currentText = it }, onValueChange = { currentText = it },
label = { Text(stringResource(id = R.string.use_tunnel_on_wifi_name)) }, label = { Text(stringResource(id = R.string.use_tunnel_on_wifi_name)) },
supportingText = { WildcardSupportingLabel { context.openWebUrl(it) } },
modifier = modifier =
Modifier Modifier
.padding( .padding(
@@ -284,10 +279,10 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), focusReq
stringResource(R.string.set_custom_ping_ip), stringResource(R.string.set_custom_ping_ip),
stringResource(R.string.default_ping_ip), stringResource(R.string.default_ping_ip),
focusRequester, focusRequester,
isErrorValue = { !it.isNullOrBlank() && !it.isValidIpv4orIpv6Address() }, isErrorValue = { !(it?.isValidIpv4orIpv6Address() ?: true) },
onSubmit = { onSubmit = {
optionsViewModel.saveTunnelChanges( optionsViewModel.saveTunnelChanges(
config.copy(pingIp = it.ifBlank { null }), config.copy(pingIp = it),
) )
}, },
) )
@@ -301,12 +296,11 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), focusReq
focusRequester, focusRequester,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number, keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
), ),
isErrorValue = ::isSecondsError, isErrorValue = ::isSecondsError,
onSubmit = { onSubmit = {
optionsViewModel.saveTunnelChanges( optionsViewModel.saveTunnelChanges(
config.copy(pingInterval = if (it.isBlank()) null else it.toLong() * 1000), config.copy(pingInterval = it.toLong() * 1000),
) )
}, },
) )
@@ -321,7 +315,7 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), focusReq
isErrorValue = ::isSecondsError, isErrorValue = ::isSecondsError,
onSubmit = { onSubmit = {
optionsViewModel.saveTunnelChanges( optionsViewModel.saveTunnelChanges(
config.copy(pingCooldown = if (it.isBlank()) null else it.toLong() * 1000), config.copy(pingCooldown = it.toLong() * 1000),
) )
}, },
) )
@@ -5,24 +5,23 @@ 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import xyz.teamgravity.pin_lock_compose.PinLock import xyz.teamgravity.pin_lock_compose.PinLock
@Composable @Composable
fun PinLockScreen(appViewModel: AppViewModel) { fun PinLockScreen(navController: NavController, appViewModel: AppViewModel) {
val context = LocalContext.current val context = LocalContext.current
val navController = LocalNavController.current
val snackbar = SnackbarController.current val snackbar = SnackbarController.current
PinLock( PinLock(
title = { pinExists -> title = { pinExists ->
Text( Text(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSecondary,
text = text =
if (pinExists) { if (pinExists) {
stringResource(id = R.string.enter_pin) stringResource(id = R.string.enter_pin)
@@ -33,16 +32,15 @@ fun PinLockScreen(appViewModel: AppViewModel) {
}, },
) )
}, },
backgroundColor = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.secondary,
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 (context.isRunningOnTv()) {
navController.navigate(Route.Main) navController.navigate(Screen.Main.route)
} else { } else {
val isPopped = navController.popBackStack() val isPopped = navController.popBackStack()
if (!isPopped) { if (!isPopped) {
navController.navigate(Route.Main) navController.navigate(Screen.Main.route)
} }
} }
}, },
@@ -1,46 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.scanner
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.journeyapps.barcodescanner.CompoundBarcodeView
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun ScannerScreen(viewModel: ScannerViewModel = hiltViewModel()) {
val context = LocalContext.current
val navController = LocalNavController.current
val success = viewModel.success.collectAsStateWithLifecycle(null)
LaunchedEffect(success.value) {
if (success.value != null) navController.popBackStack()
}
val barcodeView = remember {
CompoundBarcodeView(context).apply {
this.initializeFromIntent((context as Activity).intent)
this.setStatusText("")
this.decodeSingle { result ->
result.text?.let { barCodeOrQr ->
viewModel.onTunnelQrResult(barCodeOrQr)
}
}
}
}
AndroidView(factory = { barcodeView })
DisposableEffect(Unit) {
barcodeView.resume()
onDispose {
barcodeView.pause()
}
}
}
@@ -1,69 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.scanner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class ScannerViewModel @Inject
constructor(
private val appDataRepository: AppDataRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
private val _success = MutableSharedFlow<Boolean>()
val success = _success.asSharedFlow()
private suspend fun makeTunnelNameUnique(name: String): String {
return withContext(ioDispatcher) {
val tunnels = appDataRepository.tunnels.getAll()
var tunnelName = name
var num = 1
while (tunnels.any { it.name == tunnelName }) {
tunnelName = "$name($num)"
num++
}
tunnelName
}
}
fun onTunnelQrResult(result: String) = viewModelScope.launch(ioDispatcher) {
kotlin.runCatching {
val amConfig = TunnelConfig.configFromAmQuick(result)
val amQuick = amConfig.toAwgQuickString(true)
val wgQuick = amConfig.toWgQuickString()
val tunnelName = makeTunnelNameUnique(generateQrCodeDefaultName(result))
val tunnelConfig = TunnelConfig(name = tunnelName, wgQuick = wgQuick, amQuick = amQuick)
appDataRepository.tunnels.save(tunnelConfig)
_success.emit(true)
}.onFailure {
_success.emit(false)
Timber.e(it)
SnackbarController.showMessage(StringValue.StringResource(R.string.error_invalid_code))
}
}
private fun generateQrCodeDefaultName(config: String): String {
return try {
TunnelConfig.configFromAmQuick(config).peers[0].endpoint.get().host
} catch (e: Exception) {
Timber.e(e)
NumberUtils.generateRandomTunnelName()
}
}
}
@@ -1,10 +1,10 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import android.Manifest import android.Manifest
import android.app.Activity
import android.content.Context.POWER_SERVICE import android.content.Context.POWER_SERVICE
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
@@ -59,6 +59,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi 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
@@ -67,10 +68,9 @@ import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.AppUiState import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
@@ -90,39 +90,54 @@ import xyz.teamgravity.pin_lock_compose.PinManager
ExperimentalLayoutApi::class, ExperimentalLayoutApi::class,
) )
@Composable @Composable
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel: AppViewModel, uiState: AppUiState, focusRequester: FocusRequester) { fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
appViewModel: AppViewModel,
uiState: AppUiState,
navController: NavController,
focusRequester: FocusRequester,
) {
val context = LocalContext.current val context = LocalContext.current
val navController = LocalNavController.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val snackbar = SnackbarController.current val snackbar = SnackbarController.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isRunningOnTv = context.isRunningOnTv() val isRunningOnTv = context.isRunningOnTv()
val settingsUiState by viewModel.uiState.collectAsStateWithLifecycle() val kernelSupport by viewModel.kernelSupport.collectAsStateWithLifecycle()
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) }
var showVpnPermissionDialog by remember { mutableStateOf(false) } var showVpnPermissionDialog by remember { mutableStateOf(false) }
var showLocationServicesAlertDialog by remember { mutableStateOf(false) } var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
val didExportFiles by remember { mutableStateOf(false) } var didExportFiles by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) } var showAuthPrompt by remember { mutableStateOf(false) }
var showLocationDialog by remember { mutableStateOf(false) } var showLocationDialog by remember { mutableStateOf(false) }
val screenPadding = 5.dp val screenPadding = 5.dp
val fillMaxWidth = .85f val fillMaxWidth = .85f
LaunchedEffect(Unit) {
viewModel.checkKernelSupport()
}
LaunchedEffect(uiState.settings.trustedNetworkSSIDs) { LaunchedEffect(uiState.settings.trustedNetworkSSIDs) {
currentText = "" currentText = ""
} }
val notificationPermissionState =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
} else {
null
}
val startForResult = val startForResult =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(), ActivityResultContracts.StartActivityForResult(),
) { result: ActivityResult -> ) { result: ActivityResult ->
if (result.resultCode == RESULT_OK) { if (result.resultCode == Activity.RESULT_OK) {
result.data result.data
// Handle the Intent // Handle the Intent
} }
@@ -150,20 +165,22 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
fun requestBatteryOptimizationsDisabled() { fun requestBatteryOptimizationsDisabled() {
val intent = val intent =
Intent().apply { Intent().apply {
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS this.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${context.packageName}") data = Uri.fromParts("package", context.packageName, null)
} }
startForResult.launch(intent) startForResult.launch(intent)
} }
fun handleAutoTunnelToggle() { fun handleAutoTunnelToggle() {
if (!uiState.generalState.isBatteryOptimizationDisableShown && if (!uiState.generalState.isBatteryOptimizationDisableShown || !isBatteryOptimizationsDisabled()) return requestBatteryOptimizationsDisabled()
!isBatteryOptimizationsDisabled() && !context.isRunningOnTv() if (notificationPermissionState != null && !notificationPermissionState.status.isGranted) {
) { snackbar.showMessage(
return requestBatteryOptimizationsDisabled() context.getString(R.string.notification_permission_required),
)
return notificationPermissionState.launchPermissionRequest()
} }
val intent = if (!uiState.settings.isKernelEnabled) { val intent = if (!uiState.settings.isKernelEnabled) {
VpnService.prepare(context) com.wireguard.android.backend.GoBackend.VpnService.prepare(context)
} else { } else {
null null
} }
@@ -257,18 +274,6 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
) )
} }
fun onAutoTunnelWifiChecked() {
when (false) {
isBackgroundLocationGranted -> showLocationDialog = true
fineLocationState.status.isGranted -> showLocationDialog = true
viewModel.isLocationEnabled(context) ->
showLocationServicesAlertDialog = true
else -> {
viewModel.onToggleTunnelOnWifi()
}
}
}
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
@@ -317,10 +322,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
enabled = !uiState.settings.isAlwaysOnVpnEnabled, enabled = !uiState.settings.isAlwaysOnVpnEnabled,
checked = uiState.settings.isTunnelOnWifiEnabled, checked = uiState.settings.isTunnelOnWifiEnabled,
padding = screenPadding, padding = screenPadding,
onCheckChanged = { checked -> onCheckChanged = { viewModel.onToggleTunnelOnWifi() },
if (!checked || settingsUiState.isRooted) viewModel.onToggleTunnelOnWifi().also { return@ConfigurationToggle }
onAutoTunnelWifiChecked()
},
modifier = modifier =
if (uiState.settings.isAutoTunnelEnabled) { if (uiState.settings.isAutoTunnelEnabled) {
Modifier Modifier
@@ -448,7 +450,23 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
TextButton( TextButton(
onClick = { onClick = {
if (uiState.tunnels.isEmpty()) return@TextButton context.showToast(R.string.tunnel_required) if (uiState.tunnels.isEmpty()) return@TextButton context.showToast(R.string.tunnel_required)
handleAutoTunnelToggle() if (
uiState.settings.isTunnelOnWifiEnabled &&
!uiState.settings.isAutoTunnelEnabled
) {
when (false) {
isBackgroundLocationGranted -> showLocationDialog = true
fineLocationState.status.isGranted -> showLocationDialog = true
viewModel.isLocationEnabled(context) ->
showLocationServicesAlertDialog = true
else -> {
handleAutoTunnelToggle()
}
}
} else {
handleAutoTunnelToggle()
}
}, },
) { ) {
val autoTunnelButtonText = val autoTunnelButtonText =
@@ -481,6 +499,20 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
title = stringResource(id = R.string.backend), title = stringResource(id = R.string.backend),
padding = screenPadding, padding = screenPadding,
) )
ConfigurationToggle(
stringResource(R.string.use_amnezia),
enabled =
!(
uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.vpnState.status == TunnelState.UP) || uiState.settings.isKernelEnabled
),
checked = uiState.settings.isAmneziaEnabled,
padding = screenPadding,
onCheckChanged = {
viewModel.onToggleAmnezia()
},
)
ConfigurationToggle( ConfigurationToggle(
stringResource(R.string.use_kernel), stringResource(R.string.use_kernel),
enabled = enabled =
@@ -488,7 +520,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
uiState.settings.isAutoTunnelEnabled || uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled || uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.vpnState.status == TunnelState.UP) || (uiState.vpnState.status == TunnelState.UP) ||
!settingsUiState.isKernelAvailable kernelSupport
), ),
checked = uiState.settings.isKernelEnabled, checked = uiState.settings.isKernelEnabled,
padding = screenPadding, padding = screenPadding,
@@ -538,12 +570,9 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
ConfigurationToggle( ConfigurationToggle(
stringResource(R.string.always_on_vpn_support), stringResource(R.string.always_on_vpn_support),
enabled = !( enabled = !(
( uiState.settings.isTunnelOnWifiEnabled ||
uiState.settings.isTunnelOnWifiEnabled || uiState.settings.isTunnelOnWifiEnabled ||
uiState.settings.isTunnelOnEthernetEnabled || uiState.settings.isTunnelOnMobileDataEnabled
uiState.settings.isTunnelOnMobileDataEnabled
) &&
uiState.settings.isAutoTunnelEnabled
), ),
checked = uiState.settings.isAlwaysOnVpnEnabled, checked = uiState.settings.isAlwaysOnVpnEnabled,
padding = screenPadding, padding = screenPadding,
@@ -577,7 +606,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
} else { } else {
// TODO may want to show a dialog before proceeding in the future // TODO may want to show a dialog before proceeding in the future
PinManager.initialize(WireGuardAutoTunnel.instance) PinManager.initialize(WireGuardAutoTunnel.instance)
navController.navigate(Route.Lock) navController.navigate(Screen.Lock.route)
} }
}, },
) )
@@ -1,6 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
data class SettingsUiState(
val isRooted: Boolean = false,
val isKernelAvailable: Boolean = false,
)
@@ -13,14 +13,13 @@ import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.StringValue
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -39,16 +38,8 @@ constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState()) private val _kernelSupport = MutableStateFlow(false)
val uiState = _uiState.onStart { val kernelSupport = _kernelSupport.asStateFlow()
_uiState.update {
it.copy(isKernelAvailable = isKernelSupported(), isRooted = isRooted())
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
SettingsUiState(),
)
private val settings = appDataRepository.settings.getSettingsFlow() private val settings = appDataRepository.settings.getSettingsFlow()
.stateIn(viewModelScope, SharingStarted.Eagerly, Settings()) .stateIn(viewModelScope, SharingStarted.Eagerly, Settings())
@@ -220,9 +211,13 @@ constructor(
} }
} }
private suspend fun isKernelSupported(): Boolean { fun checkKernelSupport() = viewModelScope.launch {
return withContext(ioDispatcher) { val kernelSupport =
WgQuickBackend.hasKernelSupport() withContext(ioDispatcher) {
WgQuickBackend.hasKernelSupport()
}
_kernelSupport.update {
kernelSupport
} }
} }
@@ -236,17 +231,6 @@ constructor(
} }
} }
private suspend fun isRooted(): Boolean {
return try {
withContext(ioDispatcher) {
rootShell.get().start()
}
true
} catch (_: Exception) {
false
}
}
private suspend fun requestRoot(): Result<Unit> { private suspend fun requestRoot(): Result<Unit> {
return withContext(ioDispatcher) { return withContext(ioDispatcher) {
kotlin.runCatching { kotlin.runCatching {
@@ -18,7 +18,7 @@ fun WildcardSupportingLabel(onClick: (url: String) -> Unit) {
buildAnnotatedString { buildAnnotatedString {
pushStringAnnotation( pushStringAnnotation(
tag = "details", tag = "details",
annotation = stringResource(id = R.string.docs_wildcards), annotation = stringResource(id = R.string.docs_features),
) )
withStyle( withStyle(
style = SpanStyle(color = MaterialTheme.colorScheme.primary), style = SpanStyle(color = MaterialTheme.colorScheme.primary),
@@ -42,19 +42,18 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
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.AppUiState import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.launchSupportEmail import com.zaneschepke.wireguardautotunnel.util.extensions.launchSupportEmail
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
@Composable @Composable
fun SupportScreen(focusRequester: FocusRequester, appUiState: AppUiState) { fun SupportScreen(navController: NavController, focusRequester: FocusRequester, appUiState: AppUiState) {
val context = LocalContext.current val context = LocalContext.current
val navController = LocalNavController.current
val fillMaxWidth = .85f val fillMaxWidth = .85f
Column( Column(
@@ -245,7 +244,7 @@ fun SupportScreen(focusRequester: FocusRequester, appUiState: AppUiState) {
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
TextButton( TextButton(
onClick = { navController.navigate(Route.Logs) }, onClick = { navController.navigate(Screen.Support.Logs.route) },
modifier = Modifier.padding(vertical = 5.dp), modifier = Modifier.padding(vertical = 5.dp),
) { ) {
Row( Row(
@@ -1,6 +1,7 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.widget.Toast
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -12,7 +13,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -20,12 +21,8 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
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
@@ -38,7 +35,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.logcatter.model.LogMessage import com.zaneschepke.logcatter.model.LogMessage
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.text.LogTypeLabel import com.zaneschepke.wireguardautotunnel.ui.common.text.LogTypeLabel
import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable @Composable
@@ -46,52 +45,35 @@ fun LogsScreen(viewModel: LogsViewModel = hiltViewModel()) {
val logs = viewModel.logs val logs = viewModel.logs
val context = LocalContext.current val context = LocalContext.current
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val lazyColumnListState = rememberLazyListState() val lazyColumnListState = rememberLazyListState()
var isAutoScrolling by remember { mutableStateOf(true) } val clipboardManager: ClipboardManager = LocalClipboardManager.current
var lastScrollPosition by remember { mutableIntStateOf(0) } val scope = rememberCoroutineScope()
LaunchedEffect(isAutoScrolling) {
if (isAutoScrolling) {
lazyColumnListState.animateScrollToItem(logs.size)
}
}
LaunchedEffect(logs.size) { LaunchedEffect(logs.size) {
if (isAutoScrolling) { scope.launch {
lazyColumnListState.animateScrollToItem(logs.size) lazyColumnListState.animateScrollToItem(logs.size)
} }
} }
LaunchedEffect(lazyColumnListState) {
snapshotFlow { lazyColumnListState.firstVisibleItemIndex }
.collect { currentScrollPosition ->
if (currentScrollPosition < lastScrollPosition && isAutoScrolling) {
isAutoScrolling = false
}
val visible = lazyColumnListState.layoutInfo.visibleItemsInfo
if (visible.isNotEmpty()) {
if (visible.last().index
== lazyColumnListState.layoutInfo.totalItemsCount - 1 && !isAutoScrolling
) {
isAutoScrolling = true
}
}
lastScrollPosition = currentScrollPosition
}
}
Scaffold( Scaffold(
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
viewModel.shareLogs(context) scope.launch {
viewModel.saveLogsToFile().onSuccess {
Toast.makeText(
context,
context.getString(R.string.logs_saved),
Toast.LENGTH_SHORT,
).show()
}
}
}, },
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
) { ) {
val icon = Icons.Filled.Share val icon = Icons.Filled.Save
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = icon.name, contentDescription = icon.name,
@@ -1,25 +1,19 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
import android.content.Context
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.zaneschepke.logcatter.LogReader import com.zaneschepke.logcatter.LocalLogCollector
import com.zaneschepke.logcatter.model.LogMessage import com.zaneschepke.logcatter.model.LogMessage
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.module.MainDispatcher import com.zaneschepke.wireguardautotunnel.module.MainDispatcher
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.extensions.chunked import com.zaneschepke.wireguardautotunnel.util.extensions.chunked
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -28,7 +22,8 @@ import javax.inject.Inject
class LogsViewModel class LogsViewModel
@Inject @Inject
constructor( constructor(
private val localLogCollector: LogReader, private val localLogCollector: LocalLogCollector,
private val fileUtils: FileUtils,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher, @MainDispatcher private val mainDispatcher: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -49,19 +44,13 @@ constructor(
} }
} }
fun shareLogs(context: Context): Job = viewModelScope.launch(ioDispatcher) { suspend fun saveLogsToFile(): Result<Unit> {
runCatching { val file =
val sharePath = File(context.filesDir, "external_files") localLogCollector.getLogFile().getOrElse {
if (sharePath.exists()) sharePath.delete() return Result.failure(it)
sharePath.mkdir() }
val file = File("${sharePath.path + "/" + Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.zip") val fileContent = fileUtils.readBytesFromFile(file)
if (file.exists()) file.delete() val fileName = "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.txt"
file.createNewFile() return fileUtils.saveByteArrayToDownloads(fileContent, fileName)
localLogCollector.zipLogFiles(file.absolutePath)
val uri = FileProvider.getUriForFile(context, context.getString(R.string.provider), file)
context.launchShareFile(uri)
}.onFailure {
Timber.e(it)
}
} }
} }
@@ -2,37 +2,17 @@ package com.zaneschepke.wireguardautotunnel.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val OffWhite = Color(0xFFE5E1E5) val Purple80 = Color(0xFFD0BCFF)
val LightGrey = Color(0xFF8D9D9F) val PurpleGrey80 = Color(0xFFCCC2DC)
val Aqua = Color(0xFF76BEBD) val Pink80 = Color(0xFF492532)
val SilverTree = Color(0xFF6DB58B) val virdigris = Color(0xFF5BC0BE)
val Plantation = Color(0xFF264A49)
val Shark = Color(0xFF21272A)
val BalticSea = Color(0xFF1C1B1F)
val Brick = Color(0xFFCE4257)
val Corn = Color(0xFFFBEC5D)
sealed class ThemeColors( val Purple40 = Color(0xFF6650a4)
val background: Color, val PurpleGrey40 = Color(0xFF625b71)
val surface: Color, val Pink40 = Color(0xFFFFFFFF)
val primary: Color,
val secondary: Color,
val onSurface: Color,
) {
// TODO fix light theme colors
data object Light : ThemeColors(
background = LightGrey,
surface = OffWhite,
primary = Plantation,
secondary = OffWhite,
onSurface = BalticSea,
)
data object Dark : ThemeColors( // status colors
background = BalticSea, val brickRed = Color(0xFFCE4257)
surface = Shark, val corn = Color(0xFFFBEC5D)
primary = Aqua, val pinkRed = Color(0xFFEF476F)
secondary = Plantation, val mint = Color(0xFF52B788)
onSurface = OffWhite,
)
}
@@ -18,22 +18,30 @@ import androidx.core.view.WindowCompat
private val DarkColorScheme = private val DarkColorScheme =
darkColorScheme( darkColorScheme(
primary = ThemeColors.Dark.primary, // primary = Purple80,
surface = ThemeColors.Dark.surface, primary = virdigris,
background = ThemeColors.Dark.background, secondary = PurpleGrey40,
secondaryContainer = ThemeColors.Dark.secondary, // secondary = PurpleGrey80,
onSurface = ThemeColors.Dark.onSurface, tertiary = Pink40,
onSecondaryContainer = ThemeColors.Dark.primary, surfaceTint = Pink80,
// tertiary = Pink80
) )
private val LightColorScheme = private val LightColorScheme =
lightColorScheme( lightColorScheme(
primary = ThemeColors.Light.primary, primary = Purple40,
surface = ThemeColors.Light.surface, secondary = PurpleGrey40,
background = ThemeColors.Light.background, tertiary = Pink40,
secondaryContainer = ThemeColors.Light.secondary, surfaceTint = Pink80,
onSurface = ThemeColors.Light.onSurface, /* Other default colors to override
onSecondaryContainer = ThemeColors.Light.primary, background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
) )
@Composable @Composable
@@ -52,8 +60,7 @@ fun WireguardAutoTunnelTheme(
} }
} }
useDarkTheme -> DarkColorScheme useDarkTheme -> DarkColorScheme
// TODO force dark theme for now until light theme designed else -> LightColorScheme
else -> DarkColorScheme
} }
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
@@ -2,58 +2,35 @@ package com.zaneschepke.wireguardautotunnel.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.zaneschepke.wireguardautotunnel.R
// Set of Material typography styles to start with // Set of Material typography styles to start with
val inter = FontFamily(
Font(R.font.inter),
)
val Typography = val Typography =
Typography( Typography(
bodyLarge = bodyLarge =
TextStyle( TextStyle(
fontFamily = inter, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.5.sp, letterSpacing = 0.5.sp,
), ),
bodySmall = TextStyle( /* Other default text styles to override
fontFamily = inter, titleLarge = TextStyle(
fontWeight = FontWeight.Normal, fontFamily = FontFamily.Default,
fontSize = 13.sp, fontWeight = FontWeight.Normal,
lineHeight = 20.sp, fontSize = 22.sp,
letterSpacing = 1.sp, lineHeight = 28.sp,
color = LightGrey, letterSpacing = 0.sp
), ),
labelLarge = TextStyle( labelSmall = TextStyle(
fontFamily = inter, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Medium,
fontSize = 15.sp, fontSize = 11.sp,
lineHeight = 18.sp, lineHeight = 16.sp,
letterSpacing = 0.sp, letterSpacing = 0.5.sp
), )
labelMedium = TextStyle( */
fontFamily = inter,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
titleMedium = TextStyle(
fontFamily = inter,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
lineHeight = 21.sp,
letterSpacing = 0.sp,
),
) )
val iconSize = 15.dp
@@ -4,20 +4,21 @@ object Constants {
const val BASE_LOG_FILE_NAME = "wg_tunnel_logs" const val BASE_LOG_FILE_NAME = "wg_tunnel_logs"
const val LOG_BUFFER_SIZE = 3_000L const val LOG_BUFFER_SIZE = 3_000L
const val MANUAL_TUNNEL_CONFIG_ID = 0 const val MANUAL_TUNNEL_CONFIG_ID = "0"
const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1_000L // 10 minutes const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1_000L // 10 minutes
const val VPN_STATISTIC_CHECK_INTERVAL = 1_000L
const val WATCHER_COLLECTION_DELAY = 3_000L const val WATCHER_COLLECTION_DELAY = 3_000L
const val CONF_FILE_EXTENSION = ".conf" const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip" const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content" const val URI_CONTENT_SCHEME = "content"
const val ALLOWED_FILE_TYPES = "*/*"
const val TEXT_MIME_TYPE = "text/plain" const val TEXT_MIME_TYPE = "text/plain"
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 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"
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs" const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
const val ALWAYS_ON_VPN_ACTION = "android.net.VpnService"
const val VPN_SETTINGS_PACKAGE = "android.net.vpn.SETTINGS" const val VPN_SETTINGS_PACKAGE = "android.net.vpn.SETTINGS"
const val EMAIL_MIME_TYPE = "plain/text"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024 const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
const val SUBSCRIPTION_TIMEOUT = 5_000L const val SUBSCRIPTION_TIMEOUT = 5_000L
@@ -27,11 +28,12 @@ object Constants {
const val DEFAULT_PING_IP = "1.1.1.1" const val DEFAULT_PING_IP = "1.1.1.1"
const val PING_TIMEOUT = 5_000L const val PING_TIMEOUT = 5_000L
const val VPN_RESTART_DELAY = 1_000L
const val PING_INTERVAL = 60_000L const val PING_INTERVAL = 60_000L
const val PING_COOLDOWN = PING_INTERVAL * 60 // one hour const val PING_COOLDOWN = PING_INTERVAL * 60 // one hour
const val UNREADABLE_SSID = "<unknown ssid>" const val UNREADABLE_SSID = "<unknown ssid>"
val amProperties = listOf("Jc", "Jmin", "Jmax", "S1", "S2", "H1", "H2", "H3", "H4") val amneziaProperties = listOf("Jc", "Jmin", "Jmax", "S1", "S2", "H1", "H2", "H3", "H4")
const val QR_CODE_NAME_PROPERTY = "# Name =" const val QR_CODE_NAME_PROPERTY = "# Name ="
} }
@@ -1,13 +0,0 @@
package com.zaneschepke.wireguardautotunnel.util
object InvalidFileExtensionException : Exception() {
private fun readResolve(): Any = InvalidFileExtensionException
}
object FileReadException : Exception() {
private fun readResolve(): Any = FileReadException
}
object ConfigExportException : Exception() {
private fun readResolve(): Any = ConfigExportException
}
@@ -12,6 +12,8 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.OutputStream import java.io.OutputStream
import java.time.Instant import java.time.Instant
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
@@ -21,6 +23,23 @@ class FileUtils(
private val context: Context, private val context: Context,
private val ioDispatcher: CoroutineDispatcher, private val ioDispatcher: CoroutineDispatcher,
) { ) {
suspend fun readBytesFromFile(file: File): ByteArray {
return withContext(ioDispatcher) {
FileInputStream(file).use {
it.readBytes()
}
}
}
suspend fun readTextFromFileName(fileName: String): String {
return withContext(ioDispatcher) {
context.assets.open(fileName).use { stream ->
stream.bufferedReader(Charsets.UTF_8).use {
it.readText()
}
}
}
}
fun createWgFiles(tunnels: TunnelConfigs): List<File> { fun createWgFiles(tunnels: TunnelConfigs): List<File> {
return tunnels.map { config -> return tunnels.map { config ->
@@ -42,6 +61,43 @@ class FileUtils(
} }
} }
suspend fun saveByteArrayToDownloads(content: ByteArray, fileName: String): Result<Unit> {
return withContext(ioDispatcher) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues =
ContentValues().apply {
put(MediaColumns.DISPLAY_NAME, fileName)
put(MediaColumns.MIME_TYPE, Constants.TEXT_MIME_TYPE)
put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val resolver = context.contentResolver
val uri =
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
resolver.openOutputStream(uri).use { output ->
output?.write(content)
}
}
} else {
val target =
File(
Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS,
),
fileName,
)
FileOutputStream(target).use { output ->
output.write(content)
}
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}
suspend fun saveFilesToZip(files: List<File>): Result<Unit> { suspend fun saveFilesToZip(files: List<File>): Result<Unit> {
return withContext(ioDispatcher) { return withContext(ioDispatcher) {
try { try {
@@ -62,13 +118,13 @@ class FileUtils(
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
Result.failure(ConfigExportException) Result.failure(WgTunnelExceptions.ConfigExportFailed())
} }
} }
} }
// TODO issue with android 9 // TODO issue with android 9
private fun createDownloadsFileOutputStream(fileName: String, mimeType: String = Constants.ALL_FILE_TYPES): OutputStream? { private fun createDownloadsFileOutputStream(fileName: String, mimeType: String = Constants.ALLOWED_FILE_TYPES): OutputStream? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver val resolver = context.contentResolver
val contentValues = val contentValues =
@@ -0,0 +1,63 @@
package com.zaneschepke.wireguardautotunnel.util
import android.content.Context
import com.zaneschepke.wireguardautotunnel.R
sealed class WgTunnelExceptions : Exception() {
abstract fun getMessage(context: Context): String
data class ConfigExportFailed(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.export_configs_failed,
),
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class ConfigParseError(private val appendMessage: StringValue = StringValue.Empty) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return StringValue.StringResource(R.string.config_parse_error).asString(context) + (
if (appendMessage != StringValue.Empty) ": ${appendMessage.asString(context)}" else ""
)
}
}
data class InvalidQrCode(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.error_invalid_code,
),
) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class InvalidFileExtension(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.error_file_extension,
),
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class FileReadFailed(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.error_file_format,
),
) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
}
@@ -26,16 +26,6 @@ fun Context.openWebUrl(url: String): Result<Unit> {
} }
} }
fun Context.launchShareFile(file: Uri) {
val shareIntent = Intent().apply {
setAction(Intent.ACTION_SEND)
setType("*/*")
putExtra(Intent.EXTRA_STREAM, file)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
this.startActivity(Intent.createChooser(shareIntent, ""))
}
fun Context.showToast(resId: Int) { fun Context.showToast(resId: Int) {
Toast.makeText( Toast.makeText(
this, this,
@@ -44,21 +34,21 @@ fun Context.showToast(resId: Int) {
).show() ).show()
} }
fun Context.launchSupportEmail() { fun Context.launchSupportEmail(): Result<Unit> {
val intent = return runCatching {
Intent(Intent.ACTION_SENDTO).apply { val intent =
data = Uri.parse("mailto:") Intent(Intent.ACTION_SENDTO).apply {
putExtra(Intent.EXTRA_EMAIL, arrayOf(getString(R.string.my_email))) type = Constants.EMAIL_MIME_TYPE
putExtra(Intent.EXTRA_SUBJECT, getString(R.string.email_subject)) putExtra(Intent.EXTRA_EMAIL, arrayOf(getString(R.string.my_email)))
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) putExtra(Intent.EXTRA_SUBJECT, getString(R.string.email_subject))
} addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(packageManager) != null) { }
startActivity( startActivity(
Intent.createChooser(intent, getString(R.string.email_chooser)).apply { Intent.createChooser(intent, getString(R.string.email_chooser)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}, },
) )
} else { }.onFailure {
showToast(R.string.no_email_detected) showToast(R.string.no_email_detected)
} }
} }
@@ -1,7 +1,11 @@
package com.zaneschepke.wireguardautotunnel.util.extensions package com.zaneschepke.wireguardautotunnel.util.extensions
import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import java.math.BigDecimal import java.math.BigDecimal
import java.text.DecimalFormat import java.text.DecimalFormat
@@ -17,3 +21,10 @@ fun <T> List<T>.removeAt(index: Int): List<T> = toMutableList().apply { this.rem
typealias TunnelConfigs = List<TunnelConfig> typealias TunnelConfigs = List<TunnelConfig>
typealias Packages = List<PackageInfo> typealias Packages = List<PackageInfo>
fun Throwable.getMessage(context: Context): String {
return when (this) {
is WgTunnelExceptions -> this.getMessage(context)
else -> this.message ?: StringValue.StringResource(R.string.unknown_error).asString(context)
}
}
@@ -3,8 +3,6 @@ package com.zaneschepke.wireguardautotunnel.util.extensions
import timber.log.Timber import timber.log.Timber
import java.util.regex.Pattern import java.util.regex.Pattern
val hasNumberInParentheses = """^(.+?)\((\d+)\)$""".toRegex()
fun String.isValidIpv4orIpv6Address(): Boolean { fun String.isValidIpv4orIpv6Address(): Boolean {
val ipv4Pattern = Pattern.compile( val ipv4Pattern = Pattern.compile(
"^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\$", "^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\$",
@@ -15,18 +13,6 @@ fun String.isValidIpv4orIpv6Address(): Boolean {
return ipv4Pattern.matcher(this).matches() || ipv6Pattern.matcher(this).matches() return ipv4Pattern.matcher(this).matches() || ipv6Pattern.matcher(this).matches()
} }
fun String.hasNumberInParentheses(): Boolean {
return hasNumberInParentheses.matches(this)
}
// Function to extract name and number
fun String.extractNameAndNumber(): Pair<String, Int>? {
val matchResult = hasNumberInParentheses.matchEntire(this)
return matchResult?.let {
Pair(it.groupValues[1], it.groupValues[2].toInt())
}
}
fun List<String>.isMatchingToWildcardList(value: String): Boolean { fun List<String>.isMatchingToWildcardList(value: String): Boolean {
val excludeValues = this.filter { it.startsWith("!") }.map { it.removePrefix("!").toRegexWithWildcards() } val excludeValues = this.filter { it.startsWith("!") }.map { it.removePrefix("!").toRegexWithWildcards() }
Timber.d("Excluded values: $excludeValues") Timber.d("Excluded values: $excludeValues")
@@ -1,12 +1,8 @@
package com.zaneschepke.wireguardautotunnel.util.extensions package com.zaneschepke.wireguardautotunnel.util.extensions
import androidx.compose.ui.graphics.Color
import com.wireguard.android.util.RootShell
import com.wireguard.config.Peer import com.wireguard.config.Peer
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.ui.theme.Corn
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import org.amnezia.awg.config.Config import org.amnezia.awg.config.Config
@@ -52,26 +48,13 @@ fun Peer.isReachable(): Boolean {
return reachable return reachable
} }
fun TunnelStatistics?.asColor(): Color {
return this?.mapPeerStats()
?.map { it.value?.handshakeStatus() }
?.let { statuses ->
when {
statuses.all { it == HandshakeStatus.HEALTHY } -> SilverTree
statuses.any { it == HandshakeStatus.STALE } -> Corn
statuses.all { it == HandshakeStatus.NOT_STARTED } -> Color.Gray
else -> Color.Gray
}
} ?: Color.Gray
}
fun Config.toWgQuickString(): String { fun Config.toWgQuickString(): String {
val amQuick = toAwgQuickString(true) val amQuick = toAwgQuickString(true)
val lines = amQuick.lines().toMutableList() val lines = amQuick.lines().toMutableList()
val linesIterator = lines.iterator() val linesIterator = lines.iterator()
while (linesIterator.hasNext()) { while (linesIterator.hasNext()) {
val next = linesIterator.next() val next = linesIterator.next()
Constants.amProperties.forEach { Constants.amneziaProperties.forEach {
if (next.startsWith(it, ignoreCase = true)) { if (next.startsWith(it, ignoreCase = true)) {
linesIterator.remove() linesIterator.remove()
} }
@@ -79,9 +62,3 @@ fun Config.toWgQuickString(): String {
} }
return lines.joinToString(System.lineSeparator()) return lines.joinToString(System.lineSeparator())
} }
fun RootShell.getCurrentWifiName(): String? {
val response = mutableListOf<String>()
this.run(response, "dumpsys wifi | grep -o \"SSID: [^,]*\" | cut -d ' ' -f2- | tr -d '\"'")
return response.lastOrNull()
}
@@ -1,10 +0,0 @@
package com.zaneschepke.wireguardautotunnel.util.extensions
import androidx.navigation.NavController
import com.zaneschepke.wireguardautotunnel.ui.Route
fun NavController.navigateAndForget(route: Route) {
navigate(route) {
popUpTo(0)
}
}
Binary file not shown.
-2
View File
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
+1 -7
View File
@@ -8,8 +8,7 @@
<string name="docs_url" translatable="false">https://zaneschepke.com/wgtunnel-docs/overview.html</string> <string name="docs_url" translatable="false">https://zaneschepke.com/wgtunnel-docs/overview.html</string>
<string name="docs_features" translatable="false">https://zaneschepke.com/wgtunnel-docs/features.html</string> <string name="docs_features" translatable="false">https://zaneschepke.com/wgtunnel-docs/features.html</string>
<string name="privacy_policy_url" translatable="false">https://zaneschepke.com/wgtunnel-docs/privacypolicy.html</string> <string name="privacy_policy_url" translatable="false">https://zaneschepke.com/wgtunnel-docs/privacypolicy.html</string>
<string name="docs_wildcards" translatable="false" >https://zaneschepke.com/wgtunnel-docs/features.html#wildcard-wi-fi-name-support</string> <string name="error_file_extension">File is not a .conf or .zip</string>
<string name="error_file_extension">File is not a .conf or .zip</string>
<string name="turn_off_tunnel">Action requires tunnel off</string> <string name="turn_off_tunnel">Action requires tunnel off</string>
<string name="no_tunnels">No tunnels added yet!</string> <string name="no_tunnels">No tunnels added yet!</string>
<string name="discord_url" translatable="false">https://discord.gg/rbRRNh6H7V</string> <string name="discord_url" translatable="false">https://discord.gg/rbRRNh6H7V</string>
@@ -195,9 +194,4 @@
<string name="set_custom_ping_cooldown">Ping restart cooldown (sec)</string> <string name="set_custom_ping_cooldown">Ping restart cooldown (sec)</string>
<string name="wildcard_supported">Learn about supported wildcards.</string> <string name="wildcard_supported">Learn about supported wildcards.</string>
<string name="details">details</string> <string name="details">details</string>
<string name="show_amnezia_properties">Show Amnezia properties</string>
<string name="never">never</string>
<string name="sec">sec</string>
<string name="handshake">handshake</string>
<string name="logs">Logs</string>
</resources> </resources>
+4 -2
View File
@@ -5,9 +5,11 @@
<item name="android:windowBackground">@color/black_background</item> <item name="android:windowBackground">@color/black_background</item>
</style> </style>
<style name="Theme.App.Start" parent="@style/Theme.SplashScreen"> <style name="Theme.AppSplashScreen" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/black_background</item> <!--<item name="windowSplashScreenBackground">@color/white</item>-->
<!-- icon has to be a circle -->
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item> <item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item>
<item name="windowSplashScreenAnimationDuration">500</item>
<item name="postSplashScreenTheme">@style/Theme.WireguardAutoTunnel</item> <item name="postSplashScreenTheme">@style/Theme.WireguardAutoTunnel</item>
</style> </style>
</resources> </resources>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path
name="share"
path="external_files/"/>
</paths>
+6 -2
View File
@@ -1,7 +1,7 @@
object Constants { object Constants {
const val VERSION_NAME = "3.5.4" const val VERSION_NAME = "3.5.1"
const val JVM_TARGET = "17" const val JVM_TARGET = "17"
const val VERSION_CODE = 35400 const val VERSION_CODE = 35103
const val TARGET_SDK = 34 const val TARGET_SDK = 34
const val MIN_SDK = 26 const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel" const val APP_ID = "com.zaneschepke.wireguardautotunnel"
@@ -15,5 +15,9 @@ object Constants {
const val RELEASE = "release" const val RELEASE = "release"
const val NIGHTLY = "nightly" const val NIGHTLY = "nightly"
const val PRERELEASE = "prerelease" const val PRERELEASE = "prerelease"
const val DEBUG = "debug"
const val TYPE = "type" const val TYPE = "type"
const val NIGHTLY_CODE = 42
const val PRERELEASE_CODE = 54
} }
@@ -1,6 +0,0 @@
Was ist Neu:
- Abstürze behoben
- Leistung der Kacheln verbessert
- Reaktivieren von PIN-Sperren
- "Beim Hochfahren starten" zur Einstellung gemacht
- verschiedene Leistungsanpassungen und Bugs behoben
@@ -1,5 +0,0 @@
Was ist neu:
- Verbesserungen der Tunnelsteuerung in der AndroidTV Benutzeroberfläche
- Fehler beim Sperren im Portrait-Modus behoben
- Fehler welcher eine Umgehung der PIN-Sperre ermöglichte behoben
- Fehler in der Auot-Tunnel Kachel behoben
@@ -1,6 +0,0 @@
Was ist neu?
- Verbesserte Zuverlässigkeit des automatischen Tunnels
- Unterstützung für hell/dunkel/dynamisches Theming hinzugefügt
- Unterstützung für Pre-/Post-Up-/Down-Skripte hinzugefügt
- Entfernen der Benachrichtigung über das Fortbestehen des Tunnels
- Verschiedene andere Korrekturen und Verbesserungen
@@ -1,5 +0,0 @@
Was ist neu?
- Behebung von Problemen beim Starten von Tunneln im Hintergrund
- Unterstützung für den Neustart von Diensten nach dem Update hinzugefügt
- Verbesserungen der UI-Animationsgeschwindigkeit
- Andere Optimierungen
@@ -1,6 +0,0 @@
What's new:
- Added wildcard support for wifi names
- Live edit of tunnel/auto-tunnel settings while active
- Fix slowness on mobile data
- Various bug fixes and improvements
- UI optimizations
@@ -1,6 +0,0 @@
What's new:
- Rooted devices now get wifi name without location
- Logs screen scroll and sharing improvements
- Tunnel import bug fixes for AndroidTV 14
- Tunnel statistics UI enhancements
- Other bug fixes and improvements
@@ -1,5 +0,0 @@
What's new:
- Fix Android 12 crashing issue
- Fix copy tunnel bug
- Auto toggle Amnezia props
- Hide preshared key without auth
@@ -1,5 +1,2 @@
Mejoras: Mejoras:
- Añadida compatiblidad básica de Kernel WireGuard - Añadida compatiblidad básica de Kernel WireGuard
- Flujo de divulgación de ubicación mejorada
- Corregido el fallo ede los persmisos del túnel automático
- Correcciones y mejoras varias
@@ -1,5 +0,0 @@
Novedades:
- Corrección de la regresión del túnel de parada
- Añadir ofuscación de registros
- Añadir ocultar FAB en scroll
- Añadir localización turca
@@ -1,3 +0,0 @@
Novedades:
- Agregue Amnezia junto con WireGuard
- Se corrigió el error de los accesos directos de la aplicación
@@ -1,6 +0,0 @@
Novedades:
- Soporte oficial para AmneziaWG
- Importación/exportación de configuraciones de Amnezia
- El túnel automático se activará solo una vez por cambio de red
- Soporte para idiomas adicionales
- Otras correcciones de errores y mejoras
@@ -1,5 +0,0 @@
Novedades:
- Mejorar la denominación de las importaciones de túneles
- Se solucionó el error del estado inicial de tunelización automática
- Manejo de errores mejorado
- Se solucionó el error de importación del zip de Amnezia

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