diff options
Diffstat (limited to 'ui/src/main/java/com/wireguard/android/activity')
7 files changed, 1254 insertions, 0 deletions
diff --git a/ui/src/main/java/com/wireguard/android/activity/BaseActivity.kt b/ui/src/main/java/com/wireguard/android/activity/BaseActivity.kt new file mode 100644 index 00000000..56810377 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/BaseActivity.kt @@ -0,0 +1,96 @@ +/* + * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.activity + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.CallbackRegistry +import androidx.databinding.CallbackRegistry.NotifierCallback +import androidx.lifecycle.lifecycleScope +import com.wireguard.android.Application +import com.wireguard.android.model.ObservableTunnel +import kotlinx.coroutines.launch + +/** + * Base class for activities that need to remember the currently-selected tunnel. + */ +abstract class BaseActivity : AppCompatActivity() { + private val selectionChangeRegistry = SelectionChangeRegistry() + private var created = false + var selectedTunnel: ObservableTunnel? = null + set(value) { + val oldTunnel = field + if (oldTunnel == value) return + field = value + if (created) { + if (!onSelectedTunnelChanged(oldTunnel, value)) { + field = oldTunnel + } else { + selectionChangeRegistry.notifyCallbacks(oldTunnel, 0, value) + } + } + } + + fun addOnSelectedTunnelChangedListener(listener: OnSelectedTunnelChangedListener) { + selectionChangeRegistry.add(listener) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Restore the saved tunnel if there is one; otherwise grab it from the arguments. + val savedTunnelName = when { + savedInstanceState != null -> savedInstanceState.getString(KEY_SELECTED_TUNNEL) + intent != null -> intent.getStringExtra(KEY_SELECTED_TUNNEL) + else -> null + } + if (savedTunnelName != null) { + lifecycleScope.launch { + val tunnel = Application.getTunnelManager().getTunnels()[savedTunnelName] + if (tunnel == null) + created = true + selectedTunnel = tunnel + created = true + } + } else { + created = true + } + } + + override fun onSaveInstanceState(outState: Bundle) { + if (selectedTunnel != null) outState.putString(KEY_SELECTED_TUNNEL, selectedTunnel!!.name) + super.onSaveInstanceState(outState) + } + + protected abstract fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?): Boolean + + fun removeOnSelectedTunnelChangedListener( + listener: OnSelectedTunnelChangedListener + ) { + selectionChangeRegistry.remove(listener) + } + + interface OnSelectedTunnelChangedListener { + fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) + } + + private class SelectionChangeNotifier : NotifierCallback<OnSelectedTunnelChangedListener, ObservableTunnel, ObservableTunnel>() { + override fun onNotifyCallback( + listener: OnSelectedTunnelChangedListener, + oldTunnel: ObservableTunnel?, + ignored: Int, + newTunnel: ObservableTunnel? + ) { + listener.onSelectedTunnelChanged(oldTunnel, newTunnel) + } + } + + private class SelectionChangeRegistry : + CallbackRegistry<OnSelectedTunnelChangedListener, ObservableTunnel, ObservableTunnel>(SelectionChangeNotifier()) + + companion object { + private const val KEY_SELECTED_TUNNEL = "selected_tunnel" + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt b/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt new file mode 100644 index 00000000..69e33bc9 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt @@ -0,0 +1,382 @@ +/* + * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.activity + +import android.content.ClipDescription.compareMimeTypes +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Intent +import android.database.Cursor +import android.database.MatrixCursor +import android.graphics.Typeface.BOLD +import android.net.Uri +import android.os.Bundle +import android.os.ParcelFileDescriptor +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.collection.CircularArray +import androidx.core.app.ShareCompat +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textview.MaterialTextView +import com.wireguard.android.BuildConfig +import com.wireguard.android.R +import com.wireguard.android.databinding.LogViewerActivityBinding +import com.wireguard.android.util.DownloadsFileSaver +import com.wireguard.android.util.ErrorMessages +import com.wireguard.android.util.resolveAttribute +import com.wireguard.crypto.KeyPair +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets +import java.text.DateFormat +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import java.util.regex.Matcher +import java.util.regex.Pattern + +class LogViewerActivity : AppCompatActivity() { + private lateinit var binding: LogViewerActivityBinding + private lateinit var logAdapter: LogEntryAdapter + private var logLines = CircularArray<LogLine>() + private var rawLogLines = CircularArray<String>() + private var recyclerView: RecyclerView? = null + private var saveButton: MenuItem? = null + private val year by lazy { + val yearFormatter: DateFormat = SimpleDateFormat("yyyy", Locale.US) + yearFormatter.format(Date()) + } + + private val defaultColor by lazy { resolveAttribute(com.google.android.material.R.attr.colorOnSurface) } + + private val debugColor by lazy { ResourcesCompat.getColor(resources, R.color.debug_tag_color, theme) } + + private val errorColor by lazy { ResourcesCompat.getColor(resources, R.color.error_tag_color, theme) } + + private val infoColor by lazy { ResourcesCompat.getColor(resources, R.color.info_tag_color, theme) } + + private val warningColor by lazy { ResourcesCompat.getColor(resources, R.color.warning_tag_color, theme) } + + private var lastUri: Uri? = null + + private fun revokeLastUri() { + lastUri?.let { + LOGS.remove(it.pathSegments.lastOrNull()) + revokeUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION) + lastUri = null + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = LogViewerActivityBinding.inflate(layoutInflater) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + logAdapter = LogEntryAdapter() + binding.recyclerView.apply { + recyclerView = this + layoutManager = LinearLayoutManager(context) + adapter = logAdapter + addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL)) + } + + lifecycleScope.launch(Dispatchers.IO) { streamingLog() } + + val revokeLastActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + revokeLastUri() + } + + binding.shareFab.setOnClickListener { + lifecycleScope.launch { + revokeLastUri() + val key = KeyPair().privateKey.toHex() + LOGS[key] = rawLogBytes() + lastUri = Uri.parse("content://${BuildConfig.APPLICATION_ID}.exported-log/$key") + val shareIntent = ShareCompat.IntentBuilder(this@LogViewerActivity) + .setType("text/plain") + .setSubject(getString(R.string.log_export_subject)) + .setStream(lastUri) + .setChooserTitle(R.string.log_export_title) + .createChooserIntent() + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + grantUriPermission("android", lastUri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + revokeLastActivityResultLauncher.launch(shareIntent) + } + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.log_viewer, menu) + saveButton = menu.findItem(R.id.save_log) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + true + } + + R.id.save_log -> { + saveButton?.isEnabled = false + lifecycleScope.launch { saveLog() } + true + } + + else -> super.onOptionsItemSelected(item) + } + } + + private val downloadsFileSaver = DownloadsFileSaver(this) + + private suspend fun rawLogBytes(): ByteArray { + val builder = StringBuilder() + withContext(Dispatchers.IO) { + for (i in 0 until rawLogLines.size()) { + builder.append(rawLogLines[i]) + builder.append('\n') + } + } + return builder.toString().toByteArray(Charsets.UTF_8) + } + + private suspend fun saveLog() { + var exception: Throwable? = null + var outputFile: DownloadsFileSaver.DownloadsFile? = null + withContext(Dispatchers.IO) { + try { + outputFile = downloadsFileSaver.save("wireguard-log.txt", "text/plain", true) + outputFile?.outputStream?.write(rawLogBytes()) + } catch (e: Throwable) { + outputFile?.delete() + exception = e + } + } + saveButton?.isEnabled = true + if (outputFile == null) + return + Snackbar.make( + findViewById(android.R.id.content), + if (exception == null) getString(R.string.log_export_success, outputFile?.fileName) + else getString(R.string.log_export_error, ErrorMessages[exception]), + if (exception == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG + ) + .setAnchorView(binding.shareFab) + .show() + } + + private suspend fun streamingLog() = withContext(Dispatchers.IO) { + val builder = ProcessBuilder().command("logcat", "-b", "all", "-v", "threadtime", "*:V") + 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)) + + var posStart = 0 + var timeLastNotify = System.nanoTime() + var priorModified = false + val bufferedLogLines = arrayListOf<LogLine>() + var timeout = 1000000000L / 2 // The timeout is initially small so that the view gets populated immediately. + val MAX_LINES = (1 shl 16) - 1 + val MAX_BUFFERED_LINES = (1 shl 14) - 1 + + while (true) { + val line = stdout.readLine() ?: break + if (rawLogLines.size() >= MAX_LINES) + rawLogLines.popFirst() + rawLogLines.addLast(line) + val logLine = parseLine(line) + if (logLine != null) { + bufferedLogLines.add(logLine) + } else { + if (bufferedLogLines.isNotEmpty()) { + bufferedLogLines.last().msg += "\n$line" + } else if (!logLines.isEmpty()) { + logLines[logLines.size() - 1].msg += "\n$line" + priorModified = true + } + } + val timeNow = System.nanoTime() + if (bufferedLogLines.size < MAX_BUFFERED_LINES && (timeNow - timeLastNotify) < timeout && stdout.ready()) + continue + timeout = 1000000000L * 5 / 2 // Increase the timeout after the initial view has something in it. + timeLastNotify = timeNow + + withContext(Dispatchers.Main.immediate) { + val isScrolledToBottomAlready = recyclerView?.canScrollVertically(1) == false + if (priorModified) { + logAdapter.notifyItemChanged(posStart - 1) + priorModified = false + } + val fullLen = logLines.size() + bufferedLogLines.size + if (fullLen >= MAX_LINES) { + val numToRemove = fullLen - MAX_LINES + 1 + logLines.removeFromStart(numToRemove) + logAdapter.notifyItemRangeRemoved(0, numToRemove) + posStart -= numToRemove + + } + for (bufferedLine in bufferedLogLines) { + logLines.addLast(bufferedLine) + } + bufferedLogLines.clear() + logAdapter.notifyItemRangeInserted(posStart, logLines.size() - posStart) + posStart = logLines.size() + + if (isScrolledToBottomAlready) { + recyclerView?.scrollToPosition(logLines.size() - 1) + } + } + } + } finally { + process?.destroy() + } + } + + private fun parseTime(timeStr: String): Date? { + val formatter: DateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + return try { + formatter.parse("$year-$timeStr") + } catch (e: ParseException) { + null + } + } + + private fun parseLine(line: String): LogLine? { + val m: Matcher = THREADTIME_LINE.matcher(line) + return if (m.matches()) { + LogLine(m.group(2)!!.toInt(), m.group(3)!!.toInt(), parseTime(m.group(1)!!), m.group(4)!!, m.group(5)!!, m.group(6)!!) + } else { + null + } + } + + private data class LogLine(val pid: Int, val tid: Int, val time: Date?, val level: String, val tag: String, var msg: String) + + companion object { + /** + * Match a single line of `logcat -v threadtime`, such as: + * + * <pre>05-26 11:02:36.886 5689 5689 D AndroidRuntime: CheckJNI is OFF.</pre> + */ + private val THREADTIME_LINE: Pattern = + Pattern.compile("^(\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}.\\d{3})(?:\\s+[0-9A-Za-z]+)?\\s+(\\d+)\\s+(\\d+)\\s+([A-Z])\\s+(.+?)\\s*: (.*)$") + private val LOGS: MutableMap<String, ByteArray> = ConcurrentHashMap() + private const val TAG = "WireGuard/LogViewerActivity" + } + + private inner class LogEntryAdapter : RecyclerView.Adapter<LogEntryAdapter.ViewHolder>() { + + private inner class ViewHolder(val layout: View, var isSingleLine: Boolean = true) : RecyclerView.ViewHolder(layout) + + private fun levelToColor(level: String): Int { + return when (level) { + "V", "D" -> debugColor + "E" -> errorColor + "I" -> infoColor + "W" -> warningColor + else -> defaultColor + } + } + + override fun getItemCount() = logLines.size() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.log_viewer_entry, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val line = logLines[position] + val spannable = if (position > 0 && logLines[position - 1].tag == line.tag) + SpannableString(line.msg) + else + SpannableString("${line.tag}: ${line.msg}").apply { + setSpan(StyleSpan(BOLD), 0, "${line.tag}:".length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan( + ForegroundColorSpan(levelToColor(line.level)), + 0, "${line.tag}:".length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + holder.layout.apply { + findViewById<MaterialTextView>(R.id.log_date).text = line.time.toString() + findViewById<MaterialTextView>(R.id.log_msg).apply { + setSingleLine() + text = spannable + setOnClickListener { + isSingleLine = !holder.isSingleLine + holder.isSingleLine = !holder.isSingleLine + } + } + } + } + } + + class ExportedLogContentProvider : ContentProvider() { + private fun logForUri(uri: Uri): ByteArray? = LOGS[uri.pathSegments.lastOrNull()] + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = + logForUri(uri)?.let { + val m = MatrixCursor(arrayOf(android.provider.OpenableColumns.DISPLAY_NAME, android.provider.OpenableColumns.SIZE), 1) + m.addRow(arrayOf("wireguard-log.txt", it.size.toLong())) + m + } + + override fun onCreate(): Boolean = true + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0 + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0 + + override fun getType(uri: Uri): String? = logForUri(uri)?.let { "text/plain" } + + override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array<String>? = + getType(uri)?.let { if (compareMimeTypes(it, mimeTypeFilter)) arrayOf(it) else null } + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { + if (mode != "r") return null + val log = logForUri(uri) ?: return null + return openPipeHelper(uri, "text/plain", null, log) { output, _, _, _, l -> + try { + FileOutputStream(output.fileDescriptor).write(l!!) + } catch (_: Throwable) { + } + } + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/MainActivity.kt b/ui/src/main/java/com/wireguard/android/activity/MainActivity.kt new file mode 100644 index 00000000..80c4868c --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/MainActivity.kt @@ -0,0 +1,129 @@ +/* + * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.activity + +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.activity.addCallback +import androidx.appcompat.app.ActionBar +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentTransaction +import androidx.fragment.app.commit +import com.wireguard.android.R +import com.wireguard.android.fragment.TunnelDetailFragment +import com.wireguard.android.fragment.TunnelEditorFragment +import com.wireguard.android.model.ObservableTunnel + +/** + * CRUD interface for WireGuard tunnels. This activity serves as the main entry point to the + * WireGuard application, and contains several fragments for listing, viewing details of, and + * editing the configuration and interface state of WireGuard tunnels. + */ +class MainActivity : BaseActivity(), FragmentManager.OnBackStackChangedListener { + private var actionBar: ActionBar? = null + private var isTwoPaneLayout = false + private var backPressedCallback: OnBackPressedCallback? = null + + private fun handleBackPressed() { + val backStackEntries = supportFragmentManager.backStackEntryCount + // If the two-pane layout does not have an editor open, going back should exit the app. + if (isTwoPaneLayout && backStackEntries <= 1) { + finish() + return + } + + if (backStackEntries >= 1) + supportFragmentManager.popBackStack() + + // Deselect the current tunnel on navigating back from the detail pane to the one-pane list. + if (backStackEntries == 1) + selectedTunnel = null + } + + override fun onBackStackChanged() { + val backStackEntries = supportFragmentManager.backStackEntryCount + backPressedCallback?.isEnabled = backStackEntries >= 1 + if (actionBar == null) return + // Do not show the home menu when the two-pane layout is at the detail view (see above). + val minBackStackEntries = if (isTwoPaneLayout) 2 else 1 + actionBar!!.setDisplayHomeAsUpEnabled(backStackEntries >= minBackStackEntries) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.main_activity) + actionBar = supportActionBar + isTwoPaneLayout = findViewById<View?>(R.id.master_detail_wrapper) != null + supportFragmentManager.addOnBackStackChangedListener(this) + backPressedCallback = onBackPressedDispatcher.addCallback(this) { handleBackPressed() } + onBackStackChanged() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.main_activity, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + // The back arrow in the action bar should act the same as the back button. + onBackPressedDispatcher.onBackPressed() + true + } + + R.id.menu_action_edit -> { + supportFragmentManager.commit { + replace(R.id.detail_container, TunnelEditorFragment()) + setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + addToBackStack(null) + } + true + } + // This menu item is handled by the editor fragment. + R.id.menu_action_save -> false + R.id.menu_settings -> { + startActivity(Intent(this, SettingsActivity::class.java)) + true + } + + else -> super.onOptionsItemSelected(item) + } + } + + override fun onSelectedTunnelChanged( + oldTunnel: ObservableTunnel?, + newTunnel: ObservableTunnel? + ): Boolean { + val fragmentManager = supportFragmentManager + if (fragmentManager.isStateSaved) { + return false + } + + val backStackEntries = fragmentManager.backStackEntryCount + if (newTunnel == null) { + // Clear everything off the back stack (all editors and detail fragments). + fragmentManager.popBackStackImmediate(0, FragmentManager.POP_BACK_STACK_INCLUSIVE) + return true + } + if (backStackEntries == 2) { + // Pop the editor off the back stack to reveal the detail fragment. Use the immediate + // method to avoid the editor picking up the new tunnel while it is still visible. + fragmentManager.popBackStackImmediate() + } else if (backStackEntries == 0) { + // Create and show a new detail fragment. + fragmentManager.commit { + add(R.id.detail_container, TunnelDetailFragment()) + setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + addToBackStack(null) + } + } + return true + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.kt b/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.kt new file mode 100644 index 00000000..bd6e1f78 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/SettingsActivity.kt @@ -0,0 +1,107 @@ +/* + * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.activity + +import android.content.ComponentName +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.service.quicksettings.TileService +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import com.wireguard.android.Application +import com.wireguard.android.QuickTileService +import com.wireguard.android.R +import com.wireguard.android.backend.WgQuickBackend +import com.wireguard.android.preference.PreferencesPreferenceDataStore +import com.wireguard.android.util.AdminKnobs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Interface for changing application-global persistent settings. + */ +class SettingsActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (supportFragmentManager.findFragmentById(android.R.id.content) == null) { + supportFragmentManager.commit { + add(android.R.id.content, SettingsFragment()) + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finish() + return true + } + return super.onOptionsItemSelected(item) + } + + class SettingsFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, key: String?) { + preferenceManager.preferenceDataStore = PreferencesPreferenceDataStore(lifecycleScope, Application.getPreferencesDataStore()) + addPreferencesFromResource(R.xml.preferences) + preferenceScreen.initialExpandedChildrenCount = 5 + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || QuickTileService.isAdded) { + val quickTile = preferenceManager.findPreference<Preference>("quick_tile") + quickTile?.parent?.removePreference(quickTile) + --preferenceScreen.initialExpandedChildrenCount + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val darkTheme = preferenceManager.findPreference<Preference>("dark_theme") + darkTheme?.parent?.removePreference(darkTheme) + --preferenceScreen.initialExpandedChildrenCount + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + val remoteApps = preferenceManager.findPreference<Preference>("allow_remote_control_intents") + remoteApps?.parent?.removePreference(remoteApps) + } + if (AdminKnobs.disableConfigExport) { + val zipExporter = preferenceManager.findPreference<Preference>("zip_exporter") + zipExporter?.parent?.removePreference(zipExporter) + } + val wgQuickOnlyPrefs = arrayOf( + preferenceManager.findPreference("tools_installer"), + preferenceManager.findPreference("restore_on_boot"), + preferenceManager.findPreference<Preference>("multiple_tunnels") + ).filterNotNull() + wgQuickOnlyPrefs.forEach { it.isVisible = false } + lifecycleScope.launch { + if (Application.getBackend() is WgQuickBackend) { + ++preferenceScreen.initialExpandedChildrenCount + wgQuickOnlyPrefs.forEach { it.isVisible = true } + } else { + wgQuickOnlyPrefs.forEach { it.parent?.removePreference(it) } + } + } + preferenceManager.findPreference<Preference>("log_viewer")?.setOnPreferenceClickListener { + startActivity(Intent(requireContext(), LogViewerActivity::class.java)) + true + } + val kernelModuleEnabler = preferenceManager.findPreference<Preference>("kernel_module_enabler") + if (WgQuickBackend.hasKernelSupport()) { + lifecycleScope.launch { + if (Application.getBackend() !is WgQuickBackend) { + try { + withContext(Dispatchers.IO) { Application.getRootShell().start() } + } catch (_: Throwable) { + kernelModuleEnabler?.parent?.removePreference(kernelModuleEnabler) + } + } + } + } else { + kernelModuleEnabler?.parent?.removePreference(kernelModuleEnabler) + } + } + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.kt new file mode 100644 index 00000000..bdf798ca --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/TunnelCreatorActivity.kt @@ -0,0 +1,29 @@ +/* + * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.activity + +import android.os.Bundle +import androidx.fragment.app.commit +import com.wireguard.android.fragment.TunnelEditorFragment +import com.wireguard.android.model.ObservableTunnel + +/** + * Standalone activity for creating tunnels. + */ +class TunnelCreatorActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (supportFragmentManager.findFragmentById(android.R.id.content) == null) { + supportFragmentManager.commit { + add(android.R.id.content, TunnelEditorFragment()) + } + } + } + + override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?): Boolean { + finish() + return true + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt new file mode 100644 index 00000000..59b9349f --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/TunnelToggleActivity.kt @@ -0,0 +1,66 @@ +/* + * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.wireguard.android.activity + +import android.content.ComponentName +import android.os.Build +import android.os.Bundle +import android.service.quicksettings.TileService +import android.util.Log +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.wireguard.android.Application +import com.wireguard.android.QuickTileService +import com.wireguard.android.R +import com.wireguard.android.backend.GoBackend +import com.wireguard.android.backend.Tunnel +import com.wireguard.android.util.ErrorMessages +import kotlinx.coroutines.launch + +@RequiresApi(Build.VERSION_CODES.N) +class TunnelToggleActivity : AppCompatActivity() { + private val permissionActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { toggleTunnelWithPermissionsResult() } + + private fun toggleTunnelWithPermissionsResult() { + val tunnel = Application.getTunnelManager().lastUsedTunnel ?: return + lifecycleScope.launch { + try { + tunnel.setStateAsync(Tunnel.State.TOGGLE) + } catch (e: Throwable) { + TileService.requestListeningState(this@TunnelToggleActivity, ComponentName(this@TunnelToggleActivity, QuickTileService::class.java)) + val error = ErrorMessages[e] + val message = getString(R.string.toggle_error, error) + Log.e(TAG, message, e) + Toast.makeText(this@TunnelToggleActivity, message, Toast.LENGTH_LONG).show() + finishAffinity() + return@launch + } + TileService.requestListeningState(this@TunnelToggleActivity, ComponentName(this@TunnelToggleActivity, QuickTileService::class.java)) + finishAffinity() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launch { + if (Application.getBackend() is GoBackend) { + val intent = GoBackend.VpnService.prepare(this@TunnelToggleActivity) + if (intent != null) { + permissionActivityResultLauncher.launch(intent) + return@launch + } + } + toggleTunnelWithPermissionsResult() + } + } + + companion object { + private const val TAG = "WireGuard/TunnelToggleActivity" + } +} diff --git a/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt new file mode 100644 index 00000000..4c86b4c8 --- /dev/null +++ b/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt @@ -0,0 +1,445 @@ +/* + * Copyright © 2017-2023 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.activity + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.os.storage.StorageManager +import android.os.storage.StorageVolume +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.activity.addCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.core.view.forEach +import androidx.databinding.DataBindingUtil +import androidx.databinding.Observable +import androidx.databinding.ObservableBoolean +import androidx.databinding.ObservableField +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.wireguard.android.Application +import com.wireguard.android.R +import com.wireguard.android.backend.GoBackend +import com.wireguard.android.backend.Tunnel +import com.wireguard.android.databinding.Keyed +import com.wireguard.android.databinding.ObservableKeyedArrayList +import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter +import com.wireguard.android.databinding.TvActivityBinding +import com.wireguard.android.databinding.TvFileListItemBinding +import com.wireguard.android.databinding.TvTunnelListItemBinding +import com.wireguard.android.model.ObservableTunnel +import com.wireguard.android.util.ErrorMessages +import com.wireguard.android.util.QuantityFormatter +import com.wireguard.android.util.TunnelImporter +import com.wireguard.android.util.UserKnobs +import com.wireguard.android.util.applicationScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +class TvMainActivity : AppCompatActivity() { + private val tunnelFileImportResultLauncher = registerForActivityResult(object : ActivityResultContracts.GetContent() { + override fun createIntent(context: Context, input: String): Intent { + val intent = super.createIntent(context, input) + + /* 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. */ + val activitiesToResolveIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())) + } else { + @Suppress("DEPRECATION") + context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + } + if (activitiesToResolveIntent.all { + val name = it.activityInfo.packageName + name.startsWith("com.google.android.tv.frameworkpackagestubs") || name.startsWith("com.android.tv.frameworkpackagestubs") + }) { + throw ActivityNotFoundException() + } + return intent + } + }) { data -> + if (data == null) return@registerForActivityResult + lifecycleScope.launch { + TunnelImporter.importTunnel(contentResolver, data) { + Toast.makeText(this@TvMainActivity, it, Toast.LENGTH_LONG).show() + } + } + } + private var pendingTunnel: ObservableTunnel? = null + private val permissionActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + val tunnel = pendingTunnel + if (tunnel != null) + setTunnelStateWithPermissionsResult(tunnel) + pendingTunnel = null + } + + private fun setTunnelStateWithPermissionsResult(tunnel: ObservableTunnel) { + lifecycleScope.launch { + try { + tunnel.setStateAsync(Tunnel.State.TOGGLE) + } catch (e: Throwable) { + val error = ErrorMessages[e] + val message = getString(R.string.error_up, error) + Toast.makeText(this@TvMainActivity, message, Toast.LENGTH_LONG).show() + Log.e(TAG, message, e) + } + updateStats() + } + } + + private lateinit var binding: TvActivityBinding + private val isDeleting = ObservableBoolean() + private val files = ObservableKeyedArrayList<String, KeyedFile>() + private val filesRoot = ObservableField("") + + override fun onCreate(savedInstanceState: Bundle?) { + if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_YES) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + applicationScope.launch { + UserKnobs.setDarkTheme(true) + } + } + } + super.onCreate(savedInstanceState) + binding = TvActivityBinding.inflate(layoutInflater) + lifecycleScope.launch { + binding.tunnels = Application.getTunnelManager().getTunnels() + if (binding.tunnels?.isEmpty() == true) + binding.importButton.requestFocus() + else + binding.tunnelList.requestFocus() + } + binding.isDeleting = isDeleting + binding.files = files + binding.filesRoot = filesRoot + val gridManager = binding.tunnelList.layoutManager as GridLayoutManager + gridManager.spanSizeLookup = SlatedSpanSizeLookup(gridManager) + binding.tunnelRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TvTunnelListItemBinding, ObservableTunnel> { + override fun onConfigureRow(binding: TvTunnelListItemBinding, item: ObservableTunnel, position: Int) { + binding.isDeleting = isDeleting + binding.isFocused = ObservableBoolean() + binding.root.setOnFocusChangeListener { _, focused -> + binding.isFocused?.set(focused) + } + binding.root.setOnClickListener { + lifecycleScope.launch { + if (isDeleting.get()) { + try { + item.deleteAsync() + if (this@TvMainActivity.binding.tunnels?.isEmpty() != false) + isDeleting.set(false) + } catch (e: Throwable) { + val error = ErrorMessages[e] + val message = getString(R.string.config_delete_error, error) + Toast.makeText(this@TvMainActivity, message, Toast.LENGTH_LONG).show() + Log.e(TAG, message, e) + } + } else { + if (Application.getBackend() is GoBackend) { + val intent = GoBackend.VpnService.prepare(binding.root.context) + if (intent != null) { + pendingTunnel = item + permissionActivityResultLauncher.launch(intent) + return@launch + } + } + setTunnelStateWithPermissionsResult(item) + } + } + } + } + } + + binding.filesRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TvFileListItemBinding, KeyedFile> { + override fun onConfigureRow(binding: TvFileListItemBinding, item: KeyedFile, position: Int) { + binding.root.setOnClickListener { + if (item.file.isDirectory) + navigateTo(item.file) + else { + val uri = Uri.fromFile(item.file) + files.clear() + filesRoot.set("") + lifecycleScope.launch { + TunnelImporter.importTunnel(contentResolver, uri) { + Toast.makeText(this@TvMainActivity, it, Toast.LENGTH_LONG).show() + } + } + runOnUiThread { + this@TvMainActivity.binding.tunnelList.requestFocus() + } + } + } + } + } + + binding.importButton.setOnClickListener { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + if (filesRoot.get()?.isEmpty() != false) { + navigateTo(File("/")) + runOnUiThread { + binding.filesList.requestFocus() + } + } else { + files.clear() + filesRoot.set("") + runOnUiThread { + binding.tunnelList.requestFocus() + } + } + } else { + try { + tunnelFileImportResultLauncher.launch("*/*") + } catch (_: Throwable) { + MaterialAlertDialogBuilder(binding.root.context).setMessage(R.string.tv_no_file_picker).setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> + try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://webstoreredirect"))) + } catch (_: Throwable) { + } + }.show() + } + } + } + + binding.deleteButton.setOnClickListener { + isDeleting.set(!isDeleting.get()) + runOnUiThread { + binding.tunnelList.requestFocus() + } + } + + val backPressedCallback = onBackPressedDispatcher.addCallback(this) { handleBackPressed() } + val updateBackPressedCallback = object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable?, propertyId: Int) { + backPressedCallback.isEnabled = isDeleting.get() || filesRoot.get()?.isNotEmpty() == true + } + } + isDeleting.addOnPropertyChangedCallback(updateBackPressedCallback) + filesRoot.addOnPropertyChangedCallback(updateBackPressedCallback) + backPressedCallback.isEnabled = false + + binding.executePendingBindings() + setContentView(binding.root) + + lifecycleScope.launch { + while (true) { + updateStats() + delay(1000) + } + } + } + + private var pendingNavigation: File? = null + private val permissionRequestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { + val to = pendingNavigation + if (it && to != null) + navigateTo(to) + pendingNavigation = null + } + + private var cachedRoots: Collection<KeyedFile>? = null + + private suspend fun makeStorageRoots(): Collection<KeyedFile> = withContext(Dispatchers.IO) { + cachedRoots?.let { return@withContext it } + val list = HashSet<KeyedFile>() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val storageManager: StorageManager = getSystemService() ?: return@withContext list + list.addAll(storageManager.storageVolumes.mapNotNull { volume -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + volume.directory?.let { KeyedFile(it, volume.getDescription(this@TvMainActivity)) } + } else { + KeyedFile((StorageVolume::class.java.getMethod("getPathFile").invoke(volume) as File), volume.getDescription(this@TvMainActivity)) + } + }) + } else { + @Suppress("DEPRECATION") + list.add(KeyedFile(Environment.getExternalStorageDirectory())) + try { + File("/storage").listFiles()?.forEach { + if (!it.isDirectory) return@forEach + try { + if (Environment.isExternalStorageRemovable(it)) { + list.add(KeyedFile(it)) + } + } catch (_: Throwable) { + } + } + } catch (_: Throwable) { + } + } + cachedRoots = list + list + } + + private fun isBelowCachedRoots(maybeChild: File): Boolean { + val cachedRoots = cachedRoots ?: return true + for (root in cachedRoots) { + if (maybeChild.canonicalPath.startsWith(root.file.canonicalPath)) + return false + } + return true + } + + private fun navigateTo(directory: File) { + require(Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) + + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + pendingNavigation = directory + permissionRequestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + return + } + + lifecycleScope.launch { + if (isBelowCachedRoots(directory)) { + val roots = makeStorageRoots() + if (roots.count() == 1) { + navigateTo(roots.first().file) + return@launch + } + files.clear() + files.addAll(roots) + filesRoot.set(getString(R.string.tv_select_a_storage_drive)) + return@launch + } + + val newFiles = withContext(Dispatchers.IO) { + val newFiles = ArrayList<KeyedFile>() + try { + directory.parentFile?.let { + newFiles.add(KeyedFile(it, "../")) + } + val listing = directory.listFiles() ?: return@withContext null + listing.forEach { + if (it.extension == "conf" || it.extension == "zip" || it.isDirectory) + newFiles.add(KeyedFile(it)) + } + newFiles.sortWith { a, b -> + if (a.file.isDirectory && !b.file.isDirectory) -1 + else if (!a.file.isDirectory && b.file.isDirectory) 1 + else a.file.compareTo(b.file) + } + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + } + newFiles + } + if (newFiles?.isEmpty() != false) + return@launch + files.clear() + files.addAll(newFiles) + filesRoot.set(directory.canonicalPath) + } + } + + private fun handleBackPressed() { + when { + isDeleting.get() -> { + isDeleting.set(false) + runOnUiThread { + binding.tunnelList.requestFocus() + } + } + + filesRoot.get()?.isNotEmpty() == true -> { + files.clear() + filesRoot.set("") + runOnUiThread { + binding.tunnelList.requestFocus() + } + } + } + } + + private suspend fun updateStats() { + binding.tunnelList.forEach { viewItem -> + val listItem = DataBindingUtil.findBinding<TvTunnelListItemBinding>(viewItem) + ?: return@forEach + try { + val tunnel = listItem.item!! + if (tunnel.state != Tunnel.State.UP || isDeleting.get()) { + throw Exception() + } + val statistics = tunnel.getStatisticsAsync() + val rx = statistics.totalRx() + val tx = statistics.totalTx() + listItem.tunnelTransfer.text = getString(R.string.transfer_rx_tx, QuantityFormatter.formatBytes(rx), QuantityFormatter.formatBytes(tx)) + listItem.tunnelTransfer.visibility = View.VISIBLE + } catch (_: Throwable) { + listItem.tunnelTransfer.visibility = View.GONE + listItem.tunnelTransfer.text = "" + } + } + } + + class KeyedFile(val file: File, private val forcedKey: String? = null) : Keyed<String> { + override val key: String + get() = forcedKey ?: if (file.isDirectory) "${file.name}/" else file.name + } + + private class SlatedSpanSizeLookup(private val gridManager: GridLayoutManager) : SpanSizeLookup() { + private val originalHeight = gridManager.spanCount + private var newWidth = 0 + private lateinit var sizeMap: Array<IntArray?> + + private fun emptyUnderIndex(index: Int, size: Int): Int { + sizeMap[size - 1]?.let { return it[index] } + val sizes = IntArray(size) + val oh = originalHeight + val nw = newWidth + var empties = 0 + for (i in 0 until size) { + val ox = (i + empties) / oh + val oy = (i + empties) % oh + var empty = 0 + for (j in oy + 1 until oh) { + val ni = nw * j + ox + if (ni < size) + break + empty++ + } + empties += empty + sizes[i] = empty + } + sizeMap[size - 1] = sizes + return sizes[index] + } + + override fun getSpanSize(position: Int): Int { + if (newWidth == 0) { + val child = gridManager.getChildAt(0) ?: return 1 + if (child.width == 0) return 1 + newWidth = gridManager.width / child.width + sizeMap = Array(originalHeight * newWidth - 1) { null } + } + val total = gridManager.itemCount + if (total >= originalHeight * newWidth || total == 0) + return 1 + return emptyUnderIndex(position, total) + 1 + } + } + + companion object { + private const val TAG = "WireGuard/TvMainActivity" + } +} |