init: first somewhat working version

This commit is contained in:
Arthur K. 2025-01-12 17:12:34 +03:00
commit fcf72cf9a5
Signed by: wzray
GPG key ID: B97F30FDC4636357
81 changed files with 3259 additions and 0 deletions

67
app/build.gradle.kts Normal file
View file

@ -0,0 +1,67 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.google.ksp)
alias(libs.plugins.google.dagger.hilt)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.wzray.openconnect"
compileSdk = 35
defaultConfig {
applicationId = "com.wzray.openconnect"
minSdk = 23
targetSdk = 35
versionCode = 1
versionName = "0.1.0-alpha"
}
buildTypes { release { isMinifyEnabled = false } }
buildFeatures { compose = true }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
jvmToolchain(17)
}
}
dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.material3.adaptive.navigation.suite)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.material3)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.compose.preference)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.datastore.preferences)
ksp(libs.androidx.room.compiler)
implementation(libs.google.dagger.hilt)
implementation(libs.androidx.hilt.navigation.compose)
ksp(libs.google.dagger.hilt.compiler)
implementation(libs.compose.destinations.core)
implementation(libs.compose.destinations.bottomSheet)
ksp(libs.compose.destinations.ksp)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.core.splashscreen)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".Application"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.OpenConnect"
android:enableOnBackInvokedCallback="true"
tools:targetApi="tiramisu">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.OpenConnect">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".tunnel.OpenConnect$VpnService"
android:exported="true"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<!-- disable always-on for now -->
<meta-data
android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
android:value="false" />
</service>
</application>
</manifest>

View file

@ -0,0 +1,54 @@
package com.wzray.openconnect
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room.Room
import com.wzray.openconnect.core.data.Database
import com.wzray.openconnect.tunnel.VpnManager
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import java.lang.ref.WeakReference
@HiltAndroidApp
class Application : android.app.Application() {
private lateinit var prefDataStore: DataStore<Preferences>
private lateinit var db: Database
private val coroutineScope = CoroutineScope(Job() + Dispatchers.Main.immediate)
private var vpnManager: VpnManager = VpnManager(this)
override fun onCreate() {
super.onCreate()
prefDataStore = PreferenceDataStoreFactory.create {
applicationContext.preferencesDataStoreFile("settings")
}
db = Room.databaseBuilder(this, Database::class.java, "database.db").build()
Log.i(TAG, "Starting application...")
}
companion object {
private const val TAG = "OpenConnect/Application"
private lateinit var thisWeakRef: WeakReference<Application>
fun get() = thisWeakRef.get()!!
val prefDataStore
get() = get().prefDataStore
val db: Database
get() = get().db
val coroutineScope
get() = get().coroutineScope
val vpnManager
get() = get().vpnManager
}
init {
thisWeakRef = WeakReference(this)
System.loadLibrary("openconnect")
System.loadLibrary("stoken")
}
}

View file

@ -0,0 +1,33 @@
package com.wzray.openconnect
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import com.wzray.openconnect.ui.OpenConnectApp
import dagger.hilt.android.AndroidEntryPoint
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Application.vpnManager.activityResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
Application.vpnManager.activityCallback?.invoke(it)
}
installSplashScreen()
enableEdgeToEdge()
setContent {
OpenConnectApp()
}
}
override fun onDestroy() {
super.onDestroy()
Application.vpnManager.activityResultLauncher = null
}
}

View file

@ -0,0 +1,17 @@
package com.wzray.openconnect.core.data
import androidx.room.Database
import androidx.room.RoomDatabase
import com.wzray.openconnect.core.data.dao.ConnectionsDao
import com.wzray.openconnect.core.data.model.Connection
@Database(
entities = [
Connection::class,
],
version = 1,
exportSchema = false
)
abstract class Database : RoomDatabase() {
abstract fun connectionsDao(): ConnectionsDao
}

View file

@ -0,0 +1,31 @@
package com.wzray.openconnect.core.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import com.wzray.openconnect.core.data.model.Connection
import kotlinx.coroutines.flow.Flow
@Dao
abstract class ConnectionsDao {
@Query("SELECT COUNT(*) FROM connections")
abstract fun count(): Flow<Int>
@Query("SELECT * FROM connections WHERE name = :name")
abstract fun connection(name: String): Flow<Connection>
@Query("SELECT * FROM connections")
abstract fun getAll(): Flow<List<Connection>>
@Delete
abstract fun delete(connection: Connection): Int
@Insert
abstract suspend fun insert(connection: Connection)
@Update
abstract suspend fun update(connection: Connection)
}

View file

@ -0,0 +1,33 @@
package com.wzray.openconnect.core.data.model
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
import java.util.UUID
enum class Protocols(name: String) {
ANYCONNECT("anyconnect")
}
@Entity(
tableName = "connections",
indices = [
Index("uuid", unique = true)
]
)
@Serializable
data class Connection(
val name: String,
val url: String,
val username: String,
val password: String,
val protocol: Protocols = Protocols.ANYCONNECT,
val xmlPost: Boolean = true,
val pfs: Boolean = false,
val useDTLS: Boolean = true,
val reportedOs: String = "android",
val mtu: Int = 1280,
@PrimaryKey val uuid: String = UUID.randomUUID().toString(),
)

View file

@ -0,0 +1,43 @@
package com.wzray.openconnect.core.viewmodels
import androidx.compose.runtime.MutableState
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.*
import androidx.lifecycle.viewModelScope
import com.wzray.openconnect.Application
import com.wzray.openconnect.core.data.model.Connection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
class ConnectionEditorViewModel (c: Connection) : ViewModel() {
private val dao = Application.db.connectionsDao()
private val connectionFlow = MutableStateFlow(c)
val connection = connectionFlow.asStateFlow()
fun updateConnection(c: Connection) {
viewModelScope.launch {
connectionFlow.update { c }
withContext(Dispatchers.IO) {
dao.update(c)
}
}
}
// companion object {
// fun provideFactory(
// connection: Connection
// ): Factory = object : Factory {
// @Suppress("UNCHECKED_CAST")
// override fun <T : ViewModel> create(modelClass: Class<T>): T {
// return ConnectionEditorViewModel(connection) as T
// }
// }
// }
}

View file

@ -0,0 +1,120 @@
package com.wzray.openconnect.core.viewmodels
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wzray.openconnect.Application
import com.wzray.openconnect.core.data.model.Connection
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
sealed interface HomeUiState {
val connections: List<Connection>
data class ConnectionList(
override val connections: List<Connection>
) : HomeUiState
data class SelectionList(
override val connections: List<Connection>,
val selectedConnections: List<Connection>
) : HomeUiState
}
private data class ViewModelState(
val connections: List<Connection>? = null,
val selectedConnections: List<Connection>? = null
) {
fun toUiState() = if (selectedConnections == null)
HomeUiState.ConnectionList(connections ?: listOf())
else
HomeUiState.SelectionList(connections ?: listOf(), selectedConnections)
}
@HiltViewModel
class HomeViewModel @Inject constructor() : ViewModel() {
private var vmState = MutableStateFlow(ViewModelState())
var uiState: StateFlow<HomeUiState> = vmState.map { it.toUiState() }
.stateIn(viewModelScope, SharingStarted.Eagerly, vmState.value.toUiState())
private val dao = Application.db.connectionsDao()
init {
viewModelScope.launch {
dao.getAll().collect { c ->
vmState.update {
it.copy(connections = c)
}
}
}
}
fun connect(connection: Connection) {
Application.vpnManager.establish(connection)
}
fun openSettings(connection: Connection) {
}
fun toggleSelection(connection: Connection) {
if (vmState.value.selectedConnections == null)
vmState.update {
it.copy(selectedConnections = listOf())
}
if (!vmState.value.selectedConnections?.contains(connection)!!)
vmState.update {
it.copy(selectedConnections = it.selectedConnections?.plus(connection))
}
else
vmState.update {
it.copy(selectedConnections = it.selectedConnections?.minus(connection))
}
if (vmState.value.selectedConnections?.isEmpty()!!)
vmState.update {
it.copy(selectedConnections = null)
}
}
fun deleteSelected() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val db = Application.db
val dao = db.connectionsDao()
vmState.value.selectedConnections?.forEach { e ->
dao.delete(e)
}
vmState.update { it.copy(selectedConnections = null) }
}
}
}
fun selectAll() = viewModelScope.launch {
vmState.value.connections?.forEach { c ->
if (!vmState.value.selectedConnections?.contains(c)!!)
vmState.update {
it.copy(selectedConnections = it.selectedConnections?.plus(c))
}
}
}
fun copySelected() {
Log.e(TAG, "Copy selected is not yet implemented.")
}
companion object {
private const val TAG = "OpenConnect/HomeViewModel"
}
}

View file

@ -0,0 +1,107 @@
package com.wzray.openconnect.core.viewmodels
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.nio.charset.StandardCharsets
import java.util.LinkedList
import javax.inject.Inject
enum class LogLevel(val shortName: Char, val fullName: String) {
VERBOSE('V', "Verbose"),
DEBUG('D', "Debug"),
INFO('I', "Info"),
WARN('W', "Warn"),
ERROR('E', "Error"),
FATAL('F', "Fatal")
}
data class UIState(
val isPaused: Boolean = false,
val logLevel: LogLevel = LogLevel.ERROR,
val logList: List<String> = listOf() // TODO: proper structure for log lines
)
@HiltViewModel
class LogViewModel @Inject constructor() : ViewModel() {
private var logCollectorJob: Job
private val backlog = LinkedList<String>()
private var mutableUiState = MutableStateFlow(UIState())
val uiState = mutableUiState.asStateFlow()
init {
logCollectorJob = viewModelScope.launch {
collectLogs()
}
}
fun pauseLog() {
mutableUiState.update {
it.copy(isPaused = !mutableUiState.value.isPaused, logList = it.logList + backlog)
}
backlog.clear()
}
fun clearLog() {
mutableUiState.update {
it.copy(logList = listOf())
}
backlog.clear()
}
fun setLogLevel(logLevel: LogLevel) {
logCollectorJob.cancel()
backlog.clear()
mutableUiState.update {
it.copy(logLevel = logLevel, logList = listOf())
}
logCollectorJob = viewModelScope.launch {
collectLogs()
}
}
private suspend fun collectLogs() = withContext(Dispatchers.IO) {
val builder = ProcessBuilder(
"logcat", "-b", "all", "*:${uiState.value.logLevel.shortName}"
)
builder.environment()["LC_ALL"] = "C"
var process: Process? = null
try {
process = try {
builder.start()
} catch (e: IOException) {
Log.e(TAG, Log.getStackTraceString(e))
return@withContext
}
val stdout =
BufferedReader(InputStreamReader(process!!.inputStream, StandardCharsets.UTF_8))
while (isActive) {
val line = stdout.readLine() ?: break
if (uiState.value.isPaused) backlog.add(line)
else mutableUiState.update {
it.copy(logList = it.logList + line)
}
}
} finally {
process?.destroy()
}
}
companion object {
private const val TAG = "OpenConnect/LogViewModel"
}
}

View file

@ -0,0 +1,40 @@
package com.wzray.openconnect.tunnel
import java.util.Locale
// TODO: single interface, v4 and v6 versions
@Suppress("MemberVisibilityCanBePrivate", "unused")
data class NormalizedIP(val ip: String, val cidr: Int) {
constructor(ip: String, mask: String) : this(
ip,
if(mask.contains(".")) maskToCidr(mask) else mask.toInt()
)
constructor(addrWithNetMask: String) : this(
addrWithNetMask.split("/")[0],
addrWithNetMask.split("/")[1],
)
override fun toString(): String {
return String.format(Locale.ROOT, "%s/%d", ip, cidr)
}
companion object {
fun maskToCidr(mask: String) = 32 - ((ipToInt(mask).inv()).countOneBits())
fun cidrToMask(cidr: Int) = intToIp(((1 shl cidr) - 1) shl (32 - cidr))
fun ipToInt(ip: String) = ip.split(".").map { it.toInt() }.reversed()
.reduceIndexed { idx, acc, it -> acc + (it shl (idx * 8)) }
fun intToIp(ip: Int) = String.format(
Locale.ROOT, "%d.%d.%d.%d",
ip and (0xff shl 24) shr 24,
ip and (0xff shl 16) shr 16,
ip and (0xff shl 8) shr 8,
ip and 0xff
)
}
}

View file

@ -0,0 +1,121 @@
package com.wzray.openconnect.tunnel
import android.util.Log
import com.wzray.openconnect.core.data.model.Connection
import org.infradead.libopenconnect.LibOpenConnect
import java.io.IOException
class OpenConnect(private val connection: Connection) : Runnable {
private val vpnService: VpnService = VpnService()
private lateinit var backend: Backend
private fun createBackend() {
backend = Backend()
backend.setProtocol(connection.protocol.name)
backend.setXMLPost(connection.xmlPost)
backend.setPFS(connection.pfs)
if (!connection.useDTLS)
backend.disableDTLS()
backend.setReportedOS(connection.reportedOs)
backend.setMobileInfo(
"1.0",
connection.reportedOs,
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
)
val retVal = backend.parseURL(connection.url)
Log.e(TAG, "call to oc.parseURL: $retVal")
if (retVal != 0)
throw Exception("Invalid url")
}
override fun run() {
createBackend()
var retVal = backend.obtainCookie()
Log.e(TAG, "obtainCookie ret: $retVal")
retVal = backend.makeCSTPConnection()
Log.e(TAG, "makeCSTPConnection ret: $retVal")
val b = vpnService.getBuilder()
b.setSession(connection.name)
val ip = backend.ipInfo
Log.e(TAG, "call to svc.getBuilder()")
val normalizedIP = NormalizedIP(ip.addr, ip.netmask)
b.addAddress(ip.addr, normalizedIP.cidr)
b.setMtu(1280)
b.addRoute("0.0.0.0", 0)
b.addDnsServer("1.1.1.1")
val pfd = b.establish()
Log.e(TAG, "call to b.establish(): ${pfd != null}")
backend.setupTunFD(pfd!!.fd)
Log.e(TAG, "call to oc.setupTunFD(${pfd.fd})")
while (true)
if (backend.mainloop(300, LibOpenConnect.RECONNECT_INTERVAL_MIN) < 0)
break
try {
pfd.close()
} catch (_: IOException) {
}
}
fun cancel() {
backend.cancel()
}
inner class Backend : LibOpenConnect() {
override fun onProcessAuthForm(authForm: AuthForm): Int {
val opt = authForm.opts[0]
Log.d(TAG, "Auth form ${authForm.message}")
when (opt.type) {
OC_FORM_OPT_TEXT -> opt.value = connection.username
OC_FORM_OPT_PASSWORD -> opt.value = connection.password
else -> Log.e(TAG, "FAIL AUTH FORM ${authForm.message}")
}
return 0
}
override fun onProgress(level: Int, msg: String?) {
if (msg == null) return
Log.println(
when (level) {
PRG_ERR -> Log.ERROR
PRG_INFO -> Log.INFO
PRG_DEBUG -> Log.DEBUG
else -> Log.VERBOSE
}, TAG, msg
)
}
override fun onProtectSocket(fd: Int) {
val x = vpnService.protect(fd)
Log.d(TAG, "onProtectSocket, result: $x, fd: $fd")
}
}
class VpnService : android.net.VpnService() {
fun getBuilder(): android.net.VpnService.Builder {
return Builder()
}
override fun onRevoke() {
super.onRevoke()
}
}
companion object {
private const val TAG = "OpenConnect/Backend"
}
}

View file

@ -0,0 +1,61 @@
package com.wzray.openconnect.tunnel
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.util.Log
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import com.wzray.openconnect.core.data.model.Connection
import com.wzray.openconnect.util.applicationScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
class VpnManager(private val context: Context) {
var activityCallback: ((ActivityResult) -> Unit)? = null
private set
var activityResultLauncher: ActivityResultLauncher<Intent>? = null
private var openConnect: OpenConnect? = null
private var openConnectThread: Thread? = null
fun disconnect() = applicationScope.launch {
openConnect?.cancel()
openConnectThread?.join(1000)
}
val isConnected: Boolean
get() = openConnect != null || openConnectThread != null
fun establish(connection: Connection) {
if (isConnected)
disconnect()
openConnect = OpenConnect(connection)
openConnectThread = Thread(openConnect)
activityCallback = {
openConnectThread?.start()
}
val intent = VpnService.prepare(context)
if (intent != null) {
Log.d(TAG, "VPN permission not granted, requesting")
try {
activityResultLauncher!!.launch(intent)
} catch (e: Exception) { // we want to have control over what gets thrown
throw Exception("Missing VPN permission")
}
} else
openConnectThread?.start()
}
fun reconnect(name: String) {
TODO("Not yet implemented")
}
companion object {
private const val TAG = "OpenConnect/VpnManager"
}
}

View file

@ -0,0 +1,15 @@
package com.wzray.openconnect.ui
import com.ramcosta.composedestinations.annotation.NavGraph
import com.ramcosta.composedestinations.annotation.NavHostGraph
import com.ramcosta.composedestinations.annotation.RootGraph
import com.wzray.openconnect.ui.theme.Fade
@NavGraph<RootGraph>
annotation class ConnectionEditorGraph
@NavHostGraph(
route = "main",
defaultTransitions = Fade::class
)
annotation class MainGraph

View file

@ -0,0 +1,149 @@
package com.wzray.openconnect.ui
import android.util.Log
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.automirrored.outlined.Article
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.NavGraphs
import com.ramcosta.composedestinations.generated.destinations.HomeRouteDestination
import com.ramcosta.composedestinations.generated.destinations.LogRouteDestination
import com.ramcosta.composedestinations.generated.destinations.SettingsScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.dependency
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
import com.ramcosta.composedestinations.utils.currentDestinationAsState
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
import com.ramcosta.composedestinations.utils.startDestination
import com.wzray.openconnect.R
import com.wzray.openconnect.ui.theme.OpenConnectTheme
enum class NavDestinations(
@StringRes val title: Int,
val route: DirectionDestinationSpec,
val icon: ImageVector,
val iconSelected: ImageVector
) {
LOGS(
title = R.string.logs,
route = LogRouteDestination,
icon = Icons.AutoMirrored.Outlined.Article,
iconSelected = Icons.AutoMirrored.Filled.Article
),
HOME(
title = R.string.home,
route = HomeRouteDestination,
icon = Icons.Outlined.Home,
iconSelected = Icons.Filled.Home
),
SETTINGS(
title = R.string.settings,
route = SettingsScreenDestination,
icon = Icons.Outlined.Settings,
iconSelected = Icons.Filled.Settings
),
}
@Composable
private fun BottomBar(
navController: NavHostController
) {
val navigator = navController.rememberDestinationsNavigator()
val currentRoute =
navController.currentDestinationAsState().value ?: NavGraphs.root.startDestination
NavigationBar {
NavDestinations.entries.forEach { destination ->
val isSelected = currentRoute == destination.route
NavigationBarItem(
selected = isSelected,
onClick = {
navigator.navigate(destination.route) {
popUpTo(NavDestinations.HOME.route) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = {
Icon(
if (isSelected) destination.iconSelected else destination.icon,
null
)
},
label = { Text(stringResource(destination.title)) },
)
}
}
}
class RootDestinationsNavigator(value: DestinationsNavigator) : DestinationsNavigator by value
// This is *really* the only way to have pretty animations with bottom navbar...
@Destination<RootGraph>(
start = true
)
@Composable
fun MainGraphNavHost(
rootNavController: NavController
) {
val navController = rememberNavController()
Scaffold(
bottomBar = {
BottomBar(navController)
},
) { padding ->
// compensate for Scaffold's smartness
val bottomOffset = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding()
val outerPadding = PaddingValues(bottom = padding.calculateBottomPadding() - bottomOffset)
DestinationsNavHost(
navGraph = NavGraphs.main,
navController = navController,
dependenciesContainerBuilder = {
dependency(RootDestinationsNavigator(rootNavController.rememberDestinationsNavigator()))
dependency(outerPadding)
}
)
}
}
@Composable
fun OpenConnectApp() {
val navHostController = rememberNavController()
OpenConnectTheme {
DestinationsNavHost(
navGraph = NavGraphs.root,
navController = navHostController,
)
}
}

View file

@ -0,0 +1,43 @@
package com.wzray.openconnect.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wzray.openconnect.ui.theme.OpenConnectTheme
@Composable
fun CheckboxText(
text: String,
checked: Boolean,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(text)
Spacer(Modifier.width(36.dp))
Checkbox(checked, null)
}
}
@Preview(showBackground = true)
@Composable
private fun UiPreview() {
val enabled by remember { mutableStateOf(true) }
OpenConnectTheme {
CheckboxText("Preview", enabled)
}
}

View file

@ -0,0 +1,90 @@
package com.wzray.openconnect.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wzray.openconnect.ui.theme.OpenConnectTheme
import me.zhanghai.compose.preference.CheckboxPreference
import me.zhanghai.compose.preference.ProvidePreferenceLocals
import me.zhanghai.compose.preference.TwoTargetIconButtonPreference
import me.zhanghai.compose.preference.rememberPreferenceState
// TwoTargetIconButtonPreference specialization for connection list
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ConnectionElement(
onClick: () -> Unit,
onLongClick: () -> Unit,
onSettingsClick: () -> Unit,
connectionName: String
) {
TwoTargetIconButtonPreference(
title = { Text(connectionName) },
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick),
onIconButtonClick = onSettingsClick,
iconButtonIcon = {
Icon(imageVector = Icons.Filled.Settings, contentDescription = "Settings")
},
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ConnectionElementSelected(
onChangeCallback: () -> Unit,
isEnabled: Boolean,
connectionName: String
) {
CheckboxPreference(
value = isEnabled,
title = { Text(connectionName) },
onValueChange = {
onChangeCallback.invoke()
}
)
}
@Preview(showBackground = true)
@Composable
private fun ConnectionElementPreview() {
OpenConnectTheme {
Surface {
Column {
ConnectionElement({}, {}, {}, "Menu element")
ConnectionElementSelected({}, false, "Not selected element")
ConnectionElementSelected({}, true, "Example selected")
}
}
}
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ConnectionElementPreviewDark() {
OpenConnectTheme {
Surface {
Column {
ConnectionElement({}, {}, {}, "Menu element")
ConnectionElementSelected({}, false, "Not selected element")
ConnectionElementSelected({}, true, "Selected element")
}
}
}
}

View file

@ -0,0 +1,33 @@
package com.wzray.openconnect.ui.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.Text
import androidx.compose.material3.RadioButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun RadioButtonText(text: String, isSelected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(selected = isSelected, onClick = {
onClick.invoke()
})
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = isSelected, onClick = {
onClick.invoke()
})
Text(
text = text, modifier = Modifier.padding(start = 16.dp)
)
}
}

View file

@ -0,0 +1,25 @@
package com.wzray.openconnect.ui.components
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun SimpleIconButton(
onClick: () -> Unit,
icon: ImageVector,
contentDescription: String?,
modifier: Modifier = Modifier
) {
IconButton(
onClick = onClick,
modifier = modifier
) {
Icon(
imageVector = icon,
contentDescription = contentDescription
)
}
}

View file

@ -0,0 +1,13 @@
package com.wzray.openconnect.ui.components
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun SimpleTextButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
TextButton(onClick = onClick, modifier = modifier) {
Text(text = text)
}
}

View file

@ -0,0 +1,69 @@
package com.wzray.openconnect.ui.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.foundation.layout.requiredWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@Composable
fun <T> SingleSelectDialog(
title: String,
submitButtonText: String,
dismissButtonText: String,
options: Map<T, String>,
defaultSelected: T,
onSubmitButtonClick: (T) -> Unit,
onDismissRequest: () -> Unit,
) {
var selectedOption by remember { mutableStateOf(defaultSelected) }
Dialog(onDismissRequest = onDismissRequest) {
Surface(
modifier = Modifier.requiredWidth(300.dp), shape = RoundedCornerShape(24.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 8.dp, start = 8.dp, bottom = 12.dp)
)
LazyColumn {
options.forEach {
item {
RadioButtonText(
text = it.value, isSelected = (it.key == selectedOption)
) {
selectedOption = it.key
}
}
}
}
Row(
horizontalArrangement = Arrangement.Absolute.Right,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
) {
SimpleTextButton(onDismissRequest, dismissButtonText)
SimpleTextButton({
onSubmitButtonClick.invoke(selectedOption)
onDismissRequest.invoke()
}, submitButtonText)
}
}
}
}
}

View file

@ -0,0 +1,80 @@
package com.wzray.openconnect.ui.screens
import android.os.Build
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DarkMode
import androidx.compose.material.icons.outlined.FormatPaint
import androidx.compose.material.icons.outlined.LightMode
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.ramcosta.composedestinations.annotation.Destination
import com.wzray.openconnect.R
import com.wzray.openconnect.ui.MainGraph
import com.wzray.openconnect.util.Settings
import com.wzray.openconnect.util.UiMode
import com.wzray.openconnect.util.toFormalCase
import me.zhanghai.compose.preference.ListPreference
import me.zhanghai.compose.preference.PreferenceCategory
import me.zhanghai.compose.preference.SwitchPreference
@Destination<MainGraph>
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(outerPadding: PaddingValues) {
Scaffold(
modifier = Modifier.padding(outerPadding),
topBar = { TopAppBar(title = { Text(stringResource(R.string.settings)) }) }
) { padding ->
Column(modifier = Modifier.padding(padding)) {
val uiModeToString = UiMode.entries.associateBy { it.toString().toFormalCase() }
val uiMode by Settings.uiMode.asState()
val dynamicColors by Settings.useDynamicColor.asState()
PreferenceCategory(
title = { Text(stringResource(R.string.look_and_feel)) }
)
ListPreference(
value = uiMode.toString().toFormalCase(),
onValueChange = {
Settings.uiMode.set(
uiModeToString[it]
)
},
values = uiModeToString.keys.toList(),
title = { Text(stringResource(R.string.app_theme)) },
summary = { Text(stringResource(R.string.app_theme_summary)) },
icon = {
Icon(
when (uiMode) {
UiMode.DARK -> Icons.Outlined.DarkMode
UiMode.LIGHT -> Icons.Outlined.LightMode
UiMode.AUTO -> Icons.Outlined.FormatPaint
}, null
)
},
)
SwitchPreference(
value = dynamicColors && (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S),
onValueChange = { Settings.useDynamicColor.set(it) },
icon = { Icon(Icons.Outlined.Palette, null) },
title = { Text(stringResource(R.string.use_dynamic_colors)) },
summary = { Text(stringResource(R.string.use_dynamic_colors_summary)) },
enabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
)
}
}
}

View file

@ -0,0 +1,127 @@
package com.wzray.openconnect.ui.screens.connection_editor
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination
import com.wzray.openconnect.R
import com.wzray.openconnect.core.data.model.Connection
import com.wzray.openconnect.core.viewmodels.ConnectionEditorViewModel
import com.wzray.openconnect.ui.ConnectionEditorGraph
import com.wzray.openconnect.ui.theme.SlideEnd
import me.zhanghai.compose.preference.ListPreference
import me.zhanghai.compose.preference.PreferenceCategory
import me.zhanghai.compose.preference.TextFieldPreference
import me.zhanghai.compose.preference.preferenceCategory
import me.zhanghai.compose.preference.textFieldPreference
//private val protocolToString = mapOf<Protocols, @receiver:StringRes Int>(
// Protocols.ANYCONNECT to R.string.home
//)
private fun LazyListScope.textFieldPreference(
key: String,
@StringRes title: Int,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
item(key = key, contentType = "TextFieldPreference") {
TextFieldPreference(
value = value,
summary = { Text(value) },
title = { Text(stringResource(title)) },
onValueChange = onValueChange,
textToValue = { it },
modifier = modifier
)
}
}
private fun LazyListScope.passwordTextFieldPreference(
key: String,
@StringRes title: Int,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
item(key = key, contentType = "TextFieldPreference") {
TextFieldPreference(
value = value,
summary = { Text("".repeat(value.length)) },
title = { Text(stringResource(title)) },
onValueChange = onValueChange,
textToValue = { it },
modifier = modifier
)
}
}
@Destination<ConnectionEditorGraph>(start = true, style = SlideEnd::class)
@Composable
fun ConnectionEditorScreen(
initialConnection: Connection,
) {
val viewModel = remember { ConnectionEditorViewModel(initialConnection) }
val connection by viewModel.connection.collectAsState()
Scaffold { padding ->
LazyColumn(modifier = Modifier.padding(padding)) {
preferenceCategory(
key = "cat_server",
title = { Text(stringResource(R.string.server)) })
textFieldPreference(
key = "connection_name",
value = connection.name,
title = R.string.profile_name,
onValueChange = {
viewModel.updateConnection(connection.copy(name = it))
},
)
textFieldPreference(
key = "server_address",
value = connection.url,
title = R.string.server_address,
onValueChange = {
viewModel.updateConnection(connection.copy(url = it))
}
)
preferenceCategory(
key = "cat_auth",
title = { Text(stringResource(R.string.authentication)) })
textFieldPreference(
key = "username",
value = connection.username,
title = R.string.username,
onValueChange = {
viewModel.updateConnection(connection.copy(username = it))
},
)
passwordTextFieldPreference(
key = "password",
value = connection.password,
title = R.string.password,
onValueChange = {
viewModel.updateConnection(connection.copy(password = it))
},
)
}
}
}

View file

@ -0,0 +1,23 @@
package com.wzray.openconnect.ui.screens.home
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.ramcosta.composedestinations.annotation.NavGraph
import com.ramcosta.composedestinations.annotation.NavHostGraph
import com.ramcosta.composedestinations.annotation.RootGraph
import com.wzray.openconnect.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeTopBar(
modifier: Modifier = Modifier,
title: @Composable () -> Unit = { Text(stringResource(R.string.app_name)) },
actions: @Composable (RowScope.() -> Unit) = {}
) {
TopAppBar(title = title, modifier = modifier, actions = actions)
}

View file

@ -0,0 +1,43 @@
package com.wzray.openconnect.ui.screens.home
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.ramcosta.composedestinations.annotation.Destination
import com.wzray.openconnect.core.viewmodels.HomeUiState
import com.wzray.openconnect.core.viewmodels.HomeViewModel
import androidx.hilt.navigation.compose.hiltViewModel
import com.wzray.openconnect.ui.MainGraph
import com.wzray.openconnect.ui.RootDestinationsNavigator
@Destination<MainGraph>(
start = true,
)
@Composable
fun HomeRoute(
viewModel: HomeViewModel = hiltViewModel(),
outerPadding: PaddingValues,
navigator: RootDestinationsNavigator
) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is HomeUiState.ConnectionList ->
HomeScreen(
{ viewModel.connect(it) },
{ viewModel.toggleSelection(it) },
viewModel.uiState,
outerPadding,
navigator
)
is HomeUiState.SelectionList -> HomeScreenSelection(
onChangeState = { viewModel.toggleSelection(it) },
onDeleteClick = { viewModel.deleteSelected() },
onSelectAllClick = { viewModel.selectAll() },
onCopyClick = { viewModel.copySelected() },
uiStateFlow = viewModel.uiState,
paddingValues = outerPadding
)
}
}

View file

@ -0,0 +1,93 @@
package com.wzray.openconnect.ui.screens.home
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import com.wzray.openconnect.R
import com.wzray.openconnect.core.data.model.Connection
import com.wzray.openconnect.ui.components.ConnectionElementSelected
import com.wzray.openconnect.core.viewmodels.HomeUiState
import kotlinx.coroutines.flow.StateFlow
@Composable
fun SelectionTopBar(
count: Int,
onDeleteClick: () -> Unit,
onSelectAllClick: () -> Unit,
onCopyClick: () -> Unit,
modifier: Modifier = Modifier,
title: @Composable () -> Unit = {
Text(
pluralStringResource(
R.plurals.selection_top_bar_count_selected,
count,
count
)
)
}
) {
HomeTopBar(modifier, title) {
if (count == 1)
IconButton(onClick = onCopyClick) {
Icon(imageVector = Icons.Filled.ContentCopy, "Duplicate")
}
IconButton(onClick = onSelectAllClick) {
Icon(imageVector = Icons.Filled.SelectAll, "Select all")
}
IconButton(onClick = onDeleteClick) {
Icon(imageVector = Icons.Filled.Delete, "Select all")
}
}
}
@Composable
fun HomeScreenSelection(
onChangeState: (Connection) -> Unit,
onDeleteClick: () -> Unit,
onSelectAllClick: () -> Unit,
onCopyClick: () -> Unit,
uiStateFlow: StateFlow<HomeUiState>,
paddingValues: PaddingValues,
) {
val uiState by uiStateFlow.collectAsState()
Scaffold(
topBar = { SelectionTopBar(
(uiState as HomeUiState.SelectionList).selectedConnections.count(),
onDeleteClick = onDeleteClick,
onSelectAllClick = onSelectAllClick,
onCopyClick = onCopyClick,
) },
modifier = Modifier.padding(paddingValues),
) { padding ->
Box(modifier = Modifier.padding(top = padding.calculateTopPadding())) {
LazyColumn {
items(uiState.connections) {
ConnectionElementSelected(
onChangeCallback = { onChangeState(it) },
isEnabled = (uiState as HomeUiState.SelectionList)
.selectedConnections.contains(it),
connectionName = it.name
)
}
}
}
}
}

View file

@ -0,0 +1,96 @@
package com.wzray.openconnect.ui.screens.home
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import com.ramcosta.composedestinations.generated.destinations.ConnectionEditorScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.wzray.openconnect.Application
import com.wzray.openconnect.core.data.model.Connection
import com.wzray.openconnect.ui.components.ConnectionElement
import com.wzray.openconnect.util.applicationScope
import com.wzray.openconnect.core.viewmodels.HomeUiState
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@Composable
fun MultiUseFab() { // TODO: you don't have to tell me that this is bad
val vpnManager = Application.vpnManager
if (vpnManager.isConnected)
FloatingActionButton(
onClick = { vpnManager.disconnect() },
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.secondary
) {
Icon(Icons.Filled.Stop, "Stop the VPN")
}
else
FloatingActionButton(
onClick = {
Application.applicationScope.launch {
val dao = Application.db.connectionsDao()
dao.insert(Connection("New connection", "", "", ""))
}
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.secondary
) {
Icon(Icons.Filled.Add, "Add a new connection")
}
}
@Composable
fun HomeScreen(
onConnectionClick: (Connection) -> Unit,
onLongClick: (Connection) -> Unit,
uiStateFlow: StateFlow<HomeUiState>,
paddingValues: PaddingValues,
navigator: DestinationsNavigator,
) {
val uiState by uiStateFlow.collectAsState()
Scaffold(
topBar = { HomeTopBar() },
modifier = Modifier.padding(paddingValues),
floatingActionButton = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
MultiUseFab()
}
}
) { padding ->
val haptics = LocalHapticFeedback.current
Box(modifier = Modifier.padding(top = padding.calculateTopPadding())) {
LazyColumn {
items(uiState.connections) {
ConnectionElement(
onClick = { onConnectionClick(it) },
onLongClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick(it)
},
onSettingsClick = { navigator.navigate(ConnectionEditorScreenDestination(it)) },
connectionName = it.name
)
}
}
}
}
}

View file

@ -0,0 +1,30 @@
package com.wzray.openconnect.ui.screens.logs
import android.widget.Toast
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination
import com.wzray.openconnect.ui.MainGraph
import com.wzray.openconnect.core.viewmodels.LogViewModel
@Destination<MainGraph>
@Composable
fun LogRoute(
viewModel: LogViewModel = hiltViewModel(),
outerPadding: PaddingValues,
) {
val ctx = LocalContext.current // TODO: remove this
val makeToast: (String) -> Unit = {
Toast.makeText(ctx, it, Toast.LENGTH_SHORT).show()
}
LogScreen( // TODO: remove dependency on UIState and provide all values individually
onPauseClick = { viewModel.pauseLog() },
onShareClick = { makeToast("onShareClick") },
onSetLogLevel = { viewModel.setLogLevel(it) },
onClearClick = { viewModel.clearLog() },
uiStateFlow = viewModel.uiState,
padding = outerPadding
)
}

View file

@ -0,0 +1,173 @@
package com.wzray.openconnect.ui.screens.logs
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wzray.openconnect.R
import com.wzray.openconnect.ui.components.CheckboxText
import com.wzray.openconnect.ui.components.SimpleIconButton
import com.wzray.openconnect.ui.components.SingleSelectDialog
import com.wzray.openconnect.util.Settings
import com.wzray.openconnect.core.viewmodels.LogLevel
import com.wzray.openconnect.core.viewmodels.UIState
import kotlinx.coroutines.flow.StateFlow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopBar(
onPauseClick: () -> Unit,
onShareClick: () -> Unit,
onClearClick: () -> Unit,
isPaused: Boolean,
dialogState: MutableState<Boolean>,
) {
var isMenuExpanded by remember { mutableStateOf(false) }
val followLogs by Settings.followLogs.asState()
var isDialogOpen by dialogState
TopAppBar(
title = { Text(stringResource(R.string.logs)) },
actions = {
SimpleIconButton(
onClick = onPauseClick,
icon = if (isPaused) Icons.Filled.PlayArrow else Icons.Filled.Pause,
contentDescription = stringResource(R.string.pause_logs)
)
SimpleIconButton(
onClick = onClearClick,
icon = Icons.Filled.Delete,
contentDescription = stringResource(R.string.clear_logs)
)
Box {
SimpleIconButton(
onClick = { isMenuExpanded = !isMenuExpanded },
icon = Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.more_options)
)
DropdownMenu(
expanded = isMenuExpanded,
onDismissRequest = { isMenuExpanded = false }
) {
DropdownMenuItem(
text = {
CheckboxText(
stringResource(R.string.follow_logs),
followLogs
)
},
onClick = { Settings.followLogs.set(!followLogs); isMenuExpanded = false }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.log_level)) },
onClick = { isMenuExpanded = false; isDialogOpen = true }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.export_logs)) },
onClick = onShareClick
)
}
}
},
)
}
@Composable
fun LogScreen(
onPauseClick: () -> Unit,
onShareClick: () -> Unit,
onSetLogLevel: (LogLevel) -> Unit,
onClearClick: () -> Unit,
uiStateFlow: StateFlow<UIState>,
padding: PaddingValues
) {
val uiState by uiStateFlow.collectAsState()
val dialogState = remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopBar(
onPauseClick = onPauseClick,
onShareClick = onShareClick,
onClearClick = onClearClick,
isPaused = uiState.isPaused,
dialogState = dialogState,
)
},
modifier = Modifier.padding(padding)
) { innerPadding ->
val followLogs by Settings.followLogs.asState()
var isDialogOpen by dialogState
val scrollState = rememberScrollState()
if (isDialogOpen)
SingleSelectDialog(
title = stringResource(R.string.log_level),
submitButtonText = stringResource(R.string.submit),
dismissButtonText = stringResource(R.string.cancel),
options = LogLevel.entries.associateWith { it.fullName },
defaultSelected = uiState.logLevel,
onSubmitButtonClick = onSetLogLevel,
onDismissRequest = { isDialogOpen = false },
)
SelectionContainer(
modifier = Modifier.padding(innerPadding)
) {
if (followLogs) LaunchedEffect(uiState.logList.size) {
if (uiState.logList.isNotEmpty()) scrollState.scrollTo(scrollState.maxValue)
}
Column(
modifier = Modifier
.verticalScroll(scrollState)
.fillMaxWidth()
.fillMaxHeight()
.padding(horizontal = 8.dp)
) {
uiState.logList.toList().forEach {
Row {
Text(
text = it,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
lineHeight = 20.sp,
modifier = Modifier.padding(bottom = 12.dp)
)
}
}
}
}
}
}

View file

@ -0,0 +1,59 @@
package com.wzray.openconnect.ui.theme
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.navigation.NavBackStackEntry
import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle
import com.ramcosta.composedestinations.spec.DestinationStyle
object SlideEnd : DestinationStyle.Animated() {
private const val DURATION = 200
override val enterTransition:
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition? = {
slideInHorizontally(
initialOffsetX = { 1000 }, animationSpec = tween(DURATION)
)
}
override val exitTransition:
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition? = {
slideOutHorizontally(
targetOffsetX = { -1000 }, animationSpec = tween(DURATION)
)
}
override val popEnterTransition:
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition? = {
slideInHorizontally(
initialOffsetX = { -1000 }, animationSpec = tween(DURATION)
)
}
override val popExitTransition:
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition? = {
slideOutHorizontally(
targetOffsetX = { 1000 }, animationSpec = tween(DURATION)
)
}
}
object Fade : NavHostAnimatedDestinationStyle() {
private const val DURATION = 175
override val enterTransition:
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
fadeIn(
initialAlpha = 0f, animationSpec = tween(DURATION)
)
}
override val exitTransition:
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
fadeOut(
targetAlpha = 0f, animationSpec = tween(DURATION)
)
}
}

View file

@ -0,0 +1,11 @@
package com.wzray.openconnect.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View file

@ -0,0 +1,70 @@
package com.wzray.openconnect.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import com.wzray.openconnect.util.Settings
import com.wzray.openconnect.util.UiMode
import me.zhanghai.compose.preference.ProvidePreferenceLocals
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
)
@Composable
fun OpenConnectTheme(
content: @Composable () -> Unit
) {
val dynamicColor by Settings.useDynamicColor.asState()
val uiMode by Settings.uiMode.asState()
val darkTheme = when (uiMode) {
UiMode.AUTO -> isSystemInDarkTheme()
UiMode.DARK -> true
UiMode.LIGHT -> false
}
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = {
ProvidePreferenceLocals {
content()
}
}
)
}

View file

@ -0,0 +1,34 @@
package com.wzray.openconnect.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View file

@ -0,0 +1,10 @@
package com.wzray.openconnect.util
import com.wzray.openconnect.Application
import kotlinx.coroutines.CoroutineScope
fun String.toFormalCase() = this.lowercase().replaceFirstChar { it.uppercaseChar() }
val Any.applicationScope: CoroutineScope
get() = Application.coroutineScope

View file

@ -0,0 +1,74 @@
package com.wzray.openconnect.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.wzray.openconnect.Application
import com.wzray.openconnect.core.viewmodels.LogLevel
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
abstract class PreferenceFieldBase<StorageType, ValueType>(
private val dataStore: DataStore<Preferences>,
private val key: Preferences.Key<StorageType>,
protected open val default: ValueType,
) {
abstract fun toStorage(v: ValueType): StorageType
abstract fun fromStorage(v: StorageType?): ValueType?
@Composable
fun asState(): State<ValueType> =
remember { dataStore.data.map { fromStorage(it[key]) ?: default } }.collectAsState(default)
fun set(value: ValueType?) = applicationScope.launch {
dataStore.edit { if (value != null) it[key] = toStorage(value) else it.remove(key) }
}
}
class PreferenceField<T>(
dataStore: DataStore<Preferences>,
key: Preferences.Key<T>,
default: T,
) : PreferenceFieldBase<T, T>(dataStore, key, default) {
override fun toStorage(v: T): T = v
override fun fromStorage(v: T?): T? = v
}
class EnumPreferenceField<E : Enum<E>>(
dataStore: DataStore<Preferences>,
key: Preferences.Key<String>,
default: E,
) : PreferenceFieldBase<String, E>(dataStore, key, default) {
override fun toStorage(v: E): String = v.name
override fun fromStorage(v: String?): E? =
v?.let { default::class.java.enumConstants!!.firstOrNull { it.name == v } }
}
enum class UiMode {
AUTO, DARK, LIGHT
}
object Settings {
private val dataStore = Application.prefDataStore
private val KEY_FOLLOW_LOGS = booleanPreferencesKey("follow_logs")
private val KEY_LAST_CONNECTION = stringPreferencesKey("last_connection")
private val KEY_UI_MODE = stringPreferencesKey("ui_mode")
private val KEY_LOG_LEVEL = stringPreferencesKey("log_level")
private val KEY_USE_DYNAMIC_COLOR = booleanPreferencesKey("use_dynamic_color")
val followLogs = PreferenceField(dataStore, KEY_FOLLOW_LOGS, true)
val lastConnection = PreferenceField(dataStore, KEY_LAST_CONNECTION, "")
val uiMode = EnumPreferenceField(dataStore, KEY_UI_MODE, UiMode.AUTO)
val logLevel = EnumPreferenceField(dataStore, KEY_LOG_LEVEL, LogLevel.INFO)
val useDynamicColor = PreferenceField(dataStore, KEY_USE_DYNAMIC_COLOR, true)
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,34 @@
<resources>
<string name="app_name">OpenConnect</string>
<string name="logs">Logs</string>
<string name="pause_logs">Pause logs</string>
<string name="share_logs">Share logs</string>
<string name="more_options">More options</string>
<string name="clear_logs">Clear logs</string>
<string name="follow_logs">Follow logs</string>
<string name="log_level">Log level</string>
<string name="export_logs">Export logs</string>
<string name="submit">Submit</string>
<string name="dismiss">Dismiss</string>
<string name="cancel">Cancel</string>
<string name="dark_mode">Dark mode</string>
<string name="app_theme">Theme</string>
<string name="use_dynamic_colors">Enable dynamic colors</string>
<string name="settings">Settings</string>
<string name="home">Home</string>
<string name="use_dynamic_colors_summary">Use Material You colorscheme</string>
<string name="app_theme_summary">Choose between Auto, Light, or Dark mode.</string>
<string name="look_and_feel"><![CDATA[Look & feel]]></string>
<string name="server">Server</string>
<string name="authentication">Authentication</string>
<string name="advanced">Advanced</string>
<string name="profile_name">Profile name</string>
<string name="vpn_protocol">VPN Protocol</string>
<string name="username">Username</string>
<string name="password">Password</string>
<string name="server_address">Server address</string>
<plurals name="selection_top_bar_count_selected">
<item quantity="one">%d selected</item>
<item quantity="other">%d selected</item>
</plurals>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.OpenConnect" parent="android:Theme.DeviceDefault.NoActionBar" />
</resources>

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
</full-backup-content>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
</cloud-backup>
</data-extraction-rules>