diff options
| author | Albin <albin@mullvad.net> | 2021-10-13 18:14:45 +0200 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2021-12-16 11:38:25 +0100 |
| commit | 6c4d31dfc59772e9a80d0260fee34cf03d5c05f5 (patch) | |
| tree | 8418020b852073a6dfb0c4c44fabdb1586404d11 /android/app/src | |
| parent | 4dcdb1306d23a8e831389130fdef452cae4dc809 (diff) | |
| download | mullvadvpn-6c4d31dfc59772e9a80d0260fee34cf03d5c05f5.tar.xz mullvadvpn-6c4d31dfc59772e9a80d0260fee34cf03d5c05f5.zip | |
Split Android project and app module
The purpose of this is to:
* Comply better with the default Android project structure
(see https://developer.android.com/studio/build).
* Avoid conflicts between project and app dependencies and plugins.
Diffstat (limited to 'android/app/src')
427 files changed, 22552 insertions, 0 deletions
diff --git a/android/app/src/androidTest/AndroidManifest.xml b/android/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..41b8daf8c8 --- /dev/null +++ b/android/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="net.mullvad.mullvadvpn.test"> + + <!-- Required on certain Android versions and/or ABIs + https://github.com/mockk/mockk/issues/297#issuecomment-641361770 --> + <application android:extractNativeLibs="true" /> +</manifest> diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/RecyclerViewMatcher.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/RecyclerViewMatcher.kt new file mode 100644 index 0000000000..2e87df9720 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/RecyclerViewMatcher.kt @@ -0,0 +1,52 @@ +package net.mullvad.mullvadvpn + +import android.content.res.Resources +import android.content.res.Resources.NotFoundException +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher + +class RecyclerViewMatcher(private val recyclerViewId: Int) { + fun atPosition(position: Int): Matcher<View> { + return atPositionOnView(position) + } + + fun atPositionOnView(position: Int, targetViewId: Int? = null): Matcher<View> = + object : TypeSafeMatcher<View>() { + var resources: Resources? = null + var childView: View? = null + + override fun describeTo(description: Description) { + val idDescription = resources?.let { + try { + it.getResourceName(recyclerViewId) + } catch (var4: NotFoundException) { + "$recyclerViewId (resource name not found)" + } + } ?: recyclerViewId.toString() + description.appendText("with id: $idDescription") + } + + override fun matchesSafely(view: View): Boolean { + resources = view.resources + val recyclerView = + view.rootView.findViewById<View>(recyclerViewId) as RecyclerView? + if (recyclerView == null || recyclerView.id != recyclerViewId) { + return false + } + childView = recyclerView.findViewHolderForAdapterPosition(position)?.itemView + val targetView = targetViewId?.let { id -> + childView?.findViewById<View>(id) + } ?: childView + return view == targetView + } + } + + companion object { + fun withRecyclerView(recyclerViewId: Int): RecyclerViewMatcher { + return RecyclerViewMatcher(recyclerViewId) + } + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ipc/HandlerFlowTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ipc/HandlerFlowTest.kt new file mode 100644 index 0000000000..709f330b0d --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ipc/HandlerFlowTest.kt @@ -0,0 +1,48 @@ +package net.mullvad.mullvadvpn.ipc + +import android.os.Bundle +import android.os.Looper +import android.os.Message +import android.os.Parcelable +import kotlin.test.assertEquals +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.parcelize.Parcelize +import org.junit.Test + +class HandlerFlowTest { + val looper by lazy { Looper.getMainLooper() } + + val handler: HandlerFlow<Data?> by lazy { + HandlerFlow(looper) { message -> + message.data.getParcelable(DATA_KEY) + } + } + + @Test + fun test_message_extraction() { + sendMessage(Data(1)) + sendMessage(Data(2)) + sendMessage(Data(3)) + + val extractedData = runBlocking { handler.take(3).toList() } + + assertEquals(listOf(Data(1), Data(2), Data(3)), extractedData) + } + + private fun sendMessage(messageData: Data) { + val message = Message().apply { + data = Bundle().apply { putParcelable(DATA_KEY, messageData) } + } + + handler.handleMessage(message) + } + + companion object { + const val DATA_KEY = "data" + + @Parcelize + data class Data(val id: Int) : Parcelable + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragmentTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragmentTest.kt new file mode 100644 index 0000000000..8bd3cc70b8 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragmentTest.kt @@ -0,0 +1,145 @@ +package net.mullvad.mullvadvpn.ui.fragments + +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.lifecycle.Lifecycle +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerifyAll +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkClass +import io.mockk.unmockkAll +import io.mockk.verifyAll +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.runBlocking +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.RecyclerViewMatcher.Companion.withRecyclerView +import net.mullvad.mullvadvpn.applist.ViewIntent +import net.mullvad.mullvadvpn.di.APPS_SCOPE +import net.mullvad.mullvadvpn.model.ListItemData +import net.mullvad.mullvadvpn.model.WidgetState +import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.qualifier.named +import org.koin.core.scope.Scope +import org.koin.test.KoinTest +import org.koin.test.mock.MockProviderRule +import org.koin.test.mock.declareMock + +@RunWith(AndroidJUnit4::class) +@LargeTest +class SplitTunnelingFragmentTest : KoinTest { + + private val mockedViewModel = mockk<SplitTunnelingViewModel>(relaxUnitFun = true) + private val sharedFlow = MutableSharedFlow<List<ListItemData>>() + private val scope: Scope = getKoin().getOrCreateScope(APPS_SCOPE, named(APPS_SCOPE)) + + @get:Rule + val mockProvider = MockProviderRule.create { clazz -> + when (clazz) { + SplitTunnelingViewModel::class -> mockedViewModel + else -> mockkClass(clazz) + } + } + + @Before + fun setUp() { + scope.declareMock<SplitTunnelingViewModel>() + every { mockedViewModel.listItems } returns sharedFlow + coEvery { mockedViewModel.processIntent(ViewIntent.ViewIsReady) } just Runs + } + + @After + fun tearDown() { + scope.close() + unmockkAll() + } + + @Test + fun test_fragment_title() { + launchFragmentInContainer<SplitTunnelingFragment>(themeResId = R.style.AppTheme) + + onView(withId(R.id.collapsing_toolbar)) + .check(matches(withContentDescription("Split tunneling"))) + } + + @Test + fun test_fragment_loading() { + val scenario = launchFragmentInContainer<SplitTunnelingFragment>( + themeResId = R.style.AppTheme, initialState = Lifecycle.State.CREATED + ) + scenario.moveToState(Lifecycle.State.RESUMED) + sharedFlow.tryEmit(emptyList()) + + verifyAll { + mockedViewModel.listItems + } + } + + @Test + fun test_fragment_list_displayed() = runBlocking { + launchFragmentInContainer<SplitTunnelingFragment>( + themeResId = R.style.AppTheme, initialState = Lifecycle.State.RESUMED + ) + + sharedFlow.emit( + listOf( + ListItemData.build("testItem") { + type = ListItemData.PLAIN + text = "Test Item" + action = ListItemData.ItemAction(text.toString()) + } + ) + ) + + onView(withRecyclerView(R.id.recyclerView).atPositionOnView(0, R.id.plain_text)) + .check(matches(withText("Test Item"))) + + verifyAll { + mockedViewModel.listItems + } + } + + @Test + fun test_fragment_list_click_application_item() = runBlocking { + val testListItem = ListItemData.build("test.package.name") { + type = ListItemData.APPLICATION + text = "Test App Name" + action = ListItemData.ItemAction("test.package.name") + widget = WidgetState.ImageState(R.drawable.ic_icons_add) + } + + coEvery { + mockedViewModel.processIntent(ViewIntent.ChangeApplicationGroup(testListItem)) + } just Runs + + launchFragmentInContainer<SplitTunnelingFragment>( + themeResId = R.style.AppTheme, initialState = Lifecycle.State.RESUMED + ) + + sharedFlow.emit(listOf(testListItem)) + + onView(withRecyclerView(R.id.recyclerView).atPositionOnView(0, R.id.itemText)) + .check(matches(withText("Test App Name"))) + + onView(withRecyclerView(R.id.recyclerView).atPosition(0)).perform(click()) + + coVerifyAll { + mockedViewModel.listItems + mockedViewModel.processIntent(ViewIntent.ChangeApplicationGroup(testListItem)) + } + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..66edb7dc33 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,78 @@ +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + package="net.mullvad.mullvadvpn"> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> + <uses-feature android:name="android.hardware.touchscreen" + android:required="false" /> + <uses-feature android:name="android.hardware.faketouch" + android:required="false" /> + <uses-feature android:name="android.hardware.screen.portrait" + android:required="false" /> + <uses-feature android:name="android.hardware.screen.landscape" + android:required="false" /> + <uses-feature android:name="android.software.leanback" + android:required="false" /> + <application android:label="@string/app_name" + android:icon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/ic_launcher" + android:theme="@style/AppTheme" + android:extractNativeLibs="true" + android:allowBackup="false" + android:banner="@drawable/banner" + android:name=".MullvadApplication" + tools:ignore="GoogleAppIndexingWarning"> + <activity android:name="net.mullvad.mullvadvpn.ui.MainActivity" + android:label="@string/app_name" + android:launchMode="singleTask" + android:configChanges="orientation|screenSize|screenLayout" + android:screenOrientation="sensorPortrait" + android:windowSoftInputMode="adjustPan"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + <intent-filter> + <action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" /> + </intent-filter> + </activity> + <activity android:name="net.mullvad.mullvadvpn.ui.activities.TVActivity" + android:label="@string/app_name" + android:launchMode="singleTask" + android:configChanges="orientation|screenSize|screenLayout" + android:screenOrientation="sensor" + android:windowSoftInputMode="adjustPan"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> + </intent-filter> + </activity> + <service android:name="net.mullvad.mullvadvpn.service.MullvadVpnService" + android:permission="android.permission.BIND_VPN_SERVICE" + android:process=":mullvadvpn_daemon"> + <intent-filter> + <action android:name="android.net.VpnService" /> + </intent-filter> + <intent-filter> + <action android:name="net.mullvad.mullvadvpn.connect_action" /> + </intent-filter> + <intent-filter> + <action android:name="net.mullvad.mullvadvpn.disconnect_action" /> + </intent-filter> + <intent-filter> + <action android:name="net.mullvad.mullvadvpn.quit_action" /> + </intent-filter> + </service> + <service android:name="net.mullvad.mullvadvpn.service.MullvadTileService" + android:label="@string/app_name" + android:icon="@drawable/small_logo_black" + android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" + android:process=":mullvadvpn_tile"> + <intent-filter> + <action android:name="android.service.quicksettings.action.QS_TILE" /> + </intent-filter> + </service> + </application> +</manifest> diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/MullvadApplication.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/MullvadApplication.kt new file mode 100644 index 0000000000..0c3a0d8640 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/MullvadApplication.kt @@ -0,0 +1,20 @@ +package net.mullvad.mullvadvpn + +import android.app.Application +import net.mullvad.mullvadvpn.di.appModule +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin + +class MullvadApplication : Application() { + + override fun onCreate() { + super.onCreate() + // start Koin! + startKoin { + // declare used Android context + androidContext(this@MullvadApplication) + // declare modules + modules(appModule) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppData.kt new file mode 100644 index 0000000000..ec5912c244 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppData.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.applist + +data class AppData( + val packageName: String, + val iconRes: Int, + val name: String, + val isSystemApp: Boolean = false +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManager.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManager.kt new file mode 100644 index 0000000000..ebfbc1f379 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManager.kt @@ -0,0 +1,27 @@ +package net.mullvad.mullvadvpn.applist + +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.os.Looper +import androidx.annotation.WorkerThread +import androidx.collection.LruCache + +class ApplicationsIconManager(private val packageManager: PackageManager) { + private val iconsCache = LruCache<String, Drawable>(500) + + @WorkerThread + @Throws(PackageManager.NameNotFoundException::class) + fun getAppIcon(packageName: String): Drawable { + check(!Looper.getMainLooper().isCurrentThread) { "Should not be called from MainThread" } + iconsCache.get(packageName)?.let { + return it + } + return packageManager.getApplicationIcon(packageName).also { + iconsCache.put(packageName, it) + } + } + + fun dispose() { + iconsCache.evictAll() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProvider.kt new file mode 100644 index 0000000000..774392f1a6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProvider.kt @@ -0,0 +1,44 @@ +package net.mullvad.mullvadvpn.applist + +import android.Manifest +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager + +class ApplicationsProvider( + private val packageManager: PackageManager, + private val thisPackageName: String +) { + private val applicationFilterPredicate: (ApplicationInfo) -> Boolean = { appInfo -> + hasInternetPermission(appInfo.packageName) && + !isSelfApplication(appInfo.packageName) + } + + fun getAppsList(): List<AppData> { + return packageManager.getInstalledApplications(PackageManager.GET_META_DATA) + .asSequence() + .filter(applicationFilterPredicate) + .map { info -> + AppData( + info.packageName, + info.icon, + info.loadLabel(packageManager).toString(), + !isLaunchable(info.packageName) + ) + } + .toList() + } + + private fun hasInternetPermission(packageName: String): Boolean { + return PackageManager.PERMISSION_GRANTED == + packageManager.checkPermission(Manifest.permission.INTERNET, packageName) + } + + private fun isLaunchable(packageName: String): Boolean { + return packageManager.getLaunchIntentForPackage(packageName) != null || + packageManager.getLeanbackLaunchIntentForPackage(packageName) != null + } + + private fun isSelfApplication(packageName: String): Boolean { + return packageName == thisPackageName + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ViewIntent.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ViewIntent.kt new file mode 100644 index 0000000000..47391f2971 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ViewIntent.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.applist + +import net.mullvad.mullvadvpn.model.ListItemData + +sealed class ViewIntent { + // In future we will have search intent + data class ChangeApplicationGroup(val item: ListItemData) : ViewIntent() + object ViewIsReady : ViewIntent() + data class ShowSystemApps(internal val show: Boolean) : ViewIntent() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt new file mode 100644 index 0000000000..52795f0964 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt @@ -0,0 +1,137 @@ +package net.mullvad.mullvadvpn.dataproxy + +import java.io.File +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.channels.sendBlocking + +const val PROBLEM_REPORT_FILE = "problem_report.txt" + +class MullvadProblemReport { + private sealed class Command { + class Collect() : Command() + class Load(val logs: CompletableDeferred<String>) : Command() + class Send(val result: CompletableDeferred<Boolean>) : Command() + class Delete() : Command() + } + + val logDirectory = CompletableDeferred<File>() + val cacheDirectory = CompletableDeferred<File>() + + private val commandChannel = spawnActor() + + private val problemReportPath = GlobalScope.async(Dispatchers.Default) { + File(logDirectory.await(), PROBLEM_REPORT_FILE) + } + + private var isCollected = false + + var confirmNoEmail: CompletableDeferred<Boolean>? = null + + var userEmail = "" + var userMessage = "" + + init { + System.loadLibrary("mullvad_jni") + } + + fun collect() { + commandChannel.sendBlocking(Command.Collect()) + } + + suspend fun load(): String { + val logs = CompletableDeferred<String>() + + commandChannel.send(Command.Load(logs)) + + return logs.await() + } + + fun send(): Deferred<Boolean> { + val result = CompletableDeferred<Boolean>() + + commandChannel.sendBlocking(Command.Send(result)) + + return result + } + + fun deleteReportFile() { + commandChannel.sendBlocking(Command.Delete()) + } + + private fun spawnActor() = GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) { + try { + while (true) { + val command = channel.receive() + + when (command) { + is Command.Collect -> doCollect() + is Command.Load -> command.logs.complete(doLoad()) + is Command.Send -> command.result.complete(doSend()) + is Command.Delete -> doDelete() + } + } + } catch (exception: ClosedReceiveChannelException) { + } + } + + private suspend fun doCollect() { + val logDirectoryPath = logDirectory.await().absolutePath + val reportPath = problemReportPath.await().absolutePath + + doDelete() + + isCollected = collectReport(logDirectoryPath, reportPath) + } + + private suspend fun doLoad(): String { + if (!isCollected) { + doCollect() + } + + if (isCollected) { + return problemReportPath.await().readText() + } else { + return "Failed to collect logs for problem report" + } + } + + private suspend fun doSend(): Boolean { + if (!isCollected) { + doCollect() + } + + val result = isCollected && + sendProblemReport( + userEmail, + userMessage, + problemReportPath.await().absolutePath, + cacheDirectory.await().absolutePath + ) + + if (result) { + doDelete() + } + + return result + } + + private suspend fun doDelete() { + problemReportPath.await().delete() + isCollected = false + } + + private external fun collectReport(logDirectory: String, reportPath: String): Boolean + private external fun sendProblemReport( + userEmail: String, + userMessage: String, + reportPath: String, + cacheDirectory: String + ): Boolean +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt new file mode 100644 index 0000000000..03b8c1f09a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt @@ -0,0 +1,36 @@ +package net.mullvad.mullvadvpn.di + +import android.content.pm.PackageManager +import android.os.Messenger +import kotlinx.coroutines.Dispatchers +import net.mullvad.mullvadvpn.applist.ApplicationsIconManager +import net.mullvad.mullvadvpn.applist.ApplicationsProvider +import net.mullvad.mullvadvpn.ipc.EventDispatcher +import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling +import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.koin.dsl.onClose + +val appModule = module { + + single<PackageManager> { androidContext().packageManager } + single<String> (named(SELF_PACKAGE_NAME)) { androidContext().packageName } + + scope(named(APPS_SCOPE)) { + viewModel { SplitTunnelingViewModel(get(), get(), Dispatchers.Default) } + scoped { ApplicationsIconManager(get()) } onClose { it?.dispose() } + scoped { ApplicationsProvider(get(), get(named(SELF_PACKAGE_NAME))) } + } + + scope(named(SERVICE_CONNECTION_SCOPE)) { + scoped<SplitTunneling> { (messenger: Messenger, dispatcher: EventDispatcher) -> + SplitTunneling(messenger, dispatcher) + } + } +} +const val APPS_SCOPE = "APPS_SCOPE" +const val SERVICE_CONNECTION_SCOPE = "SERVICE_CONNECTION_SCOPE" +const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/DispatchingHandler.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/DispatchingHandler.kt new file mode 100644 index 0000000000..93c79a1ab9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/DispatchingHandler.kt @@ -0,0 +1,48 @@ +package net.mullvad.mullvadvpn.ipc + +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.util.Log +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.withLock +import kotlin.reflect.KClass + +class DispatchingHandler<T : Any>( + looper: Looper, + private val extractor: (Message) -> T? +) : Handler(looper), MessageDispatcher<T> { + private val handlers = HashMap<KClass<out T>, (T) -> Unit>() + private val lock = ReentrantReadWriteLock() + + override fun <V : T> registerHandler(variant: KClass<V>, handler: (V) -> Unit) { + lock.writeLock().withLock { + handlers.put(variant) { instance -> + @Suppress("UNCHECKED_CAST") + handler(instance as V) + } + } + } + + override fun handleMessage(message: Message) { + lock.readLock().withLock { + val instance = extractor(message) + + if (instance != null) { + val handler = handlers.get(instance::class) + + handler?.invoke(instance) + } else { + Log.e("mullvad", "Dispatching handler received an unexpected message") + } + } + } + + fun onDestroy() { + lock.writeLock().withLock { + handlers.clear() + } + + removeCallbacksAndMessages(null) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Event.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Event.kt new file mode 100644 index 0000000000..a5854232a8 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Event.kt @@ -0,0 +1,71 @@ +package net.mullvad.mullvadvpn.ipc + +import android.os.Message as RawMessage +import android.os.Messenger +import kotlinx.parcelize.Parcelize +import net.mullvad.mullvadvpn.model.AppVersionInfo as AppVersionInfoData +import net.mullvad.mullvadvpn.model.GeoIpLocation +import net.mullvad.mullvadvpn.model.KeygenEvent +import net.mullvad.mullvadvpn.model.LoginStatus as LoginStatusData +import net.mullvad.mullvadvpn.model.RelayList +import net.mullvad.mullvadvpn.model.Settings +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.model.VoucherSubmissionResult as VoucherSubmissionResultData + +// Events that can be sent from the service +sealed class Event : Message.EventMessage() { + protected override val messageKey = MESSAGE_KEY + + @Parcelize + data class AccountHistory(val history: String?) : Event() + + @Parcelize + data class AppVersionInfo(val versionInfo: AppVersionInfoData?) : Event() + + @Parcelize + data class AuthToken(val token: String?) : Event() + + @Parcelize + data class CurrentVersion(val version: String?) : Event() + + @Parcelize + data class ListenerReady(val connection: Messenger, val listenerId: Int) : Event() + + @Parcelize + data class LoginStatus(val status: LoginStatusData?) : Event() + + @Parcelize + data class NewLocation(val location: GeoIpLocation?) : Event() + + @Parcelize + data class NewRelayList(val relayList: RelayList?) : Event() + + @Parcelize + data class SettingsUpdate(val settings: Settings?) : Event() + + @Parcelize + data class SplitTunnelingUpdate(val excludedApps: List<String>?) : Event() + + @Parcelize + data class TunnelStateChange(val tunnelState: TunnelState) : Event() + + @Parcelize + data class VoucherSubmissionResult( + val voucher: String, + val result: VoucherSubmissionResultData + ) : Event() + + @Parcelize + object VpnPermissionRequest : Event() + + @Parcelize + data class WireGuardKeyStatus(val keyStatus: KeygenEvent?) : Event() + + companion object { + private const val MESSAGE_KEY = "event" + + fun fromMessage(message: RawMessage): Event? = Message.fromMessage(message, MESSAGE_KEY) + } +} + +typealias EventDispatcher = MessageDispatcher<Event> diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/HandlerFlow.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/HandlerFlow.kt new file mode 100644 index 0000000000..943c55eeff --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/HandlerFlow.kt @@ -0,0 +1,45 @@ +package net.mullvad.mullvadvpn.ipc + +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedSendChannelException +import kotlinx.coroutines.channels.sendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.onCompletion + +class HandlerFlow<T>( + looper: Looper, + private val extractor: (Message) -> T +) : Handler(looper), Flow<T> { + private val channel = Channel<T>(Channel.UNLIMITED) + private val flow = channel.consumeAsFlow().onCompletion { + removeCallbacksAndMessages(null) + } + + @InternalCoroutinesApi + override suspend fun collect(collector: FlowCollector<T>) = flow.collect(collector) + + override fun handleMessage(message: Message) { + val extractedData = extractor(message) + + try { + channel.sendBlocking(extractedData) + } catch (exception: Exception) { + when (exception) { + is ClosedSendChannelException, is CancellationException -> { + Log.w("mullvad", "Received a message after HandlerFlow was closed", exception) + removeCallbacksAndMessages(null) + } + else -> throw exception + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Message.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Message.kt new file mode 100644 index 0000000000..7758f6c926 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Message.kt @@ -0,0 +1,29 @@ +package net.mullvad.mullvadvpn.ipc + +import android.os.Bundle +import android.os.Message as RawMessage +import android.os.Parcelable + +sealed class Message(private val messageId: Int) : Parcelable { + abstract class EventMessage : Message(1) + abstract class RequestMessage : Message(2) + + protected abstract val messageKey: String + + val message: RawMessage + get() = RawMessage.obtain().also { message -> + message.what = messageId + message.data = Bundle() + message.data.putParcelable(messageKey, this) + } + + companion object { + internal fun <T : Parcelable> fromMessage(message: RawMessage, key: String): T? { + val data = message.data + + data.classLoader = Message::class.java.classLoader + + return data.getParcelable(key) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/MessageDispatcher.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/MessageDispatcher.kt new file mode 100644 index 0000000000..8a681b2ce4 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/MessageDispatcher.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.ipc + +import kotlin.reflect.KClass + +interface MessageDispatcher<T : Any> { + fun <V : T> registerHandler(variant: KClass<V>, handler: (V) -> Unit) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Request.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Request.kt new file mode 100644 index 0000000000..27a93b0a2d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Request.kt @@ -0,0 +1,109 @@ +package net.mullvad.mullvadvpn.ipc + +import android.os.Message as RawMessage +import android.os.Messenger +import java.net.InetAddress +import kotlinx.parcelize.Parcelize +import net.mullvad.mullvadvpn.model.LocationConstraint +import org.joda.time.DateTime + +// Requests that the service can handle +sealed class Request : Message.RequestMessage() { + protected override val messageKey = MESSAGE_KEY + + @Parcelize + data class AddCustomDnsServer(val address: InetAddress) : Request() + + @Parcelize + object Connect : Request() + + @Parcelize + object CreateAccount : Request() + + @Parcelize + object Disconnect : Request() + + @Parcelize + data class ExcludeApp(val packageName: String) : Request() + + @Parcelize + object FetchAccountExpiry : Request() + + @Parcelize + object FetchAuthToken : Request() + + @Parcelize + data class IncludeApp(val packageName: String) : Request() + + @Parcelize + data class InvalidateAccountExpiry(val expiry: DateTime) : Request() + + @Parcelize + data class Login(val account: String?) : Request() + + @Parcelize + object Logout : Request() + + @Parcelize + object PersistExcludedApps : Request() + + @Parcelize + object Reconnect : Request() + + @Parcelize + data class RegisterListener(val listener: Messenger) : Request() + + @Parcelize + object ClearAccountHistory : Request() + + @Parcelize + data class RemoveCustomDnsServer(val address: InetAddress) : Request() + + @Parcelize + data class ReplaceCustomDnsServer( + val oldAddress: InetAddress, + val newAddress: InetAddress + ) : Request() + + @Parcelize + data class SetAccount(val account: String?) : Request() + + @Parcelize + data class SetAllowLan(val allow: Boolean) : Request() + + @Parcelize + data class SetAutoConnect(val autoConnect: Boolean) : Request() + + @Parcelize + data class SetEnableCustomDns(val enable: Boolean) : Request() + + @Parcelize + data class SetEnableSplitTunneling(val enable: Boolean) : Request() + + @Parcelize + data class SetRelayLocation(val relayLocation: LocationConstraint?) : Request() + + @Parcelize + data class SetWireGuardMtu(val mtu: Int?) : Request() + + @Parcelize + data class SubmitVoucher(val voucher: String) : Request() + + @Parcelize + data class UnregisterListener(val listenerId: Int) : Request() + + @Parcelize + data class VpnPermissionResponse(val isGranted: Boolean) : Request() + + @Parcelize + object WireGuardGenerateKey : Request() + + @Parcelize + object WireGuardVerifyKey : Request() + + companion object { + private const val MESSAGE_KEY = "request" + + fun fromMessage(message: RawMessage): Request? = Message.fromMessage(message, MESSAGE_KEY) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/ServiceConnection.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/ServiceConnection.kt new file mode 100644 index 0000000000..66ac88c91d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/ServiceConnection.kt @@ -0,0 +1,126 @@ +package net.mullvad.mullvadvpn.ipc + +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.os.Looper +import android.os.Messenger +import kotlin.reflect.KClass +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.model.ServiceResult +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.service.MullvadVpnService +import net.mullvad.mullvadvpn.util.DispatchingFlow +import net.mullvad.mullvadvpn.util.bindServiceFlow +import net.mullvad.mullvadvpn.util.dispatchTo + +@FlowPreview +class ServiceConnection(context: Context, scope: CoroutineScope) { + private val activeListeners = MutableStateFlow<Pair<Messenger, Int>?>(null) + private val handler = HandlerFlow(Looper.getMainLooper(), Event::fromMessage) + private val listener = Messenger(handler) + private val listenerId = MutableStateFlow<Int?>(null) + + private lateinit var listenerRegistrations: StateFlow<Pair<Messenger, Int>?> + + lateinit var tunnelState: Flow<Pair<TunnelState, ServiceResult.ConnectionState>> + private set + + private val serviceConnectionStateChannel = + Channel<ServiceResult.ConnectionState>(Channel.RENDEZVOUS) + + init { + val dispatcher = handler + .filterNotNull() + .dispatchTo { + listenerRegistrations = subscribeToState(Event.ListenerReady::class, scope) { + Pair(connection, listenerId) + } + + val tunnelStateEvents = subscribeToState( + Event.TunnelStateChange::class, + scope, + TunnelState.Disconnected + ) { tunnelState } + + tunnelState = tunnelStateEvents + .combine( + serviceConnectionStateChannel.consumeAsFlow() + ) { tunnelState, serviceConnectionState -> + tunnelState to serviceConnectionState + } + } + + scope.launch { connect(context) } + scope.launch { dispatcher.collect() } + scope.launch { unregisterOldListeners() } + scope.launch { listenerRegistrations.collect { activeListeners.value = it } } + } + + private suspend fun connect(context: Context) { + val intent = Intent(context, MullvadVpnService::class.java) + + context + .bindServiceFlow(intent) + .onStart { emit(ServiceResult.NOT_CONNECTED) } + .onEach { result -> serviceConnectionStateChannel.send(result.connectionState) } + .collect { result -> + activeListeners.value = null + result.binder?.let(::registerListener) + } + } + + private fun registerListener(binder: IBinder) { + val request = Request.RegisterListener(listener) + val messenger = Messenger(binder) + + messenger.send(request.message) + } + + private suspend fun unregisterOldListeners() { + var oldListener: Pair<Messenger, Int>? = null + + activeListeners + .onCompletion { oldListener?.let(::unregisterListener) } + .collect { newListener -> + oldListener?.let(::unregisterListener) + oldListener = newListener + } + } + + private fun unregisterListener(registration: Pair<Messenger, Int>) { + val (messenger, listenerId) = registration + val request = Request.UnregisterListener(listenerId) + + messenger.send(request.message) + } + + private fun <V : Any, D> DispatchingFlow<in V>.subscribeToState( + event: KClass<V>, + scope: CoroutineScope, + dataExtractor: suspend V.() -> D + ) = subscribe(event).map(dataExtractor).stateIn(scope, SharingStarted.Lazily, null) + + private fun <V : Any, D> DispatchingFlow<in V>.subscribeToState( + event: KClass<V>, + scope: CoroutineScope, + initialValue: D, + dataExtractor: suspend V.() -> D + ) = subscribe(event).map(dataExtractor).stateIn(scope, SharingStarted.Lazily, initialValue) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountData.kt new file mode 100644 index 0000000000..6dda6b8352 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountData.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.model + +data class AccountData(val expiry: String) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AppVersionInfo.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AppVersionInfo.kt new file mode 100644 index 0000000000..cc1127d026 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AppVersionInfo.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class AppVersionInfo( + val supported: Boolean, + val suggestedUpgrade: String? +) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Constraint.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Constraint.kt new file mode 100644 index 0000000000..37b98298ab --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Constraint.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class Constraint<T>() : Parcelable { + @Parcelize + @Suppress("PARCELABLE_PRIMARY_CONSTRUCTOR_IS_EMPTY") + class Any<T>() : Constraint<T>() + + @Parcelize + data class Only<T : Parcelable>(val value: T) : Constraint<T>() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomTunnelEndpoint.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomTunnelEndpoint.kt new file mode 100644 index 0000000000..05dd38a80b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomTunnelEndpoint.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.model + +class CustomTunnelEndpoint() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DnsOptions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DnsOptions.kt new file mode 100644 index 0000000000..6cef280b61 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DnsOptions.kt @@ -0,0 +1,9 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import java.net.InetAddress +import java.util.ArrayList +import kotlinx.parcelize.Parcelize + +@Parcelize +data class DnsOptions(val custom: Boolean, val addresses: ArrayList<InetAddress>) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt new file mode 100644 index 0000000000..e15ab20376 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt @@ -0,0 +1,14 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import java.net.InetAddress +import kotlinx.parcelize.Parcelize + +@Parcelize +data class GeoIpLocation( + val ipv4: InetAddress?, + val ipv6: InetAddress?, + val country: String, + val city: String?, + val hostname: String? +) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/GetAccountDataResult.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/GetAccountDataResult.kt new file mode 100644 index 0000000000..cbed622df6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/GetAccountDataResult.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.model + +sealed class GetAccountDataResult { + class Ok(val accountData: AccountData) : GetAccountDataResult() + object InvalidAccount : GetAccountDataResult() + object RpcError : GetAccountDataResult() + object OtherError : GetAccountDataResult() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/KeygenEvent.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/KeygenEvent.kt new file mode 100644 index 0000000000..ced83db74a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/KeygenEvent.kt @@ -0,0 +1,35 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class KeygenEvent : Parcelable { + @Parcelize + class NewKey( + val publicKey: PublicKey, + val verified: Boolean?, + val replacementFailure: KeygenFailure? + ) : KeygenEvent() { + constructor(publicKey: PublicKey) : this (publicKey, null, null) + } + + @Parcelize + object TooManyKeys : KeygenEvent() + + @Parcelize + object GenerationFailure : KeygenEvent() + + fun failure(): KeygenFailure? { + return when (this) { + is KeygenEvent.TooManyKeys -> KeygenFailure.TooManyKeys + is KeygenEvent.GenerationFailure -> KeygenFailure.GenerationFailure + else -> null + } + } +} + +@Parcelize +enum class KeygenFailure : Parcelable { + TooManyKeys, + GenerationFailure, +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/ListItemData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/ListItemData.kt new file mode 100644 index 0000000000..465669a08f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/ListItemData.kt @@ -0,0 +1,59 @@ +package net.mullvad.mullvadvpn.model + +import androidx.annotation.DrawableRes +import androidx.annotation.IntDef +import androidx.annotation.StringRes +import java.lang.IllegalArgumentException + +data class ListItemData +private constructor( + val identifier: String, + val text: String? = null, + @StringRes val textRes: Int? = null, + @DrawableRes val iconRes: Int?, + val isSelected: Boolean, + @ItemType val type: Int, + val widget: WidgetState? = null, + val action: ItemAction? = null +) { + + @Retention + @IntDef(DIVIDER, PLAIN, ACTION) + annotation class ItemType + + class Builder(private val identifier: String) { + var text: String? = null + @StringRes + var textRes: Int? = null + @DrawableRes + var iconRes: Int? = null + var isSelected: Boolean = false + @ItemType + var type: Int = 0 + var widget: WidgetState? = null + var action: ItemAction? = null + + fun build(): ListItemData { + if ((this.text == null && this.textRes == null) && type > PROGRESS) + throw IllegalArgumentException("ListItem should be configured with text") + + return ListItemData( + this.identifier, this.text, this.textRes, this.iconRes, + this.isSelected, this.type, this.widget, this.action + ) + } + } + + data class ItemAction(val identifier: String) + + companion object { + const val DIVIDER = 0 + const val PROGRESS = 1 + const val PLAIN = 2 + const val ACTION = 3 + const val DOUBLE_ACTION = 4 + const val APPLICATION = 5 + fun build(identifier: String, setUp: Builder.() -> Unit): ListItemData = + Builder(identifier).also(setUp).build() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/LocationConstraint.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/LocationConstraint.kt new file mode 100644 index 0000000000..6734ff418e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/LocationConstraint.kt @@ -0,0 +1,30 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class LocationConstraint : Parcelable { + abstract val location: GeoIpLocation + + @Parcelize + data class Country(val countryCode: String) : LocationConstraint() { + override val location: GeoIpLocation + get() = GeoIpLocation(null, null, countryCode, null, null) + } + + @Parcelize + data class City(val countryCode: String, val cityCode: String) : LocationConstraint() { + override val location: GeoIpLocation + get() = GeoIpLocation(null, null, countryCode, cityCode, null) + } + + @Parcelize + data class Hostname( + val countryCode: String, + val cityCode: String, + val hostname: String + ) : LocationConstraint() { + override val location: GeoIpLocation + get() = GeoIpLocation(null, null, countryCode, cityCode, hostname) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/LoginStatus.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/LoginStatus.kt new file mode 100644 index 0000000000..e143cc630c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/LoginStatus.kt @@ -0,0 +1,15 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.joda.time.DateTime + +@Parcelize +data class LoginStatus( + val account: String, + val expiry: DateTime?, + val isNewAccount: Boolean +) : Parcelable { + val isExpired: Boolean + get() = expiry != null && expiry.isAfterNow() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/PublicKey.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/PublicKey.kt new file mode 100644 index 0000000000..4ee6ad51df --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/PublicKey.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class PublicKey(val key: ByteArray, val dateCreated: String) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Relay.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Relay.kt new file mode 100644 index 0000000000..23f9d87f77 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Relay.kt @@ -0,0 +1,14 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Relay( + val hostname: String, + val active: Boolean, + val tunnels: RelayTunnels +) : Parcelable { + val hasWireguardTunnels + get() = !tunnels.wireguard.isEmpty() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayConstraints.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayConstraints.kt new file mode 100644 index 0000000000..cd36577dae --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayConstraints.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class RelayConstraints(val location: Constraint<LocationConstraint>) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayConstraintsUpdate.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayConstraintsUpdate.kt new file mode 100644 index 0000000000..94aa58c56a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayConstraintsUpdate.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.model + +data class RelayConstraintsUpdate(var location: Constraint<LocationConstraint>?) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayList.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayList.kt new file mode 100644 index 0000000000..2373eba536 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayList.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import java.util.ArrayList +import kotlinx.parcelize.Parcelize + +@Parcelize +data class RelayList(val countries: ArrayList<RelayListCountry>) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayListCity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayListCity.kt new file mode 100644 index 0000000000..d4b3e21f58 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayListCity.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import java.util.ArrayList +import kotlinx.parcelize.Parcelize + +@Parcelize +data class RelayListCity( + val name: String, + val code: String, + val relays: ArrayList<Relay> +) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayListCountry.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayListCountry.kt new file mode 100644 index 0000000000..20fdd7de71 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayListCountry.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import java.util.ArrayList +import kotlinx.parcelize.Parcelize + +@Parcelize +data class RelayListCountry( + val name: String, + val code: String, + val cities: ArrayList<RelayListCity> +) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelaySettings.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelaySettings.kt new file mode 100644 index 0000000000..6a247997db --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelaySettings.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class RelaySettings : Parcelable { + @Parcelize + object CustomTunnelEndpoint : RelaySettings() + + @Parcelize + class Normal(val relayConstraints: RelayConstraints) : RelaySettings() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelaySettingsUpdate.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelaySettingsUpdate.kt new file mode 100644 index 0000000000..85f5de2a32 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelaySettingsUpdate.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.model + +sealed class RelaySettingsUpdate { + object CustomTunnelEndpoint : RelaySettingsUpdate() + data class Normal(var constraints: RelayConstraintsUpdate) : RelaySettingsUpdate() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayTunnels.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayTunnels.kt new file mode 100644 index 0000000000..5691932888 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayTunnels.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import java.util.ArrayList +import kotlinx.parcelize.Parcelize + +@Parcelize +data class RelayTunnels(val wireguard: ArrayList<WireguardEndpointData>) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/ServiceResult.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/ServiceResult.kt new file mode 100644 index 0000000000..b1d9f5be4c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/ServiceResult.kt @@ -0,0 +1,25 @@ +package net.mullvad.mullvadvpn.model + +import android.os.IBinder + +data class ServiceResult( + val binder: IBinder? +) { + enum class ConnectionState { + CONNECTED, + DISCONNECTED; + } + + val connectionState: ConnectionState + get() { + return if (binder == null) { + ConnectionState.DISCONNECTED + } else { + ConnectionState.CONNECTED + } + } + + companion object { + val NOT_CONNECTED = ServiceResult(null) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Settings.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Settings.kt new file mode 100644 index 0000000000..03ef69c638 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Settings.kt @@ -0,0 +1,14 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Settings( + val accountToken: String?, + val relaySettings: RelaySettings, + val allowLan: Boolean, + val autoConnect: Boolean, + val tunnelOptions: TunnelOptions, + val showBetaReleases: Boolean +) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelOptions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelOptions.kt new file mode 100644 index 0000000000..944a98b0b8 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelOptions.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class TunnelOptions( + val wireguard: WireguardTunnelOptions, + val dnsOptions: DnsOptions +) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt new file mode 100644 index 0000000000..918396a263 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt @@ -0,0 +1,78 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import net.mullvad.talpid.net.TunnelEndpoint +import net.mullvad.talpid.tunnel.ActionAfterDisconnect +import net.mullvad.talpid.tunnel.ErrorState +import net.mullvad.talpid.tunnel.ErrorStateCause + +sealed class TunnelState() : Parcelable { + @Parcelize + object Disconnected : TunnelState(), Parcelable + + @Parcelize + class Connecting( + val endpoint: TunnelEndpoint?, + val location: GeoIpLocation? + ) : TunnelState(), Parcelable + + @Parcelize + class Connected( + val endpoint: TunnelEndpoint, + val location: GeoIpLocation? + ) : TunnelState(), Parcelable + + @Parcelize + class Disconnecting( + val actionAfterDisconnect: ActionAfterDisconnect + ) : TunnelState(), Parcelable + + @Parcelize + class Error(val errorState: ErrorState) : TunnelState(), Parcelable + + companion object { + const val DISCONNECTED = "disconnected" + const val CONNECTING = "connecting" + const val CONNECTED = "connected" + const val RECONNECTING = "reconnecting" + const val DISCONNECTING = "disconnecting" + const val BLOCKING = "blocking" + const val ERROR = "error" + + fun fromString(description: String, endpoint: TunnelEndpoint?): TunnelState { + return when (description) { + DISCONNECTED -> TunnelState.Disconnected + CONNECTING -> TunnelState.Connecting(endpoint, null) + CONNECTED -> TunnelState.Connected(endpoint!!, null) + RECONNECTING -> TunnelState.Disconnecting(ActionAfterDisconnect.Reconnect) + DISCONNECTING -> TunnelState.Disconnecting(ActionAfterDisconnect.Nothing) + BLOCKING -> TunnelState.Error(ErrorState(ErrorStateCause.StartTunnelError, true)) + ERROR -> { + TunnelState.Error(ErrorState(ErrorStateCause.SetFirewallPolicyError, false)) + } + else -> TunnelState.Error(ErrorState(ErrorStateCause.SetFirewallPolicyError, false)) + } + } + } + + override fun toString(): String = when (this) { + is TunnelState.Disconnected -> DISCONNECTED + is TunnelState.Connecting -> CONNECTING + is TunnelState.Connected -> CONNECTED + is TunnelState.Disconnecting -> { + if (actionAfterDisconnect == ActionAfterDisconnect.Reconnect) { + RECONNECTING + } else { + DISCONNECTING + } + } + is TunnelState.Error -> { + if (errorState.isBlocking) { + BLOCKING + } else { + ERROR + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmission.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmission.kt new file mode 100644 index 0000000000..bf96646516 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmission.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class VoucherSubmission(val timeAdded: Long, val newExpiry: String) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionError.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionError.kt new file mode 100644 index 0000000000..1cf778400a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionError.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class VoucherSubmissionError : Parcelable { + InvalidVoucher, + VoucherAlreadyUsed, + RpcError, + OtherError, +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionResult.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionResult.kt new file mode 100644 index 0000000000..b78957d5c0 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionResult.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class VoucherSubmissionResult : Parcelable { + @Parcelize + data class Ok(val submission: VoucherSubmission) : VoucherSubmissionResult() + + @Parcelize + data class Error(val error: VoucherSubmissionError) : VoucherSubmissionResult() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/WidgetState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/WidgetState.kt new file mode 100644 index 0000000000..3877c2d564 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/WidgetState.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.model + +import androidx.annotation.DrawableRes + +sealed class WidgetState { + data class ImageState(@DrawableRes val imageRes: Int) : WidgetState() + data class SwitchState(val isChecked: Boolean) : WidgetState() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardEndpointData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardEndpointData.kt new file mode 100644 index 0000000000..aee9b56082 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardEndpointData.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Suppress("PARCELABLE_PRIMARY_CONSTRUCTOR_IS_EMPTY") +@Parcelize +class WireguardEndpointData() : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardTunnelOptions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardTunnelOptions.kt new file mode 100644 index 0000000000..251571021a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardTunnelOptions.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import net.mullvad.talpid.net.wireguard.TunnelOptions as TalpidWireguardTunnelOptions + +@Parcelize +data class WireguardTunnelOptions(val options: TalpidWireguardTunnelOptions) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/GetItemResult.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/GetItemResult.kt new file mode 100644 index 0000000000..d443d30cfe --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/GetItemResult.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.relaylist + +sealed class GetItemResult { + data class Item(val item: RelayItem) : GetItemResult() + data class Count(val count: Int) : GetItemResult() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Relay.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Relay.kt new file mode 100644 index 0000000000..080236cff9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Relay.kt @@ -0,0 +1,22 @@ +package net.mullvad.mullvadvpn.relaylist + +import net.mullvad.mullvadvpn.model.LocationConstraint + +data class Relay( + val city: RelayCity, + override val name: String, + override val active: Boolean +) : RelayItem { + override val code = name + override val type = RelayItemType.Relay + override val location = LocationConstraint.Hostname(city.country.code, city.code, name) + override val hasChildren = false + + override val visibleChildCount = 0 + + override val locationName = "${city.name} ($name)" + + override var expanded + get() = false + set(_) {} +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCity.kt new file mode 100644 index 0000000000..2c8493de8a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCity.kt @@ -0,0 +1,56 @@ +package net.mullvad.mullvadvpn.relaylist + +import net.mullvad.mullvadvpn.model.LocationConstraint + +class RelayCity( + val country: RelayCountry, + override val name: String, + override val code: String, + override var expanded: Boolean, + val relays: List<Relay> +) : RelayItem { + override val type = RelayItemType.City + override val location = LocationConstraint.City(country.code, code) + + override val active + get() = relays.any { relay -> relay.active } + + override val hasChildren + get() = !relays.isEmpty() + + override val visibleChildCount: Int + get() { + if (expanded) { + return relays.size + } else { + return 0 + } + } + + fun getItem(position: Int): GetItemResult { + if (position == 0) { + return GetItemResult.Item(this) + } + + if (!expanded) { + return GetItemResult.Count(1) + } + + val offset = position - 1 + val relayCount = relays.size + + if (offset >= relayCount) { + return GetItemResult.Count(1 + relayCount) + } else { + return GetItemResult.Item(relays[offset]) + } + } + + fun getItemCount(): Int { + if (expanded) { + return 1 + relays.size + } else { + return 1 + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCountry.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCountry.kt new file mode 100644 index 0000000000..197387d1c2 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCountry.kt @@ -0,0 +1,53 @@ +package net.mullvad.mullvadvpn.relaylist + +import net.mullvad.mullvadvpn.model.LocationConstraint + +class RelayCountry( + override val name: String, + override val code: String, + override var expanded: Boolean, + val cities: List<RelayCity> +) : RelayItem { + override val type = RelayItemType.Country + override val location = LocationConstraint.Country(code) + + override val active + get() = cities.any { city -> city.active } + + override val hasChildren + get() = !cities.isEmpty() + + override val visibleChildCount: Int + get() { + if (expanded) { + return cities.map { city -> city.visibleItemCount }.sum() + } else { + return 0 + } + } + + fun getItem(position: Int): GetItemResult { + if (position == 0) { + return GetItemResult.Item(this) + } + + var itemCount = 1 + var remaining = position - 1 + + if (expanded) { + for (city in cities) { + val itemOrCount = city.getItem(remaining) + + when (itemOrCount) { + is GetItemResult.Item -> return itemOrCount + is GetItemResult.Count -> { + remaining -= itemOrCount.count + itemCount += itemOrCount.count + } + } + } + } + + return GetItemResult.Count(itemCount) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt new file mode 100644 index 0000000000..e5f28acee6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.relaylist + +import net.mullvad.mullvadvpn.model.LocationConstraint + +interface RelayItem { + val type: RelayItemType + val name: String + val code: String + val location: LocationConstraint + val active: Boolean + val hasChildren: Boolean + val visibleChildCount: Int + + val visibleItemCount: Int + get() = visibleChildCount + 1 + + val locationName: String + get() = name + + var expanded: Boolean +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemHolder.kt new file mode 100644 index 0000000000..a16313f797 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemHolder.kt @@ -0,0 +1,132 @@ +package net.mullvad.mullvadvpn.relaylist + +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import net.mullvad.mullvadvpn.R + +class RelayItemHolder( + private val view: View, + private val adapter: RelayListAdapter, + var itemPosition: RelayListAdapterPosition +) : ViewHolder(view) { + private val name: TextView = view.findViewById(R.id.name) + private val chevron: ImageButton = view.findViewById(R.id.chevron) + private val clickArea: View = view.findViewById(R.id.click_area) + private val relayStatus: View = view.findViewById(R.id.status) + private val relayActive: ImageView = view.findViewById(R.id.relay_active) + private val selectedIcon: View = view.findViewById(R.id.selected) + + private val context = view.context + private val countryColor = context.getColor(R.color.blue) + private val cityColor = context.getColor(R.color.blue40) + private val relayColor = context.getColor(R.color.blue20) + private val selectedColor = context.getColor(R.color.green) + + private val resources = view.resources + private val countryPadding = resources.getDimensionPixelSize(R.dimen.country_row_padding) + private val cityPadding = resources.getDimensionPixelSize(R.dimen.city_row_padding) + private val relayPadding = resources.getDimensionPixelSize(R.dimen.relay_row_padding) + + var item: RelayItem? = null + set(value) { + field = value + updateView() + } + + var selected = false + set(value) { + field = value + updateView() + } + + init { + chevron.setOnClickListener { toggle() } + clickArea.setOnClickListener { adapter.selectItem(item, this) } + } + + private fun updateView() { + val item = this.item + + if (item != null) { + name.text = item.name + + if (item.active) { + name.alpha = 1.0F + } else { + name.alpha = 0.5F + } + + if (selected) { + relayActive.visibility = View.INVISIBLE + selectedIcon.visibility = View.VISIBLE + } else { + relayActive.visibility = View.VISIBLE + selectedIcon.visibility = View.INVISIBLE + + if (item.active) { + relayActive.setImageDrawable(adapter.activeRelayIcon) + } else { + relayActive.setImageDrawable(adapter.inactiveRelayIcon) + } + } + + clickArea.setEnabled(item.active) + + when (item.type) { + RelayItemType.Country -> setViewStyle(countryColor, countryPadding) + RelayItemType.City -> setViewStyle(cityColor, cityPadding) + RelayItemType.Relay -> setViewStyle(relayColor, relayPadding) + } + + if (item.hasChildren) { + chevron.visibility = View.VISIBLE + + if (item.expanded) { + chevron.rotation = 180.0F + } else { + chevron.rotation = 0.0F + } + } else { + chevron.visibility = View.GONE + } + } else { + name.text = "" + chevron.visibility = View.GONE + } + } + + private fun setViewStyle(rowColor: Int, padding: Int) { + var backgroundColor = rowColor + + if (selected) { + backgroundColor = selectedColor + } + + (relayStatus.layoutParams as? MarginLayoutParams)?.let { parameters -> + parameters.leftMargin = padding + relayStatus.layoutParams = parameters + } + + view.setBackgroundColor(backgroundColor) + } + + private fun toggle() { + item?.let { item -> + if (!item.expanded) { + item.expanded = true + chevron.rotation = 180.0F + adapter.expandItem(itemPosition, item.visibleChildCount) + } else { + val childCount = item.visibleChildCount + + item.expanded = false + chevron.rotation = 0.0F + adapter.collapseItem(itemPosition, childCount) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemType.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemType.kt new file mode 100644 index 0000000000..cdbd58b291 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemType.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.relaylist + +enum class RelayItemType { + Country, + City, + Relay, +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayList.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayList.kt new file mode 100644 index 0000000000..aed15f9508 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayList.kt @@ -0,0 +1,84 @@ +package net.mullvad.mullvadvpn.relaylist + +import net.mullvad.mullvadvpn.model.Constraint +import net.mullvad.mullvadvpn.model.LocationConstraint + +class RelayList { + val countries: List<RelayCountry> + + constructor(model: net.mullvad.mullvadvpn.model.RelayList) { + var relayCountries = model.countries + .map { country -> + val cities = mutableListOf<RelayCity>() + val relayCountry = RelayCountry(country.name, country.code, false, cities) + + for (city in country.cities) { + val relays = mutableListOf<Relay>() + val relayCity = RelayCity(relayCountry, city.name, city.code, false, relays) + + val validCityRelays = city.relays.filter { relay -> relay.hasWireguardTunnels } + + for (relay in validCityRelays) { + relays.add(Relay(relayCity, relay.hostname, relay.active)) + } + relays.sortWith(RelayNameComparator) + + if (relays.isNotEmpty()) { + cities.add(relayCity) + } + } + + cities.sortBy({ it.name }) + relayCountry + } + .filter { country -> country.cities.isNotEmpty() } + .toMutableList() + + relayCountries.sortBy({ it.name }) + + countries = relayCountries.toList() + } + + fun findItemForLocation( + constraint: Constraint<LocationConstraint>, + expand: Boolean = false + ): RelayItem? { + when (constraint) { + is Constraint.Any -> return null + is Constraint.Only -> { + val location = constraint.value + + when (location) { + is LocationConstraint.Country -> { + return countries.find { country -> country.code == location.countryCode } + } + is LocationConstraint.City -> { + val country = countries.find { country -> + country.code == location.countryCode + } + + if (expand) { + country?.expanded = true + } + + return country?.cities?.find { city -> city.code == location.cityCode } + } + is LocationConstraint.Hostname -> { + val country = countries.find { country -> + country.code == location.countryCode + } + + val city = country?.cities?.find { city -> city.code == location.cityCode } + + if (expand) { + country?.expanded = true + city?.expanded = true + } + + return city?.relays?.find { relay -> relay.name == location.hostname } + } + } + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt new file mode 100644 index 0000000000..0937592399 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt @@ -0,0 +1,121 @@ +package net.mullvad.mullvadvpn.relaylist + +import android.content.res.Resources +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView.Adapter +import java.lang.ref.WeakReference +import java.util.LinkedList +import net.mullvad.mullvadvpn.R + +class RelayListAdapter(private val resources: Resources) : Adapter<RelayItemHolder>() { + private var relayList: RelayList? = null + private var selectedItem: RelayItem? = null + private val activeIndices = LinkedList<WeakReference<RelayListAdapterPosition>>() + private var selectedItemHolder: RelayItemHolder? = null + + val activeRelayIcon = resources.getDrawable(R.drawable.icon_relay_active, null) + val inactiveRelayIcon = resources.getDrawable(R.drawable.icon_relay_inactive, null) + + var onSelect: ((RelayItem?) -> Unit)? = null + + override fun onCreateViewHolder(parentView: ViewGroup, type: Int): RelayItemHolder { + val inflater = LayoutInflater.from(parentView.context) + val view = inflater.inflate(R.layout.relay_list_item, parentView, false) + val index = RelayListAdapterPosition(0) + + activeIndices.add(WeakReference(index)) + + return RelayItemHolder(view, this, index) + } + + override fun onBindViewHolder(holder: RelayItemHolder, position: Int) { + val relayList = this.relayList + + if (relayList != null) { + var remaining = position + + for (country in relayList.countries) { + val itemOrCount = country.getItem(remaining) + + when (itemOrCount) { + is GetItemResult.Item -> { + bindHolderToItem(holder, itemOrCount.item, position) + return + } + is GetItemResult.Count -> remaining -= itemOrCount.count + } + } + } + } + + override fun getItemCount() = + relayList?.countries?.map { country -> country.visibleItemCount }?.sum() ?: 0 + + fun onRelayListChange(newRelayList: RelayList, newSelectedItem: RelayItem?) { + val initializedRelayList = relayList == null + + relayList = newRelayList + selectedItem = newSelectedItem + + if (initializedRelayList) { + notifyItemRangeInserted(0, getItemCount()) + } else { + notifyDataSetChanged() + } + } + + fun selectItem(item: RelayItem?, holder: RelayItemHolder?) { + selectedItemHolder?.selected = false + + selectedItem = item + selectedItemHolder = holder + selectedItemHolder?.apply { selected = true } + + onSelect?.invoke(item) + } + + fun expandItem(itemIndex: RelayListAdapterPosition, childCount: Int) { + val position = itemIndex.position + + updateActiveIndices(position, childCount) + notifyItemRangeInserted(position + 1, childCount) + } + + fun collapseItem(itemIndex: RelayListAdapterPosition, childCount: Int) { + val position = itemIndex.position + + updateActiveIndices(position, -childCount) + notifyItemRangeRemoved(position + 1, childCount) + } + + private fun updateActiveIndices(position: Int, delta: Int) { + val activeIndicesIterator = activeIndices.iterator() + + while (activeIndicesIterator.hasNext()) { + val index = activeIndicesIterator.next().get() + + if (index == null) { + activeIndicesIterator.remove() + } else { + val indexPosition = index.position + + if (indexPosition > position) { + index.position = indexPosition + delta + } + } + } + } + + private fun bindHolderToItem(holder: RelayItemHolder, item: RelayItem, position: Int) { + holder.item = item + holder.itemPosition.position = position + + if (selectedItem != null && selectedItem == item) { + holder.selected = true + selectedItemHolder = holder + } else { + holder.selected = false + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapterPosition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapterPosition.kt new file mode 100644 index 0000000000..09dfafebc8 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapterPosition.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.relaylist + +data class RelayListAdapterPosition(var position: Int) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparator.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparator.kt new file mode 100644 index 0000000000..32f473b194 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparator.kt @@ -0,0 +1,35 @@ +package net.mullvad.mullvadvpn.relaylist + +internal object RelayNameComparator : Comparator<Relay> { + override fun compare(o1: Relay, o2: Relay): Int { + val partitions1 = o1.name.split(regex) + val partitions2 = o2.name.split(regex) + return if (partitions1.size > partitions2.size) + partitions1 compareWith partitions2 + else + -(partitions2 compareWith partitions1) + } + + private infix fun List<String>.compareWith(other: List<String>): Int { + this.forEachIndexed { index, s -> + if (other.size <= index) + return 1 + val partsCompareResult = compareStringOrInt(other[index], s) + if (partsCompareResult != 0) + return partsCompareResult + } + return 0 + } + + private fun compareStringOrInt(s1: String, s2: String): Int { + val int1 = s1.toIntOrNull() + val int2 = s2.toIntOrNull() + return if (int1 == null || int2 == null || int1 == int2) { + s2.compareTo(s1) + } else { + int2.compareTo(int1) + } + } + + private val regex = "(?<=\\d)(?=\\D)|(?<=\\D)(?=\\d)".toRegex() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt new file mode 100644 index 0000000000..23b127addf --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt @@ -0,0 +1,117 @@ +package net.mullvad.mullvadvpn.service + +import java.io.File +import kotlin.properties.Delegates.observable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.channels.sendBlocking +import net.mullvad.mullvadvpn.util.Intermittent + +private const val API_IP_ADDRESS_FILE = "api-ip-address.txt" +private const val RELAYS_FILE = "relays.json" + +class DaemonInstance(val vpnService: MullvadVpnService) { + private enum class Command { + START, + STOP, + } + + private val commandChannel = spawnActor() + + private var daemon by observable<MullvadDaemon?>(null) { _, oldInstance, _ -> + oldInstance?.onDestroy() + } + + val intermittentDaemon = Intermittent<MullvadDaemon>() + + fun start() { + commandChannel.sendBlocking(Command.START) + } + + fun stop() { + commandChannel.sendBlocking(Command.STOP) + } + + fun onDestroy() { + commandChannel.close() + intermittentDaemon.onDestroy() + } + + private fun spawnActor() = GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) { + var isRunning = true + + prepareFiles() + + while (isRunning) { + if (!waitForCommand(channel, Command.START)) { + break + } + + startDaemon() + + isRunning = waitForCommand(channel, Command.STOP) + + stopDaemon() + } + } + + private suspend fun waitForCommand( + channel: ReceiveChannel<Command>, + command: Command + ): Boolean { + try { + while (channel.receive() != command) { + // Wait for command + } + + return true + } catch (exception: ClosedReceiveChannelException) { + return false + } + } + + private fun prepareFiles() { + FileMigrator(File("/data/data/net.mullvad.mullvadvpn"), vpnService.filesDir).apply { + migrate(RELAYS_FILE) + migrate("settings.json") + migrate("daemon.log") + migrate("daemon.old.log") + migrate("wireguard.log") + migrate("wireguard.old.log") + } + + val shouldOverwriteRelayList = + lastUpdatedTime() > File(vpnService.filesDir, RELAYS_FILE).lastModified() + + FileResourceExtractor(vpnService).apply { + extract(API_IP_ADDRESS_FILE, false) + extract(RELAYS_FILE, shouldOverwriteRelayList) + } + } + + private suspend fun startDaemon() { + val newDaemon = MullvadDaemon(vpnService).apply { + onDaemonStopped = { + intermittentDaemon.spawnUpdate(null) + daemon = null + } + } + + daemon = newDaemon + intermittentDaemon.update(newDaemon) + } + + private fun stopDaemon() { + daemon?.shutdown() + } + + private fun lastUpdatedTime(): Long { + return vpnService.run { + packageManager.getPackageInfo(packageName, 0).lastUpdateTime + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/FileMigrator.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/FileMigrator.kt new file mode 100644 index 0000000000..cd325d8a6f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/FileMigrator.kt @@ -0,0 +1,18 @@ +package net.mullvad.mullvadvpn.service + +import android.util.Log +import java.io.File + +class FileMigrator(val oldDirectory: File, val newDirectory: File) { + fun migrate(fileName: String) { + try { + val oldPath = File(oldDirectory, fileName) + + if (oldPath.exists()) { + oldPath.renameTo(File(newDirectory, fileName)) + } + } catch (exception: Exception) { + Log.w("mullvad", "Failed to migrate $fileName from $oldDirectory to $newDirectory") + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/FileResourceExtractor.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/FileResourceExtractor.kt new file mode 100644 index 0000000000..34ca0eaa89 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/FileResourceExtractor.kt @@ -0,0 +1,26 @@ +package net.mullvad.mullvadvpn.service + +import android.content.Context +import java.io.File +import java.io.FileOutputStream + +class FileResourceExtractor(val context: Context) { + fun extract(asset: String, force: Boolean = false) { + val destination = File(context.filesDir, asset) + + if (!destination.exists() || force) { + extractFile(asset, destination) + } + } + + private fun extractFile(asset: String, destination: File) { + val destinationStream = FileOutputStream(destination) + + context + .assets + .open(asset) + .copyTo(destinationStream) + + destinationStream.close() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt new file mode 100644 index 0000000000..92c4fcd078 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt @@ -0,0 +1,136 @@ +package net.mullvad.mullvadvpn.service + +import android.app.KeyguardManager +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import kotlin.properties.Delegates.observable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.channels.sendBlocking +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.service.endpoint.ConnectionProxy +import net.mullvad.mullvadvpn.service.notifications.TunnelStateNotification +import net.mullvad.talpid.util.autoSubscribable + +class ForegroundNotificationManager( + val service: MullvadVpnService, + val connectionProxy: ConnectionProxy, + val keyguardManager: KeyguardManager +) { + private sealed class UpdaterMessage { + class UpdateNotification : UpdaterMessage() + class UpdateAction : UpdaterMessage() + class NewTunnelState(val newState: TunnelState) : UpdaterMessage() + } + + private val updater = runUpdater() + + private val tunnelStateNotification = TunnelStateNotification(service) + + private val deviceLockListener = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + + if (action == Intent.ACTION_USER_PRESENT || action == Intent.ACTION_SCREEN_OFF) { + deviceIsUnlocked = !keyguardManager.isDeviceLocked + } + } + } + + private var deviceIsUnlocked by observable(!keyguardManager.isDeviceLocked) { _, _, _ -> + updater.sendBlocking(UpdaterMessage.UpdateAction()) + } + + private var loggedIn by observable(false) { _, _, _ -> + updater.sendBlocking(UpdaterMessage.UpdateAction()) + } + + private val tunnelState + get() = connectionProxy.onStateChange.latestEvent + + private val shouldBeOnForeground + get() = lockedToForeground || !(tunnelState is TunnelState.Disconnected) + + var accountNumberEvents by autoSubscribable<String?>(this, null) { accountNumber -> + loggedIn = accountNumber != null + } + + var onForeground = false + private set + + var lockedToForeground by observable(false) { _, _, _ -> + updater.sendBlocking(UpdaterMessage.UpdateNotification()) + } + + init { + connectionProxy.onStateChange.subscribe(this) { newState -> + updater.sendBlocking(UpdaterMessage.NewTunnelState(newState)) + } + + service.apply { + registerReceiver( + deviceLockListener, + IntentFilter().apply { + addAction(Intent.ACTION_USER_PRESENT) + addAction(Intent.ACTION_SCREEN_OFF) + } + ) + } + + updater.sendBlocking(UpdaterMessage.UpdateNotification()) + } + + fun onDestroy() { + accountNumberEvents = null + + connectionProxy.onStateChange.unsubscribe(this) + service.unregisterReceiver(deviceLockListener) + + updater.close() + } + + private fun runUpdater() = GlobalScope.actor<UpdaterMessage>( + Dispatchers.Main, + Channel.UNLIMITED + ) { + for (message in channel) { + when (message) { + is UpdaterMessage.UpdateNotification -> updateNotification() + is UpdaterMessage.UpdateAction -> updateNotificationAction() + is UpdaterMessage.NewTunnelState -> { + tunnelStateNotification.tunnelState = message.newState + updateNotification() + } + } + } + } + + private fun showOnForeground() { + service.startForeground( + TunnelStateNotification.NOTIFICATION_ID, + tunnelStateNotification.build() + ) + + onForeground = true + } + + fun updateNotification() { + if (shouldBeOnForeground != onForeground) { + if (shouldBeOnForeground) { + showOnForeground() + } else { + service.stopForeground(Service.STOP_FOREGROUND_DETACH) + onForeground = false + } + } + } + + private fun updateNotificationAction() { + tunnelStateNotification.showAction = loggedIn && deviceIsUnlocked + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt new file mode 100644 index 0000000000..4565844daa --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt @@ -0,0 +1,212 @@ +package net.mullvad.mullvadvpn.service + +import net.mullvad.mullvadvpn.model.AppVersionInfo +import net.mullvad.mullvadvpn.model.DnsOptions +import net.mullvad.mullvadvpn.model.GeoIpLocation +import net.mullvad.mullvadvpn.model.GetAccountDataResult +import net.mullvad.mullvadvpn.model.KeygenEvent +import net.mullvad.mullvadvpn.model.PublicKey +import net.mullvad.mullvadvpn.model.RelayList +import net.mullvad.mullvadvpn.model.RelaySettingsUpdate +import net.mullvad.mullvadvpn.model.Settings +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.model.VoucherSubmissionResult +import net.mullvad.talpid.util.EventNotifier + +class MullvadDaemon(val vpnService: MullvadVpnService) { + protected var daemonInterfaceAddress = 0L + + val onSettingsChange = EventNotifier<Settings?>(null) + var onTunnelStateChange = EventNotifier<TunnelState>(TunnelState.Disconnected) + + var onAppVersionInfoChange: ((AppVersionInfo) -> Unit)? = null + var onKeygenEvent: ((KeygenEvent) -> Unit)? = null + var onRelayListChange: ((RelayList) -> Unit)? = null + var onDaemonStopped: (() -> Unit)? = null + + init { + System.loadLibrary("mullvad_jni") + initialize(vpnService, vpnService.cacheDir.absolutePath, vpnService.filesDir.absolutePath) + + onSettingsChange.notify(getSettings()) + onTunnelStateChange.notify(getState() ?: TunnelState.Disconnected) + } + + fun connect() { + connect(daemonInterfaceAddress) + } + + fun createNewAccount(): String? { + return createNewAccount(daemonInterfaceAddress) + } + + fun disconnect() { + disconnect(daemonInterfaceAddress) + } + + fun generateWireguardKey(): KeygenEvent? { + return generateWireguardKey(daemonInterfaceAddress) + } + + fun getAccountData(accountToken: String): GetAccountDataResult { + return getAccountData(daemonInterfaceAddress, accountToken) + } + + fun getAccountHistory(): String? { + return getAccountHistory(daemonInterfaceAddress) + } + + fun getWwwAuthToken(): String { + return getWwwAuthToken(daemonInterfaceAddress) ?: "" + } + + fun getCurrentLocation(): GeoIpLocation? { + return getCurrentLocation(daemonInterfaceAddress) + } + + fun getCurrentVersion(): String? { + return getCurrentVersion(daemonInterfaceAddress) + } + + fun getRelayLocations(): RelayList? { + return getRelayLocations(daemonInterfaceAddress) + } + + fun getSettings(): Settings? { + return getSettings(daemonInterfaceAddress) + } + + fun getState(): TunnelState? { + return getState(daemonInterfaceAddress) + } + + fun getVersionInfo(): AppVersionInfo? { + return getVersionInfo(daemonInterfaceAddress) + } + + fun getWireguardKey(): PublicKey? { + return getWireguardKey(daemonInterfaceAddress) + } + + fun reconnect() { + reconnect(daemonInterfaceAddress) + } + + fun clearAccountHistory() { + clearAccountHistory(daemonInterfaceAddress) + } + + fun setAccount(accountToken: String?) { + setAccount(daemonInterfaceAddress, accountToken) + } + + fun setAllowLan(allowLan: Boolean) { + setAllowLan(daemonInterfaceAddress, allowLan) + } + + fun setAutoConnect(autoConnect: Boolean) { + setAutoConnect(daemonInterfaceAddress, autoConnect) + } + + fun setDnsOptions(dnsOptions: DnsOptions) { + setDnsOptions(daemonInterfaceAddress, dnsOptions) + } + + fun setWireguardMtu(wireguardMtu: Int?) { + setWireguardMtu(daemonInterfaceAddress, wireguardMtu) + } + + fun shutdown() { + shutdown(daemonInterfaceAddress) + } + + fun submitVoucher(voucher: String): VoucherSubmissionResult { + return submitVoucher(daemonInterfaceAddress, voucher) + } + + fun updateRelaySettings(update: RelaySettingsUpdate) { + updateRelaySettings(daemonInterfaceAddress, update) + } + + fun verifyWireguardKey(): Boolean? { + return verifyWireguardKey(daemonInterfaceAddress) + } + + fun onDestroy() { + onSettingsChange.unsubscribeAll() + onTunnelStateChange.unsubscribeAll() + + onAppVersionInfoChange = null + onKeygenEvent = null + onRelayListChange = null + onDaemonStopped = null + + deinitialize() + } + + private external fun initialize( + vpnService: MullvadVpnService, + cacheDirectory: String, + resourceDirectory: String + ) + private external fun deinitialize() + + private external fun connect(daemonInterfaceAddress: Long) + private external fun createNewAccount(daemonInterfaceAddress: Long): String? + private external fun disconnect(daemonInterfaceAddress: Long) + private external fun generateWireguardKey(daemonInterfaceAddress: Long): KeygenEvent? + private external fun getAccountData( + daemonInterfaceAddress: Long, + accountToken: String + ): GetAccountDataResult + private external fun getAccountHistory(daemonInterfaceAddress: Long): String? + private external fun getWwwAuthToken(daemonInterfaceAddress: Long): String? + private external fun getCurrentLocation(daemonInterfaceAddress: Long): GeoIpLocation? + private external fun getCurrentVersion(daemonInterfaceAddress: Long): String? + private external fun getRelayLocations(daemonInterfaceAddress: Long): RelayList? + private external fun getSettings(daemonInterfaceAddress: Long): Settings? + private external fun getState(daemonInterfaceAddress: Long): TunnelState? + private external fun getVersionInfo(daemonInterfaceAddress: Long): AppVersionInfo? + private external fun getWireguardKey(daemonInterfaceAddress: Long): PublicKey? + private external fun reconnect(daemonInterfaceAddress: Long) + private external fun clearAccountHistory(daemonInterfaceAddress: Long) + private external fun setAccount(daemonInterfaceAddress: Long, accountToken: String?) + private external fun setAllowLan(daemonInterfaceAddress: Long, allowLan: Boolean) + private external fun setAutoConnect(daemonInterfaceAddress: Long, alwaysOn: Boolean) + private external fun setDnsOptions(daemonInterfaceAddress: Long, dnsOptions: DnsOptions) + private external fun setWireguardMtu(daemonInterfaceAddress: Long, wireguardMtu: Int?) + private external fun shutdown(daemonInterfaceAddress: Long) + private external fun submitVoucher( + daemonInterfaceAddress: Long, + voucher: String + ): VoucherSubmissionResult + private external fun updateRelaySettings( + daemonInterfaceAddress: Long, + update: RelaySettingsUpdate + ) + private external fun verifyWireguardKey(daemonInterfaceAddress: Long): Boolean? + + private fun notifyAppVersionInfoEvent(appVersionInfo: AppVersionInfo) { + onAppVersionInfoChange?.invoke(appVersionInfo) + } + + private fun notifyKeygenEvent(event: KeygenEvent) { + onKeygenEvent?.invoke(event) + } + + private fun notifyRelayListEvent(relayList: RelayList) { + onRelayListChange?.invoke(relayList) + } + + private fun notifySettingsEvent(settings: Settings) { + onSettingsChange.notify(settings) + } + + private fun notifyTunnelStateEvent(event: TunnelState) { + onTunnelStateChange.notify(event) + } + + private fun notifyDaemonStopped() { + onDaemonStopped?.invoke() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadTileService.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadTileService.kt new file mode 100644 index 0000000000..4c15559912 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadTileService.kt @@ -0,0 +1,105 @@ +package net.mullvad.mullvadvpn.service + +import android.content.Intent +import android.graphics.drawable.Icon +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import kotlin.properties.Delegates.observable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.ipc.ServiceConnection +import net.mullvad.mullvadvpn.model.ServiceResult +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.talpid.tunnel.ActionAfterDisconnect + +class MullvadTileService : TileService() { + private var secured by observable(false) { _, _, _ -> + updateTileState() + } + + private lateinit var scope: CoroutineScope + + private lateinit var securedIcon: Icon + private lateinit var unsecuredIcon: Icon + + override fun onCreate() { + super.onCreate() + + securedIcon = Icon.createWithResource(this, R.drawable.small_logo_white) + unsecuredIcon = Icon.createWithResource(this, R.drawable.small_logo_black) + } + + override fun onStartListening() { + super.onStartListening() + + scope = MainScope() + + scope.launch { listenToTunnelState() } + } + + override fun onClick() { + super.onClick() + + val intent = Intent(this, MullvadVpnService::class.java) + + if (secured) { + intent.action = MullvadVpnService.KEY_DISCONNECT_ACTION + startService(intent) + } else { + intent.action = MullvadVpnService.KEY_CONNECT_ACTION + startForegroundService(intent) + } + } + + override fun onStopListening() { + scope.cancel() + super.onStopListening() + } + + @OptIn(FlowPreview::class) + private suspend fun listenToTunnelState() { + ServiceConnection(this@MullvadTileService, scope) + .tunnelState + .debounce(300L) + .collect { updateTunnelState(it.first, it.second) } + } + + private fun updateTunnelState( + tunnelState: TunnelState, + connectionState: ServiceResult.ConnectionState + ) { + secured = if (connectionState == ServiceResult.ConnectionState.CONNECTED) { + when (tunnelState) { + is TunnelState.Disconnected -> false + is TunnelState.Connecting -> true + is TunnelState.Connected -> true + is TunnelState.Disconnecting -> { + tunnelState.actionAfterDisconnect == ActionAfterDisconnect.Reconnect + } + is TunnelState.Error -> tunnelState.errorState.isBlocking + } + } else { + false + } + } + + private fun updateTileState() { + qsTile?.apply { + if (secured) { + state = Tile.STATE_ACTIVE + icon = securedIcon + } else { + state = Tile.STATE_INACTIVE + icon = unsecuredIcon + } + + updateTile() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt new file mode 100644 index 0000000000..71067c44d7 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt @@ -0,0 +1,244 @@ +package net.mullvad.mullvadvpn.service + +import android.app.KeyguardManager +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.os.IBinder +import android.os.Looper +import android.util.Log +import kotlin.properties.Delegates.observable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.model.Settings +import net.mullvad.mullvadvpn.service.endpoint.ServiceEndpoint +import net.mullvad.mullvadvpn.service.notifications.AccountExpiryNotification +import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.talpid.TalpidVpnService + +class MullvadVpnService : TalpidVpnService() { + companion object { + private val TAG = "mullvad" + + val KEY_CONNECT_ACTION = "net.mullvad.mullvadvpn.connect_action" + val KEY_DISCONNECT_ACTION = "net.mullvad.mullvadvpn.disconnect_action" + val KEY_QUIT_ACTION = "net.mullvad.mullvadvpn.quit_action" + + init { + System.loadLibrary("mullvad_jni") + } + } + + private enum class PendingAction { + Connect, + Disconnect, + } + + private enum class State { + Running, + Stopping, + Stopped, + } + + private val connectionProxy + get() = endpoint.connectionProxy + + private var state = State.Running + + private var setUpDaemonJob: Job? = null + + private lateinit var accountExpiryNotification: AccountExpiryNotification + private lateinit var daemonInstance: DaemonInstance + private lateinit var endpoint: ServiceEndpoint + private lateinit var keyguardManager: KeyguardManager + private lateinit var notificationManager: ForegroundNotificationManager + + private var pendingAction by observable<PendingAction?>(null) { _, _, _ -> + endpoint.settingsListener.settings?.let { settings -> + handlePendingAction(settings) + } + } + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "Initializing service") + + daemonInstance = DaemonInstance(this) + keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + + endpoint = ServiceEndpoint( + Looper.getMainLooper(), + daemonInstance.intermittentDaemon, + connectivityListener, + this + ) + + endpoint.splitTunneling.onChange.subscribe(this@MullvadVpnService) { excludedApps -> + disallowedApps = excludedApps + markTunAsStale() + connectionProxy.reconnect() + } + + notificationManager = + ForegroundNotificationManager(this, connectionProxy, keyguardManager).apply { + accountNumberEvents = endpoint.settingsListener.accountNumberNotifier + } + + accountExpiryNotification = AccountExpiryNotification( + this, + daemonInstance.intermittentDaemon, + endpoint.accountCache + ) + + daemonInstance.apply { + intermittentDaemon.registerListener(this@MullvadVpnService) { daemon -> + handleDaemonInstance(daemon) + } + + start() + } + + // Remove any leftover tunnel state persistence data + getSharedPreferences("tunnel_state", MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "Starting service") + val startResult = super.onStartCommand(intent, flags, startId) + var quitCommand = false + + notificationManager.updateNotification() + + if (!keyguardManager.isDeviceLocked) { + val action = intent?.action + + if (action == VpnService.SERVICE_INTERFACE || action == KEY_CONNECT_ACTION) { + pendingAction = PendingAction.Connect + } else if (action == KEY_DISCONNECT_ACTION) { + pendingAction = PendingAction.Disconnect + } else if (action == KEY_QUIT_ACTION && !notificationManager.onForeground) { + quitCommand = true + stop() + } + } + + if (state == State.Stopping && !quitCommand) { + restart() + } + + return startResult + } + + override fun onBind(intent: Intent): IBinder { + Log.d(TAG, "New connection to service") + return super.onBind(intent) ?: endpoint.messenger.binder + } + + override fun onRebind(intent: Intent) { + Log.d(TAG, "Connection to service restored") + if (state == State.Stopping) { + restart() + } + } + + override fun onRevoke() { + pendingAction = PendingAction.Disconnect + } + + override fun onUnbind(intent: Intent): Boolean { + Log.d(TAG, "Closed all connections to service") + + if (state != State.Running) { + stop() + } + + return true + } + + override fun onDestroy() { + Log.d(TAG, "Service has stopped") + state = State.Stopped + accountExpiryNotification.onDestroy() + notificationManager.onDestroy() + daemonInstance.onDestroy() + super.onDestroy() + } + + private fun handleDaemonInstance(daemon: MullvadDaemon?) { + setUpDaemonJob?.cancel() + + if (daemon != null) { + setUpDaemonJob = setUpDaemon(daemon) + } else { + Log.d(TAG, "Daemon has stopped") + + if (state == State.Running) { + restart() + } + } + } + + private fun setUpDaemon(daemon: MullvadDaemon) = GlobalScope.launch(Dispatchers.Main) { + if (state != State.Stopped) { + val settings = daemon.getSettings() + + if (settings != null) { + handlePendingAction(settings) + } else { + restart() + } + } + } + + private fun stop() { + Log.d(TAG, "Stopping service") + state = State.Stopping + daemonInstance.stop() + stopSelf() + } + + private fun restart() { + if (state != State.Stopped) { + Log.d(TAG, "Restarting service") + + state = State.Running + + daemonInstance.apply { + stop() + start() + } + } else { + Log.d(TAG, "Ignoring restart because onDestroy has executed") + } + } + + private fun handlePendingAction(settings: Settings) { + when (pendingAction) { + PendingAction.Connect -> { + if (settings.accountToken != null) { + connectionProxy.connect() + } else { + openUi() + } + } + PendingAction.Disconnect -> connectionProxy.disconnect() + null -> return + } + + pendingAction = null + } + + private fun openUi() { + val intent = Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + + startActivity(intent) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt new file mode 100644 index 0000000000..768b00e1b5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt @@ -0,0 +1,287 @@ +package net.mullvad.mullvadvpn.service.endpoint + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.channels.sendBlocking +import kotlinx.coroutines.delay +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.model.GetAccountDataResult +import net.mullvad.mullvadvpn.model.LoginStatus +import net.mullvad.mullvadvpn.util.ExponentialBackoff +import net.mullvad.mullvadvpn.util.JobTracker +import net.mullvad.talpid.util.EventNotifier +import org.joda.time.DateTime +import org.joda.time.format.DateTimeFormat + +class AccountCache(private val endpoint: ServiceEndpoint) { + companion object { + public val EXPIRY_FORMAT = DateTimeFormat.forPattern("YYYY-MM-dd HH:mm:ss z") + + // Number of retry attempts to check for a changed expiry before giving up. + // Current value will force the cache to keep fetching for about four minutes or until a new + // expiry value is received. + // This is only used if the expiry was invalidated and fetching a new expiry returns the + // same value as before the invalidation. + private const val MAX_INVALIDATED_RETRIES = 7 + + private sealed class Command { + object CreateAccount : Command() + data class Login(val account: String) : Command() + object Logout : Command() + } + } + + private val commandChannel = spawnActor() + + private val daemon + get() = endpoint.intermittentDaemon + + val onAccountNumberChange = EventNotifier<String?>(null) + val onAccountExpiryChange = EventNotifier<DateTime?>(null) + val onAccountHistoryChange = EventNotifier<String?>(null) + val onLoginStatusChange = EventNotifier<LoginStatus?>(null) + + var newlyCreatedAccount = false + private set + + private val jobTracker = JobTracker() + + private var accountNumber by onAccountNumberChange.notifiable() + private var accountExpiry by onAccountExpiryChange.notifiable() + private var accountHistory by onAccountHistoryChange.notifiable() + + private var createdAccountExpiry: DateTime? = null + private var oldAccountExpiry: DateTime? = null + + var account: String? + get() = endpoint.settingsListener.accountNumberNotifier.latestEvent + set(value) { + jobTracker.newBackgroundJob("setAccount") { + daemon.await().setAccount(value) + } + } + + var loginStatus by onLoginStatusChange.notifiable() + private set + + init { + endpoint.settingsListener.accountNumberNotifier.subscribe(this) { accountNumber -> + handleNewAccountNumber(accountNumber) + } + + onAccountHistoryChange.subscribe(this) { history -> + endpoint.sendEvent(Event.AccountHistory(history)) + } + + onLoginStatusChange.subscribe(this) { status -> + endpoint.sendEvent(Event.LoginStatus(status)) + } + + endpoint.dispatcher.apply { + registerHandler(Request.CreateAccount::class) { _ -> + commandChannel.sendBlocking(Command.CreateAccount) + } + + registerHandler(Request.Login::class) { request -> + request.account?.let { account -> + commandChannel.sendBlocking(Command.Login(account)) + } + } + + registerHandler(Request.Logout::class) { _ -> + commandChannel.sendBlocking(Command.Logout) + } + + registerHandler(Request.FetchAccountExpiry::class) { _ -> + fetchAccountExpiry() + } + + registerHandler(Request.InvalidateAccountExpiry::class) { request -> + invalidateAccountExpiry(request.expiry) + } + + registerHandler(Request.ClearAccountHistory::class) { _ -> + clearAccountHistory() + } + } + } + + fun onDestroy() { + endpoint.settingsListener.accountNumberNotifier.unsubscribe(this) + jobTracker.cancelAllJobs() + + onAccountNumberChange.unsubscribeAll() + onAccountExpiryChange.unsubscribeAll() + onAccountHistoryChange.unsubscribeAll() + onLoginStatusChange.unsubscribeAll() + + commandChannel.close() + } + + private fun fetchAccountExpiry() { + synchronized(this) { + accountNumber?.let { account -> + jobTracker.newBackgroundJob("fetch") { + val delays = ExponentialBackoff().apply { + cap = 2 /* h */ * 60 /* min */ * 60 /* s */ * 1000 /* ms */ + } + + do { + val result = daemon.await().getAccountData(account) + + if (result is GetAccountDataResult.Ok) { + val expiry = result.accountData.expiry + val retryAttempt = delays.iteration + + if (handleNewExpiry(account, expiry, retryAttempt)) { + break + } + } else if (result is GetAccountDataResult.InvalidAccount) { + break + } + + delay(delays.next()) + } while (onAccountExpiryChange.hasListeners()) + } + } + } + } + + private fun invalidateAccountExpiry(accountExpiryToInvalidate: DateTime) { + synchronized(this) { + if (accountExpiry == accountExpiryToInvalidate) { + oldAccountExpiry = accountExpiryToInvalidate + fetchAccountExpiry() + } + } + } + + private fun clearAccountHistory() { + jobTracker.newBackgroundJob("clearAccountHistory") { + daemon.await().clearAccountHistory() + fetchAccountHistory() + } + } + + private fun spawnActor() = GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) { + try { + for (command in channel) { + when (command) { + is Command.CreateAccount -> doCreateAccount() + is Command.Login -> doLogin(command.account) + is Command.Logout -> doLogout() + } + } + } catch (exception: ClosedReceiveChannelException) { + // Command channel was closed, stop the actor + } + } + + private suspend fun doCreateAccount() { + newlyCreatedAccount = true + createdAccountExpiry = null + + daemon.await().createNewAccount() + } + + private suspend fun doLogin(account: String) { + val result = daemon.await().getAccountData(account) + + when (result) { + is GetAccountDataResult.Ok -> { + val expiry = DateTime.parse(result.accountData.expiry, EXPIRY_FORMAT) + + finishLogin(account, expiry) + } + is GetAccountDataResult.RpcError -> finishLogin(account, null) + else -> finishLogin(null, null) + } + } + + private suspend fun finishLogin(maybeAccount: String?, expiry: DateTime?) { + synchronized(this) { + markAccountAsNotNew() + + accountNumber = maybeAccount + accountExpiry = expiry + + loginStatus = maybeAccount?.let { account -> + LoginStatus(account, expiry, false) + } + } + + daemon.await().setAccount(maybeAccount) + } + + private suspend fun doLogout() { + if (accountNumber != null) { + daemon.await().setAccount(null) + } + } + + private fun fetchAccountHistory() { + jobTracker.newBackgroundJob("fetchHistory") { + daemon.await().getAccountHistory().let { history -> + accountHistory = history + } + } + } + + private fun markAccountAsNotNew() { + newlyCreatedAccount = false + createdAccountExpiry = null + } + + private fun handleNewAccountNumber(newAccountNumber: String?) { + synchronized(this) { + accountExpiry = null + accountNumber = newAccountNumber + + loginStatus = newAccountNumber?.let { account -> + LoginStatus(account, null, newlyCreatedAccount) + } + + fetchAccountExpiry() + fetchAccountHistory() + } + } + + private fun handleNewExpiry( + accountNumberUsedForFetch: String, + expiryString: String, + retryAttempt: Int + ): Boolean { + synchronized(this) { + if (accountNumber !== accountNumberUsedForFetch) { + return true + } + + val newAccountExpiry = DateTime.parse(expiryString, EXPIRY_FORMAT) + + if (newAccountExpiry != oldAccountExpiry || retryAttempt >= MAX_INVALIDATED_RETRIES) { + accountExpiry = newAccountExpiry + oldAccountExpiry = null + + loginStatus = loginStatus?.let { currentStatus -> + LoginStatus(currentStatus.account, newAccountExpiry, currentStatus.isNewAccount) + } + + if (accountExpiry != null && newlyCreatedAccount) { + if (createdAccountExpiry == null) { + createdAccountExpiry = accountExpiry + } else if (accountExpiry != createdAccountExpiry) { + markAccountAsNotNew() + } + } + + return true + } + + return false + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AppVersionInfoCache.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AppVersionInfoCache.kt new file mode 100644 index 0000000000..0c95293ae7 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AppVersionInfoCache.kt @@ -0,0 +1,56 @@ +package net.mullvad.mullvadvpn.service.endpoint + +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.model.AppVersionInfo +import net.mullvad.mullvadvpn.service.MullvadDaemon + +class AppVersionInfoCache(endpoint: ServiceEndpoint) { + private val daemon = endpoint.intermittentDaemon + + var appVersionInfo by observable<AppVersionInfo?>(null) { _, _, info -> + endpoint.sendEvent(Event.AppVersionInfo(info)) + } + private set + + var currentVersion by observable<String?>(null) { _, _, version -> + endpoint.sendEvent(Event.CurrentVersion(version)) + } + private set + + init { + daemon.registerListener(this) { newDaemon -> + newDaemon?.let { daemon -> + initializeCurrentVersion(daemon) + registerVersionInfoListener(daemon) + fetchInitialVersionInfo(daemon) + } + } + } + + fun onDestroy() { + daemon.unregisterListener(this) + } + + private fun initializeCurrentVersion(daemon: MullvadDaemon) { + if (currentVersion == null) { + currentVersion = daemon.getCurrentVersion() + } + } + + private fun registerVersionInfoListener(daemon: MullvadDaemon) { + daemon.onAppVersionInfoChange = { newAppVersionInfo -> + synchronized(this@AppVersionInfoCache) { + appVersionInfo = newAppVersionInfo + } + } + } + + private fun fetchInitialVersionInfo(daemon: MullvadDaemon) { + synchronized(this@AppVersionInfoCache) { + if (appVersionInfo == null) { + appVersionInfo = daemon.getVersionInfo() + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AuthTokenCache.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AuthTokenCache.kt new file mode 100644 index 0000000000..1ea2acec39 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AuthTokenCache.kt @@ -0,0 +1,49 @@ +package net.mullvad.mullvadvpn.service.endpoint + +import kotlin.properties.Delegates.observable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.channels.sendBlocking +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.Request + +class AuthTokenCache(endpoint: ServiceEndpoint) { + companion object { + private enum class Command { + Fetch + } + } + + private val daemon = endpoint.intermittentDaemon + private val requestQueue = spawnActor() + + var authToken by observable<String?>(null) { _, _, token -> + endpoint.sendEvent(Event.AuthToken(token)) + } + private set + + init { + endpoint.dispatcher.registerHandler(Request.FetchAuthToken::class) { _ -> + requestQueue.sendBlocking(Command.Fetch) + } + } + + fun onDestroy() { + requestQueue.close() + } + + private fun spawnActor() = GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) { + try { + for (command in channel) { + when (command) { + Command.Fetch -> authToken = daemon.await().getWwwAuthToken() + } + } + } catch (exception: ClosedReceiveChannelException) { + // Closed sender, so stop the actor + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ConnectionProxy.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ConnectionProxy.kt new file mode 100644 index 0000000000..94cc9f05b8 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ConnectionProxy.kt @@ -0,0 +1,84 @@ +package net.mullvad.mullvadvpn.service.endpoint + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.channels.sendBlocking +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.talpid.util.EventNotifier + +class ConnectionProxy(val vpnPermission: VpnPermission, endpoint: ServiceEndpoint) { + private enum class Command { + CONNECT, + RECONNECT, + DISCONNECT, + } + + private val commandChannel = spawnActor() + private val daemon = endpoint.intermittentDaemon + private val initialState = TunnelState.Disconnected + + var onStateChange = EventNotifier<TunnelState>(initialState) + + var state by onStateChange.notifiable() + private set + + init { + daemon.registerListener(this) { newDaemon -> + newDaemon?.onTunnelStateChange?.subscribe(this@ConnectionProxy) { newState -> + state = newState + } + } + + onStateChange.subscribe(this) { tunnelState -> + endpoint.sendEvent(Event.TunnelStateChange(tunnelState)) + } + + endpoint.dispatcher.apply { + registerHandler(Request.Connect::class) { _ -> connect() } + registerHandler(Request.Reconnect::class) { _ -> reconnect() } + registerHandler(Request.Disconnect::class) { _ -> disconnect() } + } + } + + fun connect() { + commandChannel.sendBlocking(Command.CONNECT) + } + + fun reconnect() { + commandChannel.sendBlocking(Command.RECONNECT) + } + + fun disconnect() { + commandChannel.sendBlocking(Command.DISCONNECT) + } + + fun onDestroy() { + commandChannel.close() + onStateChange.unsubscribeAll() + daemon.unregisterListener(this) + } + + private fun spawnActor() = GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) { + try { + while (true) { + val command = channel.receive() + + when (command) { + Command.CONNECT -> { + vpnPermission.request() + daemon.await().connect() + } + Command.RECONNECT -> daemon.await().reconnect() + Command.DISCONNECT -> daemon.await().disconnect() + } + } + } catch (exception: ClosedReceiveChannelException) { + // Closed sender, so stop the actor + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomDns.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomDns.kt new file mode 100644 index 0000000000..7c22cef50c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomDns.kt @@ -0,0 +1,113 @@ +package net.mullvad.mullvadvpn.service.endpoint + +import java.net.InetAddress +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.channels.sendBlocking +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.model.DnsOptions + +class CustomDns(private val endpoint: ServiceEndpoint) { + private sealed class Command { + class AddDnsServer(val server: InetAddress) : Command() + class RemoveDnsServer(val server: InetAddress) : Command() + class ReplaceDnsServer(val oldServer: InetAddress, val newServer: InetAddress) : Command() + class SetEnabled(val enabled: Boolean) : Command() + } + + private val commandChannel = spawnActor() + private val dnsServers = ArrayList<InetAddress>() + + private val daemon + get() = endpoint.intermittentDaemon + + private var enabled = false + + init { + endpoint.settingsListener.dnsOptionsNotifier.subscribe(this) { maybeDnsOptions -> + maybeDnsOptions?.let { dnsOptions -> + enabled = dnsOptions.custom + dnsServers.clear() + dnsServers.addAll(dnsOptions.addresses) + } + } + + endpoint.dispatcher.apply { + registerHandler(Request.AddCustomDnsServer::class) { request -> + commandChannel.sendBlocking(Command.AddDnsServer(request.address)) + } + + registerHandler(Request.RemoveCustomDnsServer::class) { request -> + commandChannel.sendBlocking(Command.RemoveDnsServer(request.address)) + } + + registerHandler(Request.ReplaceCustomDnsServer::class) { request -> + commandChannel.sendBlocking( + Command.ReplaceDnsServer(request.oldAddress, request.newAddress) + ) + } + + registerHandler(Request.SetEnableCustomDns::class) { request -> + commandChannel.sendBlocking(Command.SetEnabled(request.enable)) + } + } + } + + fun onDestroy() { + endpoint.settingsListener.dnsOptionsNotifier.unsubscribe(this) + commandChannel.close() + } + + private fun spawnActor() = GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) { + try { + while (true) { + val command = channel.receive() + + when (command) { + is Command.AddDnsServer -> doAddDnsServer(command.server) + is Command.RemoveDnsServer -> doRemoveDnsServer(command.server) + is Command.ReplaceDnsServer -> { + doReplaceDnsServer(command.oldServer, command.newServer) + } + is Command.SetEnabled -> changeDnsOptions(command.enabled) + } + } + } catch (exception: ClosedReceiveChannelException) { + // Closed sender, so stop the actor + } + } + + private suspend fun doAddDnsServer(server: InetAddress) { + if (!dnsServers.contains(server)) { + dnsServers.add(server) + changeDnsOptions(enabled) + } + } + + private suspend fun doReplaceDnsServer(oldServer: InetAddress, newServer: InetAddress) { + if (oldServer != newServer && !dnsServers.contains(newServer)) { + val index = dnsServers.indexOf(oldServer) + + if (index >= 0) { + dnsServers.removeAt(index) + dnsServers.add(index, newServer) + changeDnsOptions(enabled) + } + } + } + + private suspend fun doRemoveDnsServer(server: InetAddress) { + if (dnsServers.remove(server)) { + changeDnsOptions(enabled) + } + } + + private suspend fun changeDnsOptions(enable: Boolean) { + val options = DnsOptions(enable, dnsServers) + + daemon.await().setDnsOptions(options) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/KeyStatusListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/KeyStatusListener.kt new file mode 100644 index 0000000000..70ac7ef827 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/KeyStatusListener.kt @@ -0,0 +1,95 @@ +package net.mullvad.mullvadvpn.service.endpoint + +import kotlin.properties.Delegates.observable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.channels.sendBlocking +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.model.KeygenEvent + +class KeyStatusListener(endpoint: ServiceEndpoint) { + companion object { + private enum class Command { + GenerateKey, + VerifyKey, + } + } + + private val daemon = endpoint.intermittentDaemon + + private val commandChannel = spawnActor() + + var keyStatus by observable<KeygenEvent?>(null) { _, _, status -> + endpoint.sendEvent(Event.WireGuardKeyStatus(status)) + } + private set + + init { + daemon.registerListener(this) { newDaemon -> + newDaemon?.apply { + keyStatus = getWireguardKey()?.let { wireguardKey -> + KeygenEvent.NewKey(wireguardKey, null, null) + } + + onKeygenEvent = { event -> keyStatus = event } + } + } + + endpoint.dispatcher.apply { + registerHandler(Request.WireGuardGenerateKey::class) { _ -> + commandChannel.sendBlocking(Command.GenerateKey) + } + + registerHandler(Request.WireGuardVerifyKey::class) { _ -> + commandChannel.sendBlocking(Command.VerifyKey) + } + } + } + + fun onDestroy() { + commandChannel.close() + daemon.unregisterListener(this) + } + + private fun spawnActor() = GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) { + try { + for (command in channel) { + when (command) { + Command.GenerateKey -> generateKey() + Command.VerifyKey -> verifyKey() + } + } + } catch (exception: ClosedReceiveChannelException) { + } + } + + private suspend fun generateKey() { + val oldStatus = keyStatus + val newStatus = daemon.await().generateWireguardKey() + val newFailure = newStatus?.failure() + if (oldStatus is KeygenEvent.NewKey && newFailure != null) { + keyStatus = KeygenEvent.NewKey( + oldStatus.publicKey, + oldStatus.verified, + newFailure + ) + } else { + keyStatus = newStatus ?: KeygenEvent.GenerationFailure + } + } + + private suspend fun verifyKey() { + // Only update verification status if the key is actually there + (keyStatus as? KeygenEvent.NewKey)?.let { currentStatus -> + keyStatus = KeygenEvent.NewKey( + currentStatus.publicKey, + daemon.await().verifyWireguardKey(), + currentStatus.replacementFailure + ) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/LocationInfoCache.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/LocationInfoCache.kt new file mode 100644 index 0000000000..df769abad9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/LocationInfoCache.kt @@ -0,0 +1,139 @@ +package net.mullvad.mullvadvpn.service.endpoint + +import kotlin.properties.Delegates.observable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.channels.sendBlocking +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.receiveAsFlow +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.model.Constraint +import net.mullvad.mullvadvpn.model.GeoIpLocation +import net.mullvad.mullvadvpn.model.RelaySettings +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.util.ExponentialBackoff +import net.mullvad.talpid.tunnel.ActionAfterDisconnect + +class LocationInfoCache(private val endpoint: ServiceEndpoint) { + companion object { + private enum class RequestFetch { + ForRealLocation, + ForRelayLocation, + } + } + + private val fetchRetryDelays = ExponentialBackoff().apply { + scale = 50 + cap = 30 /* min */ * 60 /* s */ * 1000 /* ms */ + count = 17 // ceil(log2(cap / scale) + 1) + } + + private val fetchRequestChannel = runFetcher() + + private val daemon + get() = endpoint.intermittentDaemon + + private var lastKnownRealLocation: GeoIpLocation? = null + private var selectedRelayLocation: GeoIpLocation? = null + + var location: GeoIpLocation? by observable(null) { _, _, newLocation -> + endpoint.sendEvent(Event.NewLocation(newLocation)) + } + + var state by observable<TunnelState>(TunnelState.Disconnected) { _, _, newState -> + when (newState) { + is TunnelState.Disconnected -> { + location = lastKnownRealLocation + fetchRequestChannel.sendBlocking(RequestFetch.ForRealLocation) + } + is TunnelState.Connecting -> location = newState.location + is TunnelState.Connected -> { + location = newState.location + fetchRequestChannel.sendBlocking(RequestFetch.ForRelayLocation) + } + is TunnelState.Disconnecting -> { + when (newState.actionAfterDisconnect) { + ActionAfterDisconnect.Nothing -> location = lastKnownRealLocation + ActionAfterDisconnect.Block -> location = null + ActionAfterDisconnect.Reconnect -> location = selectedRelayLocation + } + } + is TunnelState.Error -> location = null + } + } + + init { + endpoint.connectionProxy.onStateChange.subscribe(this) { newState -> + state = newState + } + + endpoint.connectivityListener.connectivityNotifier.subscribe(this) { isConnected -> + if (isConnected && state is TunnelState.Disconnected) { + fetchRequestChannel.sendBlocking(RequestFetch.ForRealLocation) + } + } + + endpoint.settingsListener.relaySettingsNotifier.subscribe(this, ::updateSelectedLocation) + } + + fun onDestroy() { + endpoint.connectionProxy.onStateChange.unsubscribe(this) + endpoint.connectivityListener.connectivityNotifier.unsubscribe(this) + endpoint.settingsListener.relaySettingsNotifier.unsubscribe(this) + + fetchRequestChannel.close() + } + + private fun runFetcher() = GlobalScope.actor<RequestFetch>( + Dispatchers.Default, + Channel.CONFLATED + ) { + try { + fetcherLoop(channel) + } catch (exception: ClosedReceiveChannelException) { + } + } + + private suspend fun fetcherLoop(channel: ReceiveChannel<RequestFetch>) { + channel.receiveAsFlow() + .flatMapLatest(::fetchCurrentLocation) + .collect(::handleFetchedLocation) + } + + private fun fetchCurrentLocation(fetchType: RequestFetch) = flow { + var newLocation = daemon.await().getCurrentLocation() + + fetchRetryDelays.reset() + + while (newLocation == null) { + delay(fetchRetryDelays.next()) + newLocation = daemon.await().getCurrentLocation() + } + + emit(Pair(newLocation, fetchType)) + } + + private suspend fun handleFetchedLocation(pairItem: Pair<GeoIpLocation, RequestFetch>) { + val (newLocation, fetchType) = pairItem + + if (fetchType == RequestFetch.ForRealLocation) { + lastKnownRealLocation = newLocation + } + + location = newLocation + } + + private fun updateSelectedLocation(relaySettings: RelaySettings?) { + val settings = relaySettings as? RelaySettings.Normal + val constraint = settings?.relayConstraints?.location as? Constraint.Only + + selectedRelayLocation = constraint?.value?.location + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayListListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayListListener.kt new file mode 100644 index 0000000000..a65c313f54 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayListListener.kt @@ -0,0 +1,91 @@ +package net.mullvad.mullvadvpn.service.endpoint + +import kotlin.properties.Delegates.observable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.channels.sendBlocking +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.model.Constraint +import net.mullvad.mullvadvpn.model.LocationConstraint +import net.mullvad.mullvadvpn.model.RelayConstraintsUpdate +import net.mullvad.mullvadvpn.model.RelayList +import net.mullvad.mullvadvpn.model.RelaySettingsUpdate +import net.mullvad.mullvadvpn.service.MullvadDaemon + +class RelayListListener(endpoint: ServiceEndpoint) { + companion object { + private enum class Command { + SetRelayLocation, + } + } + + private val commandChannel = spawnActor() + private val daemon = endpoint.intermittentDaemon + + private var selectedRelayLocation by observable<LocationConstraint?>(null) { _, _, _ -> + commandChannel.sendBlocking(Command.SetRelayLocation) + } + + var relayList by observable<RelayList?>(null) { _, _, relays -> + endpoint.sendEvent(Event.NewRelayList(relays)) + } + private set + + init { + daemon.registerListener(this) { newDaemon -> + newDaemon?.let { daemon -> + setUpListener(daemon) + fetchInitialRelayList(daemon) + } + } + + endpoint.dispatcher.registerHandler(Request.SetRelayLocation::class) { request -> + selectedRelayLocation = request.relayLocation + } + } + + fun onDestroy() { + commandChannel.close() + daemon.unregisterListener(this) + } + + private fun setUpListener(daemon: MullvadDaemon) { + daemon.onRelayListChange = { relayLocations -> + relayList = relayLocations + } + } + + private fun fetchInitialRelayList(daemon: MullvadDaemon) { + synchronized(this) { + if (relayList == null) { + relayList = daemon.getRelayLocations() + } + } + } + + private fun spawnActor() = GlobalScope.actor<Command>(Dispatchers.Default, Channel.CONFLATED) { + try { + for (command in channel) { + when (command) { + Command.SetRelayLocation -> updateRelayConstraints() + } + } + } catch (exception: ClosedReceiveChannelException) { + // Closed sender, so stop the actor + } + } + + private suspend fun updateRelayConstraints() { + val constraint: Constraint<LocationConstraint> = selectedRelayLocation?.let { location -> + Constraint.Only(location) + } ?: Constraint.Any() + + val update = RelaySettingsUpdate.Normal(RelayConstraintsUpdate(constraint)) + + daemon.await().updateRelaySettings(update) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt new file mode 100644 index 0000000000..0a0c41b42e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt @@ -0,0 +1,170 @@ +package net.mullvad.mullvadvpn.service.endpoint + +import android.content.Context +import android.os.DeadObjectException +import android.os.Looper +import android.os.Messenger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.channels.sendBlocking +import net.mullvad.mullvadvpn.ipc.DispatchingHandler +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.service.MullvadDaemon +import net.mullvad.mullvadvpn.service.persistence.SplitTunnelingPersistence +import net.mullvad.mullvadvpn.util.Intermittent +import net.mullvad.talpid.ConnectivityListener + +class ServiceEndpoint( + looper: Looper, + internal val intermittentDaemon: Intermittent<MullvadDaemon>, + val connectivityListener: ConnectivityListener, + context: Context +) { + companion object { + sealed class Command { + data class RegisterListener(val listener: Messenger) : Command() + data class UnregisterListener(val listenerId: Int) : Command() + } + } + + private val listeners = mutableMapOf<Int, Messenger>() + private val commands: SendChannel<Command> = startRegistrator() + + internal val dispatcher = DispatchingHandler(looper) { message -> + Request.fromMessage(message) + } + + private var listenerIdCounter = 0 + + val messenger = Messenger(dispatcher) + + val vpnPermission = VpnPermission(context, this) + + val connectionProxy = ConnectionProxy(vpnPermission, this) + val settingsListener = SettingsListener(this) + + val accountCache = AccountCache(this) + val appVersionInfoCache = AppVersionInfoCache(this) + val authTokenCache = AuthTokenCache(this) + val customDns = CustomDns(this) + val keyStatusListener = KeyStatusListener(this) + val locationInfoCache = LocationInfoCache(this) + val relayListListener = RelayListListener(this) + val splitTunneling = SplitTunneling(SplitTunnelingPersistence(context), this) + val voucherRedeemer = VoucherRedeemer(this) + + init { + dispatcher.apply { + registerHandler(Request.RegisterListener::class) { request -> + commands.sendBlocking(Command.RegisterListener(request.listener)) + } + + registerHandler(Request.UnregisterListener::class) { request -> + commands.sendBlocking(Command.UnregisterListener(request.listenerId)) + } + } + } + + fun onDestroy() { + dispatcher.onDestroy() + commands.close() + + accountCache.onDestroy() + appVersionInfoCache.onDestroy() + authTokenCache.onDestroy() + connectionProxy.onDestroy() + customDns.onDestroy() + keyStatusListener.onDestroy() + locationInfoCache.onDestroy() + relayListListener.onDestroy() + settingsListener.onDestroy() + splitTunneling.onDestroy() + voucherRedeemer.onDestroy() + } + + internal fun sendEvent(event: Event) { + synchronized(this) { + val deadListeners = mutableSetOf<Int>() + + for ((id, listener) in listeners) { + try { + listener.send(event.message) + } catch (_: DeadObjectException) { + deadListeners.add(id) + } + } + + deadListeners.forEach { listeners.remove(it) } + } + } + + private fun startRegistrator() = GlobalScope.actor<Command>( + Dispatchers.Default, + Channel.UNLIMITED + ) { + try { + for (command in channel) { + when (command) { + is Command.RegisterListener -> { + intermittentDaemon.await() + + registerListener(command.listener) + } + is Command.UnregisterListener -> unregisterListener(command.listenerId) + } + } + } catch (exception: ClosedReceiveChannelException) { + // Registration queue closed; stop registrator + } + } + + private fun registerListener(listener: Messenger) { + synchronized(this) { + val listenerId = newListenerId() + + listeners.put(listenerId, listener) + + val initialEvents = mutableListOf( + Event.TunnelStateChange(connectionProxy.state), + Event.LoginStatus(accountCache.onLoginStatusChange.latestEvent), + Event.AccountHistory(accountCache.onAccountHistoryChange.latestEvent), + Event.SettingsUpdate(settingsListener.settings), + Event.NewLocation(locationInfoCache.location), + Event.WireGuardKeyStatus(keyStatusListener.keyStatus), + Event.SplitTunnelingUpdate(splitTunneling.onChange.latestEvent), + Event.CurrentVersion(appVersionInfoCache.currentVersion), + Event.AppVersionInfo(appVersionInfoCache.appVersionInfo), + Event.NewRelayList(relayListListener.relayList), + Event.AuthToken(authTokenCache.authToken), + Event.ListenerReady(messenger, listenerId) + ) + + if (vpnPermission.waitingForResponse) { + initialEvents.add(Event.VpnPermissionRequest) + } + + initialEvents.forEach { event -> + listener.send(event.message) + } + } + } + + private fun unregisterListener(listenerId: Int) { + synchronized(this) { + listeners.remove(listenerId) + } + } + + private fun newListenerId(): Int { + val listenerId = listenerIdCounter + + listenerIdCounter += 1 + + return listenerId + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt new file mode 100644 index 0000000000..c903fc9e37 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt @@ -0,0 +1,127 @@ +package net.mullvad.mullvadvpn.service.endpoint + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.channels.sendBlocking +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.model.DnsOptions +import net.mullvad.mullvadvpn.model.RelaySettings +import net.mullvad.mullvadvpn.model.Settings +import net.mullvad.mullvadvpn.service.MullvadDaemon +import net.mullvad.talpid.util.EventNotifier + +class SettingsListener(endpoint: ServiceEndpoint) { + private sealed class Command { + class SetAllowLan(val allow: Boolean) : Command() + class SetAutoConnect(val autoConnect: Boolean) : Command() + class SetWireGuardMtu(val mtu: Int?) : Command() + } + + private val commandChannel = spawnActor() + private val daemon = endpoint.intermittentDaemon + + val accountNumberNotifier = EventNotifier<String?>(null) + val dnsOptionsNotifier = EventNotifier<DnsOptions?>(null) + val relaySettingsNotifier = EventNotifier<RelaySettings?>(null) + val settingsNotifier = EventNotifier<Settings?>(null) + + var settings by settingsNotifier.notifiable() + private set + + init { + daemon.registerListener(this) { newDaemon -> + if (newDaemon != null) { + registerListener(newDaemon) + fetchInitialSettings(newDaemon) + } + } + + settingsNotifier.subscribe(this) { settings -> + endpoint.sendEvent(Event.SettingsUpdate(settings)) + } + + endpoint.dispatcher.apply { + registerHandler(Request.SetAllowLan::class) { request -> + commandChannel.sendBlocking(Command.SetAllowLan(request.allow)) + } + + registerHandler(Request.SetAutoConnect::class) { request -> + commandChannel.sendBlocking(Command.SetAutoConnect(request.autoConnect)) + } + + registerHandler(Request.SetWireGuardMtu::class) { request -> + commandChannel.sendBlocking(Command.SetWireGuardMtu(request.mtu)) + } + } + } + + fun onDestroy() { + commandChannel.close() + daemon.unregisterListener(this) + + accountNumberNotifier.unsubscribeAll() + dnsOptionsNotifier.unsubscribeAll() + relaySettingsNotifier.unsubscribeAll() + settingsNotifier.unsubscribeAll() + } + + fun subscribe(id: Any, listener: (Settings) -> Unit) { + settingsNotifier.subscribe(id) { maybeSettings -> + maybeSettings?.let { settings -> + listener(settings) + } + } + } + + fun unsubscribe(id: Any) { + settingsNotifier.unsubscribe(id) + } + + private fun registerListener(daemon: MullvadDaemon) { + daemon.onSettingsChange.subscribe(this, ::handleNewSettings) + } + + private fun fetchInitialSettings(daemon: MullvadDaemon) { + synchronized(this) { + handleNewSettings(daemon.getSettings()) + } + } + + private fun handleNewSettings(newSettings: Settings?) { + if (newSettings != null) { + synchronized(this) { + if (settings?.accountToken != newSettings.accountToken) { + accountNumberNotifier.notify(newSettings.accountToken) + } + + if (settings?.tunnelOptions?.dnsOptions != newSettings.tunnelOptions.dnsOptions) { + dnsOptionsNotifier.notify(newSettings.tunnelOptions.dnsOptions) + } + + if (settings?.relaySettings != newSettings.relaySettings) { + relaySettingsNotifier.notify(newSettings.relaySettings) + } + + settings = newSettings + } + } + } + + private fun spawnActor() = GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) { + try { + for (command in channel) { + when (command) { + is Command.SetAllowLan -> daemon.await().setAllowLan(command.allow) + is Command.SetAutoConnect -> daemon.await().setAutoConnect(command.autoConnect) + is Command.SetWireGuardMtu -> daemon.await().setWireguardMtu(command.mtu) + } + } + } catch (exception: ClosedReceiveChannelException) { + // Closed sender, so stop the actor + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SplitTunneling.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SplitTunneling.kt new file mode 100644 index 0000000000..d6455ea9a3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SplitTunneling.kt @@ -0,0 +1,58 @@ +package net.mullvad.mullvadvpn.service.endpoint + +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.service.persistence.SplitTunnelingPersistence +import net.mullvad.talpid.util.EventNotifier + +class SplitTunneling(persistence: SplitTunnelingPersistence, endpoint: ServiceEndpoint) { + private val excludedApps = persistence.excludedApps.toMutableSet() + + private var enabled by observable(persistence.enabled) { _, wasEnabled, isEnabled -> + if (wasEnabled != isEnabled) { + persistence.enabled = isEnabled + update() + } + } + + val onChange = EventNotifier<List<String>?>(excludedApps.toList()) + + init { + onChange.subscribe(this) { excludedApps -> + endpoint.sendEvent(Event.SplitTunnelingUpdate(excludedApps)) + } + + endpoint.dispatcher.apply { + registerHandler(Request.IncludeApp::class) { request -> + excludedApps.remove(request.packageName) + update() + } + + registerHandler(Request.ExcludeApp::class) { request -> + excludedApps.add(request.packageName) + update() + } + + registerHandler(Request.SetEnableSplitTunneling::class) { request -> + enabled = request.enable + } + + registerHandler(Request.PersistExcludedApps::class) { _ -> + persistence.excludedApps = excludedApps + } + } + } + + fun onDestroy() { + onChange.unsubscribeAll() + } + + private fun update() { + if (enabled) { + onChange.notify(excludedApps.toList()) + } else { + onChange.notify(null) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VoucherRedeemer.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VoucherRedeemer.kt new file mode 100644 index 0000000000..6b83b0d333 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VoucherRedeemer.kt @@ -0,0 +1,40 @@ +package net.mullvad.mullvadvpn.service.endpoint + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.channels.sendBlocking +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.model.VoucherSubmissionResult + +class VoucherRedeemer(private val endpoint: ServiceEndpoint) { + private val daemon + get() = endpoint.intermittentDaemon + + private val voucherChannel = spawnActor() + + init { + endpoint.dispatcher.registerHandler(Request.SubmitVoucher::class) { request -> + voucherChannel.sendBlocking(request.voucher) + } + } + + fun onDestroy() { + voucherChannel.close() + } + + private fun spawnActor() = GlobalScope.actor<String>(Dispatchers.Default, Channel.UNLIMITED) { + try { + for (voucher in channel) { + val result = daemon.await().submitVoucher(voucher) + + endpoint.sendEvent(Event.VoucherSubmissionResult(voucher, result)) + } + } catch (exception: ClosedReceiveChannelException) { + // Voucher channel was closed, stop the actor + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VpnPermission.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VpnPermission.kt new file mode 100644 index 0000000000..9602ec7a3b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VpnPermission.kt @@ -0,0 +1,59 @@ +package net.mullvad.mullvadvpn.service.endpoint + +import android.app.UiModeManager +import android.content.Context +import android.content.Context.UI_MODE_SERVICE +import android.content.Intent +import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION +import android.net.VpnService +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.mullvadvpn.ui.activities.TVActivity +import net.mullvad.mullvadvpn.util.Intermittent + +class VpnPermission(private val context: Context, private val endpoint: ServiceEndpoint) { + private val activityClass = discoverActivityClass() + private val isGranted = Intermittent<Boolean>() + + var waitingForResponse = false + private set + + init { + endpoint.dispatcher.registerHandler(Request.VpnPermissionResponse::class) { request -> + waitingForResponse = false + isGranted.spawnUpdate(request.isGranted) + } + } + + suspend fun request(): Boolean { + val intent = VpnService.prepare(context) + + if (intent == null) { + isGranted.update(true) + } else { + val activityIntent = Intent(context, activityClass).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + + isGranted.update(null) + waitingForResponse = true + + context.startActivity(activityIntent) + endpoint.sendEvent(Event.VpnPermissionRequest) + } + + return isGranted.await() + } + + private fun discoverActivityClass(): Class<out MainActivity> { + val uiModeManager = context.getSystemService(UI_MODE_SERVICE) as UiModeManager + + return if (uiModeManager.currentModeType == UI_MODE_TYPE_TELEVISION) { + TVActivity::class.java + } else { + MainActivity::class.java + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt new file mode 100644 index 0000000000..049dd68d0a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt @@ -0,0 +1,120 @@ +package net.mullvad.mullvadvpn.service.notifications + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import kotlin.properties.Delegates.observable +import kotlinx.coroutines.delay +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.LoginStatus +import net.mullvad.mullvadvpn.service.MullvadDaemon +import net.mullvad.mullvadvpn.service.endpoint.AccountCache +import net.mullvad.mullvadvpn.util.Intermittent +import net.mullvad.mullvadvpn.util.JobTracker +import org.joda.time.DateTime +import org.joda.time.Duration + +class AccountExpiryNotification( + val context: Context, + val daemon: Intermittent<MullvadDaemon>, + val accountCache: AccountCache +) { + companion object { + val NOTIFICATION_ID: Int = 2 + val REMAINING_TIME_FOR_REMINDERS = Duration.standardDays(2) + val TIME_BETWEEN_CHECKS: Long = 12 /* h */ * 60 /* min */ * 60 /* s */ * 1000 /* ms */ + } + + private val jobTracker = JobTracker() + private val resources = context.resources + + private val buyMoreTimeUrl = resources.getString(R.string.account_url) + + private val channel = NotificationChannel( + context, + "mullvad_account_time", + R.string.account_time_notification_channel_name, + R.string.account_time_notification_channel_description, + NotificationManager.IMPORTANCE_HIGH + ) + + var loginStatus by observable<LoginStatus?>(null) { _, oldValue, newValue -> + if (oldValue != newValue) { + jobTracker.newUiJob("update") { update(newValue) } + } + } + + init { + accountCache.onLoginStatusChange.subscribe(this) { newStatus -> + loginStatus = newStatus + } + } + + fun onDestroy() { + accountCache.onAccountNumberChange.unsubscribe(this) + loginStatus = null + } + + private suspend fun update(loginStatus: LoginStatus?) { + val remainingTime = loginStatus?.expiry?.let { expiry -> Duration(DateTime.now(), expiry) } + val closeToExpire = remainingTime?.isShorterThan(REMAINING_TIME_FOR_REMINDERS) ?: false + val accountIsNew = loginStatus?.isNewAccount ?: false + + if (closeToExpire && !accountIsNew) { + val notification = build(loginStatus!!.expiry!!, remainingTime!!) + + channel.notificationManager.notify(NOTIFICATION_ID, notification) + + jobTracker.newUiJob("scheduleUpdate") { scheduleUpdate() } + } else { + channel.notificationManager.cancel(NOTIFICATION_ID) + jobTracker.cancelJob("scheduleUpdate") + } + } + + private suspend fun scheduleUpdate() { + delay(TIME_BETWEEN_CHECKS) + update(loginStatus) + } + + private suspend fun build(expiry: DateTime, remainingTime: Duration): Notification { + val url = jobTracker.runOnBackground { + Uri.parse("$buyMoreTimeUrl?token=${daemon.await().getWwwAuthToken()}") + } + + val intent = Intent(Intent.ACTION_VIEW, url) + val flags = PendingIntent.FLAG_UPDATE_CURRENT + val pendingIntent = PendingIntent.getActivity(context, 1, intent, flags) + + return channel.buildNotification(pendingIntent, format(expiry, remainingTime)) + } + + private fun format(expiry: DateTime, remainingTime: Duration): String { + if (remainingTime.isShorterThan(Duration.ZERO)) { + return resources.getString(R.string.account_credit_has_expired) + } else { + val remainingTimeInfo = remainingTime.toPeriodTo(expiry) + + if (remainingTimeInfo.days >= 1) { + return getRemainingText( + R.plurals.account_credit_expires_in_days, + remainingTime.standardDays.toInt() + ) + } else if (remainingTimeInfo.hours >= 1) { + return getRemainingText( + R.plurals.account_credit_expires_in_hours, + remainingTime.standardHours.toInt() + ) + } else { + return resources.getString(R.string.account_credit_expires_in_a_few_minutes) + } + } + } + + private fun getRemainingText(pluralId: Int, quantity: Int): String { + return resources.getQuantityString(pluralId, quantity, quantity) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationChannel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationChannel.kt new file mode 100644 index 0000000000..e251e5b4de --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationChannel.kt @@ -0,0 +1,84 @@ +package net.mullvad.mullvadvpn.service.notifications + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import androidx.core.app.NotificationCompat +import net.mullvad.mullvadvpn.R + +class NotificationChannel( + val context: Context, + val id: String, + val name: Int, + val description: Int, + val importance: Int +) { + private val badgeColor by lazy { + context.getColor(R.color.colorPrimary) + } + + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + init { + val channelName = context.getString(name) + val channelDescription = context.getString(description) + + val channel = NotificationChannel(id, channelName, importance).apply { + description = channelDescription + setShowBadge(true) + } + + notificationManager.createNotificationChannel(channel) + } + + fun buildNotification( + intent: PendingIntent, + title: String, + deleteIntent: PendingIntent? = null + ): Notification { + return buildNotification(intent, title, emptyList(), deleteIntent) + } + + fun buildNotification( + intent: PendingIntent, + title: Int, + deleteIntent: PendingIntent? = null + ): Notification { + return buildNotification(intent, title, emptyList(), deleteIntent) + } + + fun buildNotification( + pendingIntent: PendingIntent, + title: Int, + actions: List<NotificationCompat.Action>, + deleteIntent: PendingIntent? = null + ): Notification { + return buildNotification(pendingIntent, context.getString(title), actions, deleteIntent) + } + + fun buildNotification( + pendingIntent: PendingIntent, + title: String, + actions: List<NotificationCompat.Action>, + deleteIntent: PendingIntent? = null + ): Notification { + val builder = NotificationCompat.Builder(context, id) + .setSmallIcon(R.drawable.small_logo_black) + .setColor(badgeColor) + .setContentTitle(title) + .setContentIntent(pendingIntent) + + for (action in actions) { + builder.addAction(action) + } + + deleteIntent?.let { intent -> + builder.setDeleteIntent(intent) + } + + return builder.build() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotification.kt new file mode 100644 index 0000000000..10f929ab97 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotification.kt @@ -0,0 +1,112 @@ +package net.mullvad.mullvadvpn.service.notifications + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.talpid.tunnel.ActionAfterDisconnect + +class TunnelStateNotification(val context: Context) { + companion object { + val NOTIFICATION_ID: Int = 1 + } + + private val channel = NotificationChannel( + context, + "vpn_tunnel_status", + R.string.foreground_notification_channel_name, + R.string.foreground_notification_channel_description, + NotificationManager.IMPORTANCE_MIN + ) + + private val notificationText: Int + get() = when (val state = tunnelState) { + is TunnelState.Disconnected -> R.string.unsecured + is TunnelState.Connecting -> { + if (reconnecting) { + R.string.reconnecting + } else { + R.string.connecting + } + } + is TunnelState.Connected -> R.string.secured + is TunnelState.Disconnecting -> { + when (state.actionAfterDisconnect) { + ActionAfterDisconnect.Reconnect -> R.string.reconnecting + else -> R.string.disconnecting + } + } + is TunnelState.Error -> { + if (state.errorState.isBlocking) { + R.string.blocking_all_connections + } else { + R.string.critical_error + } + } + } + + private var reconnecting = false + private var showingReconnecting = false + + var showAction by observable(false) { _, _, _ -> update() } + + var tunnelState by observable<TunnelState>(TunnelState.Disconnected) { _, _, newState -> + reconnecting = + ( + newState is TunnelState.Disconnecting && + newState.actionAfterDisconnect == ActionAfterDisconnect.Reconnect + ) || + (newState is TunnelState.Connecting && reconnecting) + + update() + } + + var visible by observable(true) { _, _, newValue -> + if (newValue == true) { + update() + } else { + channel.notificationManager.cancel(NOTIFICATION_ID) + } + } + + private fun update() { + if (visible && (!reconnecting || !showingReconnecting)) { + channel.notificationManager.notify(NOTIFICATION_ID, build()) + } + } + + fun build(): Notification { + val intent = Intent(context, MainActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + .setAction(Intent.ACTION_MAIN) + + val pendingIntent = + PendingIntent.getActivity(context, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT) + + val actions = if (showAction) { + listOf(buildAction()) + } else { + emptyList() + } + + return channel.buildNotification(pendingIntent, notificationText, actions) + } + + private fun buildAction(): NotificationCompat.Action { + val action = TunnelStateNotificationAction.from(tunnelState) + val label = context.getString(action.text) + + val intent = Intent(action.key).setPackage("net.mullvad.mullvadvpn") + val flags = PendingIntent.FLAG_UPDATE_CURRENT + + val pendingIntent = PendingIntent.getForegroundService(context, 1, intent, flags) + + return NotificationCompat.Action(action.icon, label, pendingIntent) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotificationAction.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotificationAction.kt new file mode 100644 index 0000000000..714264efbf --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotificationAction.kt @@ -0,0 +1,54 @@ +package net.mullvad.mullvadvpn.service.notifications + +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.service.MullvadVpnService +import net.mullvad.talpid.tunnel.ActionAfterDisconnect + +enum class TunnelStateNotificationAction { + Connect, + Disconnect, + Cancel, + Dismiss; + + companion object { + fun from(tunnelState: TunnelState) = when (tunnelState) { + is TunnelState.Disconnected -> Connect + is TunnelState.Connecting -> Cancel + is TunnelState.Connected -> Disconnect + is TunnelState.Disconnecting -> { + when (tunnelState.actionAfterDisconnect) { + ActionAfterDisconnect.Reconnect -> Cancel + else -> Connect + } + } + is TunnelState.Error -> { + if (tunnelState.errorState.isBlocking) { + Disconnect + } else { + Dismiss + } + } + } + } + + val text + get() = when (this) { + Connect -> R.string.connect + Disconnect -> R.string.disconnect + Cancel -> R.string.cancel + Dismiss -> R.string.dismiss + } + + val key + get() = when (this) { + Connect -> MullvadVpnService.KEY_CONNECT_ACTION + else -> MullvadVpnService.KEY_DISCONNECT_ACTION + } + + val icon + get() = when (this) { + Connect -> R.drawable.icon_notification_connect + else -> R.drawable.icon_notification_disconnect + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/persistence/SplitTunnelingPersistence.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/persistence/SplitTunnelingPersistence.kt new file mode 100644 index 0000000000..425aec8836 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/persistence/SplitTunnelingPersistence.kt @@ -0,0 +1,35 @@ +package net.mullvad.mullvadvpn.service.persistence + +import android.content.Context +import java.io.File +import kotlin.properties.Delegates.observable + +// The spelling of the shared preferences location can't be changed to American English without +// either having users lose their preferences on update or implementing some migration code. +private const val SHARED_PREFERENCES = "split_tunnelling" +private const val KEY_ENABLED = "enabled" + +class SplitTunnelingPersistence(context: Context) { + // The spelling of the app list file name can't be changed to American English without either + // having users lose their preferences on update or implementing some migration code. + private val appListFile = File(context.filesDir, "split-tunnelling.txt") + private val preferences = context.getSharedPreferences(SHARED_PREFERENCES, Context.MODE_PRIVATE) + + var enabled by observable(preferences.getBoolean(KEY_ENABLED, false)) { _, _, isEnabled -> + preferences.edit().apply { + putBoolean(KEY_ENABLED, isEnabled) + apply() + } + } + + var excludedApps by observable(loadExcludedApps()) { _, _, excludedAppsSet -> + appListFile.writeText(excludedAppsSet.joinToString(separator = "\n")) + } + + private fun loadExcludedApps(): Set<String> { + return when { + appListFile.exists() -> appListFile.readLines().toSet() + else -> emptySet() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt new file mode 100644 index 0000000000..2b8d9341cf --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt @@ -0,0 +1,196 @@ +package net.mullvad.mullvadvpn.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.FragmentManager +import java.text.DateFormat +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.ui.widget.Button +import net.mullvad.mullvadvpn.ui.widget.CopyableInformationView +import net.mullvad.mullvadvpn.ui.widget.InformationView +import net.mullvad.mullvadvpn.ui.widget.RedeemVoucherButton +import net.mullvad.mullvadvpn.ui.widget.SitePaymentButton +import net.mullvad.talpid.tunnel.ErrorStateCause +import org.joda.time.DateTime + +class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { + override val isSecureScreen = true + + private val dateStyle = DateFormat.MEDIUM + private val timeStyle = DateFormat.SHORT + private val expiryFormatter = DateFormat.getDateTimeInstance(dateStyle, timeStyle) + + private var oldAccountExpiry: DateTime? = null + + private var currentAccountExpiry: DateTime? = null + set(value) { + field = value + + synchronized(this) { + if (value != oldAccountExpiry) { + oldAccountExpiry = null + } + } + } + + private var hasConnectivity = true + set(value) { + field = value + sitePaymentButton.setEnabled(value) + } + + private var isOffline = true + set(value) { + field = value + redeemVoucherButton.setEnabled(!value) + } + + private lateinit var accountExpiryView: InformationView + private lateinit var accountNumberView: CopyableInformationView + private lateinit var sitePaymentButton: SitePaymentButton + private lateinit var redeemVoucherButton: RedeemVoucherButton + private lateinit var titleController: CollapsibleTitleController + + override fun onSafelyCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.account, container, false) + + view.findViewById<View>(R.id.back).setOnClickListener { + parentActivity.onBackPressed() + } + + sitePaymentButton = view.findViewById<SitePaymentButton>(R.id.site_payment).apply { + newAccount = false + + prepare(authTokenCache, jobTracker) { + checkForAddedTime() + } + } + + redeemVoucherButton = view.findViewById<RedeemVoucherButton>(R.id.redeem_voucher).apply { + prepare(parentFragmentManager, jobTracker) + } + + view.findViewById<Button>(R.id.logout).setOnClickAction("logout", jobTracker) { + logout() + } + + accountNumberView = view.findViewById<CopyableInformationView>(R.id.account_number).apply { + displayFormatter = { rawAccountNumber -> addSpacesToAccountNumber(rawAccountNumber) } + } + + accountExpiryView = view.findViewById(R.id.account_expiry) + + titleController = CollapsibleTitleController(view) + + return view + } + + override fun onSafelyStart() { + accountCache.onAccountNumberChange.subscribe(this) { accountNumber -> + jobTracker.newUiJob("updateAccountNumber") { + accountNumberView.information = accountNumber + } + } + + accountCache.onAccountExpiryChange.subscribe(this) { accountExpiry -> + jobTracker.newUiJob("updateAccountExpiry") { + currentAccountExpiry = accountExpiry + updateAccountExpiry(accountExpiry) + } + } + + connectionProxy.onUiStateChange.subscribe(this) { uiState -> + jobTracker.newUiJob("updateHasConnectivity") { + hasConnectivity = uiState is TunnelState.Connected || + uiState is TunnelState.Disconnected || + (uiState is TunnelState.Error && !uiState.errorState.isBlocking) + isOffline = uiState is TunnelState.Error && + uiState.errorState.cause is ErrorStateCause.IsOffline + } + } + + oldAccountExpiry?.let { expiry -> + accountCache.invalidateAccountExpiry(expiry) + } + } + + override fun onSafelyStop() { + accountCache.onAccountNumberChange.unsubscribe(this) + accountCache.onAccountExpiryChange.unsubscribe(this) + } + + override fun onSafelyDestroyView() { + titleController.onDestroy() + } + + private fun checkForAddedTime() { + currentAccountExpiry?.let { expiry -> + oldAccountExpiry = expiry + accountCache.invalidateAccountExpiry(expiry) + } + } + + private fun updateAccountExpiry(accountExpiry: DateTime?) { + if (accountExpiry != null) { + accountExpiryView.information = expiryFormatter.format(accountExpiry.toDate()) + } else { + accountExpiryView.information = null + accountCache.fetchAccountExpiry() + } + } + + private fun showRedeemVoucherDialog() { + val transaction = parentFragmentManager.beginTransaction() + + transaction.addToBackStack(null) + + RedeemVoucherDialogFragment().show(transaction, null) + } + + private suspend fun logout() { + accountCache.logout() + clearBackStack() + goToLoginScreen() + } + + private fun clearBackStack() { + parentFragmentManager.apply { + val firstEntry = getBackStackEntryAt(0) + + popBackStack(firstEntry.id, FragmentManager.POP_BACK_STACK_INCLUSIVE) + } + } + + private fun goToLoginScreen() { + parentFragmentManager.beginTransaction().apply { + setCustomAnimations( + R.anim.do_nothing, + R.anim.fragment_exit_to_bottom, + R.anim.do_nothing, + R.anim.do_nothing + ) + replace(R.id.main_fragment, LoginFragment()) + commit() + } + } + + private fun addSpacesToAccountNumber(rawAccountNumber: String): String { + return rawAccountNumber + .asSequence() + .fold(StringBuilder()) { formattedAccountNumber, nextDigit -> + if ((formattedAccountNumber.length % 5) == 4) { + formattedAccountNumber.append(' ') + } + + formattedAccountNumber.append(nextDigit) + } + .toString() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt new file mode 100644 index 0000000000..33fbd295ea --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt @@ -0,0 +1,200 @@ +package net.mullvad.mullvadvpn.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import java.net.InetAddress +import kotlinx.coroutines.CompletableDeferred +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.Settings +import net.mullvad.mullvadvpn.ui.customdns.CustomDnsAdapter +import net.mullvad.mullvadvpn.ui.fragments.SplitTunnelingFragment +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnection +import net.mullvad.mullvadvpn.ui.widget.CellSwitch +import net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView +import net.mullvad.mullvadvpn.ui.widget.MtuCell +import net.mullvad.mullvadvpn.ui.widget.NavigateCell +import net.mullvad.mullvadvpn.ui.widget.ToggleCell +import net.mullvad.mullvadvpn.util.AdapterWithHeader + +class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) { + private var isAllowLanEnabled = false + + // Both customDnsAdapter and customDnsToggle are nullable since onNewServiceConnection, + // which sets up custom dns subscriptions, is called before onSafelyCreateView. + private var customDnsAdapter: CustomDnsAdapter? = null + private var customDnsToggle: ToggleCell? = null + + private lateinit var wireguardMtuInput: MtuCell + private lateinit var titleController: CollapsibleTitleController + + override fun onSafelyCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.advanced, container, false) + + view.findViewById<View>(R.id.back).setOnClickListener { + customDnsAdapter?.stopEditing() + parentActivity.onBackPressed() + } + + titleController = CollapsibleTitleController(view, R.id.contents) + + customDnsAdapter = CustomDnsAdapter( + onAddServer = { address -> customDns.addDnsServer(address) }, + onRemoveDnsServer = { address -> customDns.removeDnsServer(address) }, + onSetCustomDnsEnabled = { isEnabled -> + if (isEnabled) { + customDns.enable() + } else { + customDns.disable() + } + }, + onReplaceDnsServer = { oldServer, newServer -> + customDns.replaceDnsServer(oldServer, newServer) + } + ).also { newCustomDnsAdapter -> + newCustomDnsAdapter.confirmAddAddress = ::confirmAddAddress + + view.findViewById<CustomRecyclerView>(R.id.contents).apply { + layoutManager = LinearLayoutManager(parentActivity) + + adapter = AdapterWithHeader(newCustomDnsAdapter, R.layout.advanced_header).apply { + onHeaderAvailable = { headerView -> + configureHeader(headerView) + titleController.expandedTitleView = + headerView.findViewById(R.id.expanded_title) + } + } + + addItemDecoration( + ListItemDividerDecoration( + topOffset = resources.getDimensionPixelSize(R.dimen.list_item_divider) + ) + ) + } + } + + attachBackButtonHandler() + + return view + } + + override fun onNewServiceConnection(serviceConnection: ServiceConnection) { + super.onNewServiceConnection(serviceConnection) + subscribeToCustomDnsChanges() + } + + override fun onSafelyDestroyView() { + detachBackButtonHandler() + customDnsAdapter?.onDestroy() + titleController.onDestroy() + settingsListener.settingsNotifier.unsubscribe(this) + } + + private fun configureHeader(view: View) { + wireguardMtuInput = view.findViewById<MtuCell>(R.id.wireguard_mtu).apply { + onSubmit = { mtu -> settingsListener.wireguardMtu = mtu } + } + + view.findViewById<NavigateCell>(R.id.wireguard_keys).apply { + targetFragment = WireguardKeyFragment::class + } + + view.findViewById<NavigateCell>(R.id.split_tunneling).apply { + targetFragment = SplitTunnelingFragment::class + } + + customDnsToggle = view.findViewById<ToggleCell>(R.id.enable_custom_dns).apply { + listener = { state -> + jobTracker.newBackgroundJob("toggleCustomDns") { + if (state == CellSwitch.State.ON) { + customDns.enable() + } else { + customDns.disable() + } + } + } + } + + settingsListener.settingsNotifier.subscribe(this) { maybeSettings -> + maybeSettings?.let { settings -> + updateUi(settings) + } + + isAllowLanEnabled = maybeSettings?.allowLan ?: false + } + + subscribeToCustomDnsChanges() + } + + private fun subscribeToCustomDnsChanges() { + // Ensure there are no previous subscriptions as this function might be called either when + // there view has been created or when there is a new service connection. + customDns.onEnabledChanged.unsubscribe(this) + customDns.onDnsServersChanged.unsubscribe(this) + + customDns.onEnabledChanged.subscribe(this) { isEnabled -> + customDnsAdapter?.updateState(isEnabled) + jobTracker.newUiJob("updateEnabled") { + if (isEnabled) { + customDnsToggle?.state = CellSwitch.State.ON + } else { + customDnsToggle?.state = CellSwitch.State.OFF + } + } + } + + customDns.onDnsServersChanged.subscribe(this) { servers -> + customDnsAdapter?.updateServers(servers) + } + } + + private fun updateUi(settings: Settings) { + jobTracker.newUiJob("updateUi") { + if (!wireguardMtuInput.hasFocus) { + wireguardMtuInput.value = settings.tunnelOptions.wireguard.options.mtu + } + } + } + + private suspend fun confirmAddAddress(address: InetAddress): Boolean { + val isLocalAddress = address.isLinkLocalAddress() || address.isSiteLocalAddress() + + return !isLocalAddress || isAllowLanEnabled || showConfirmDnsServerDialog() + } + + private suspend fun showConfirmDnsServerDialog(): Boolean { + val confirmation = CompletableDeferred<Boolean>() + val transaction = parentFragmentManager.beginTransaction() + + detachBackButtonHandler() + transaction.addToBackStack(null) + + ConfirmDnsDialogFragment(confirmation) + .show(transaction, null) + + val result = confirmation.await() + + attachBackButtonHandler() + + return result + } + + private fun attachBackButtonHandler() { + parentActivity.backButtonHandler = { + if (customDnsAdapter?.isEditing == true) { + customDnsAdapter?.stopEditing() + } + false + } + } + + private fun detachBackButtonHandler() { + parentActivity.backButtonHandler = null + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/BlockingController.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/BlockingController.kt new file mode 100644 index 0000000000..a98394001e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/BlockingController.kt @@ -0,0 +1,37 @@ +package net.mullvad.mullvadvpn.ui + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class BlockingController(val blockableView: BlockableView) { + var job: Job? = null + var innerJob: Job? = null + + fun action() { + if (!(job?.isActive ?: false)) { + job = GlobalScope.launch(Dispatchers.Main) { + blockableView.setEnabled(false) + innerJob = blockableView.onClick() + innerJob?.join() + blockableView.setEnabled(true) + } + } + } + + fun onPause() { + innerJob?.cancel() + job?.cancel() + blockableView.setEnabled(true) + } + + fun onDestroy() { + onPause() + } +} + +interface BlockableView { + fun setEnabled(enabled: Boolean) + fun onClick(): Job +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt new file mode 100644 index 0000000000..dcde70b923 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt @@ -0,0 +1,212 @@ +package net.mullvad.mullvadvpn.ui + +import android.view.View +import android.view.View.OnLayoutChangeListener +import android.view.ViewGroup.MarginLayoutParams +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.util.LinearInterpolation +import net.mullvad.mullvadvpn.util.ListenableScrollableView + +// In order to use this view controller, the parent view must contain four views with specific IDs: +// +// 1. A scroll area `View` with the `scrollAreaId` that implements `ListenableScrollableView`, which +// is used to animate the title based on the scroll offset. +// 2. A view inside the scroll area with the ID `expanded_title`. This view is made invisible so +// that it's not drawn, but it is used to measure the layout and the animation positions. +// 3. A view outside the scroll area with the ID `collapsed_title`. This view is also made +// invisible just like the `expanded_view`. +// 4. A view with the ID `title`. This is the view that's actually drawn, and it's position and size +// are interpolated from the expanded title to the collapsed title. This view should be placed +// somewhere where it is drawn over all other views. +// +// The animation interpolation is calculated based on the Y scroll offset of the scroll area. Once +// the offset reaches a value that completely hides the expanded title inside the scroll view, the +// animation finishes with the title being in the collapsed state. +class CollapsibleTitleController(val parentView: View, scrollAreaId: Int = R.id.scroll_area) { + private inner class LayoutListener(val listener: (View) -> Unit) : OnLayoutChangeListener { + override fun onLayoutChange( + view: View, + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int + ) { + listener.invoke(view) + update() + } + } + + private val scaleInterpolation = LinearInterpolation() + private val scrollInterpolation = LinearInterpolation() + private val xOffsetInterpolation = LinearInterpolation() + private val yOffsetInterpolation = LinearInterpolation() + + private val collapsedTitleLayoutListener: LayoutListener = LayoutListener() { collapsedTitle -> + val (x, y) = calculateViewCoordinates(collapsedTitle) + + collapsedTitleHeight = collapsedTitle.height.toFloat() + + scaleInterpolation.end = collapsedTitleHeight / maxOf(1.0f, titleHeight) + xOffsetInterpolation.end = x + yOffsetInterpolation.end = y + } + + private val collapsedTitleView = parentView.findViewById<View>(R.id.collapsed_title).apply { + addOnLayoutChangeListener(collapsedTitleLayoutListener) + visibility = View.INVISIBLE + } + + private val expandedTitleLayoutListener: LayoutListener = LayoutListener() { expandedTitle -> + val (x, y) = calculateViewCoordinates(expandedTitle) + + val expandedTitleMarginTop = when (val layoutParams = expandedTitle.layoutParams) { + is MarginLayoutParams -> layoutParams.topMargin + else -> 0 + } + + expandedTitleHeight = expandedTitle.height.toFloat() + + scaleInterpolation.start = expandedTitleHeight / maxOf(1.0f, titleHeight) + xOffsetInterpolation.start = x + yOffsetInterpolation.start = y + + scrollInterpolation.end = expandedTitleHeight + expandedTitleMarginTop + } + + private val titleLayoutListener: LayoutListener = LayoutListener() { title -> + val (x, y) = calculateViewCoordinates(title) + + titleWidth = title.width.toFloat() + titleHeight = title.height.toFloat() + + scaleInterpolation.start = expandedTitleHeight / maxOf(1.0f, titleHeight) + scaleInterpolation.end = collapsedTitleHeight / maxOf(1.0f, titleHeight) + xOffsetInterpolation.reference = x + yOffsetInterpolation.reference = y + } + + private val titleView = parentView.findViewById<View>(R.id.title).apply { + addOnLayoutChangeListener(titleLayoutListener) + + // Setting the scale pivot point to the left corner simplifies the calculations + pivotX = 0.0f + pivotY = 0.0f + } + + private val scrollAreaLayoutListener: LayoutListener = LayoutListener() { + scrollOffset = scrollArea.verticalScrollOffset.toFloat() + } + + private val scrollArea = parentView.findViewById<View>(scrollAreaId).let { view -> + val scrollableView = view as ListenableScrollableView + + view.addOnLayoutChangeListener(scrollAreaLayoutListener) + + scrollableView.onScrollListener = { _, top, _, _ -> + scrollOffset = top.toFloat() + update() + } + + scrollableView + } + + private var scrollOffsetUpdated = false + get() { + if (field == true) { + field = false + return true + } else { + return false + } + } + + private var collapsedTitleHeight = 0.0f + private var expandedTitleHeight = 0.0f + private var titleWidth = 0.0f + private var titleHeight = 0.0f + + private var scrollOffset: Float by observable(0.0f) { _, old, new -> + if (scrollOffsetUpdated == false && old != new) { + scrollOffsetUpdated = true + } + } + + val fullCollapseScrollOffset: Float + get() = scrollInterpolation.end + + var expandedTitleView by observable<View?>(null) { _, oldView, newView -> + oldView?.removeOnLayoutChangeListener(expandedTitleLayoutListener) + newView?.apply { + addOnLayoutChangeListener(expandedTitleLayoutListener) + expandedTitleLayoutListener.listener(this) + visibility = View.INVISIBLE + } + } + + init { + expandedTitleView = parentView.findViewById<View>(R.id.expanded_title) + update() + } + + fun onDestroy() { + scrollArea.onScrollListener = null + (scrollArea as View).removeOnLayoutChangeListener(scrollAreaLayoutListener) + + collapsedTitleView.removeOnLayoutChangeListener(collapsedTitleLayoutListener) + expandedTitleView?.removeOnLayoutChangeListener(expandedTitleLayoutListener) + titleView.removeOnLayoutChangeListener(titleLayoutListener) + } + + private fun update() { + val shouldUpdate = + scrollOffsetUpdated || + scaleInterpolation.updated || + xOffsetInterpolation.updated || + yOffsetInterpolation.updated + + if (shouldUpdate) { + val progress = if (expandedTitleView != null) { + maxOf(0.0f, minOf(1.0f, scrollInterpolation.progress(scrollOffset))) + } else { + 1.0f + } + + val scale = scaleInterpolation.interpolate(progress) + val offsetX = xOffsetInterpolation.interpolate(progress) + val offsetY = yOffsetInterpolation.interpolate(progress) + + titleView.apply { + scaleX = scale + scaleY = scale + translationX = offsetX + translationY = offsetY + } + } + } + + private fun calculateViewCoordinates(view: View): Pair<Float, Float> { + var currentView = view + var x = 0.0f + var y = 0.0f + + while (currentView != parentView) { + val parent = currentView.parent + + x += currentView.x - currentView.translationX + y += currentView.y - currentView.translationY + + if (parent is View) { + currentView = parent + } else { + break + } + } + + return Pair(x, y) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConfirmDnsDialogFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConfirmDnsDialogFragment.kt new file mode 100644 index 0000000000..ef2aa1667b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConfirmDnsDialogFragment.kt @@ -0,0 +1,66 @@ +package net.mullvad.mullvadvpn.ui + +import android.app.Dialog +import android.content.DialogInterface +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import android.widget.Button +import androidx.fragment.app.DialogFragment +import kotlinx.coroutines.CompletableDeferred +import net.mullvad.mullvadvpn.R + +class ConfirmDnsDialogFragment @JvmOverloads constructor( + private var confirmation: CompletableDeferred<Boolean>? = null +) : DialogFragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.confirm_dns, container, false) + + view.findViewById<Button>(R.id.back_button).setOnClickListener { + activity?.onBackPressed() + } + + view.findViewById<Button>(R.id.confirm_button).setOnClickListener { + confirmation?.complete(true) + confirmation = null + dismiss() + } + + return view + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) + + dialog.window?.setBackgroundDrawable(ColorDrawable(android.R.color.transparent)) + + return dialog + } + + override fun onStart() { + super.onStart() + + dialog?.window?.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + + if (confirmation == null) { + dismiss() + } + } + + override fun onDismiss(dialogInterface: DialogInterface) { + confirmation?.complete(false) + } + + override fun onDestroy() { + confirmation?.cancel() + + super.onDestroy() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConfirmNoEmailDialogFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConfirmNoEmailDialogFragment.kt new file mode 100644 index 0000000000..8271e8141d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConfirmNoEmailDialogFragment.kt @@ -0,0 +1,65 @@ +package net.mullvad.mullvadvpn.ui + +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import android.widget.Button +import androidx.fragment.app.DialogFragment +import kotlinx.coroutines.CompletableDeferred +import net.mullvad.mullvadvpn.R + +class ConfirmNoEmailDialogFragment : DialogFragment() { + private var confirmNoEmail: CompletableDeferred<Boolean>? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + + val parentActivity = context as MainActivity + + confirmNoEmail = parentActivity.problemReport.confirmNoEmail + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.confirm_no_email, container, false) + + view.findViewById<Button>(R.id.back_button).setOnClickListener { + activity?.onBackPressed() + } + + view.findViewById<Button>(R.id.send_button).setOnClickListener { + confirmNoEmail?.complete(true) + confirmNoEmail = null + dismiss() + } + + return view + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) + + dialog.window?.setBackgroundDrawable(ColorDrawable(android.R.color.transparent)) + + return dialog + } + + override fun onStart() { + super.onStart() + + dialog?.window?.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + } + + override fun onDismiss(dialogInterface: DialogInterface) { + confirmNoEmail?.complete(false) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectActionButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectActionButton.kt new file mode 100644 index 0000000000..7fbc0875f5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectActionButton.kt @@ -0,0 +1,133 @@ +package net.mullvad.mullvadvpn.ui + +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.widget.Button +import android.widget.ImageButton +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.talpid.tunnel.ActionAfterDisconnect + +class ConnectActionButton(val parentView: View) { + private val mainButton: Button = parentView.findViewById(R.id.action_button) + private val reconnectButton: ImageButton = parentView.findViewById(R.id.reconnect_button) + + private val resources = parentView.context.resources + private val greenBackground = resources.getDrawable(R.drawable.green_button_background, null) + private val leftRedBackground = + resources.getDrawable(R.drawable.transparent_red_left_half_button_background, null) + + private var showReconnectButton = false + set(value) { + if (field != value) { + field = value + updateReconnectButton() + } + } + + private var reconnectButtonSpace = 0 + set(value) { + if (field != value) { + field = value + updateReconnectButton() + } + } + + var tunnelState: TunnelState = TunnelState.Disconnected + set(value) { + when (value) { + is TunnelState.Disconnected -> disconnected() + is TunnelState.Disconnecting -> { + when (value.actionAfterDisconnect) { + ActionAfterDisconnect.Nothing -> disconnected() + ActionAfterDisconnect.Block -> connected() + ActionAfterDisconnect.Reconnect -> connecting() + } + } + is TunnelState.Connecting -> connecting() + is TunnelState.Connected -> connected() + is TunnelState.Error -> { + if (value.errorState.isBlocking) { + connected() + } else { + blockError() + } + } + } + + field = value + } + + var onConnect: (() -> Unit)? = null + var onCancel: (() -> Unit)? = null + var onReconnect: (() -> Unit)? = null + var onDisconnect: (() -> Unit)? = null + + init { + mainButton.setOnClickListener { action() } + reconnectButton.setOnClickListener { onReconnect?.invoke() } + + reconnectButton.addOnLayoutChangeListener { _, left, _, right, _, _, _, _, _ -> + val width = right - left + val layoutParams = reconnectButton.layoutParams + val leftMargin = when (layoutParams) { + is MarginLayoutParams -> layoutParams.leftMargin + else -> 0 + } + + reconnectButtonSpace = width + leftMargin + } + } + + private fun action() { + val state = tunnelState + + when (state) { + is TunnelState.Disconnected -> onConnect?.invoke() + is TunnelState.Disconnecting -> onConnect?.invoke() + is TunnelState.Connecting -> onCancel?.invoke() + is TunnelState.Connected -> onDisconnect?.invoke() + is TunnelState.Error -> { + if (state.errorState.isBlocking) { + onDisconnect?.invoke() + } else { + onCancel?.invoke() + } + } + } + } + + private fun disconnected() { + mainButton.background = greenBackground + mainButton.setText(R.string.connect) + showReconnectButton = false + } + + private fun connecting() { + redButton(R.string.cancel) + } + + private fun connected() { + redButton(R.string.disconnect) + } + + private fun blockError() { + redButton(R.string.dismiss) + } + + private fun redButton(text: Int) { + mainButton.background = leftRedBackground + mainButton.setText(text) + showReconnectButton = true + } + + private fun updateReconnectButton() { + if (showReconnectButton) { + reconnectButton.visibility = View.VISIBLE + mainButton.setPadding(reconnectButtonSpace, 0, 0, 0) + } else { + reconnectButton.visibility = View.GONE + mainButton.setPadding(0, 0, 0, 0) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectFragment.kt new file mode 100644 index 0000000000..6f701c9ba7 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectFragment.kt @@ -0,0 +1,187 @@ +package net.mullvad.mullvadvpn.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.delay +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.ui.notification.AccountExpiryNotification +import net.mullvad.mullvadvpn.ui.notification.KeyStatusNotification +import net.mullvad.mullvadvpn.ui.notification.TunnelStateNotification +import net.mullvad.mullvadvpn.ui.notification.VersionInfoNotification +import net.mullvad.mullvadvpn.ui.widget.HeaderBar +import net.mullvad.mullvadvpn.ui.widget.NotificationBanner +import net.mullvad.mullvadvpn.ui.widget.SwitchLocationButton +import org.joda.time.DateTime + +val KEY_IS_TUNNEL_INFO_EXPANDED = "is_tunnel_info_expanded" + +class ConnectFragment : + ServiceDependentFragment(OnNoService.GoToLaunchScreen), NavigationBarPainter { + private lateinit var actionButton: ConnectActionButton + private lateinit var switchLocationButton: SwitchLocationButton + private lateinit var headerBar: HeaderBar + private lateinit var notificationBanner: NotificationBanner + private lateinit var status: ConnectionStatus + private lateinit var locationInfo: LocationInfo + + private var isTunnelInfoExpanded = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + isTunnelInfoExpanded = + savedInstanceState?.getBoolean(KEY_IS_TUNNEL_INFO_EXPANDED, false) ?: false + } + + override fun onSafelyCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.connect, container, false) + + headerBar = view.findViewById<HeaderBar>(R.id.header_bar).apply { + tunnelState = TunnelState.Disconnected + } + + notificationBanner = view.findViewById<NotificationBanner>(R.id.notification_banner).apply { + notifications.apply { + register(TunnelStateNotification(parentActivity, connectionProxy)) + register(KeyStatusNotification(parentActivity, authTokenCache, keyStatusListener)) + register(VersionInfoNotification(parentActivity, appVersionInfoCache)) + register(AccountExpiryNotification(parentActivity, authTokenCache, accountCache)) + } + } + + status = ConnectionStatus(view, parentActivity) + + locationInfo = LocationInfo(view, requireContext()) + locationInfo.isTunnelInfoExpanded = isTunnelInfoExpanded + + actionButton = ConnectActionButton(view) + actionButton.apply { + onConnect = { connectionProxy.connect() } + onCancel = { connectionProxy.disconnect() } + onReconnect = { connectionProxy.reconnect() } + onDisconnect = { connectionProxy.disconnect() } + } + + switchLocationButton = view.findViewById<SwitchLocationButton>(R.id.switch_location).apply { + onClick = { openSwitchLocationScreen() } + } + + return view + } + + override fun onSafelyStart() { + locationInfo.isTunnelInfoExpanded = isTunnelInfoExpanded + + notificationBanner.onResume() + + locationInfoCache.onNewLocation = { location -> + jobTracker.newUiJob("updateLocationInfo") { + locationInfo.location = location + } + } + + relayListListener.onRelayListChange = { _, selectedRelayItem -> + jobTracker.newUiJob("updateSelectedRelayItem") { + switchLocationButton.location = selectedRelayItem + } + } + + connectionProxy.onUiStateChange.subscribe(this) { uiState -> + viewLifecycleOwner.lifecycleScope.launchWhenStarted { + updateTunnelState(uiState, connectionProxy.state) + } + } + + accountCache.onAccountExpiryChange.subscribe(this) { expiry -> + if (expiry?.isBeforeNow() ?: false) { + openOutOfTimeScreen() + } else if (expiry != null) { + scheduleNextAccountExpiryCheck(expiry) + } + } + } + + override fun onSafelyStop() { + locationInfoCache.onNewLocation = null + relayListListener.onRelayListChange = null + + accountCache.onAccountExpiryChange.unsubscribe(this) + keyStatusListener.onKeyStatusChange.unsubscribe(this) + connectionProxy.onUiStateChange.unsubscribe(this) + + notificationBanner.onPause() + + isTunnelInfoExpanded = locationInfo.isTunnelInfoExpanded + } + + override fun onSafelyDestroyView() { + notificationBanner.onDestroy() + } + + override fun onSafelySaveInstanceState(state: Bundle) { + isTunnelInfoExpanded = locationInfo.isTunnelInfoExpanded + state.putBoolean(KEY_IS_TUNNEL_INFO_EXPANDED, isTunnelInfoExpanded) + } + + override fun onResume() { + super.onResume() + paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.blue)) + } + + private fun updateTunnelState(uiState: TunnelState, realState: TunnelState) { + locationInfo.state = realState + headerBar.tunnelState = realState + status.setState(realState) + + actionButton.tunnelState = uiState + switchLocationButton.tunnelState = uiState + } + + private fun openSwitchLocationScreen() { + parentFragmentManager.beginTransaction().apply { + setCustomAnimations( + R.anim.fragment_enter_from_bottom, + R.anim.do_nothing, + R.anim.do_nothing, + R.anim.fragment_exit_to_bottom + ) + replace(R.id.main_fragment, SelectLocationFragment()) + addToBackStack(null) + commit() + } + } + + private fun openOutOfTimeScreen() { + jobTracker.newUiJob("openOutOfTimeScreen") { + parentFragmentManager.beginTransaction().apply { + replace(R.id.main_fragment, OutOfTimeFragment()) + commit() + } + } + } + + private fun scheduleNextAccountExpiryCheck(expiration: DateTime) { + jobTracker.newBackgroundJob("refetchAccountExpiry") { + val millisUntilExpiration = expiration.millis - DateTime.now().millis + + delay(millisUntilExpiration) + accountCache.fetchAccountExpiry() + + // If the account ran out of time but is still connected, fetching the expiry again will + // fail. Therefore, after a timeout of 5 seconds the app will assume the account time + // really expired and move to the out of time screen. However, if fetching the expiry + // succeeds, this job is cancelled and replaced with a new scheduled check. + delay(5_000) + openOutOfTimeScreen() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectionStatus.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectionStatus.kt new file mode 100644 index 0000000000..d2b413f1d0 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectionStatus.kt @@ -0,0 +1,66 @@ +package net.mullvad.mullvadvpn.ui + +import android.content.Context +import android.view.View +import android.widget.TextView +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.talpid.tunnel.ActionAfterDisconnect + +class ConnectionStatus(val parentView: View, context: Context) { + private val spinner: View = parentView.findViewById(R.id.connecting_spinner) + private val text: TextView = parentView.findViewById(R.id.connection_status) + + private val unsecuredTextColor = context.getColor(R.color.red) + private val connectingTextColor = context.getColor(R.color.white) + private val securedTextColor = context.getColor(R.color.green) + + fun setState(state: TunnelState) { + when (state) { + is TunnelState.Disconnecting -> { + when (state.actionAfterDisconnect) { + ActionAfterDisconnect.Nothing -> disconnected() + ActionAfterDisconnect.Block -> connected() + ActionAfterDisconnect.Reconnect -> connecting() + } + } + is TunnelState.Disconnected -> disconnected() + is TunnelState.Connecting -> connecting() + is TunnelState.Connected -> connected() + is TunnelState.Error -> errorState(state.errorState.isBlocking) + } + } + + private fun disconnected() { + spinner.visibility = View.GONE + + text.setTextColor(unsecuredTextColor) + text.setText(R.string.unsecured_connection) + } + + private fun connecting() { + spinner.visibility = View.VISIBLE + + text.setTextColor(connectingTextColor) + text.setText(R.string.creating_secure_connection) + } + + private fun connected() { + spinner.visibility = View.GONE + + text.setTextColor(securedTextColor) + text.setText(R.string.secure_connection) + } + + private fun errorState(isBlocking: Boolean) { + spinner.visibility = View.GONE + + if (isBlocking) { + text.setTextColor(securedTextColor) + text.setText(R.string.blocked_connection) + } else { + text.setTextColor(unsecuredTextColor) + text.setText(R.string.error_state) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LaunchFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LaunchFragment.kt new file mode 100644 index 0000000000..c6ce330128 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LaunchFragment.kt @@ -0,0 +1,69 @@ +package net.mullvad.mullvadvpn.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import kotlinx.coroutines.CompletableDeferred +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnection + +class LaunchFragment : ServiceAwareFragment() { + private val hasAccountToken = CompletableDeferred<Boolean>() + + override fun onNewServiceConnection(serviceConnection: ServiceConnection) { + serviceConnection.settingsListener.accountNumberNotifier.subscribe(this) { accountToken -> + hasAccountToken.complete(accountToken != null) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.launch, container, false) + + view.findViewById<View>(R.id.settings).setOnClickListener { + parentActivity.openSettings() + } + + return view + } + + override fun onStart() { + super.onStart() + + jobTracker.newUiJob("advanceToNextScreen") { + advanceToNextScreen() + } + } + + override fun onStop() { + jobTracker.cancelJob("advanceToNextScreen") + + super.onStop() + } + + private suspend fun advanceToNextScreen() { + if (hasAccountToken.await()) { + advanceToConnectScreen() + } else { + advanceToLoginScreen() + } + } + + private fun advanceToLoginScreen() { + parentFragmentManager.beginTransaction().apply { + replace(R.id.main_fragment, LoginFragment()) + commit() + } + } + + private fun advanceToConnectScreen() { + parentFragmentManager.beginTransaction().apply { + replace(R.id.main_fragment, ConnectFragment()) + commit() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemDividerDecoration.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemDividerDecoration.kt new file mode 100644 index 0000000000..4fcde0e314 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemDividerDecoration.kt @@ -0,0 +1,16 @@ +package net.mullvad.mullvadvpn.ui + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration +import androidx.recyclerview.widget.RecyclerView.State + +class ListItemDividerDecoration(private val bottomOffset: Int = 0, private val topOffset: Int = 0) : + ItemDecoration() { + + override fun getItemOffsets(offsets: Rect, view: View, parent: RecyclerView, state: State) { + offsets.bottom = bottomOffset + offsets.top = topOffset + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemListener.kt new file mode 100644 index 0000000000..72cc32196c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemListener.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.ui + +import net.mullvad.mullvadvpn.model.ListItemData + +interface ListItemListener { + fun onItemAction(item: ListItemData) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemsAdapter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemsAdapter.kt new file mode 100644 index 0000000000..1e39d45235 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemsAdapter.kt @@ -0,0 +1,117 @@ +package net.mullvad.mullvadvpn.ui + +import android.view.ViewGroup +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.recyclerview.widget.RecyclerView +import java.util.concurrent.atomic.AtomicLong +import net.mullvad.mullvadvpn.model.ListItemData +import net.mullvad.mullvadvpn.ui.listitemview.ActionListItemView +import net.mullvad.mullvadvpn.ui.listitemview.ApplicationListItemView +import net.mullvad.mullvadvpn.ui.listitemview.DividerGroupListItemView +import net.mullvad.mullvadvpn.ui.listitemview.ListItemView +import net.mullvad.mullvadvpn.ui.listitemview.PlainListItemView +import net.mullvad.mullvadvpn.ui.listitemview.ProgressListItemView +import net.mullvad.mullvadvpn.ui.listitemview.TwoActionListItemView + +class ListItemsAdapter : RecyclerView.Adapter<ListItemsAdapter.ViewHolder>() { + + var listItemListener: ListItemListener? = null + + protected var diffCallback: DiffCallback = DefaultDiffCallback() + + private val listDiffer: AsyncListDiffer<ListItemData> = createDiffer(diffCallback) + + fun setItems(items: List<ListItemData?>) = listDiffer.submitList(items) + + override fun onCreateViewHolder(parent: ViewGroup, @ListItemData.ItemType viewType: Int): + ListItemsAdapter.ViewHolder { + return ViewHolder( + when (viewType) { + ListItemData.DIVIDER -> DividerGroupListItemView(parent.context) + ListItemData.PROGRESS -> ProgressListItemView(parent.context) + ListItemData.PLAIN -> PlainListItemView(parent.context) + ListItemData.ACTION -> ActionListItemView(parent.context) + ListItemData.APPLICATION -> ApplicationListItemView(parent.context) + ListItemData.DOUBLE_ACTION -> TwoActionListItemView(parent.context) + else -> + throw IllegalArgumentException("View type '$viewType' is not supported") + } + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + (holder.itemView as ListItemView).update(getItem(position)) + holder.itemView.listItemListener = listItemListener + } + + override fun onViewRecycled(holder: ViewHolder) { + super.onViewRecycled(holder) + (holder.itemView as ListItemView).listItemListener = null + } + + override fun getItemCount(): Int = listDiffer.currentList.size + + @ListItemData.ItemType + override fun getItemViewType(position: Int): Int = getItem(position).type + + override fun getItemId(position: Int): Long = getId(getItem(position).identifier) + + private fun getItem(position: Int): ListItemData = listDiffer.currentList[position] + + private fun createDiffer(diffCallback: DiffCallback): AsyncListDiffer<ListItemData> { + return AsyncListDiffer(getListUpdateCallback(), getConfig(diffCallback)) + } + + private fun getConfig(diffUtil: DiffCallback): AsyncDifferConfig<ListItemData> { + return AsyncDifferConfig.Builder(diffUtil).build() + } + + protected fun getListUpdateCallback(): ListUpdateCallback { + return object : ListUpdateCallback { + override fun onInserted(position: Int, count: Int) { + notifyItemRangeInserted(position, count) + } + + override fun onRemoved(position: Int, count: Int) { + notifyItemRangeRemoved(position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + notifyItemMoved(fromPosition, toPosition) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + notifyItemRangeChanged(position, count, payload) + } + } + } + + internal class DefaultDiffCallback : DiffCallback() { + override fun areItemsTheSame(oldItem: ListItemData, newItem: ListItemData): Boolean { + return oldItem.type == newItem.type && oldItem.identifier == newItem.identifier + } + + override fun areContentsTheSame(oldItem: ListItemData, newItem: ListItemData): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: ListItemData, newItem: ListItemData): Any { + return Any() + } + } + + inner class ViewHolder(view: ListItemView) : RecyclerView.ViewHolder(view) + + companion object StableIdProvider { + private val idCounter = AtomicLong(0) + private val mapIds = hashMapOf<String, Long>() + + internal fun getId(stringId: String): Long = mapIds.computeIfAbsent(stringId) { + idCounter.decrementAndGet() + } + } +} +typealias DiffCallback = DiffUtil.ItemCallback<ListItemData> diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LocationInfo.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LocationInfo.kt new file mode 100644 index 0000000000..d5bbba1915 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LocationInfo.kt @@ -0,0 +1,141 @@ +package net.mullvad.mullvadvpn.ui + +import android.content.Context +import android.view.View +import android.widget.TextView +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.GeoIpLocation +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.talpid.net.Endpoint +import net.mullvad.talpid.net.TransportProtocol + +class LocationInfo(val parentView: View, val context: Context) { + private val hostnameColorCollapsed = context.getColor(R.color.white40) + private val hostnameColorExpanded = context.getColor(R.color.white) + + private val country: TextView = parentView.findViewById(R.id.country) + private val city: TextView = parentView.findViewById(R.id.city) + private val tunnelInfo: View = parentView.findViewById(R.id.tunnel_info) + private val hostname: TextView = parentView.findViewById(R.id.hostname) + private val chevron: View = parentView.findViewById(R.id.chevron) + private val protocol: TextView = parentView.findViewById(R.id.tunnel_protocol) + private val inAddress: TextView = parentView.findViewById(R.id.in_address) + private val outAddress: TextView = parentView.findViewById(R.id.out_address) + + private var endpoint: Endpoint? = null + private var isTunnelInfoVisible = false + var isTunnelInfoExpanded = false + + var location: GeoIpLocation? = null + set(value) { + field = value + + country.text = value?.country ?: "" + city.text = value?.city ?: "" + hostname.text = value?.hostname ?: "" + + updateOutAddress(value) + } + + var state: TunnelState = TunnelState.Disconnected + set(value) { + field = value + + when (value) { + is TunnelState.Connecting -> { + endpoint = value.endpoint?.endpoint + isTunnelInfoVisible = true + } + is TunnelState.Connected -> { + endpoint = value.endpoint.endpoint + isTunnelInfoVisible = true + } + else -> { + endpoint = null + isTunnelInfoVisible = false + } + } + + updateTunnelInfo() + } + + init { + tunnelInfo.setOnClickListener { toggleTunnelInfo() } + } + + private fun toggleTunnelInfo() { + isTunnelInfoExpanded = !isTunnelInfoExpanded + updateTunnelInfo() + } + + private fun updateTunnelInfo() { + if (isTunnelInfoVisible) { + showTunnelInfo() + } else { + hideTunnelInfo() + } + } + + private fun hideTunnelInfo() { + chevron.visibility = View.INVISIBLE + + protocol.text = "" + inAddress.text = "" + outAddress.text = "" + } + + private fun showTunnelInfo() { + chevron.visibility = View.VISIBLE + + if (isTunnelInfoExpanded) { + hostname.setTextColor(hostnameColorExpanded) + chevron.rotation = 180.0F + protocol.setText(R.string.wireguard) + showInAddress(endpoint) + updateOutAddress(location) + } else { + hostname.setTextColor(hostnameColorCollapsed) + chevron.rotation = 0.0F + protocol.text = "" + inAddress.text = "" + outAddress.text = "" + } + } + + private fun showInAddress(endpoint: Endpoint?) { + if (endpoint != null) { + val host = endpoint.address.address.hostAddress + val port = endpoint.address.port + val protocol = when (endpoint.protocol) { + TransportProtocol.Tcp -> context.getString(R.string.tcp) + TransportProtocol.Udp -> context.getString(R.string.udp) + } + + inAddress.text = context.getString(R.string.in_address) + " $host:$port $protocol" + } else { + inAddress.text = "" + } + } + + private fun updateOutAddress(location: GeoIpLocation?) { + val addressAvailable = location != null && (location.ipv4 != null || location.ipv6 != null) + + if (isTunnelInfoVisible && addressAvailable && isTunnelInfoExpanded) { + val ipv4 = location!!.ipv4 + val ipv6 = location.ipv6 + val ipAddress: String + + if (ipv6 == null) { + ipAddress = ipv4!!.hostAddress + } else if (ipv4 == null) { + ipAddress = ipv6.hostAddress + } else { + ipAddress = "${ipv4.hostAddress} / ${ipv6.hostAddress}" + } + + outAddress.text = context.getString(R.string.out_address) + " $ipAddress" + } else { + outAddress.text = "" + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginFragment.kt new file mode 100644 index 0000000000..399490f04b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginFragment.kt @@ -0,0 +1,224 @@ +package net.mullvad.mullvadvpn.ui + +import android.graphics.Rect +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ScrollView +import android.widget.TextView +import androidx.core.content.ContextCompat +import kotlinx.coroutines.delay +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.LoginStatus +import net.mullvad.mullvadvpn.ui.widget.AccountLogin +import net.mullvad.mullvadvpn.ui.widget.Button + +class LoginFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen), NavigationBarPainter { + companion object { + private enum class State { + Starting, + Idle, + LoggingIn, + CreatingAccount, + } + } + + private lateinit var title: TextView + private lateinit var subtitle: TextView + private lateinit var loggingInStatus: View + private lateinit var loggedInStatus: View + private lateinit var loginFailStatus: View + private lateinit var accountLogin: AccountLogin + private lateinit var scrollArea: ScrollView + private lateinit var background: View + + private var loginStatus: LoginStatus? = null + private var state = State.Starting + + override fun onSafelyCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.login, container, false) + + title = view.findViewById(R.id.title) + subtitle = view.findViewById(R.id.subtitle) + loggingInStatus = view.findViewById(R.id.logging_in_status) + loggedInStatus = view.findViewById(R.id.logged_in_status) + loginFailStatus = view.findViewById(R.id.login_fail_status) + + accountLogin = view.findViewById<AccountLogin>(R.id.account_login).apply { + onLogin = { accountToken -> login(accountToken) } + onClearHistory = { -> accountCache.clearAccountHistory() } + } + + view.findViewById<Button>(R.id.create_account) + .setOnClickAction("createAccount", jobTracker) { createAccount() } + + scrollArea = view.findViewById(R.id.scroll_area) + + background = view.findViewById<View>(R.id.contents).apply { + setOnClickListener { requestFocus() } + } + + scrollToShow(accountLogin) + + return view + } + + override fun onSafelyStart() { + accountLogin.state = LoginState.Initial + + accountCache.onAccountHistoryChange.subscribe(this) { history -> + jobTracker.newUiJob("updateHistory") { + accountLogin.accountHistory = history + } + } + + accountCache.onLoginStatusChange.subscribe(this) { status -> + jobTracker.newUiJob("updateLoginStatus") { + loginStatus = status + + if (status == null) { + if (state == State.LoggingIn || state == State.CreatingAccount) { + loginFailure() + } + } else { + if (state == State.Starting) { + openNextScreen() + } else { + loggedIn() + } + } + } + } + + parentActivity.backButtonHandler = { + if (accountLogin.hasFocus) { + background.requestFocus() + true + } else { + false + } + } + + state = State.Idle + } + + override fun onResume() { + super.onResume() + paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue)) + } + + override fun onSafelyStop() { + jobTracker.cancelJob("advanceToNextScreen") + accountCache.onAccountHistoryChange.unsubscribe(this) + accountCache.onLoginStatusChange.unsubscribe(this) + parentActivity.backButtonHandler = null + } + + private fun scrollToShow(view: View) { + val rectangle = Rect(0, 0, view.width, view.height) + + scrollArea.requestChildRectangleOnScreen(view, rectangle, false) + } + + private suspend fun createAccount() { + state = State.CreatingAccount + + title.setText(R.string.logging_in_title) + subtitle.setText(R.string.creating_new_account) + + loggingInStatus.visibility = View.VISIBLE + loginFailStatus.visibility = View.GONE + loggedInStatus.visibility = View.GONE + + accountLogin.state = LoginState.InProgress + + scrollToShow(loggingInStatus) + + accountCache.createNewAccount() + } + + private fun login(accountToken: String) { + state = State.LoggingIn + + title.setText(R.string.logging_in_title) + subtitle.setText(R.string.logging_in_description) + + loggingInStatus.visibility = View.VISIBLE + loginFailStatus.visibility = View.GONE + loggedInStatus.visibility = View.GONE + + background.requestFocus() + + accountLogin.state = LoginState.InProgress + + scrollToShow(loggingInStatus) + + accountCache.login(accountToken) + } + + private suspend fun loggedIn() { + if (loginStatus?.isNewAccount ?: false) { + showLoggedInMessage(resources.getString(R.string.account_created)) + } else { + showLoggedInMessage("") + } + + delay(1000) + openNextScreen() + } + + private fun showLoggedInMessage(subtitleMessage: String) { + title.setText(R.string.logged_in_title) + subtitle.setText(subtitleMessage) + + loggingInStatus.visibility = View.GONE + loginFailStatus.visibility = View.GONE + loggedInStatus.visibility = View.VISIBLE + + accountLogin.state = LoginState.Success + + scrollToShow(loggedInStatus) + } + + private fun openNextScreen() { + val status = loginStatus + + val fragment = when { + status == null -> return + status.isNewAccount -> WelcomeFragment() + status.isExpired -> OutOfTimeFragment() + else -> ConnectFragment() + } + + parentFragmentManager.beginTransaction().apply { + replace(R.id.main_fragment, fragment) + commit() + } + } + + private fun loginFailure() { + val description = when (state) { + State.LoggingIn -> R.string.login_fail_description + State.CreatingAccount -> R.string.failed_to_create_account + State.Idle, State.Starting -> return + } + + state = State.Idle + + title.setText(R.string.login_fail_title) + subtitle.setText(description) + + loggingInStatus.visibility = View.GONE + loginFailStatus.visibility = View.VISIBLE + loggedInStatus.visibility = View.GONE + + accountLogin.state = LoginState.Failure + + scrollToShow(accountLogin) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginState.kt new file mode 100644 index 0000000000..cece178267 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginState.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.ui + +enum class LoginState { + Initial, + InProgress, + Success, + Failure, +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt new file mode 100644 index 0000000000..dfc541d8d5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt @@ -0,0 +1,185 @@ +package net.mullvad.mullvadvpn.ui + +import android.app.Activity +import android.app.UiModeManager +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.net.VpnService +import android.os.Bundle +import android.os.IBinder +import android.os.Messenger +import android.view.WindowManager +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.BuildConfig +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport +import net.mullvad.mullvadvpn.service.MullvadVpnService +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnection +import net.mullvad.talpid.util.EventNotifier + +open class MainActivity : FragmentActivity() { + val problemReport = MullvadProblemReport() + val serviceNotifier = EventNotifier<ServiceConnection?>(null) + + private var visibleSecureScreens = HashSet<Fragment>() + + private val deviceIsTv by lazy { + val uiModeManager = getSystemService(UI_MODE_SERVICE) as UiModeManager + + uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION + } + + private var serviceConnection by observable<ServiceConnection?>( + null + ) { _, oldConnection, newConnection -> + oldConnection?.onDestroy() + + if (newConnection == null) { + serviceNotifier.notify(null) + } else { + newConnection.vpnPermission.onRequest = { -> + Unit + this.requestVpnPermission() + } + } + } + + private val serviceConnectionManager = object : android.content.ServiceConnection { + override fun onServiceConnected(className: ComponentName, binder: IBinder) { + android.util.Log.d("mullvad", "UI successfully connected to the service") + serviceConnection = ServiceConnection(Messenger(binder), ::handleNewServiceConnection) + } + + override fun onServiceDisconnected(className: ComponentName) { + android.util.Log.d("mullvad", "UI lost the connection to the service") + serviceConnection = null + } + } + + var backButtonHandler: (() -> Boolean)? = null + + override fun onCreate(savedInstanceState: Bundle?) { + if (deviceIsTv) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) + } + + super.onCreate(savedInstanceState) + + problemReport.apply { + logDirectory.complete(filesDir) + cacheDirectory.complete(cacheDir) + } + + setContentView(R.layout.main) + + if (savedInstanceState == null) { + addInitialFragment() + } + } + + override fun onStart() { + android.util.Log.d("mullvad", "Starting main activity") + super.onStart() + + val intent = Intent(this, MullvadVpnService::class.java) + + startService(intent) + bindService(intent, serviceConnectionManager, 0) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { + serviceConnection?.vpnPermission?.grant(resultCode == Activity.RESULT_OK) + } + + override fun onBackPressed() { + val handled = backButtonHandler?.invoke() ?: false + + if (!handled) { + super.onBackPressed() + } + } + + override fun onStop() { + android.util.Log.d("mullvad", "Stoping main activity") + unbindService(serviceConnectionManager) + + super.onStop() + + serviceConnection = null + } + + override fun onDestroy() { + serviceNotifier.unsubscribeAll() + serviceConnection = null + + super.onDestroy() + } + + fun enterSecureScreen(screen: Fragment) { + synchronized(this) { + visibleSecureScreens.add(screen) + + if (!BuildConfig.DEBUG) { + window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + } + + fun leaveSecureScreen(screen: Fragment) { + synchronized(this) { + visibleSecureScreens.remove(screen) + + if (!BuildConfig.DEBUG && visibleSecureScreens.isEmpty()) { + window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + } + + fun openSettings() { + supportFragmentManager.beginTransaction().apply { + setCustomAnimations( + R.anim.fragment_enter_from_bottom, + R.anim.do_nothing, + R.anim.do_nothing, + R.anim.fragment_exit_to_bottom + ) + replace(R.id.main_fragment, SettingsFragment()) + addToBackStack(null) + commit() + } + } + + fun returnToLaunchScreen() { + supportFragmentManager.apply { + popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + + beginTransaction().apply { + replace(R.id.main_fragment, LaunchFragment()) + commit() + } + } + } + + private fun handleNewServiceConnection(connection: ServiceConnection) { + serviceNotifier.notify(connection) + } + + @Suppress("DEPRECATION") + private fun requestVpnPermission() { + val intent = VpnService.prepare(this) + + startActivityForResult(intent, 0) + } + + private fun addInitialFragment() { + supportFragmentManager.beginTransaction().apply { + add(R.id.main_fragment, LaunchFragment()) + commit() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/NavigationBarPainter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/NavigationBarPainter.kt new file mode 100644 index 0000000000..1047793f6f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/NavigationBarPainter.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.ui + +import android.app.Activity +import androidx.annotation.ColorInt + +interface NavigationBarPainter : SystemPainter + +fun NavigationBarPainter.paintNavigationBar(@ColorInt color: Int) { + (getContext() as Activity?)?.window?.navigationBarColor = color +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/OutOfTimeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/OutOfTimeFragment.kt new file mode 100644 index 0000000000..1de849529e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/OutOfTimeFragment.kt @@ -0,0 +1,143 @@ +package net.mullvad.mullvadvpn.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import kotlin.properties.Delegates.observable +import kotlinx.coroutines.delay +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.ui.widget.Button +import net.mullvad.mullvadvpn.ui.widget.HeaderBar +import net.mullvad.mullvadvpn.ui.widget.RedeemVoucherButton +import net.mullvad.mullvadvpn.ui.widget.SitePaymentButton +import net.mullvad.talpid.tunnel.ActionAfterDisconnect +import net.mullvad.talpid.tunnel.ErrorStateCause +import org.joda.time.DateTime + +class OutOfTimeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { + private lateinit var headerBar: HeaderBar + + private lateinit var sitePaymentButton: SitePaymentButton + private lateinit var disconnectButton: Button + private lateinit var redeemButton: RedeemVoucherButton + + private var tunnelState by observable<TunnelState>(TunnelState.Disconnected) { _, _, state -> + updateDisconnectButton() + updateBuyButtons() + headerBar.tunnelState = state + } + + override fun onSafelyCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.out_of_time, container, false) + + headerBar = view.findViewById<HeaderBar>(R.id.header_bar).apply { + tunnelState = this@OutOfTimeFragment.tunnelState + } + + view.findViewById<TextView>(R.id.account_credit_has_expired).text = + parentActivity.getString(R.string.account_credit_has_expired) + " " + + parentActivity.getString(R.string.add_time_to_account) + + disconnectButton = view.findViewById<Button>(R.id.disconnect).apply { + setOnClickAction("disconnect", jobTracker) { + connectionProxy.disconnect() + } + } + + sitePaymentButton = view.findViewById<SitePaymentButton>(R.id.site_payment).apply { + newAccount = false + prepare(authTokenCache, jobTracker) + } + + redeemButton = view.findViewById<RedeemVoucherButton>(R.id.redeem_voucher).apply { + prepare(parentFragmentManager, jobTracker) + } + + connectionProxy.onStateChange.subscribe(this) { newState -> + jobTracker.newUiJob("updateTunnelState") { + tunnelState = newState + } + } + + return view + } + + override fun onSafelyStart() { + accountCache.onAccountExpiryChange.subscribe(this) { expiry -> + checkExpiry(expiry) + } + + jobTracker.newBackgroundJob("pollAccountData") { + while (true) { + accountCache.fetchAccountExpiry() + delay(POLL_INTERVAL) + } + } + } + + override fun onSafelyStop() { + accountCache.onAccountExpiryChange.unsubscribe(this) + jobTracker.cancelJob("pollAccountData") + } + + override fun onSafelyDestroyView() { + connectionProxy.onStateChange.unsubscribe(this) + } + + private fun updateDisconnectButton() { + val state = tunnelState + + val showButton = when (state) { + is TunnelState.Disconnected -> false + is TunnelState.Connecting, is TunnelState.Connected -> true + is TunnelState.Disconnecting -> { + state.actionAfterDisconnect != ActionAfterDisconnect.Nothing + } + is TunnelState.Error -> state.errorState.isBlocking + } + + disconnectButton.apply { + if (showButton) { + setEnabled(true) + visibility = View.VISIBLE + } else { + setEnabled(false) + visibility = View.GONE + } + } + } + + private fun updateBuyButtons() { + val currentState = tunnelState + val hasConnectivity = currentState is TunnelState.Disconnected + sitePaymentButton.setEnabled(hasConnectivity) + + val isOffline = currentState is TunnelState.Error && + currentState.errorState.cause is ErrorStateCause.IsOffline + redeemButton.setEnabled(!isOffline) + } + + private fun checkExpiry(maybeExpiry: DateTime?) { + maybeExpiry?.let { expiry -> + if (expiry.isAfterNow()) { + jobTracker.newUiJob("advanceToConnectScreen") { + advanceToConnectScreen() + } + } + } + } + + private fun advanceToConnectScreen() { + parentFragmentManager.beginTransaction().apply { + replace(R.id.main_fragment, ConnectFragment()) + commit() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/PreferencesFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/PreferencesFragment.kt new file mode 100644 index 0000000000..b3e85a94cc --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/PreferencesFragment.kt @@ -0,0 +1,84 @@ +package net.mullvad.mullvadvpn.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.Settings +import net.mullvad.mullvadvpn.ui.widget.CellSwitch +import net.mullvad.mullvadvpn.ui.widget.ToggleCell + +class PreferencesFragment : ServiceDependentFragment(OnNoService.GoBack) { + private lateinit var allowLanToggle: ToggleCell + private lateinit var autoConnectToggle: ToggleCell + private lateinit var titleController: CollapsibleTitleController + + override fun onSafelyCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.preferences, container, false) + + view.findViewById<View>(R.id.back).setOnClickListener { + parentActivity.onBackPressed() + } + + allowLanToggle = view.findViewById<ToggleCell>(R.id.allow_lan).apply { + listener = { state -> + when (state) { + CellSwitch.State.ON -> settingsListener.allowLan = true + CellSwitch.State.OFF -> settingsListener.allowLan = false + } + } + } + + autoConnectToggle = view.findViewById<ToggleCell>(R.id.auto_connect).apply { + listener = { state -> + when (state) { + CellSwitch.State.ON -> settingsListener.autoConnect = true + CellSwitch.State.OFF -> settingsListener.autoConnect = false + } + } + } + + settingsListener.settingsNotifier.subscribe(this) { maybeSettings -> + maybeSettings?.let { settings -> + updateUi(settings) + } + } + + titleController = CollapsibleTitleController(view) + + return view + } + + override fun onSafelyDestroyView() { + titleController.onDestroy() + settingsListener.settingsNotifier.unsubscribe(this) + } + + private fun updateUi(settings: Settings) { + jobTracker.newUiJob("updateUi") { + val allowLanState = boolToSwitchState(settings.allowLan) + val autoConnectState = boolToSwitchState(settings.autoConnect) + + if (isVisible) { + allowLanToggle.state = allowLanState + autoConnectToggle.state = autoConnectState + } else { + allowLanToggle.forcefullySetState(allowLanState) + autoConnectToggle.forcefullySetState(autoConnectState) + } + } + } + + private fun boolToSwitchState(pref: Boolean): CellSwitch.State { + if (pref) { + return CellSwitch.State.ON + } else { + return CellSwitch.State.OFF + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ProblemReportFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ProblemReportFragment.kt new file mode 100644 index 0000000000..d97e3a9ac9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ProblemReportFragment.kt @@ -0,0 +1,304 @@ +package net.mullvad.mullvadvpn.ui + +import android.content.Context +import android.graphics.Typeface +import android.os.Bundle +import android.text.Editable +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.TextWatcher +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.ScrollView +import android.widget.TextView +import android.widget.ViewSwitcher +import kotlin.properties.Delegates.observable +import kotlinx.coroutines.CompletableDeferred +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport +import net.mullvad.mullvadvpn.ui.fragments.BaseFragment +import net.mullvad.mullvadvpn.util.JobTracker + +class ProblemReportFragment : BaseFragment() { + private val jobTracker = JobTracker() + + private var showingEmail by observable(false) { _, oldValue, newValue -> + if (oldValue != newValue) { + if (newValue == true) { + parentActivity.enterSecureScreen(this) + } else { + parentActivity.leaveSecureScreen(this) + } + } + } + + private lateinit var parentActivity: MainActivity + private lateinit var problemReport: MullvadProblemReport + + private lateinit var bodyContainer: ViewSwitcher + private lateinit var userEmailInput: EditText + private lateinit var userMessageInput: EditText + private lateinit var sendButton: Button + + private lateinit var sendingSpinner: View + private lateinit var sentSuccessfullyIcon: View + private lateinit var failedToSendIcon: View + + private lateinit var sendStatusLabel: TextView + private lateinit var sendDetailsLabel: TextView + private lateinit var responseMessageLabel: TextView + + private lateinit var editMessageButton: Button + private lateinit var tryAgainButton: Button + + private lateinit var scrollArea: ScrollView + private lateinit var titleController: CollapsibleTitleController + + override fun onAttach(context: Context) { + super.onAttach(context) + + parentActivity = context as MainActivity + + problemReport = parentActivity.problemReport + problemReport.collect() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.problem_report, container, false) + + view.findViewById<View>(R.id.back).setOnClickListener { + activity?.onBackPressed() + } + + bodyContainer = view.findViewById<ViewSwitcher>(R.id.body_container) + userEmailInput = view.findViewById<EditText>(R.id.user_email) + userMessageInput = view.findViewById<EditText>(R.id.user_message) + sendButton = view.findViewById<Button>(R.id.send_button) + + sendingSpinner = view.findViewById<View>(R.id.sending_spinner) + sentSuccessfullyIcon = view.findViewById<View>(R.id.sent_successfully_icon) + failedToSendIcon = view.findViewById<View>(R.id.failed_to_send_icon) + + sendStatusLabel = view.findViewById<TextView>(R.id.send_status) + sendDetailsLabel = view.findViewById<TextView>(R.id.send_details) + responseMessageLabel = view.findViewById<TextView>(R.id.response_message) + + editMessageButton = view.findViewById<Button>(R.id.edit_message_button) + tryAgainButton = view.findViewById<Button>(R.id.try_again_button) + + view.findViewById<Button>(R.id.view_logs).setOnClickListener { + showLogs() + } + + sendButton.setOnClickListener { + jobTracker.newUiJob("sendReport") { + sendReport(true) + } + } + + editMessageButton.setOnClickListener { + showForm() + } + + tryAgainButton.setOnClickListener { + jobTracker.newUiJob("sendReport") { + sendReport(false) + } + } + + userEmailInput.setText(problemReport.userEmail) + userMessageInput.setText(problemReport.userMessage) + + setSendButtonEnabled(!userMessageInput.text.isEmpty()) + userMessageInput.addTextChangedListener(InputWatcher()) + + scrollArea = view.findViewById(R.id.scroll_area) + titleController = CollapsibleTitleController(view) + + return view + } + + override fun onDestroyView() { + problemReport.userEmail = userEmailInput.text.toString() + problemReport.userMessage = userMessageInput.text.toString() + problemReport.deleteReportFile() + + titleController.onDestroy() + + super.onDestroyView() + } + + override fun onDetach() { + showingEmail = false + + super.onDetach() + } + + private fun showLogs() { + parentFragmentManager.beginTransaction().apply { + setCustomAnimations( + R.anim.fragment_enter_from_right, + R.anim.fragment_half_exit_to_left, + R.anim.fragment_half_enter_from_left, + R.anim.fragment_exit_to_right + ) + replace(R.id.main_fragment, ViewLogsFragment()) + addToBackStack(null) + commit() + } + } + + private suspend fun sendReport(shouldConfirmNoEmail: Boolean) { + val userEmail = userEmailInput.text.trim().toString() + + problemReport.userEmail = userEmail + problemReport.userMessage = userMessageInput.text.toString() + + if (!userEmail.isEmpty() || !shouldConfirmNoEmail || confirmSendWithNoEmail()) { + showSendingScreen() + + if (problemReport.send().await()) { + clearForm() + showSuccessScreen(userEmail) + } else { + showErrorScreen() + } + } + } + + private suspend fun confirmSendWithNoEmail(): Boolean { + val confirmation = CompletableDeferred<Boolean>() + + problemReport.confirmNoEmail = confirmation + showConfirmNoEmailDialog() + + return confirmation.await() + } + + private fun clearForm() { + userEmailInput.setText("") + userMessageInput.setText("") + + problemReport.userEmail = "" + problemReport.userMessage = "" + } + + private fun showForm() { + bodyContainer.displayedChild = 0 + } + + private fun showConfirmNoEmailDialog() { + val transaction = parentFragmentManager.beginTransaction() + + transaction.addToBackStack(null) + + ConfirmNoEmailDialogFragment().show(transaction, null) + } + + private fun showSendingScreen() { + bodyContainer.displayedChild = 1 + + sendingSpinner.visibility = View.VISIBLE + sentSuccessfullyIcon.visibility = View.GONE + failedToSendIcon.visibility = View.GONE + + sendStatusLabel.visibility = View.VISIBLE + sendDetailsLabel.visibility = View.GONE + responseMessageLabel.visibility = View.GONE + + sendStatusLabel.setText(R.string.sending) + + editMessageButton.visibility = View.GONE + tryAgainButton.visibility = View.GONE + } + + private fun showSuccessScreen(userEmail: String) { + sendingSpinner.visibility = View.GONE + + sentSuccessfullyIcon.visibility = View.VISIBLE + sendStatusLabel.visibility = View.VISIBLE + + if (!userEmail.isEmpty()) { + showResponseMessage(userEmail) + } + + showThanksMessage() + sendStatusLabel.setText(R.string.sent) + + scrollArea.scrollTo(0, titleController.fullCollapseScrollOffset.toInt()) + } + + private fun showThanksMessage() { + val thanks = parentActivity.getString(R.string.sent_thanks) + val weWillLookIntoThis = parentActivity.getString(R.string.we_will_look_into_this) + + val colorStyle = ForegroundColorSpan(parentActivity.getColor(R.color.green)) + + sendDetailsLabel.text = SpannableStringBuilder("$thanks $weWillLookIntoThis").apply { + setSpan(colorStyle, 0, thanks.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + sendDetailsLabel.visibility = View.VISIBLE + } + + private fun showResponseMessage(userEmail: String) { + val responseMessageTemplate = parentActivity.getString(R.string.sent_contact) + val responseMessage = parentActivity.getString(R.string.sent_contact, userEmail) + + val emailStart = responseMessageTemplate.indexOf('%') + val emailEndFromStringEnd = responseMessageTemplate.length - (emailStart + 4) + val emailEnd = responseMessage.length - emailEndFromStringEnd + + val boldStyle = StyleSpan(Typeface.BOLD) + val colorStyle = ForegroundColorSpan(parentActivity.getColor(R.color.white)) + + responseMessageLabel.text = SpannableStringBuilder(responseMessage).apply { + setSpan(boldStyle, emailStart, emailEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan(colorStyle, emailStart, emailEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + responseMessageLabel.visibility = View.VISIBLE + + showingEmail = true + } + + private fun showErrorScreen() { + sendingSpinner.visibility = View.GONE + + failedToSendIcon.visibility = View.VISIBLE + sendStatusLabel.visibility = View.VISIBLE + sendDetailsLabel.visibility = View.VISIBLE + + sendStatusLabel.setText(R.string.failed_to_send) + sendDetailsLabel.setText(R.string.failed_to_send_details) + + editMessageButton.visibility = View.VISIBLE + tryAgainButton.visibility = View.VISIBLE + + scrollArea.scrollTo(0, titleController.fullCollapseScrollOffset.toInt()) + } + + private fun setSendButtonEnabled(enabled: Boolean) { + sendButton.setEnabled(enabled) + sendButton.alpha = if (enabled) 1.0F else 0.5F + } + + inner class InputWatcher : TextWatcher { + override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(text: Editable) { + setSendButtonEnabled(!text.isEmpty()) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/RedeemVoucherDialogFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/RedeemVoucherDialogFragment.kt new file mode 100644 index 0000000000..a25ac6a1d8 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/RedeemVoucherDialogFragment.kt @@ -0,0 +1,184 @@ +package net.mullvad.mullvadvpn.ui + +import android.app.Dialog +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import android.widget.EditText +import android.widget.TextView +import androidx.fragment.app.DialogFragment +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.VoucherSubmissionError +import net.mullvad.mullvadvpn.model.VoucherSubmissionResult +import net.mullvad.mullvadvpn.ui.serviceconnection.AccountCache +import net.mullvad.mullvadvpn.ui.serviceconnection.VoucherRedeemer +import net.mullvad.mullvadvpn.ui.widget.Button +import net.mullvad.mullvadvpn.util.JobTracker +import net.mullvad.mullvadvpn.util.SegmentedInputFormatter +import org.joda.time.DateTime + +const val FULL_VOUCHER_CODE_LENGTH = "XXXX-XXXX-XXXX-XXXX".length + +class RedeemVoucherDialogFragment : DialogFragment() { + private val jobTracker = JobTracker() + + private lateinit var parentActivity: MainActivity + private lateinit var errorMessage: TextView + private lateinit var voucherInput: EditText + + private var accountCache: AccountCache? = null + private var accountExpiry: DateTime? = null + private var redeemButton: Button? = null + private var voucherRedeemer: VoucherRedeemer? = null + + private var voucherInputIsValid = false + set(value) { + field = value + updateRedeemButton() + } + + override fun onAttach(context: Context) { + super.onAttach(context) + + parentActivity = context as MainActivity + + parentActivity.serviceNotifier.subscribe(this) { connection -> + accountCache?.onAccountExpiryChange?.unsubscribe(this@RedeemVoucherDialogFragment) + + accountCache = connection?.accountCache?.apply { + onAccountExpiryChange + .subscribe(this@RedeemVoucherDialogFragment) { newAccountExpiry -> + accountExpiry = newAccountExpiry + } + } + + voucherRedeemer = connection?.voucherRedeemer + + updateRedeemButton() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.redeem_voucher, container, false) + + voucherInput = view.findViewById<EditText>(R.id.voucher_code).apply { + addTextChangedListener(ValidVoucherCodeChecker()) + } + + SegmentedInputFormatter(voucherInput, '-').apply { + allCaps = true + + isValidInputCharacter = { character -> + ('A' <= character && character <= 'Z') || ('0' <= character && character <= '9') + } + } + + redeemButton = view.findViewById<Button>(R.id.redeem).apply { + setEnabled(false) + + setOnClickAction("action", jobTracker) { + submitVoucher() + } + } + + errorMessage = view.findViewById(R.id.error) + + view.findViewById<Button>(R.id.cancel).setOnClickAction("action", jobTracker) { + activity?.onBackPressed() + } + + return view + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) + + dialog.window?.setBackgroundDrawable(ColorDrawable(android.R.color.transparent)) + + return dialog + } + + override fun onStart() { + super.onStart() + + dialog?.window?.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + } + + override fun onDestroyView() { + jobTracker.cancelAllJobs() + + super.onDestroyView() + } + + override fun onDetach() { + parentActivity.serviceNotifier.unsubscribe(this) + + super.onDetach() + } + + private fun updateRedeemButton() { + redeemButton?.setEnabled(voucherInputIsValid && voucherRedeemer != null) + } + + private suspend fun submitVoucher() { + errorMessage.visibility = View.INVISIBLE + + val result = voucherRedeemer?.submit(voucherInput.text.toString()) + + when (result) { + is VoucherSubmissionResult.Ok -> handleAddedTime(result.submission.timeAdded) + is VoucherSubmissionResult.Error -> showError(result.error) + } + } + + private fun handleAddedTime(timeAdded: Long) { + if (timeAdded > 0) { + accountExpiry?.let { oldAccountExpiry -> + accountCache?.invalidateAccountExpiry(oldAccountExpiry) + } + + dismiss() + } + } + + private fun showError(error: VoucherSubmissionError) { + val message = when (error) { + VoucherSubmissionError.InvalidVoucher -> R.string.invalid_voucher + VoucherSubmissionError.VoucherAlreadyUsed -> R.string.voucher_already_used + else -> R.string.error_occurred + } + + errorMessage.apply { + setText(message) + visibility = View.VISIBLE + } + } + + inner class ValidVoucherCodeChecker : TextWatcher { + private var editRecursionCount = 0 + + override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) { + editRecursionCount += 1 + } + + override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(text: Editable) { + editRecursionCount -= 1 + + if (editRecursionCount == 0) { + voucherInputIsValid = text.length == FULL_VOUCHER_CODE_LENGTH + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SelectLocationFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SelectLocationFragment.kt new file mode 100644 index 0000000000..73d5e9d3f5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SelectLocationFragment.kt @@ -0,0 +1,206 @@ +package net.mullvad.mullvadvpn.ui + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.Animation +import android.view.animation.Animation.AnimationListener +import android.view.animation.AnimationUtils +import android.widget.ImageButton +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.collect +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.KeygenEvent +import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.relaylist.RelayList +import net.mullvad.mullvadvpn.relaylist.RelayListAdapter +import net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView +import net.mullvad.mullvadvpn.util.AdapterWithHeader + +class SelectLocationFragment : + ServiceDependentFragment(OnNoService.GoToLaunchScreen), StatusBarPainter, NavigationBarPainter { + private enum class RelayListState { + Initializing, + Loading, + Visible, + } + + private lateinit var relayListAdapter: RelayListAdapter + private lateinit var titleController: CollapsibleTitleController + + private var loadingSpinner = CompletableDeferred<View>() + private var relayListState = RelayListState.Initializing + + override fun onAttach(context: Context) { + super.onAttach(context) + + relayListAdapter = RelayListAdapter(context.resources).apply { + onSelect = { relayItem -> + jobTracker.newBackgroundJob("selectRelay") { + relayListListener.selectedRelayLocation = relayItem?.location + maybeConnect() + + jobTracker.newUiJob("close") { + close() + } + } + } + } + } + + override fun onSafelyCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.select_location, container, false) + + view.findViewById<ImageButton>(R.id.close).setOnClickListener { close() } + + titleController = CollapsibleTitleController(view, R.id.relay_list) + + view.findViewById<CustomRecyclerView>(R.id.relay_list).apply { + layoutManager = LinearLayoutManager(parentActivity) + + adapter = AdapterWithHeader(relayListAdapter, R.layout.select_location_header).apply { + onHeaderAvailable = { headerView -> + initializeLoadingSpinner(headerView) + titleController.expandedTitleView = headerView.findViewById(R.id.expanded_title) + } + } + + addItemDecoration( + ListItemDividerDecoration( + bottomOffset = resources.getDimensionPixelSize(R.dimen.list_item_divider) + ) + ) + } + + return view + } + + override fun onSafelyStart() { + // If the relay list is immediately available, setting the listener will cause it to be + // called right away, while the state is still Initializing. In that case we can skip + // showing the spinner animation and go directly to the Visible state. + // + // If it's not immediately available, then when the listener is called later the state will + // have changed to Loading, and an animation from the spinner to the new relay items will be + // shown. + // + // If the state is ready, it means that the relay list has already been shown, and we can + // update it in place. + relayListListener.onRelayListChange = { relayList, selectedItem -> + when (relayListState) { + RelayListState.Initializing -> { + jobTracker.newUiJob("updateRelayList") { + updateRelayList(relayList, selectedItem) + } + + relayListState = RelayListState.Visible + } + RelayListState.Loading -> { + jobTracker.newUiJob("updateRelayList") { + animateRelayListInitialization(relayList, selectedItem) + } + } + RelayListState.Visible -> { + jobTracker.newUiJob("updateRelayList") { + updateRelayList(relayList, selectedItem) + } + } + } + } + + if (relayListState == RelayListState.Initializing) { + relayListState = RelayListState.Loading + } + } + + override fun onSafelyStop() { + relayListListener.onRelayListChange = null + } + + override fun onSafelyDestroyView() { + titleController.onDestroy() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + lifecycleScope.launchWhenResumed { + transitionFinishedFlow.collect { + paintStatusBar(ContextCompat.getColor(requireContext(), R.color.darkBlue)) + } + } + } + + override fun onResume() { + super.onResume() + paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue)) + } + + fun close() { + activity?.onBackPressed() + } + + private fun updateRelayList(relayList: RelayList, selectedItem: RelayItem?) { + relayListAdapter.onRelayListChange(relayList, selectedItem) + } + + private fun maybeConnect() { + val keyStatus = keyStatusListener.keyStatus + + if (keyStatus == null || keyStatus is KeygenEvent.NewKey) { + connectionProxy.connect() + } + } + + private fun initializeLoadingSpinner(parentView: View) { + val spinner = parentView.findViewById<View>(R.id.loading_spinner) + + if (relayListState == RelayListState.Visible) { + // Because this method is executed inside a layout pass, hiding the spinner needs to be + // done in a new job so that it is executed after the layout pass finishes and can + // therefore schedule a new layout + jobTracker.newUiJob("hideLoadingSpinner") { + spinner.visibility = View.GONE + } + } + + loadingSpinner.complete(spinner) + } + + // Smoothly fade out the spinner before showing the relay list items. + private suspend fun animateRelayListInitialization( + relayList: RelayList, + selectedItem: RelayItem? + ) { + val animationFinished = CompletableDeferred<Unit>() + val animationListener = object : AnimationListener { + override fun onAnimationEnd(animation: Animation) { + animationFinished.complete(Unit) + } + + override fun onAnimationStart(animation: Animation) {} + override fun onAnimationRepeat(animation: Animation) {} + } + + val fadeOut = AnimationUtils.loadAnimation(parentActivity, R.anim.fade_out).apply { + setAnimationListener(animationListener) + } + + loadingSpinner.await().let { spinner -> + spinner.startAnimation(fadeOut) + + animationFinished.await() + + spinner.visibility = View.GONE + updateRelayList(relayList, selectedItem) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ServiceAwareFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ServiceAwareFragment.kt new file mode 100644 index 0000000000..5788c60ad8 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ServiceAwareFragment.kt @@ -0,0 +1,63 @@ +package net.mullvad.mullvadvpn.ui + +import android.content.Context +import net.mullvad.mullvadvpn.ui.fragments.BaseFragment +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnection +import net.mullvad.mullvadvpn.util.JobTracker + +abstract class ServiceAwareFragment : BaseFragment() { + val jobTracker = JobTracker() + + open val isSecureScreen = false + + lateinit var parentActivity: MainActivity + private set + + var serviceConnection: ServiceConnection? = null + private set + + override fun onAttach(context: Context) { + super.onAttach(context) + + parentActivity = context as MainActivity + + if (isSecureScreen) { + parentActivity.enterSecureScreen(this) + } + + parentActivity.serviceNotifier.subscribe(this) { connection -> + configureServiceConnection(connection) + } + } + + override fun onDestroyView() { + jobTracker.cancelAllJobs() + + super.onDestroyView() + } + + override fun onDetach() { + parentActivity.serviceNotifier.unsubscribe(this) + + if (isSecureScreen) { + parentActivity.leaveSecureScreen(this) + } + + super.onDetach() + } + + abstract fun onNewServiceConnection(serviceConnection: ServiceConnection) + + open fun onNoServiceConnection() { + } + + private fun configureServiceConnection(connection: ServiceConnection?) { + serviceConnection = connection + + if (connection != null) { + onNewServiceConnection(connection) + } else { + onNoServiceConnection() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ServiceDependentFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ServiceDependentFragment.kt new file mode 100644 index 0000000000..114b465391 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ServiceDependentFragment.kt @@ -0,0 +1,201 @@ +package net.mullvad.mullvadvpn.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.ui.serviceconnection.AccountCache +import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache +import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache +import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy +import net.mullvad.mullvadvpn.ui.serviceconnection.CustomDns +import net.mullvad.mullvadvpn.ui.serviceconnection.KeyStatusListener +import net.mullvad.mullvadvpn.ui.serviceconnection.LocationInfoCache +import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnection +import net.mullvad.mullvadvpn.ui.serviceconnection.SettingsListener +import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling + +abstract class ServiceDependentFragment(private val onNoService: OnNoService) : + ServiceAwareFragment() { + enum class OnNoService { + GoBack, GoToLaunchScreen + } + + enum class State { + Uninitialized, + Initialized, + Active, + Stopped, + LostConnection + } + + private var state = State.Uninitialized + + lateinit var accountCache: AccountCache + private set + + lateinit var appVersionInfoCache: AppVersionInfoCache + private set + + lateinit var authTokenCache: AuthTokenCache + private set + + lateinit var connectionProxy: ConnectionProxy + private set + + lateinit var customDns: CustomDns + private set + + lateinit var keyStatusListener: KeyStatusListener + private set + + lateinit var locationInfoCache: LocationInfoCache + private set + + lateinit var relayListListener: RelayListListener + private set + + lateinit var settingsListener: SettingsListener + private set + + lateinit var splitTunneling: SplitTunneling + private set + + override fun onNewServiceConnection(serviceConnection: ServiceConnection) { + // This method is always either called first or after an `onNoServiceConnection`, so the + // initialization of the fields doesn't have to be synchronized + accountCache = serviceConnection.accountCache + appVersionInfoCache = serviceConnection.appVersionInfoCache + authTokenCache = serviceConnection.authTokenCache + connectionProxy = serviceConnection.connectionProxy + customDns = serviceConnection.customDns + keyStatusListener = serviceConnection.keyStatusListener + locationInfoCache = serviceConnection.locationInfoCache + relayListListener = serviceConnection.relayListListener + settingsListener = serviceConnection.settingsListener + splitTunneling = serviceConnection.splitTunneling + + synchronized(this) { + when (state) { + State.Uninitialized -> state = State.Initialized + State.Active -> { + onSafelyStop() + onSafelyStart() + } + else -> {} + } + } + } + + override fun onNoServiceConnection() { + synchronized(this) { + when (state) { + State.Uninitialized -> { + state = State.LostConnection + leaveFragment() + } + State.Active -> { + state = State.LostConnection + leaveFragment() + } + else -> {} + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + synchronized(this) { + return when (state) { + State.Initialized, State.Active, State.Stopped -> { + onSafelyCreateView(inflater, container, savedInstanceState) + } + State.Uninitialized, State.LostConnection -> { + inflater.inflate(R.layout.missing_service, container, false) + } + } + } + } + + override fun onStart() { + super.onStart() + + synchronized(this) { + when (state) { + State.Initialized, State.Stopped -> { + state = State.Active + onSafelyStart() + } + else -> {} + } + } + } + + override fun onSaveInstanceState(instanceState: Bundle) { + synchronized(this) { + when (state) { + State.Initialized, State.Stopped, State.Active -> { + onSafelySaveInstanceState(instanceState) + } + else -> {} + } + } + } + + override fun onStop() { + synchronized(this) { + when (state) { + State.Initialized, State.Active -> { + onSafelyStop() + state = State.Stopped + } + else -> {} + } + } + + super.onStop() + } + + override fun onDestroyView() { + synchronized(this) { + when (state) { + State.Initialized, State.Stopped, State.Active -> onSafelyDestroyView() + else -> {} + } + } + + super.onDestroyView() + } + + abstract fun onSafelyCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View + + open fun onSafelyStart() { + } + + open fun onSafelySaveInstanceState(state: Bundle) { + } + + open fun onSafelyStop() { + } + + open fun onSafelyDestroyView() { + } + + private fun leaveFragment() { + jobTracker.newUiJob("leaveFragment") { + when (onNoService) { + OnNoService.GoBack -> parentActivity.onBackPressed() + OnNoService.GoToLaunchScreen -> parentActivity.returnToLaunchScreen() + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt new file mode 100644 index 0000000000..7204c1084c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt @@ -0,0 +1,160 @@ +package net.mullvad.mullvadvpn.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.collect +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.ui.serviceconnection.AccountCache +import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnection +import net.mullvad.mullvadvpn.ui.widget.AccountCell +import net.mullvad.mullvadvpn.ui.widget.AppVersionCell +import net.mullvad.mullvadvpn.ui.widget.NavigateCell + +class SettingsFragment : ServiceAwareFragment(), StatusBarPainter, NavigationBarPainter { + private lateinit var accountMenu: AccountCell + private lateinit var appVersionMenu: AppVersionCell + private lateinit var preferencesMenu: View + private lateinit var advancedMenu: View + private lateinit var titleController: CollapsibleTitleController + + private var active = false + + private var accountCache: AccountCache? = null + private var versionInfoCache: AppVersionInfoCache? = null + + override fun onNewServiceConnection(serviceConnection: ServiceConnection) { + accountCache = serviceConnection.accountCache + versionInfoCache = serviceConnection.appVersionInfoCache + + if (active) { + configureListeners() + } + } + + override fun onNoServiceConnection() { + accountCache = null + versionInfoCache = null + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.settings, container, false) + + view.findViewById<ImageButton>(R.id.close).setOnClickListener { + activity?.onBackPressed() + } + + accountMenu = view.findViewById<AccountCell>(R.id.account).apply { + targetFragment = AccountFragment::class + } + + preferencesMenu = view.findViewById<NavigateCell>(R.id.preferences).apply { + targetFragment = PreferencesFragment::class + } + + advancedMenu = view.findViewById<NavigateCell>(R.id.advanced).apply { + targetFragment = AdvancedFragment::class + } + + view.findViewById<NavigateCell>(R.id.report_a_problem).apply { + targetFragment = ProblemReportFragment::class + } + + appVersionMenu = view.findViewById<AppVersionCell>(R.id.app_version) + + titleController = CollapsibleTitleController(view) + + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + lifecycleScope.launchWhenResumed { + transitionFinishedFlow.collect { + paintStatusBar(ContextCompat.getColor(requireContext(), R.color.darkBlue)) + } + } + } + + override fun onResume() { + super.onResume() + paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue)) + } + + override fun onStart() { + super.onStart() + + configureListeners() + active = true + } + + override fun onStop() { + active = false + versionInfoCache?.onUpdate = null + + accountCache?.apply { + onAccountNumberChange.unsubscribe(this@SettingsFragment) + onAccountExpiryChange.unsubscribe(this@SettingsFragment) + } + + super.onStop() + } + + override fun onDestroyView() { + super.onDestroyView() + titleController.onDestroy() + } + + private fun configureListeners() { + accountCache?.apply { + onAccountNumberChange.subscribe(this@SettingsFragment) { account -> + jobTracker.newUiJob("updateLoggedInStatus") { + updateLoggedInStatus(account != null) + } + } + + onAccountExpiryChange.subscribe(this@SettingsFragment) { expiry -> + jobTracker.newUiJob("updateAccountInfo") { + accountMenu.accountExpiry = expiry + } + } + + fetchAccountExpiry() + } + + versionInfoCache?.onUpdate = { + jobTracker.newUiJob("updateVersionInfo") { + updateVersionInfo() + } + } + } + + private fun updateLoggedInStatus(loggedIn: Boolean) { + val visibility = if (loggedIn) { + View.VISIBLE + } else { + View.GONE + } + + accountMenu.visibility = visibility + preferencesMenu.visibility = visibility + advancedMenu.visibility = visibility + } + + private fun updateVersionInfo() { + val isOutdated = versionInfoCache?.isOutdated ?: false + val isSupported = versionInfoCache?.isSupported ?: true + + appVersionMenu.updateAvailable = isOutdated || !isSupported + appVersionMenu.version = versionInfoCache?.version ?: "" + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/StatusBarPainter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/StatusBarPainter.kt new file mode 100644 index 0000000000..48f94e17b5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/StatusBarPainter.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.ui + +import android.app.Activity +import androidx.annotation.ColorInt + +interface StatusBarPainter : SystemPainter + +fun StatusBarPainter.paintStatusBar(@ColorInt color: Int) { + (getContext() as Activity?)?.window?.statusBarColor = color +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SystemPainter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SystemPainter.kt new file mode 100644 index 0000000000..2f0fc32775 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SystemPainter.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.ui + +import android.content.Context + +interface SystemPainter { + fun getContext(): Context? +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/UnderNotificationBannerBehavior.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/UnderNotificationBannerBehavior.kt new file mode 100644 index 0000000000..2b28f21ff1 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/UnderNotificationBannerBehavior.kt @@ -0,0 +1,38 @@ +package net.mullvad.mullvadvpn.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.ScrollView +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior +import net.mullvad.mullvadvpn.R + +class UnderNotificationBannerBehavior( + context: Context, + attributes: AttributeSet +) : Behavior<ScrollView>(context, attributes) { + override fun layoutDependsOn(parent: CoordinatorLayout, body: ScrollView, dependency: View) = + dependency.id == R.id.notification_banner + + override fun onDependentViewChanged( + parent: CoordinatorLayout, + body: ScrollView, + dependency: View + ): Boolean { + val newPaddingTop = if (dependency.visibility == View.VISIBLE) { + dependency.height + dependency.translationY.toInt() + } else { + 0 + } + + body.getChildAt(0).apply { + if (paddingTop != newPaddingTop) { + setPadding(paddingLeft, newPaddingTop, paddingRight, paddingBottom) + return true + } else { + return false + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ViewLogsFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ViewLogsFragment.kt new file mode 100644 index 0000000000..994f432a80 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ViewLogsFragment.kt @@ -0,0 +1,56 @@ +package net.mullvad.mullvadvpn.ui + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport +import net.mullvad.mullvadvpn.ui.fragments.BaseFragment +import net.mullvad.mullvadvpn.util.JobTracker + +class ViewLogsFragment : BaseFragment() { + private val jobTracker = JobTracker() + + private lateinit var problemReport: MullvadProblemReport + + private lateinit var logArea: EditText + + override fun onAttach(context: Context) { + super.onAttach(context) + + val parentActivity = context as MainActivity + + problemReport = parentActivity.problemReport + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.view_logs, container, false) + + view.findViewById<View>(R.id.back).setOnClickListener { + activity?.onBackPressed() + } + + logArea = view.findViewById<EditText>(R.id.log_area) + + return view + } + + override fun onStart() { + super.onStart() + + jobTracker.newUiJob("showLogs") { + val logs = jobTracker.runOnBackground { + problemReport.load() + } + + logArea.setText(logs) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/WelcomeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/WelcomeFragment.kt new file mode 100644 index 0000000000..9aadce014b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/WelcomeFragment.kt @@ -0,0 +1,141 @@ +package net.mullvad.mullvadvpn.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import android.widget.Toast +import kotlinx.coroutines.delay +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.ui.widget.HeaderBar +import net.mullvad.mullvadvpn.ui.widget.RedeemVoucherButton +import net.mullvad.mullvadvpn.ui.widget.SitePaymentButton +import org.joda.time.DateTime + +val POLL_INTERVAL: Long = 15 /* s */ * 1000 /* ms */ + +class WelcomeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { + private lateinit var accountLabel: TextView + + override fun onSafelyCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.welcome, container, false) + + view.findViewById<HeaderBar>(R.id.header_bar).apply { + tunnelState = TunnelState.Disconnected + } + + accountLabel = view.findViewById<TextView>(R.id.account_number).apply { + setOnClickListener { copyAccountTokenToClipboard() } + } + + view.findViewById<TextView>(R.id.pay_to_start_using).text = + parentActivity.getString(R.string.pay_to_start_using) + " " + + parentActivity.getString(R.string.add_time_to_account) + + view.findViewById<SitePaymentButton>(R.id.site_payment).apply { + newAccount = true + prepare(authTokenCache, jobTracker) + } + + view.findViewById<RedeemVoucherButton>(R.id.redeem_voucher).apply { + prepare(parentFragmentManager, jobTracker) + } + + return view + } + + override fun onSafelyStart() { + accountCache.onAccountNumberChange.subscribe(this) { account -> + updateAccountNumber(account) + } + + accountCache.onAccountExpiryChange.subscribe(this) { expiry -> + checkExpiry(expiry) + } + + jobTracker.newBackgroundJob("pollAccountData") { + while (true) { + accountCache.fetchAccountExpiry() + delay(POLL_INTERVAL) + } + } + } + + override fun onSafelyStop() { + accountCache.onAccountNumberChange.unsubscribe(this) + accountCache.onAccountExpiryChange.unsubscribe(this) + jobTracker.cancelJob("pollAccountData") + } + + private fun updateAccountNumber(rawAccountNumber: String?) { + val accountText = rawAccountNumber?.let { account -> + addSpacesToAccountText(account) + } + + jobTracker.newUiJob("updateAccountNumber") { + accountLabel.text = accountText ?: "" + accountLabel.setEnabled(accountText != null && accountText.length > 0) + } + } + + private fun addSpacesToAccountText(account: String): String { + val length = account.length + + if (length == 0) { + return "" + } else { + val numParts = (length - 1) / 4 + 1 + + val parts = Array(numParts) { index -> + val startIndex = index * 4 + val endIndex = minOf(startIndex + 4, length) + + account.substring(startIndex, endIndex) + } + + return parts.joinToString(" ") + } + } + + private fun checkExpiry(maybeExpiry: DateTime?) { + maybeExpiry?.let { expiry -> + val tomorrow = DateTime.now().plusDays(1) + + if (expiry.isAfter(tomorrow)) { + jobTracker.newUiJob("advanceToConnectScreen") { + advanceToConnectScreen() + } + } + } + } + + private fun advanceToConnectScreen() { + parentFragmentManager.beginTransaction().apply { + replace(R.id.main_fragment, ConnectFragment()) + commit() + } + } + + private fun copyAccountTokenToClipboard() { + val accountToken = accountLabel.text + val clipboardLabel = resources.getString(R.string.mullvad_account_number) + val toastMessage = resources.getString(R.string.copied_mullvad_account_number) + + val context = parentActivity + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipData = ClipData.newPlainText(clipboardLabel, accountToken) + + clipboard.setPrimaryClip(clipData) + + Toast.makeText(context, toastMessage, Toast.LENGTH_SHORT).show() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/WireguardKeyFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/WireguardKeyFragment.kt new file mode 100644 index 0000000000..4e5601c2c3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/WireguardKeyFragment.kt @@ -0,0 +1,348 @@ +package net.mullvad.mullvadvpn.ui + +import android.content.Context +import android.os.Bundle +import android.util.Base64 +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.KeygenEvent +import net.mullvad.mullvadvpn.model.KeygenFailure +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.ui.widget.Button +import net.mullvad.mullvadvpn.ui.widget.CopyableInformationView +import net.mullvad.mullvadvpn.ui.widget.InformationView +import net.mullvad.mullvadvpn.ui.widget.InformationView.WhenMissing +import net.mullvad.mullvadvpn.ui.widget.UrlButton +import net.mullvad.mullvadvpn.util.TimeAgoFormatter +import net.mullvad.talpid.tunnel.ErrorStateCause +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import org.joda.time.format.DateTimeFormat + +val RFC3339_FORMAT = DateTimeFormat.forPattern("YYYY-MM-dd HH:mm:ss.SSSSSSSSSS z") + +class WireguardKeyFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { + override val isSecureScreen = true + + sealed class ActionState { + class Idle(val verified: Boolean) : ActionState() + class Generating(val replacing: Boolean) : ActionState() + class Verifying() : ActionState() + } + + private lateinit var timeAgoFormatter: TimeAgoFormatter + private lateinit var titleController: CollapsibleTitleController + + private var greenColor: Int = 0 + private var redColor: Int = 0 + + private var actionCompletion: CompletableDeferred<Unit>? = null + private var tunnelState: TunnelState = TunnelState.Disconnected + + private var actionState: ActionState = ActionState.Idle(false) + set(value) { + if (field != value) { + field = value + updateKeySpinners() + updateStatusMessage() + updateGenerateKeyButtonState() + updateGenerateKeyButtonText() + updateVerifyKeyButtonState() + updateVerifyingKeySpinner() + } + } + + private var keyStatus: KeygenEvent? = null + set(value) { + if (field != value) { + field = value + updateKeyInformation() + updateStatusMessage() + updateGenerateKeyButtonText() + updateVerifyKeyButtonState() + + actionCompletion?.complete(Unit) + } + } + + private var isOffline = true + set(value) { + if (field != value) { + field = value + updateStatusMessage() + updateGenerateKeyButtonState() + updateVerifyKeyButtonState() + manageKeysButton.setEnabled(!value) + } + } + + private var reconnectionExpected = false + set(value) { + field = value + + jobTracker.cancelJob("resetReconnectionExpected") + + if (value == true) { + resetReconnectionExpected() + } + } + + private lateinit var publicKey: CopyableInformationView + private lateinit var keyAge: InformationView + private lateinit var statusMessage: TextView + private lateinit var verifyingKeySpinner: View + private lateinit var manageKeysButton: UrlButton + private lateinit var generateKeyButton: Button + private lateinit var verifyKeyButton: Button + + override fun onAttach(context: Context) { + super.onAttach(context) + + redColor = context.getColor(R.color.red) + greenColor = context.getColor(R.color.green) + timeAgoFormatter = TimeAgoFormatter(context.resources) + } + + override fun onSafelyCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.wireguard_key, container, false) + + view.findViewById<View>(R.id.back).setOnClickListener { + parentActivity.onBackPressed() + } + + statusMessage = view.findViewById<TextView>(R.id.wireguard_key_status) + publicKey = view.findViewById(R.id.public_key) + keyAge = view.findViewById(R.id.key_age) + + generateKeyButton = view.findViewById<Button>(R.id.generate_key).apply { + setOnClickAction("action", jobTracker) { + onGenerateKeyPress() + } + } + + verifyKeyButton = view.findViewById<Button>(R.id.verify_key).apply { + setOnClickAction("action", jobTracker) { + onValidateKeyPress() + } + } + + verifyingKeySpinner = view.findViewById(R.id.verifying_key_spinner) + + manageKeysButton = view.findViewById<UrlButton>(R.id.manage_keys).apply { + prepare(authTokenCache, jobTracker) + } + + titleController = CollapsibleTitleController(view) + + return view + } + + override fun onSafelyStart() { + connectionProxy.onUiStateChange.subscribe(this) { uiState -> + jobTracker.newUiJob("tunnelStateUpdate") { + tunnelState = uiState + + if (actionState is ActionState.Generating) { + reconnectionExpected = !(tunnelState is TunnelState.Disconnected) + } else if (tunnelState is TunnelState.Connected) { + reconnectionExpected = false + } + + isOffline = uiState is TunnelState.Error && + uiState.errorState.cause is ErrorStateCause.IsOffline + } + } + + keyStatusListener.onKeyStatusChange.subscribe(this) { newKeyStatus -> + jobTracker.newUiJob("keyStatusUpdate") { + keyStatus = newKeyStatus + } + } + + actionState = ActionState.Idle(false) + } + + override fun onSafelyStop() { + connectionProxy.onUiStateChange.unsubscribe(this) + keyStatusListener.onKeyStatusChange.unsubscribe(this) + + if (!(actionState is ActionState.Idle)) { + actionState = ActionState.Idle(false) + } + } + + override fun onSafelyDestroyView() { + titleController.onDestroy() + } + + private fun updateKeySpinners() { + when (actionState) { + is ActionState.Generating -> { + publicKey.whenMissing = WhenMissing.ShowSpinner + keyAge.whenMissing = WhenMissing.ShowSpinner + } + is ActionState.Verifying, is ActionState.Idle -> { + publicKey.whenMissing = WhenMissing.Nothing + keyAge.whenMissing = WhenMissing.Nothing + } + } + } + + private fun updateKeyInformation() { + when (val keyState = keyStatus) { + is KeygenEvent.NewKey -> { + val key = keyState.publicKey + val publicKeyString = Base64.encodeToString(key.key, Base64.NO_WRAP) + val publicKeyAge = + DateTime.parse(key.dateCreated, RFC3339_FORMAT).withZone(DateTimeZone.UTC) + + publicKey.error = null + publicKey.information = publicKeyString + keyAge.information = timeAgoFormatter.format(publicKeyAge) + } + is KeygenEvent.TooManyKeys, is KeygenEvent.GenerationFailure -> { + publicKey.error = resources.getString(failureMessage(keyState.failure()!!)) + publicKey.information = null + keyAge.information = null + } + null -> { + publicKey.error = null + publicKey.information = null + keyAge.information = null + } + } + } + + private fun updateStatusMessage() { + when (val state = actionState) { + is ActionState.Generating -> statusMessage.visibility = View.INVISIBLE + is ActionState.Verifying -> statusMessage.visibility = View.INVISIBLE + is ActionState.Idle -> { + if (!isOffline) { + updateKeyStatus(state.verified, keyStatus) + } else { + updateOfflineStatus() + } + } + } + } + + private fun updateOfflineStatus() { + if (reconnectionExpected) { + setStatusMessage(R.string.wireguard_key_reconnecting, greenColor) + } + } + + private fun updateKeyStatus(verificationWasDone: Boolean, keyStatus: KeygenEvent?) { + if (keyStatus is KeygenEvent.NewKey) { + val replacementFailure = keyStatus.replacementFailure + + if (replacementFailure != null) { + setStatusMessage(failureMessage(replacementFailure), redColor) + } else { + updateKeyIsValid(verificationWasDone, keyStatus.verified) + } + } else { + statusMessage.visibility = View.INVISIBLE + } + } + + private fun updateKeyIsValid(verificationWasDone: Boolean, verified: Boolean?) { + when (verified) { + true -> setStatusMessage(R.string.wireguard_key_valid, greenColor) + false -> setStatusMessage(R.string.wireguard_key_invalid, redColor) + null -> { + if (verificationWasDone) { + setStatusMessage(R.string.wireguard_key_verification_failure, redColor) + } else { + statusMessage.visibility = View.INVISIBLE + } + } + } + } + + private fun updateGenerateKeyButtonState() { + generateKeyButton.setEnabled(actionState is ActionState.Idle && !isOffline) + } + + private fun updateGenerateKeyButtonText() { + val state = actionState + val replacingKey = state is ActionState.Generating && state.replacing + val hasKey = keyStatus is KeygenEvent.NewKey + + if (hasKey || replacingKey) { + generateKeyButton.setText(R.string.wireguard_replace_key) + } else { + generateKeyButton.setText(R.string.wireguard_generate_key) + } + } + + private fun updateVerifyKeyButtonState() { + val isIdle = actionState is ActionState.Idle + val hasKey = keyStatus is KeygenEvent.NewKey + + verifyKeyButton.setEnabled(isIdle && hasKey && !isOffline) + } + + private fun updateVerifyingKeySpinner() { + verifyingKeySpinner.visibility = when (actionState) { + is ActionState.Verifying -> View.VISIBLE + else -> View.INVISIBLE + } + } + + private fun setStatusMessage(message: Int, color: Int) { + statusMessage.setText(message) + statusMessage.setTextColor(color) + statusMessage.visibility = View.VISIBLE + } + + private fun failureMessage(failure: KeygenFailure): Int { + when (failure) { + KeygenFailure.TooManyKeys -> return R.string.too_many_keys + KeygenFailure.GenerationFailure -> return R.string.failed_to_generate_key + } + } + + private suspend fun onGenerateKeyPress() { + actionState = ActionState.Generating(keyStatus is KeygenEvent.NewKey) + reconnectionExpected = !(tunnelState is TunnelState.Disconnected) + + keyStatus = null + + actionCompletion = CompletableDeferred() + keyStatusListener.generateKey() + actionCompletion?.await() + + actionState = ActionState.Idle(false) + } + + private suspend fun onValidateKeyPress() { + actionState = ActionState.Verifying() + + actionCompletion = CompletableDeferred() + keyStatusListener.verifyKey() + actionCompletion?.await() + + actionState = ActionState.Idle(true) + } + + private fun resetReconnectionExpected() { + jobTracker.newUiJob("resetReconnectionExpected") { + delay(20_000) + + if (reconnectionExpected) { + reconnectionExpected = false + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/activities/TVActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/activities/TVActivity.kt new file mode 100644 index 0000000000..c83c47e7cd --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/activities/TVActivity.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.ui.activities + +import net.mullvad.mullvadvpn.ui.MainActivity + +class TVActivity : MainActivity() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/AddCustomDnsServerHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/AddCustomDnsServerHolder.kt new file mode 100644 index 0000000000..1d0f940d4b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/AddCustomDnsServerHolder.kt @@ -0,0 +1,16 @@ +package net.mullvad.mullvadvpn.ui.customdns + +import android.view.View +import net.mullvad.mullvadvpn.R + +class AddCustomDnsServerHolder(view: View, adapter: CustomDnsAdapter) : CustomDnsItemHolder(view) { + init { + view.findViewById<View>(R.id.add).setOnClickListener { + adapter.newDnsServer() + } + + view.findViewById<View>(R.id.click_area).setOnClickListener { + adapter.newDnsServer() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsAdapter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsAdapter.kt new file mode 100644 index 0000000000..1ab1429b49 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsAdapter.kt @@ -0,0 +1,301 @@ +package net.mullvad.mullvadvpn.ui.customdns + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView.Adapter +import java.net.InetAddress +import kotlin.properties.Delegates.observable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.util.JobTracker +import org.apache.commons.validator.routines.InetAddressValidator + +class CustomDnsAdapter( + val onSetCustomDnsEnabled: (Boolean) -> Unit, + val onAddServer: (InetAddress) -> Boolean, + val onRemoveDnsServer: (InetAddress) -> Unit, + val onReplaceDnsServer: (InetAddress, InetAddress) -> Boolean +) : Adapter<CustomDnsItemHolder>() { + private enum class ViewTypes { + ADD_SERVER, + EDIT_SERVER, + SHOW_SERVER, + FOOTER, + } + + private val customDnsServersLock = Mutex() + private val inetAddressValidator = InetAddressValidator.getInstance() + private val jobTracker = JobTracker() + + private var editingPosition: Int? = null + + private var activeCustomDnsServers by observable<List<InetAddress>>(emptyList()) { + _, _, servers -> + if (servers != cachedCustomDnsServers) { + cachedCustomDnsServers = servers.toMutableList() + notifyDataSetChanged() + } + } + + private var cachedCustomDnsServers = emptyList<InetAddress>().toMutableList() + + private var enabled by observable(false) { _, oldValue, newValue -> + if (oldValue != newValue) { + if (newValue == true) { + notifyItemRangeInserted(0, cachedCustomDnsServers.size + 1) + } else { + notifyItemRangeRemoved(0, cachedCustomDnsServers.size + 1) + editingPosition = null + } + } + } + + val isEditing + get() = editingPosition != null + + // By default, refuse the address so that the dialog can be recreated by the user if needed + var confirmAddAddress: suspend (InetAddress) -> Boolean = { false } + + fun updateServers(servers: List<InetAddress>) { + jobTracker.newBackgroundJob("toggleCustomDns") { + if (servers.isEmpty()) { + onSetCustomDnsEnabled(false) + } + } + + jobTracker.newUiJob("updateDnsServers") { + customDnsServersLock.withLock { + activeCustomDnsServers = servers + } + } + } + + fun updateState(isEnabled: Boolean) { + jobTracker.newUiJob("updateEnabled") { + customDnsServersLock.withLock { + enabled = isEnabled + } + } + } + + override fun getItemCount() = + if (enabled) { + cachedCustomDnsServers.size + 2 + } else { + 1 + } + + override fun getItemViewType(position: Int): Int { + val count = getItemCount() + val footer = count - 1 + val addServer = count - 2 + + if (position == footer) { + return ViewTypes.FOOTER.ordinal + } else if (position == editingPosition) { + return ViewTypes.EDIT_SERVER.ordinal + } else if (position == addServer) { + return ViewTypes.ADD_SERVER.ordinal + } else { + return ViewTypes.SHOW_SERVER.ordinal + } + } + + override fun onCreateViewHolder(parentView: ViewGroup, type: Int): CustomDnsItemHolder { + val inflater = LayoutInflater.from(parentView.context) + when (ViewTypes.values()[type]) { + ViewTypes.FOOTER -> { + val view = inflater.inflate(R.layout.custom_dns_footer, parentView, false) + return CustomDnsFooterHolder(view) + } + ViewTypes.ADD_SERVER -> { + val view = inflater.inflate(R.layout.add_custom_dns_server, parentView, false) + return AddCustomDnsServerHolder(view, this) + } + ViewTypes.EDIT_SERVER -> { + val view = inflater.inflate(R.layout.edit_custom_dns_server, parentView, false) + return EditCustomDnsServerHolder(view, this) + } + ViewTypes.SHOW_SERVER -> { + val view = inflater.inflate(R.layout.custom_dns_server, parentView, false) + return CustomDnsServerHolder(view, this) + } + } + } + + override fun onBindViewHolder(holder: CustomDnsItemHolder, position: Int) { + if (holder is CustomDnsServerHolder) { + holder.serverAddress = cachedCustomDnsServers[position] + } else if (holder is EditCustomDnsServerHolder) { + if (position >= cachedCustomDnsServers.size) { + holder.serverAddress = null + } else { + holder.serverAddress = cachedCustomDnsServers[position] + } + } + } + + fun onDestroy() { + jobTracker.newBackgroundJob("toggleCustomDns") { + if (cachedCustomDnsServers.isEmpty()) { + onSetCustomDnsEnabled(false) + } + } + } + + fun newDnsServer() { + jobTracker.newUiJob("newDnsServer") { + customDnsServersLock.withLock { + if (enabled) { + val count = getItemCount() + + editDnsServerAt(count - 2) + } + } + } + } + + fun saveDnsServer(address: String, errorCallback: () -> Unit) { + jobTracker.newUiJob("saveDnsServer $address") { + customDnsServersLock.withLock { + editingPosition?.let { position -> + var validAddress: Boolean + + if (position >= cachedCustomDnsServers.size) { + validAddress = addDnsServer(address) + } else { + validAddress = replaceDnsServer(address, position) + } + + if (!validAddress) { + errorCallback() + } + } + } + } + } + + fun editDnsServer(address: InetAddress) { + jobTracker.newUiJob("editDnsServer $address") { + customDnsServersLock.withLock { + if (enabled) { + val position = cachedCustomDnsServers.indexOf(address) + + editDnsServerAt(position) + } + } + } + } + + fun stopEditing() { + jobTracker.newUiJob("stopEditing") { + customDnsServersLock.withLock { + if (enabled) { + editDnsServerAt(null) + } + } + } + } + + fun stopEditing(address: InetAddress) { + jobTracker.newUiJob("stopEditing $address") { + customDnsServersLock.withLock { + if (enabled) { + editingPosition?.let { position -> + if (cachedCustomDnsServers.getOrNull(position) == address) { + editDnsServerAt(null) + } + } + } + } + } + } + + fun removeDnsServer(address: InetAddress) { + jobTracker.newUiJob("removeDnsServer $address") { + customDnsServersLock.withLock { + val position = jobTracker.runOnBackground { + val index = cachedCustomDnsServers.indexOf(address) + cachedCustomDnsServers.removeAt(index) + onRemoveDnsServer(address) + index + } + + // Immediately disable custom dns in the ui when the last server in the list has + // been removed to avoid glitches with the ADD_SERVER view. + if (cachedCustomDnsServers.size == 0) { + enabled = false + } + + notifyItemRemoved(position) + } + } + } + + private suspend fun addDnsServer(addressText: String): Boolean { + var added = false + + withValidAddress(addressText) { address -> + if (onAddServer(address)) { + cachedCustomDnsServers.add(address) + added = true + } + } + + if (added) { + editingPosition = null + + val count = getItemCount() + + notifyItemChanged(count - 3) + notifyItemInserted(count - 2) + } + + return added + } + + private suspend fun replaceDnsServer(address: String, position: Int): Boolean { + var replaced = false + + withValidAddress(address) { newAddress -> + val oldAddress = cachedCustomDnsServers[position] + + if (onReplaceDnsServer(oldAddress, newAddress)) { + cachedCustomDnsServers[position] = newAddress + replaced = true + } + } + + if (replaced) { + editingPosition = null + notifyItemChanged(position) + } + + return replaced + } + + private fun editDnsServerAt(position: Int?) { + editingPosition?.let { oldPosition -> + notifyItemChanged(oldPosition) + } + + editingPosition = position + + position?.let { newPosition -> + notifyItemChanged(newPosition) + } + } + + private suspend fun withValidAddress(addressText: String, handler: (InetAddress) -> Unit) { + jobTracker.runOnBackground { + if (inetAddressValidator.isValid(addressText)) { + val address = InetAddress.getByName(addressText) + + if (!address.isLoopbackAddress() && confirmAddAddress(address)) { + handler(address) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsFooterHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsFooterHolder.kt new file mode 100644 index 0000000000..d09beffbce --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsFooterHolder.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.ui.customdns + +import android.view.View + +class CustomDnsFooterHolder(view: View) : CustomDnsItemHolder(view) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsItemHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsItemHolder.kt new file mode 100644 index 0000000000..cfaf9399cc --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsItemHolder.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.ui.customdns + +import android.view.View +import androidx.recyclerview.widget.RecyclerView.ViewHolder + +abstract class CustomDnsItemHolder(view: View) : ViewHolder(view) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsServerHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsServerHolder.kt new file mode 100644 index 0000000000..49efad9310 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsServerHolder.kt @@ -0,0 +1,30 @@ +package net.mullvad.mullvadvpn.ui.customdns + +import android.view.View +import android.widget.TextView +import java.net.InetAddress +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R +import net.mullvad.talpid.util.addressString + +class CustomDnsServerHolder(view: View, adapter: CustomDnsAdapter) : CustomDnsItemHolder(view) { + private val label: TextView = view.findViewById(R.id.label) + + var serverAddress by observable<InetAddress?>(null) { _, _, address -> + label.text = address?.addressString() ?: "" + } + + init { + view.findViewById<View>(R.id.click_area).setOnClickListener { + serverAddress?.let { address -> + adapter.editDnsServer(address) + } + } + + view.findViewById<View>(R.id.remove).setOnClickListener { + serverAddress?.let { address -> + adapter.removeDnsServer(address) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/EditCustomDnsServerHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/EditCustomDnsServerHolder.kt new file mode 100644 index 0000000000..5e62f47209 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/EditCustomDnsServerHolder.kt @@ -0,0 +1,89 @@ +package net.mullvad.mullvadvpn.ui.customdns + +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import android.view.View.OnFocusChangeListener +import android.widget.EditText +import java.net.InetAddress +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.util.setOnEnterOrDoneAction +import net.mullvad.talpid.util.addressString + +class EditCustomDnsServerHolder( + view: View, + val adapter: CustomDnsAdapter +) : CustomDnsItemHolder(view) { + private enum class State { + Normal, + Error, + } + + private val errorColor = view.context.getColor(R.color.red) + private val normalColor = view.context.getColor(R.color.blue) + + private val input: EditText = view.findViewById<EditText>(R.id.input).apply { + onFocusChangeListener = OnFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + serverAddress?.let { address -> + adapter.stopEditing(address) + } + } + } + + setOnEnterOrDoneAction(::saveDnsServer) + } + + private val watcher: TextWatcher = object : TextWatcher { + override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) {} + + override fun afterTextChanged(text: Editable) { + state = State.Normal + } + + override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {} + } + + private var state by observable(State.Normal) { _, oldState, newState -> + if (oldState != newState) { + input.apply { + when (newState) { + State.Normal -> { + setTextColor(normalColor) + removeTextChangedListener(watcher) + } + State.Error -> { + setTextColor(errorColor) + addTextChangedListener(watcher) + } + } + } + } + } + + var serverAddress by observable<InetAddress?>(null) { _, _, address -> + if (address != null) { + val addressString = address.addressString() + + input.setText(addressString) + input.setSelection(addressString.length) + } else { + input.setText("") + } + + input.requestFocus() + } + + init { + view.findViewById<View>(R.id.save).setOnClickListener { + saveDnsServer() + } + } + + private fun saveDnsServer() { + val onFailCallback = { state = State.Error } + + adapter.saveDnsServer(input.text.toString(), onFailCallback) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/BaseFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/BaseFragment.kt new file mode 100644 index 0000000000..17bcc740a5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/BaseFragment.kt @@ -0,0 +1,44 @@ +package net.mullvad.mullvadvpn.ui.fragments + +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.annotation.LayoutRes +import androidx.core.view.ViewCompat +import androidx.fragment.app.Fragment +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.util.transitionFinished + +abstract class BaseFragment : Fragment { + constructor() : super() + constructor (@LayoutRes contentLayoutId: Int) : super(contentLayoutId) + + protected var transitionFinishedFlow: Flow<Unit> = emptyFlow() + private set + + override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? { + val zAdjustment = if (animationsToAdjustZorder.contains(nextAnim)) { + 1f + } else { + 0f + } + ViewCompat.setTranslationZ(requireView(), zAdjustment) + return if (nextAnim != 0 && enter) { + AnimationUtils.loadAnimation(context, nextAnim)?.apply { + transitionFinishedFlow = transitionFinished() + } + } else { + super.onCreateAnimation(transit, enter, nextAnim) + } + } + + companion object { + private val animationsToAdjustZorder = listOf( + R.anim.fragment_enter_from_right, + R.anim.fragment_exit_to_right, + R.anim.fragment_enter_from_bottom, + R.anim.fragment_exit_to_bottom + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragment.kt new file mode 100644 index 0000000000..494753845f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragment.kt @@ -0,0 +1,134 @@ +package net.mullvad.mullvadvpn.ui.fragments + +import android.os.Build +import android.os.Bundle +import android.view.KeyCharacterMap +import android.view.KeyEvent +import android.view.View +import android.view.ViewConfiguration +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.appbar.CollapsingToolbarLayout +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.applist.ViewIntent +import net.mullvad.mullvadvpn.di.APPS_SCOPE +import net.mullvad.mullvadvpn.di.SERVICE_CONNECTION_SCOPE +import net.mullvad.mullvadvpn.model.ListItemData +import net.mullvad.mullvadvpn.model.WidgetState.ImageState +import net.mullvad.mullvadvpn.model.WidgetState.SwitchState +import net.mullvad.mullvadvpn.ui.ListItemDividerDecoration +import net.mullvad.mullvadvpn.ui.ListItemListener +import net.mullvad.mullvadvpn.ui.ListItemsAdapter +import net.mullvad.mullvadvpn.util.setMargins +import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel +import org.koin.android.ext.android.getKoin +import org.koin.androidx.viewmodel.ViewModelOwner +import org.koin.androidx.viewmodel.scope.viewModel +import org.koin.core.qualifier.named +import org.koin.core.scope.Scope + +class SplitTunnelingFragment : BaseFragment(R.layout.collapsed_title_layout) { + private val listItemsAdapter = ListItemsAdapter() + private val scope: Scope = getKoin().getOrCreateScope(APPS_SCOPE, named(APPS_SCOPE)) + .also { appsScope -> + getKoin().getScopeOrNull(SERVICE_CONNECTION_SCOPE)?.let { serviceConnectionScope -> + appsScope.linkTo(serviceConnectionScope) + } + } + private val viewModel by scope.viewModel<SplitTunnelingViewModel>( + owner = { + ViewModelOwner.from(this, this) + } + ) + private val toggleSystemAppsVisibility = Channel<Boolean>(Channel.CONFLATED) + private val toggleExcludeChannel = Channel<ListItemData>(Channel.BUFFERED) + private val listItemListener = object : ListItemListener { + override fun onItemAction(item: ListItemData) { + when (item.widget) { + is ImageState -> toggleExcludeChannel.offer(item) + is SwitchState -> toggleSystemAppsVisibility.offer(!item.widget.isChecked) + } + } + } + + private var recyclerView: RecyclerView? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.findViewById<CollapsingToolbarLayout>(R.id.collapsing_toolbar).apply { + title = resources.getString(R.string.split_tunneling) + } + listItemsAdapter.listItemListener = listItemListener + listItemsAdapter.setHasStableIds(true) + recyclerView = view.findViewById<RecyclerView>(R.id.recyclerView).apply { + adapter = listItemsAdapter + addItemDecoration( + ListItemDividerDecoration( + topOffset = resources.getDimensionPixelSize(R.dimen.list_item_divider) + ) + ) + tweakMargin(this) + } + view.findViewById<View>(R.id.back).setOnClickListener { + requireActivity().onBackPressed() + } + + lifecycleScope.launchWhenStarted { + viewModel.listItems + .onEach { + listItemsAdapter.setItems(it) + } + .catch { } + .collect() + } + lifecycleScope.launchWhenResumed { + // pass view intent to view model + intents() + .onEach { viewModel.processIntent(it) } + .collect() + } + } + + override fun onDestroy() { + listItemsAdapter.listItemListener = null + recyclerView?.adapter = null + scope.close() + super.onDestroy() + } + + private fun intents(): Flow<ViewIntent> = merge( + transitionFinishedFlow.map { ViewIntent.ViewIsReady }, + toggleExcludeChannel.consumeAsFlow().map { ViewIntent.ChangeApplicationGroup(it) }, + toggleSystemAppsVisibility.consumeAsFlow().map { ViewIntent.ShowSystemApps(it) } + ) + + private fun tweakMargin(view: View) { + if (!hasNavigationBar()) { + view.setMargins(b = 0) + } + } + + private fun hasNavigationBar(): Boolean { + // Emulator + if (Build.FINGERPRINT.contains("generic")) { + return true + } + + val hasMenuKey = ViewConfiguration.get(requireContext()).hasPermanentMenuKey() + val hasBackKey = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_BACK) + val hasNoCapacitiveKeys = !hasMenuKey && !hasBackKey + + val id = resources.getIdentifier("config_showNavigationBar", "bool", "android") + val hasOnScreenNavBar = id > 0 && resources.getBoolean(id) + + return hasOnScreenNavBar || hasNoCapacitiveKeys + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ActionListItemView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ActionListItemView.kt new file mode 100644 index 0000000000..69581c245f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ActionListItemView.kt @@ -0,0 +1,119 @@ +package net.mullvad.mullvadvpn.ui.listitemview + +import android.content.Context +import android.content.res.Resources +import android.util.AttributeSet +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.WidgetState + +open class ActionListItemView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = R.attr.actionListItemViewStyle, + defStyleRes: Int = 0 +) : ListItemView(context, attrs, defStyleAttr, defStyleRes) { + + protected var widgetController: WidgetViewController<*>? = null + protected val itemText: TextView = findViewById(R.id.itemText) + protected val itemIcon: ImageView = findViewById(R.id.itemIcon) + protected val widgetContainer: ViewGroup = findViewById(R.id.widgetContainer) + + protected val clickListener = OnClickListener { + itemData.action?.let { _ -> + listItemListener?.onItemAction(itemData) + } + } + + override val layoutRes: Int + get() = R.layout.list_item_action + + override val heightRes: Int + get() = R.dimen.cell_height + + override fun onUpdate() { + updateImage() + updateText() + updateWidget() + updateAction() + } + + protected open fun updateImage() { + try { + itemData.iconRes?.let { + itemIcon.isVisible = true + itemIcon.setImageResource(it) + return + } + } catch (ignore: Resources.NotFoundException) { + itemIcon.isVisible = true + itemIcon.setImageResource(R.drawable.ic_icons_missing) + return + } + + itemIcon.isVisible = false + itemIcon.setImageDrawable(null) + } + + protected open fun updateText() { + itemData.textRes?.let { + itemText.setText(it) + return + } + itemData.text?.let { + itemText.setText(it) + return + } + itemText.text = "" + } + + protected open fun updateAction() { + if (itemData.action == null) { + setOnClickListener(null) + isClickable = false + isFocusable = false + } else { + setOnClickListener(clickListener) + isClickable = true + isFocusable = true + } + } + + protected open fun updateWidget() { + itemData.widget.let { state -> + when (state) { + is WidgetState.ImageState -> { + if (widgetController !is WidgetViewController.StandardController) { + widgetContainer.removeAllViews() + widgetContainer.isVisible = true + widgetController = WidgetViewController.StandardController(widgetContainer) + } + (widgetController as WidgetViewController.StandardController).updateState(state) + } + is WidgetState.SwitchState -> { + if (widgetController !is WidgetViewController.SwitchController) { + widgetContainer.removeAllViews() + widgetContainer.isVisible = true + widgetController = WidgetViewController.SwitchController(widgetContainer) + } + (widgetController as WidgetViewController.SwitchController).updateState(state) + } + null -> { + if (widgetController != null) { + widgetController = null + widgetContainer.removeAllViews() + widgetContainer.isVisible = false + } + } + } + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + widgetContainer.requestLayout() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ApplicationListItemView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ApplicationListItemView.kt new file mode 100644 index 0000000000..798d812802 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ApplicationListItemView.kt @@ -0,0 +1,79 @@ +package net.mullvad.mullvadvpn.ui.listitemview + +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.isVisible +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.applist.ApplicationsIconManager +import net.mullvad.mullvadvpn.di.APPS_SCOPE +import org.koin.core.component.KoinApiExtension +import org.koin.core.scope.KoinScopeComponent +import org.koin.core.scope.Scope +import org.koin.core.scope.inject + +@OptIn(KoinApiExtension::class) +class ApplicationListItemView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = R.attr.applicationListItemViewStyle, + defStyleRes: Int = 0 +) : ActionListItemView(context, attrs, defStyleAttr, defStyleRes), KoinScopeComponent { + private val viewScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val iconManager: ApplicationsIconManager by inject() + + private var updateImageJob: Job? = null + + override val scope: Scope = getKoin().getScope(APPS_SCOPE) + + init { + itemText.setTextAppearance(R.style.TextAppearance_Mullvad_Title2) + updateImage(ResourcesCompat.getDrawable(resources, R.drawable.ic_icons_missing, null)!!) + } + + override fun updateImage() { + itemIcon.isVisible = true + updateImageJob?.cancel() + updateImageJob = viewScope.launch { + loadImage()?.let { drawable -> + updateImage(drawable) + } + } + } + + override fun updateText() { + itemData.text?.let { + itemText.text = it + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + updateImage() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + updateImageJob?.cancel() + viewScope.coroutineContext.cancelChildren() + } + + private suspend fun loadImage(): Drawable? = withContext(Dispatchers.Default) { + try { + iconManager.getAppIcon(itemData.identifier) + } catch (e: PackageManager.NameNotFoundException) { + null + } + } + + private fun updateImage(drawable: Drawable) = itemIcon.setImageDrawable(drawable) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/DividerGroupListItemView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/DividerGroupListItemView.kt new file mode 100644 index 0000000000..61bb5bf400 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/DividerGroupListItemView.kt @@ -0,0 +1,15 @@ +package net.mullvad.mullvadvpn.ui.listitemview + +import android.content.Context +import androidx.appcompat.view.ContextThemeWrapper +import net.mullvad.mullvadvpn.R + +class DividerGroupListItemView(context: Context) : + ListItemView(ContextThemeWrapper(context, R.style.ListItem_DividerGroup)) { + + override val layoutRes: Int + get() = R.layout.list_item_group_divider + + override val heightRes: Int + get() = R.dimen.vertical_space +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ListItemView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ListItemView.kt new file mode 100644 index 0000000000..d1149d1afd --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ListItemView.kt @@ -0,0 +1,41 @@ +package net.mullvad.mullvadvpn.ui.listitemview + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.annotation.DimenRes +import androidx.annotation.LayoutRes +import androidx.constraintlayout.widget.ConstraintLayout +import net.mullvad.mullvadvpn.model.ListItemData +import net.mullvad.mullvadvpn.ui.ListItemListener + +abstract class ListItemView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) { + @get:LayoutRes + protected abstract val layoutRes: Int + @get:DimenRes + protected abstract val heightRes: Int? + protected lateinit var itemData: ListItemData + var listItemListener: ListItemListener? = null + + init { + val view = LayoutInflater.from(context).inflate(layoutRes, this, true) + val height = if (heightRes != null) { + resources.getDimensionPixelSize(heightRes!!) + } else { + LayoutParams.WRAP_CONTENT + } + view.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, height) + } + + fun update(data: ListItemData) { + itemData = data + onUpdate() + } + + protected open fun onUpdate() {} +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/PlainListItemView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/PlainListItemView.kt new file mode 100644 index 0000000000..f472c444df --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/PlainListItemView.kt @@ -0,0 +1,30 @@ +package net.mullvad.mullvadvpn.ui.listitemview + +import android.content.Context +import android.widget.TextView +import androidx.appcompat.view.ContextThemeWrapper +import net.mullvad.mullvadvpn.R + +class PlainListItemView(context: Context) : + ListItemView(ContextThemeWrapper(context, R.style.ListItem_PlainText)) { + override val layoutRes: Int + get() = R.layout.list_item_plain_text + override val heightRes: Int? = null + private val plainText: TextView = findViewById(R.id.plain_text) + + override fun onUpdate() { + updateText() + } + + private fun updateText() { + itemData.textRes?.let { + plainText.setText(it) + return + } + itemData.text?.let { + plainText.text = it + return + } + plainText.text = "" + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ProgressListItemView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ProgressListItemView.kt new file mode 100644 index 0000000000..724caf0c61 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ProgressListItemView.kt @@ -0,0 +1,15 @@ +package net.mullvad.mullvadvpn.ui.listitemview + +import android.content.Context +import androidx.appcompat.view.ContextThemeWrapper +import net.mullvad.mullvadvpn.R + +class ProgressListItemView(context: Context) : + ListItemView(ContextThemeWrapper(context, R.style.ListItem_DividerGroup)) { + + override val layoutRes: Int + get() = R.layout.list_item_progress + + override val heightRes: Int + get() = R.dimen.progress_size +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/TwoActionListItemView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/TwoActionListItemView.kt new file mode 100644 index 0000000000..8f349c0548 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/TwoActionListItemView.kt @@ -0,0 +1,33 @@ +package net.mullvad.mullvadvpn.ui.listitemview + +import android.content.Context +import android.view.ViewGroup +import androidx.appcompat.view.ContextThemeWrapper +import net.mullvad.mullvadvpn.R + +class TwoActionListItemView(context: Context) : + ActionListItemView(ContextThemeWrapper(context, R.style.ListItem_Action_Double)) { + override val layoutRes: Int + get() = R.layout.list_item_two_action + private val container: ViewGroup = findViewById(R.id.container_without_widget) + + init { + isClickable = false + isFocusable = false + } + + override fun updateAction() { + if (itemData.action == null) { + container.setOnClickListener(null) + container.isClickable = false + container.isFocusable = false + } else { + container.setOnClickListener(clickListener) + container.isClickable = true + container.isFocusable = true + } + widgetContainer.setOnClickListener(clickListener) + widgetContainer.isClickable = true + widgetContainer.isFocusable = true + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/WidgetViewController.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/WidgetViewController.kt new file mode 100644 index 0000000000..f1ec04bd1d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/WidgetViewController.kt @@ -0,0 +1,39 @@ +package net.mullvad.mullvadvpn.ui.listitemview + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import androidx.annotation.LayoutRes +import androidx.appcompat.widget.SwitchCompat +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.WidgetState + +sealed class WidgetViewController<T : WidgetState>(val parent: ViewGroup) { + @get:LayoutRes + protected abstract val layoutRes: Int + + init { + LayoutInflater.from(parent.context).inflate(layoutRes, parent) + } + + abstract fun updateState(state: T) + + class StandardController(parent: ViewGroup) : + WidgetViewController<WidgetState.ImageState>(parent) { + override val layoutRes: Int + get() = R.layout.list_item_widget_image + private val imageView: ImageView = parent.findViewById(R.id.widgetImage) + override fun updateState(state: WidgetState.ImageState) = + imageView.setImageResource(state.imageRes) + } + + class SwitchController(parent: ViewGroup) : + WidgetViewController<WidgetState.SwitchState>(parent) { + override val layoutRes: Int + get() = R.layout.list_item_widget_switch + private val switch: SwitchCompat = parent.findViewById(R.id.widgetSwitch) + override fun updateState(state: WidgetState.SwitchState) { + switch.isChecked = state.isChecked + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt new file mode 100644 index 0000000000..b959a06607 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt @@ -0,0 +1,46 @@ +package net.mullvad.mullvadvpn.ui.notification + +import android.content.Context +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.ui.serviceconnection.AccountCache +import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache +import net.mullvad.mullvadvpn.util.TimeLeftFormatter +import org.joda.time.DateTime + +class AccountExpiryNotification( + context: Context, + authTokenCache: AuthTokenCache, + private val accountCache: AccountCache +) : NotificationWithUrlWithToken(context, authTokenCache, R.string.account_url) { + private val timeLeftFormatter = TimeLeftFormatter(context.resources) + + init { + status = StatusLevel.Error + title = context.getString(R.string.account_credit_expires_soon) + } + + override fun onResume() { + accountCache.onAccountExpiryChange.subscribe(this) { accountExpiry -> + jobTracker.newUiJob("updateAccountExpiry") { + updateAccountExpiry(accountExpiry) + } + } + } + + override fun onPause() { + accountCache.onAccountExpiryChange.unsubscribe(this) + } + + private fun updateAccountExpiry(expiry: DateTime?) { + val threeDaysFromNow = DateTime.now().plusDays(3) + + if (expiry != null && expiry.isBefore(threeDaysFromNow)) { + message = timeLeftFormatter.format(expiry) + shouldShow = true + } else { + shouldShow = false + } + + update() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt new file mode 100644 index 0000000000..aa58b0bbf5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt @@ -0,0 +1,45 @@ +package net.mullvad.mullvadvpn.ui.notification + +import net.mullvad.mullvadvpn.util.ChangeMonitor +import net.mullvad.mullvadvpn.util.JobTracker + +abstract class InAppNotification { + private val changeMonitor = ChangeMonitor() + protected val jobTracker = JobTracker() + + var controller: InAppNotificationController? = null + + var status by changeMonitor.monitor(StatusLevel.Error) + protected set + + var title by changeMonitor.monitor("") + protected set + + var message by changeMonitor.monitor<String?>(null) + protected set + + var onClick by changeMonitor.monitor<(suspend () -> Unit)?>(null) + protected set + + var showIcon by changeMonitor.monitor(false) + protected set + + var shouldShow by changeMonitor.monitor(false) + protected set + + open fun onResume() {} + open fun onPause() {} + + open fun onDestroy() { + jobTracker.cancelAllJobs() + } + + protected fun update() { + val controller = this.controller + + if (controller != null && changeMonitor.changed) { + controller.notificationChanged(this@InAppNotification) + changeMonitor.reset() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotificationController.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotificationController.kt new file mode 100644 index 0000000000..aada1847a9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotificationController.kt @@ -0,0 +1,56 @@ +package net.mullvad.mullvadvpn.ui.notification + +import java.util.PriorityQueue +import kotlin.properties.Delegates.observable + +class InAppNotificationController(private val onNotificationChanged: (InAppNotification?) -> Unit) { + private val notificationPrioritizer = + compareByDescending<InAppNotification> { it.shouldShow } + .thenBy { it.status } + .thenBy { notifications.get(it)!! } + + private val activeNotifications = PriorityQueue(notificationPrioritizer) + private val notifications = HashMap<InAppNotification, Int>() + + var current by observable<InAppNotification?>(null) { _, oldNotification, newNotification -> + if (oldNotification != newNotification) { + onNotificationChanged.invoke(newNotification) + } + } + + fun register(notification: InAppNotification) { + notification.controller = this + + notifications.put(notification, notifications.size) + + notificationChanged(notification) + } + + fun onResume() { + for (notification in notifications.keys) { + notification.onResume() + } + } + + fun onPause() { + for (notification in notifications.keys) { + notification.onPause() + } + } + + fun onDestroy() { + for (notification in notifications.keys) { + notification.onDestroy() + } + } + + fun notificationChanged(notification: InAppNotification) { + if (notification.shouldShow && !activeNotifications.contains(notification)) { + activeNotifications.add(notification) + } else { + activeNotifications.remove(notification) + } + + current = activeNotifications.peek() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/KeyStatusNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/KeyStatusNotification.kt new file mode 100644 index 0000000000..93bd4f4cb3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/KeyStatusNotification.kt @@ -0,0 +1,58 @@ +package net.mullvad.mullvadvpn.ui.notification + +import android.content.Context +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.KeygenEvent +import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache +import net.mullvad.mullvadvpn.ui.serviceconnection.KeyStatusListener + +class KeyStatusNotification( + context: Context, + authTokenCache: AuthTokenCache, + private val keyStatusListener: KeyStatusListener +) : NotificationWithUrlWithToken(context, authTokenCache, R.string.wg_key_url) { + private val failedToGenerateKey = context.getString(R.string.failed_to_generate_key) + private val tooManyKeys = context.getString(R.string.too_many_keys) + + init { + status = StatusLevel.Error + title = context.getString(R.string.wireguard_error) + } + + override fun onResume() { + keyStatusListener.onKeyStatusChange.subscribe(this) { keyStatus -> + jobTracker.newUiJob("updateKeyStatus") { + updateKeyStatus(keyStatus) + } + } + } + + override fun onPause() { + keyStatusListener.onKeyStatusChange.unsubscribe(this) + } + + private fun updateKeyStatus(keyStatus: KeygenEvent?) { + when (keyStatus) { + null -> shouldShow = false + is KeygenEvent.NewKey -> shouldShow = false + is KeygenEvent.TooManyKeys -> showTooManyKeys() + is KeygenEvent.GenerationFailure -> showGenerationFailure() + } + + update() + } + + private fun showTooManyKeys() { + onClick = openUrl + message = tooManyKeys + showIcon = true + shouldShow = true + } + + private fun showGenerationFailure() { + onClick = null + message = failedToGenerateKey + showIcon = false + shouldShow = true + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrl.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrl.kt new file mode 100644 index 0000000000..4257f8d2a6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrl.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.ui.notification + +import android.content.Context +import android.content.Intent +import android.net.Uri + +abstract class NotificationWithUrl( + protected val context: Context, + urlId: Int +) : InAppNotification() { + private val url = Uri.parse(context.getString(urlId)) + + protected val openUrl: suspend () -> Unit = { + context.startActivity(Intent(Intent.ACTION_VIEW, url)) + } + + init { + onClick = openUrl + showIcon = true + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrlWithToken.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrlWithToken.kt new file mode 100644 index 0000000000..6c761f47b2 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrlWithToken.kt @@ -0,0 +1,26 @@ +package net.mullvad.mullvadvpn.ui.notification + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.annotation.StringRes +import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache + +abstract class NotificationWithUrlWithToken( + protected val context: Context, + protected val authTokenCache: AuthTokenCache, + @StringRes urlId: Int +) : InAppNotification() { + private val url = context.getString(urlId) + + protected val openUrl: suspend () -> Unit = { + context.startActivity(Intent(Intent.ACTION_VIEW, buildUrl())) + } + + init { + onClick = openUrl + showIcon = true + } + + private suspend fun buildUrl() = Uri.parse("$url?token=${authTokenCache.fetchAuthToken()}") +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt new file mode 100644 index 0000000000..6960fd656b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.ui.notification + +enum class StatusLevel { + Error, + Warning, +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt new file mode 100644 index 0000000000..8c26c5dc1e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt @@ -0,0 +1,114 @@ +package net.mullvad.mullvadvpn.ui.notification + +import android.content.Context +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy +import net.mullvad.talpid.tunnel.ActionAfterDisconnect +import net.mullvad.talpid.tunnel.ErrorState +import net.mullvad.talpid.tunnel.ErrorStateCause +import net.mullvad.talpid.tunnel.ParameterGenerationError +import net.mullvad.talpid.util.addressString + +class TunnelStateNotification( + private val context: Context, + private val connectionProxy: ConnectionProxy +) : InAppNotification() { + private val blockingTitle = context.getString(R.string.blocking_internet) + private val notBlockingTitle = context.getString(R.string.not_blocking_internet) + + init { + status = StatusLevel.Error + onClick = null + showIcon = false + } + + override fun onResume() { + connectionProxy.onStateChange.subscribe(this) { tunnelState -> + jobTracker.newUiJob("updateTunnelState") { + updateTunnelState(tunnelState) + } + } + } + + override fun onPause() { + connectionProxy.onStateChange.unsubscribe(this) + } + + private fun updateTunnelState(state: TunnelState) { + when (state) { + is TunnelState.Disconnecting -> { + when (state.actionAfterDisconnect) { + ActionAfterDisconnect.Nothing -> hide() + ActionAfterDisconnect.Block -> show(null) + ActionAfterDisconnect.Reconnect -> show(null) + } + } + is TunnelState.Disconnected -> hide() + is TunnelState.Connecting -> show(null) + is TunnelState.Connected -> hide() + is TunnelState.Error -> show(state.errorState) + } + + update() + } + + private fun show(error: ErrorState?) { + // if the error state is null, we can assume that we are secure + if (error?.isBlocking ?: true) { + title = blockingTitle + message = error?.cause?.let { cause -> blockingErrorMessage(cause) } + } else { + title = notBlockingTitle + message = notBlockingErrorMessage(error?.cause) + } + + shouldShow = true + } + + private fun blockingErrorMessage(cause: ErrorStateCause): String { + val messageId = when (cause) { + is ErrorStateCause.InvalidDnsServers -> { + val addresses = cause.addresses + .map { address -> address.addressString() } + .joinToString() + + return context.getString(R.string.invalid_dns_servers, addresses) + } + is ErrorStateCause.AuthFailed -> R.string.auth_failed + is ErrorStateCause.Ipv6Unavailable -> R.string.ipv6_unavailable + is ErrorStateCause.SetFirewallPolicyError -> R.string.set_firewall_policy_error + is ErrorStateCause.SetDnsError -> R.string.set_dns_error + is ErrorStateCause.StartTunnelError -> R.string.start_tunnel_error + is ErrorStateCause.IsOffline -> R.string.is_offline + is ErrorStateCause.TunnelParameterError -> { + when (cause.error) { + ParameterGenerationError.NoMatchingRelay -> R.string.no_matching_relay + ParameterGenerationError.NoMatchingBridgeRelay -> { + R.string.no_matching_bridge_relay + } + ParameterGenerationError.NoWireguardKey -> R.string.no_wireguard_key + ParameterGenerationError.CustomTunnelHostResultionError -> { + R.string.custom_tunnel_host_resolution_error + } + } + } + is ErrorStateCause.VpnPermissionDenied -> R.string.vpn_permission_denied_error + } + + return context.getString(messageId) + } + + private fun notBlockingErrorMessage(cause: ErrorStateCause?): String { + val messageId = when (cause) { + is ErrorStateCause.VpnPermissionDenied -> R.string.vpn_permission_denied_error + else -> R.string.failed_to_block_internet + } + + return context.getString(messageId) + } + + private fun hide() { + shouldShow = false + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt new file mode 100644 index 0000000000..8a8104290f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt @@ -0,0 +1,59 @@ +package net.mullvad.mullvadvpn.ui.notification + +import android.content.Context +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache + +class VersionInfoNotification( + context: Context, + private val versionInfoCache: AppVersionInfoCache +) : NotificationWithUrl(context, R.string.download_url) { + private val unsupportedVersion = context.getString(R.string.unsupported_version) + private val updateAvailable = context.getString(R.string.update_available) + + override fun onResume() { + versionInfoCache.onUpdate = { + jobTracker.newUiJob("updateVersionInfo") { + updateVersionInfo( + versionInfoCache.isOutdated, + versionInfoCache.isSupported, + versionInfoCache.upgradeVersion + ) + } + } + } + + override fun onPause() { + versionInfoCache.onUpdate = null + } + + private fun updateVersionInfo(isOutdated: Boolean, isSupported: Boolean, upgrade: String?) { + if (isOutdated || !isSupported) { + if (upgrade != null) { + val template: Int + + if (isSupported) { + status = StatusLevel.Warning + title = updateAvailable + template = R.string.update_available_description + } else { + status = StatusLevel.Error + title = unsupportedVersion + template = R.string.unsupported_version_description + } + + message = context.getString(template, upgrade) + } else { + status = StatusLevel.Error + title = unsupportedVersion + message = context.getString(R.string.unsupported_version_without_upgrade) + } + + shouldShow = true + } else { + shouldShow = false + } + + update() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AccountCache.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AccountCache.kt new file mode 100644 index 0000000000..9bf5942f4c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AccountCache.kt @@ -0,0 +1,67 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import android.os.Messenger +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.EventDispatcher +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.model.LoginStatus +import net.mullvad.talpid.util.EventNotifier +import org.joda.time.DateTime + +class AccountCache(private val connection: Messenger, eventDispatcher: EventDispatcher) { + val onAccountNumberChange = EventNotifier<String?>(null) + val onAccountExpiryChange = EventNotifier<DateTime?>(null) + val onAccountHistoryChange = EventNotifier<String?>(null) + val onLoginStatusChange = EventNotifier<LoginStatus?>(null) + + private var accountHistory by onAccountHistoryChange.notifiable() + private var loginStatus by onLoginStatusChange.notifiable() + + init { + eventDispatcher.apply { + registerHandler(Event.AccountHistory::class) { event -> + accountHistory = event.history + } + + registerHandler(Event.LoginStatus::class) { event -> + loginStatus = event.status + + onAccountNumberChange.notifyIfChanged(loginStatus?.account) + onAccountExpiryChange.notifyIfChanged(loginStatus?.expiry) + } + } + } + + fun createNewAccount() { + connection.send(Request.CreateAccount.message) + } + + fun login(account: String) { + connection.send(Request.Login(account).message) + } + + fun logout() { + connection.send(Request.Logout.message) + } + + fun fetchAccountExpiry() { + connection.send(Request.FetchAccountExpiry.message) + } + + fun invalidateAccountExpiry(accountExpiryToInvalidate: DateTime) { + val request = Request.InvalidateAccountExpiry(accountExpiryToInvalidate) + + connection.send(request.message) + } + + fun clearAccountHistory() { + connection.send(Request.ClearAccountHistory.message) + } + + fun onDestroy() { + onAccountNumberChange.unsubscribeAll() + onAccountExpiryChange.unsubscribeAll() + onAccountHistoryChange.unsubscribeAll() + onLoginStatusChange.unsubscribeAll() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoCache.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoCache.kt new file mode 100644 index 0000000000..b921063c24 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoCache.kt @@ -0,0 +1,61 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.EventDispatcher +import net.mullvad.mullvadvpn.model.AppVersionInfo + +class AppVersionInfoCache( + eventDispatcher: EventDispatcher, + private val settingsListener: SettingsListener +) { + private var appVersionInfo by observable<AppVersionInfo?>(null) { _, _, _ -> + onUpdate?.invoke() + } + + val isSupported + get() = appVersionInfo?.supported ?: true + + val isOutdated + get() = appVersionInfo?.suggestedUpgrade != null + + val upgradeVersion + get() = appVersionInfo?.suggestedUpgrade + + var onUpdate by observable<(() -> Unit)?>(null) { _, _, callback -> + callback?.invoke() + } + + var showBetaReleases by observable(false) { _, wasShowing, shouldShow -> + if (shouldShow != wasShowing) { + onUpdate?.invoke() + } + } + private set + + var version: String? = null + private set + + init { + eventDispatcher.apply { + registerHandler(Event.CurrentVersion::class) { event -> + version = event.version + } + + registerHandler(Event.AppVersionInfo::class) { event -> + appVersionInfo = event.versionInfo + } + } + + settingsListener.settingsNotifier.subscribe(this) { maybeSettings -> + maybeSettings?.let { settings -> + showBetaReleases = settings.showBetaReleases + } + } + } + + fun onDestroy() { + settingsListener.settingsNotifier.unsubscribe(this) + onUpdate = null + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AuthTokenCache.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AuthTokenCache.kt new file mode 100644 index 0000000000..2078de671a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AuthTokenCache.kt @@ -0,0 +1,42 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import android.os.Messenger +import java.util.LinkedList +import kotlinx.coroutines.CompletableDeferred +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.EventDispatcher +import net.mullvad.mullvadvpn.ipc.Request + +class AuthTokenCache(private val connection: Messenger, eventDispatcher: EventDispatcher) { + private val fetchQueue = LinkedList<CompletableDeferred<String>>() + + init { + eventDispatcher.registerHandler(Event.AuthToken::class) { event -> + synchronized(this@AuthTokenCache) { + fetchQueue.poll()?.complete(event.token ?: "") + } + } + } + + suspend fun fetchAuthToken(): String { + val authToken = CompletableDeferred<String>() + + synchronized(this) { + fetchQueue.offer(authToken) + } + + connection.send(Request.FetchAuthToken.message) + + return authToken.await() + } + + fun onDestroy() { + synchronized(this) { + for (pendingFetch in fetchQueue) { + pendingFetch.cancel() + } + + fetchQueue.clear() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ConnectionProxy.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ConnectionProxy.kt new file mode 100644 index 0000000000..5b4b88ad94 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ConnectionProxy.kt @@ -0,0 +1,139 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import android.os.Messenger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.EventDispatcher +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.talpid.tunnel.ActionAfterDisconnect +import net.mullvad.talpid.util.EventNotifier + +val ANTICIPATED_STATE_TIMEOUT_MS = 1500L + +class ConnectionProxy(private val connection: Messenger, eventDispatcher: EventDispatcher) { + private var resetAnticipatedStateJob: Job? = null + + val onStateChange = EventNotifier<TunnelState>(TunnelState.Disconnected) + val onUiStateChange = EventNotifier<TunnelState>(TunnelState.Disconnected) + + var state by onStateChange.notifiable() + private set + var uiState by onUiStateChange.notifiable() + private set + + init { + eventDispatcher.registerHandler(Event.TunnelStateChange::class) { event -> + handleNewState(event.tunnelState) + } + } + + fun connect() { + if (anticipateConnectingState()) { + connection.send(Request.Connect.message) + } + } + + fun disconnect() { + if (anticipateReconnectingState()) { + connection.send(Request.Disconnect.message) + } + } + + fun reconnect() { + if (anticipateDisconnectingState()) { + connection.send(Request.Reconnect.message) + } + } + + fun onDestroy() { + onStateChange.unsubscribeAll() + onUiStateChange.unsubscribeAll() + } + + private fun handleNewState(newState: TunnelState) { + synchronized(this) { + resetAnticipatedStateJob?.cancel() + state = newState + uiState = newState + } + } + + private fun anticipateConnectingState(): Boolean { + synchronized(this) { + val currentState = uiState + + if (currentState is TunnelState.Connecting || currentState is TunnelState.Connected) { + return false + } else { + scheduleToResetAnticipatedState() + uiState = TunnelState.Connecting(null, null) + return true + } + } + } + + private fun anticipateReconnectingState(): Boolean { + synchronized(this) { + val currentState = uiState + + val willReconnect = when (currentState) { + is TunnelState.Disconnected -> false + is TunnelState.Disconnecting -> { + when (currentState.actionAfterDisconnect) { + ActionAfterDisconnect.Nothing -> false + ActionAfterDisconnect.Reconnect -> true + ActionAfterDisconnect.Block -> true + } + } + is TunnelState.Connecting -> true + is TunnelState.Connected -> true + is TunnelState.Error -> true + } + + if (willReconnect) { + scheduleToResetAnticipatedState() + uiState = TunnelState.Disconnecting(ActionAfterDisconnect.Reconnect) + } + + return willReconnect + } + } + + private fun anticipateDisconnectingState(): Boolean { + synchronized(this) { + val currentState = uiState + + if (currentState is TunnelState.Disconnected) { + return false + } else { + scheduleToResetAnticipatedState() + uiState = TunnelState.Disconnecting(ActionAfterDisconnect.Nothing) + return true + } + } + } + + private fun scheduleToResetAnticipatedState() { + resetAnticipatedStateJob?.cancel() + + var currentJob: Job? = null + + val newJob = GlobalScope.launch(Dispatchers.Default) { + delay(ANTICIPATED_STATE_TIMEOUT_MS) + + synchronized(this@ConnectionProxy) { + if (!currentJob!!.isCancelled) { + uiState = state + } + } + } + + currentJob = newJob + resetAnticipatedStateJob = newJob + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/CustomDns.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/CustomDns.kt new file mode 100644 index 0000000000..f33c2967fa --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/CustomDns.kt @@ -0,0 +1,61 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import android.os.Messenger +import java.net.InetAddress +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.talpid.util.EventNotifier + +class CustomDns(private val connection: Messenger, private val settingsListener: SettingsListener) { + val onEnabledChanged = EventNotifier(false) + val onDnsServersChanged = EventNotifier<List<InetAddress>>(emptyList()) + + init { + settingsListener.dnsOptionsNotifier.subscribe(this) { maybeDnsOptions -> + maybeDnsOptions?.let { dnsOptions -> + synchronized(this) { + onEnabledChanged.notifyIfChanged(dnsOptions.custom) + onDnsServersChanged.notifyIfChanged(dnsOptions.addresses) + } + } + } + } + + fun enable() { + connection.send(Request.SetEnableCustomDns(true).message) + } + + fun disable() { + connection.send(Request.SetEnableCustomDns(false).message) + } + + fun addDnsServer(server: InetAddress): Boolean { + val didntAlreadyHaveServer = !onDnsServersChanged.latestEvent.contains(server) + + connection.send(Request.AddCustomDnsServer(server).message) + + return didntAlreadyHaveServer + } + + fun replaceDnsServer(oldServer: InetAddress, newServer: InetAddress): Boolean { + synchronized(this) { + val dnsServers = onDnsServersChanged.latestEvent + val containsOldServer = dnsServers.contains(oldServer) + val replacementIsValid = oldServer == newServer || !dnsServers.contains(newServer) + + connection.send(Request.ReplaceCustomDnsServer(oldServer, newServer).message) + + return containsOldServer && replacementIsValid + } + } + + fun removeDnsServer(server: InetAddress) { + connection.send(Request.RemoveCustomDnsServer(server).message) + } + + fun onDestroy() { + onEnabledChanged.unsubscribeAll() + onDnsServersChanged.unsubscribeAll() + + settingsListener.dnsOptionsNotifier.unsubscribe(this) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/KeyStatusListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/KeyStatusListener.kt new file mode 100644 index 0000000000..96e7c852aa --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/KeyStatusListener.kt @@ -0,0 +1,33 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import android.os.Messenger +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.EventDispatcher +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.model.KeygenEvent +import net.mullvad.talpid.util.EventNotifier + +class KeyStatusListener(private val connection: Messenger, eventDispatcher: EventDispatcher) { + val onKeyStatusChange = EventNotifier<KeygenEvent?>(null) + + var keyStatus by onKeyStatusChange.notifiable() + private set + + init { + eventDispatcher.registerHandler(Event.WireGuardKeyStatus::class) { event -> + keyStatus = event.keyStatus + } + } + + fun generateKey() { + connection.send(Request.WireGuardGenerateKey.message) + } + + fun verifyKey() { + connection.send(Request.WireGuardVerifyKey.message) + } + + fun onDestroy() { + onKeyStatusChange.unsubscribeAll() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/LocationInfoCache.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/LocationInfoCache.kt new file mode 100644 index 0000000000..8eee6503c7 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/LocationInfoCache.kt @@ -0,0 +1,26 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.EventDispatcher +import net.mullvad.mullvadvpn.model.GeoIpLocation + +class LocationInfoCache(eventDispatcher: EventDispatcher) { + private var location: GeoIpLocation? by observable(null) { _, _, newLocation -> + onNewLocation?.invoke(newLocation) + } + + var onNewLocation by observable<((GeoIpLocation?) -> Unit)?>(null) { _, _, callback -> + callback?.invoke(location) + } + + init { + eventDispatcher.registerHandler(Event.NewLocation::class) { event -> + location = event.location + } + } + + fun onDestroy() { + onNewLocation = null + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt new file mode 100644 index 0000000000..13c1c3dabe --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt @@ -0,0 +1,102 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import android.os.Messenger +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.EventDispatcher +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.model.Constraint +import net.mullvad.mullvadvpn.model.LocationConstraint +import net.mullvad.mullvadvpn.model.RelayConstraints +import net.mullvad.mullvadvpn.model.RelaySettings +import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.relaylist.RelayList + +class RelayListListener( + private val connection: Messenger, + eventDispatcher: EventDispatcher, + private val settingsListener: SettingsListener +) { + private var relayList: RelayList? = null + private var relaySettings: RelaySettings? = null + + var selectedRelayItem: RelayItem? = null + private set + + var selectedRelayLocation: LocationConstraint? + get() { + val settings = relaySettings as? RelaySettings.Normal + val location = settings?.relayConstraints?.location as? Constraint.Only + + return location?.value + } + set(value) { + connection.send(Request.SetRelayLocation(value).message) + } + + var onRelayListChange: ((RelayList, RelayItem?) -> Unit)? = null + set(value) { + field = value + + synchronized(this) { + val relayList = this.relayList + + if (relayList != null) { + value?.invoke(relayList, selectedRelayItem) + } + } + } + + init { + eventDispatcher.registerHandler(Event.NewRelayList::class) { event -> + event.relayList?.let { relayLocations -> + relayListChanged(RelayList(relayLocations)) + } + } + + settingsListener.relaySettingsNotifier.subscribe(this) { newRelaySettings -> + relaySettingsChanged(newRelaySettings) + } + } + + fun onDestroy() { + settingsListener.relaySettingsNotifier.unsubscribe(this) + onRelayListChange = null + } + + private fun relaySettingsChanged(newRelaySettings: RelaySettings?) { + synchronized(this) { + val relayList = this.relayList + + relaySettings = newRelaySettings + ?: RelaySettings.Normal(RelayConstraints(Constraint.Any())) + + if (relayList != null) { + relayListChanged(relayList) + } + } + } + + private fun relayListChanged(newRelayList: RelayList) { + synchronized(this) { + relayList = newRelayList + selectedRelayItem = findSelectedRelayItem() + + onRelayListChange?.invoke(newRelayList, selectedRelayItem) + } + } + + private fun findSelectedRelayItem(): RelayItem? { + val relaySettings = this.relaySettings + + when (relaySettings) { + is RelaySettings.CustomTunnelEndpoint -> return null + is RelaySettings.Normal -> { + val location = relaySettings.relayConstraints.location + + return relayList?.findItemForLocation(location, true) + } + } + + return null + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnection.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnection.kt new file mode 100644 index 0000000000..861a54a561 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnection.kt @@ -0,0 +1,84 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import android.os.Looper +import android.os.Messenger +import android.os.RemoteException +import android.util.Log +import net.mullvad.mullvadvpn.di.SERVICE_CONNECTION_SCOPE +import net.mullvad.mullvadvpn.ipc.DispatchingHandler +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.Request +import org.koin.core.component.KoinApiExtension +import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named +import org.koin.core.scope.KoinScopeComponent +import org.koin.core.scope.get + +// Container of classes that communicate with the service through an active connection +// +// The properties of this class can be used to send events to the service, to listen for events from +// the service and to get values received from events. +@OptIn(KoinApiExtension::class) +class ServiceConnection( + connection: Messenger, + onServiceReady: ((ServiceConnection) -> Unit)? = null +) : KoinScopeComponent { + private val dispatcher = DispatchingHandler(Looper.getMainLooper()) { message -> + Event.fromMessage(message) + } + + override val scope = getKoin().createScope( + SERVICE_CONNECTION_SCOPE, + named(SERVICE_CONNECTION_SCOPE), this + ) + + val accountCache = AccountCache(connection, dispatcher) + val authTokenCache = AuthTokenCache(connection, dispatcher) + val connectionProxy = ConnectionProxy(connection, dispatcher) + val keyStatusListener = KeyStatusListener(connection, dispatcher) + val locationInfoCache = LocationInfoCache(dispatcher) + val settingsListener = SettingsListener(connection, dispatcher) + val splitTunneling = get<SplitTunneling>(parameters = { parametersOf(connection, dispatcher) }) + val voucherRedeemer = VoucherRedeemer(connection, dispatcher) + val vpnPermission = VpnPermission(connection, dispatcher) + + val appVersionInfoCache = AppVersionInfoCache(dispatcher, settingsListener) + val customDns = CustomDns(connection, settingsListener) + var relayListListener = RelayListListener(connection, dispatcher, settingsListener) + + init { + dispatcher.registerHandler(Event.ListenerReady::class) { _ -> + onServiceReady?.invoke(this@ServiceConnection) + } + + registerListener(connection) + } + + fun onDestroy() { + closeScope() + dispatcher.onDestroy() + + accountCache.onDestroy() + authTokenCache.onDestroy() + connectionProxy.onDestroy() + keyStatusListener.onDestroy() + locationInfoCache.onDestroy() + settingsListener.onDestroy() + voucherRedeemer.onDestroy() + + appVersionInfoCache.onDestroy() + customDns.onDestroy() + relayListListener.onDestroy() + } + + private fun registerListener(connection: Messenger) { + val listener = Messenger(dispatcher) + val request = Request.RegisterListener(listener) + + try { + connection.send(request.message) + } catch (exception: RemoteException) { + Log.e("mullvad", "Failed to register listener for service events", exception) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt new file mode 100644 index 0000000000..cb8fac65c4 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt @@ -0,0 +1,66 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import android.os.Messenger +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.EventDispatcher +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.model.DnsOptions +import net.mullvad.mullvadvpn.model.RelaySettings +import net.mullvad.mullvadvpn.model.Settings +import net.mullvad.talpid.util.EventNotifier + +class SettingsListener(private val connection: Messenger, eventDispatcher: EventDispatcher) { + val accountNumberNotifier = EventNotifier<String?>(null) + val dnsOptionsNotifier = EventNotifier<DnsOptions?>(null) + val relaySettingsNotifier = EventNotifier<RelaySettings?>(null) + val settingsNotifier = EventNotifier<Settings?>(null) + + private var settings by settingsNotifier.notifiable() + + var account: String? + get() = accountNumberNotifier.latestEvent + set(value) { connection.send(Request.SetAccount(value).message) } + + var allowLan: Boolean + get() = settingsNotifier.latestEvent?.allowLan ?: false + set(value) { connection.send(Request.SetAllowLan(value).message) } + + var autoConnect: Boolean + get() = settingsNotifier.latestEvent?.autoConnect ?: false + set(value) { connection.send(Request.SetAutoConnect(value).message) } + + var wireguardMtu: Int? + get() = settingsNotifier.latestEvent?.tunnelOptions?.wireguard?.options?.mtu + set(value) { connection.send(Request.SetWireGuardMtu(value).message) } + + init { + eventDispatcher.registerHandler(Event.SettingsUpdate::class, ::handleNewEvent) + } + + fun onDestroy() { + accountNumberNotifier.unsubscribeAll() + dnsOptionsNotifier.unsubscribeAll() + relaySettingsNotifier.unsubscribeAll() + settingsNotifier.unsubscribeAll() + } + + private fun handleNewEvent(event: Event.SettingsUpdate) { + event.settings?.let { settings -> handleNewSettings(settings) } + } + + private fun handleNewSettings(newSettings: Settings) { + if (settings?.accountToken != newSettings.accountToken) { + accountNumberNotifier.notify(newSettings.accountToken) + } + + if (settings?.tunnelOptions?.dnsOptions != newSettings.tunnelOptions.dnsOptions) { + dnsOptionsNotifier.notify(newSettings.tunnelOptions.dnsOptions) + } + + if (settings?.relaySettings != newSettings.relaySettings) { + relaySettingsNotifier.notify(newSettings.relaySettings) + } + + settings = newSettings + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt new file mode 100644 index 0000000000..7800661b21 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt @@ -0,0 +1,38 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import android.os.Messenger +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.EventDispatcher +import net.mullvad.mullvadvpn.ipc.Request + +class SplitTunneling(private val connection: Messenger, eventDispatcher: EventDispatcher) { + private var excludedApps: Set<String> = emptySet() + + var enabled by observable(false) { _, wasEnabled, isEnabled -> + if (wasEnabled != isEnabled) { + connection.send(Request.SetEnableSplitTunneling(isEnabled).message) + } + } + + init { + eventDispatcher.registerHandler(Event.SplitTunnelingUpdate::class) { event -> + if (event.excludedApps != null) { + enabled = true + excludedApps = event.excludedApps.toSet() + } else { + enabled = false + } + } + } + + fun isAppExcluded(appPackageName: String): Boolean = excludedApps.contains(appPackageName) + + fun excludeApp(appPackageName: String) = + connection.send(Request.ExcludeApp(appPackageName).message) + + fun includeApp(appPackageName: String) = + connection.send(Request.IncludeApp(appPackageName).message) + + fun persist() = connection.send(Request.PersistExcludedApps.message) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/VoucherRedeemer.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/VoucherRedeemer.kt new file mode 100644 index 0000000000..d2378100ea --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/VoucherRedeemer.kt @@ -0,0 +1,43 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import android.os.Messenger +import kotlinx.coroutines.CompletableDeferred +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.MessageDispatcher +import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.model.VoucherSubmissionResult + +class VoucherRedeemer(val connection: Messenger, eventDispatcher: MessageDispatcher<Event>) { + private val activeSubmissions = + mutableMapOf<String, CompletableDeferred<VoucherSubmissionResult>>() + + init { + eventDispatcher.registerHandler(Event.VoucherSubmissionResult::class) { event -> + synchronized(this@VoucherRedeemer) { + activeSubmissions.remove(event.voucher)?.complete(event.result) + } + } + } + + suspend fun submit(voucher: String): VoucherSubmissionResult { + val result = CompletableDeferred<VoucherSubmissionResult>() + + synchronized(this) { + activeSubmissions.put(voucher, result) + } + + connection.send(Request.SubmitVoucher(voucher).message) + + return result.await() + } + + fun onDestroy() { + synchronized(this) { + for ((_, submission) in activeSubmissions) { + submission.cancel() + } + + activeSubmissions.clear() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/VpnPermission.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/VpnPermission.kt new file mode 100644 index 0000000000..30b672364d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/VpnPermission.kt @@ -0,0 +1,20 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import android.os.Messenger +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.MessageDispatcher +import net.mullvad.mullvadvpn.ipc.Request + +class VpnPermission(private val connection: Messenger, eventDispatcher: MessageDispatcher<Event>) { + var onRequest: (() -> Unit)? = null + + init { + eventDispatcher.registerHandler(Event.VpnPermissionRequest::class) { _ -> + onRequest?.invoke() + } + } + + fun grant(isGranted: Boolean) { + connection.send(Request.VpnPermissionResponse(isGranted).message) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountCell.kt new file mode 100644 index 0000000000..c70233c8aa --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountCell.kt @@ -0,0 +1,76 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.graphics.Typeface +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.widget.TextView +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.util.TimeLeftFormatter +import org.joda.time.DateTime +import org.joda.time.Duration + +class AccountCell : NavigateCell { + private val formatter = TimeLeftFormatter(resources) + + private val expiredColor = context.getColor(R.color.red) + private val normalColor = context.getColor(R.color.white60) + + private val remainingTimeLabel = TextView(context).apply { + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, 0.0f) + gravity = Gravity.RIGHT + + resources.getDimensionPixelSize(R.dimen.cell_inner_spacing).let { padding -> + setPadding(padding, 0, padding, 0) + } + + setAllCaps(true) + setTextColor(normalColor) + setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.text_small)) + setTypeface(null, Typeface.BOLD) + + text = "" + } + + var accountExpiry by observable<DateTime?>(null) { _, _, expiry -> + remainingTimeLabel.apply { + if (expiry != null) { + val remainingTime = Duration(DateTime.now(), expiry) + + if (remainingTime.isShorterThan(Duration.ZERO)) { + setText(R.string.out_of_time) + setTextColor(expiredColor) + } else { + setText(formatter.format(expiry, remainingTime)) + setTextColor(normalColor) + } + } else { + text = "" + } + } + } + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {} + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) {} + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) {} + + init { + cell.addView(remainingTimeLabel, cell.childCount - 1) + } + + private fun getRemainingText(pluralId: Int, quantity: Int): String { + return resources.getQuantityString(pluralId, quantity, quantity) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountHistoryAdapter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountHistoryAdapter.kt new file mode 100644 index 0000000000..48a05dd63e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountHistoryAdapter.kt @@ -0,0 +1,41 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView.Adapter +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.util.SegmentedTextFormatter + +class AccountHistoryAdapter : Adapter<AccountHistoryHolder>() { + private val formatter = SegmentedTextFormatter(' ').apply { + isValidInputCharacter = { character -> + '0' <= character && character <= '9' + } + } + + var accountHistory by observable<String?>(null) { _, _, _ -> + notifyDataSetChanged() + } + + var onSelectEntry: ((String) -> Unit)? = null + var onRemoveEntry: (() -> Unit)? = null + var onChildFocusChanged: ((String, Boolean) -> Unit)? = null + + override fun onCreateViewHolder(parentView: ViewGroup, type: Int): AccountHistoryHolder { + val inflater = LayoutInflater.from(parentView.context) + val view = inflater.inflate(R.layout.account_history_entry, parentView, false) + + return AccountHistoryHolder(view, formatter).apply { + onSelect = { account -> onSelectEntry?.invoke(account) } + onRemove = { _ -> onRemoveEntry?.invoke() } + onFocusChanged = { account, hasFocus -> onChildFocusChanged?.invoke(account, hasFocus) } + } + } + + override fun onBindViewHolder(holder: AccountHistoryHolder, position: Int) { + holder.accountToken = accountHistory ?: "" + } + + override fun getItemCount() = if (accountHistory !== null) 1 else 0 +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountHistoryHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountHistoryHolder.kt new file mode 100644 index 0000000000..4a87f4f601 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountHistoryHolder.kt @@ -0,0 +1,45 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.util.SegmentedTextFormatter + +class AccountHistoryHolder( + view: View, + private val formatter: SegmentedTextFormatter +) : ViewHolder(view) { + private val label: TextView = view.findViewById(R.id.label) + + var accountToken by observable("") { _, _, account -> + label.text = formatter.format(account) + } + + var onSelect: ((String) -> Unit)? = null + var onRemove: ((String) -> Unit)? = null + var onFocusChanged: ((String, Boolean) -> Unit)? = null + + init { + view.findViewById<View>(R.id.remove).apply { + setOnClickListener { + onRemove?.invoke(accountToken) + } + + setOnFocusChangeListener { _, hasFocus -> + onFocusChanged?.invoke(accountToken, hasFocus) + } + } + + label.apply { + setOnClickListener { + onSelect?.invoke(accountToken) + } + + setOnFocusChangeListener { _, hasFocus -> + onFocusChanged?.invoke(accountToken, hasFocus) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountInput.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountInput.kt new file mode 100644 index 0000000000..d2de7c4334 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountInput.kt @@ -0,0 +1,176 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.text.Editable +import android.text.TextWatcher +import android.text.method.DigitsKeyListener +import android.text.style.MetricAffectingSpan +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.View.OnFocusChangeListener +import android.widget.EditText +import android.widget.ImageButton +import android.widget.LinearLayout +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.ui.LoginState +import net.mullvad.mullvadvpn.util.SegmentedInputFormatter +import net.mullvad.mullvadvpn.util.setOnEnterOrDoneAction +import net.mullvad.talpid.util.EventNotifier + +const val MIN_ACCOUNT_TOKEN_LENGTH = 10 + +class AccountInput : LinearLayout { + private val disabledTextColor = context.getColor(R.color.white) + private val enabledTextColor = context.getColor(R.color.blue) + private val errorTextColor = context.getColor(R.color.red) + + private val container = + context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> + val inflater = service as LayoutInflater + + inflater.inflate(R.layout.account_input, this) + } + + private val inputWatcher = object : TextWatcher { + override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(text: Editable) { + removeFormattingSpans(text) + setButtonEnabled(text.length >= MIN_ACCOUNT_TOKEN_LENGTH) + onTextChanged.notify(Unit) + } + } + + private val input = container.findViewById<EditText>(R.id.login_input).apply { + addTextChangedListener(inputWatcher) + setOnEnterOrDoneAction(::login) + + onFocusChangeListener = OnFocusChangeListener { view, inputHasFocus -> + hasFocus = inputHasFocus && view.isEnabled + } + + // Manually initializing the `DigitsKeyListener` allows spaces to be used and still keeps + // the input type as a number so that the correct software keyboard type is shown + keyListener = DigitsKeyListener.getInstance("01234567890 ") + + SegmentedInputFormatter(this, ' ').apply { + isValidInputCharacter = { character -> + '0' <= character && character <= '9' + } + } + } + + private val button = container.findViewById<ImageButton>(R.id.login_button).apply { + setOnClickListener { login() } + } + + val onFocusChanged = EventNotifier(false) + private var hasFocus by onFocusChanged.notifiable() + + val onTextChanged = EventNotifier(Unit) + + var loginState by observable(LoginState.Initial) { _, _, state -> + when (state) { + LoginState.Initial -> initialState() + LoginState.InProgress -> loggingInState() + LoginState.Success -> successState() + LoginState.Failure -> failureState() + } + } + + var onLogin: ((String) -> Unit)? = null + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {} + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) {} + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + } + + init { + orientation = HORIZONTAL + + setButtonEnabled(false) + } + + fun loginWith(accountNumber: String) { + input.setText(accountNumber) + onLogin?.invoke(accountNumber) + } + + private fun login() { + onLogin?.invoke(input.text.replace(Regex("[^0-9]"), "")) + } + + private fun initialState() { + input.apply { + setTextColor(enabledTextColor) + setEnabled(true) + setFocusableInTouchMode(true) + visibility = View.VISIBLE + } + + button.visibility = View.VISIBLE + setButtonEnabled(input.text.length >= MIN_ACCOUNT_TOKEN_LENGTH) + } + + private fun loggingInState() { + input.apply { + setTextColor(disabledTextColor) + setEnabled(false) + setFocusable(false) + visibility = View.VISIBLE + } + + button.visibility = View.GONE + setButtonEnabled(false) + } + + private fun successState() { + button.visibility = View.GONE + setButtonEnabled(false) + + input.visibility = View.GONE + } + + private fun failureState() { + button.visibility = View.VISIBLE + setButtonEnabled(false) + + input.apply { + setTextColor(errorTextColor) + setEnabled(true) + setFocusableInTouchMode(true) + visibility = View.VISIBLE + requestFocus() + } + } + + private fun setButtonEnabled(enabled: Boolean) { + button.apply { + if (enabled != isEnabled()) { + setEnabled(enabled) + setClickable(enabled) + setFocusable(enabled) + } + } + } + + private fun removeFormattingSpans(text: Editable) { + for (span in text.getSpans(0, text.length, MetricAffectingSpan::class.java)) { + text.removeSpan(span) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLogin.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLogin.kt new file mode 100644 index 0000000000..aa3463eb94 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLogin.kt @@ -0,0 +1,219 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.animation.ValueAnimator +import android.app.Activity +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.View.OnLayoutChangeListener +import android.view.inputmethod.InputMethodManager +import android.widget.RelativeLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.ui.ListItemDividerDecoration +import net.mullvad.mullvadvpn.ui.LoginState +import net.mullvad.mullvadvpn.ui.widget.AccountLoginBorder.BorderState +import net.mullvad.mullvadvpn.util.Debouncer + +class AccountLogin : RelativeLayout { + companion object { + private val MAX_ACCOUNT_HISTORY_ENTRIES = 3 + } + + private val focusDebouncer = Debouncer(false).apply { + listener = { hasFocus -> focused = hasFocus } + } + + private val container = + context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> + val inflater = service as LayoutInflater + + inflater.inflate(R.layout.account_login, this) + } + + private val border: AccountLoginBorder = container.findViewById(R.id.border) + private val accountHistoryList: RecyclerView = container.findViewById(R.id.history) + private val input: AccountInput = container.findViewById(R.id.input) + + private val historyAdapter = AccountHistoryAdapter().apply { + onSelectEntry = { account -> input.loginWith(account) } + onChildFocusChanged = { _, hasFocus -> focusDebouncer.rawValue = hasFocus } + } + + private val dividerHeight = resources.getDimensionPixelSize(R.dimen.account_history_divider) + private val historyEntryHeight = + resources.getDimensionPixelSize(R.dimen.account_history_entry_height) + + private val historyAnimation = ValueAnimator.ofInt(0, 0).apply { + addUpdateListener { animation -> + updateHeight(animation.animatedValue as Int) + } + + duration = 350 + } + + private val maxHeight: Int + get() = MAX_ACCOUNT_HISTORY_ENTRIES * (historyEntryHeight + dividerHeight) + + private val expandedHeight: Int + get() = collapsedHeight + (historyHeight ?: 0) + + private var historyHeight by observable<Int?>(null) { _, oldHistoryHeight, newHistoryHeight -> + if (newHistoryHeight != oldHistoryHeight) { + historyAnimation.setIntValues(collapsedHeight, expandedHeight) + reposition() + } + } + + private var collapsedHeight by observable( + resources.getDimensionPixelSize(R.dimen.account_login_input_height) + ) { _, oldCollapsedHeight, newCollapsedHeight -> + if (newCollapsedHeight != oldCollapsedHeight) { + historyAnimation.setIntValues(newCollapsedHeight, expandedHeight) + reposition() + } + } + + private var focused by observable(false) { _, _, hasFocus -> + updateBorder() + shouldShowAccountHistory = hasFocus + + if (!hasFocus) { + hideKeyboard() + } + } + + private var shouldShowAccountHistory by observable(false) { _, isShown, show -> + if (isShown != show) { + if (show) { + historyAnimation.start() + } else { + historyAnimation.reverse() + } + } + } + + val hasFocus + get() = focused + + var accountHistory by observable<String?>(null) { _, _, history -> + if (history != null) { + historyHeight = historyEntryHeight + dividerHeight + historyAdapter.accountHistory = history + } else { + historyHeight = 0 + } + } + + var state: LoginState by observable(LoginState.Initial) { _, _, newState -> + input.loginState = newState + + updateBorder() + + if (newState == LoginState.Success) { + visibility = View.INVISIBLE + } + } + + var onLogin: ((String) -> Unit)? + get() = input.onLogin + set(value) { input.onLogin = value } + + var onClearHistory: (() -> Unit)? + get() = historyAdapter.onRemoveEntry + set(value) { historyAdapter.onRemoveEntry = value } + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {} + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) {} + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + } + + init { + border.elevation = elevation + 0.1f + + input.apply { + onFocusChanged.subscribe(this) { hasFocus -> + focusDebouncer.rawValue = hasFocus + } + + onTextChanged.subscribe(this) { _ -> + if (state == LoginState.Failure) { + state = LoginState.Initial + } + } + + addOnLayoutChangeListener( + OnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ -> + collapsedHeight = bottom - top + } + ) + } + + accountHistoryList.apply { + layoutManager = LinearLayoutManager(context) + adapter = historyAdapter + + addItemDecoration( + ListItemDividerDecoration( + topOffset = resources.getDimensionPixelSize(R.dimen.account_history_divider) + ) + ) + } + + historyAnimation.setIntValues(collapsedHeight, expandedHeight) + } + + fun onDestroy() { + input.onFocusChanged.unsubscribe(this) + input.onTextChanged.unsubscribe(this) + } + + private fun updateBorder() { + if (state == LoginState.Failure) { + border.borderState = BorderState.ERROR + } else if (focused) { + border.borderState = BorderState.FOCUSED + } else { + border.borderState = BorderState.UNFOCUSED + } + } + + private fun updateHeight(height: Int) { + val layoutParams = container.layoutParams as MarginLayoutParams + + layoutParams.height = height + layoutParams.bottomMargin = maxHeight - height + + container.layoutParams = layoutParams + } + + private fun reposition() { + historyAnimation.cancel() + + if (shouldShowAccountHistory) { + updateHeight(expandedHeight) + } else { + updateHeight(collapsedHeight) + } + } + + private fun hideKeyboard() { + val inputManagerId = Activity.INPUT_METHOD_SERVICE + val inputManager = context.getSystemService(inputManagerId) as InputMethodManager + + inputManager.hideSoftInputFromWindow(windowToken, 0) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLoginBorder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLoginBorder.kt new file mode 100644 index 0000000000..e5ee732e08 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLoginBorder.kt @@ -0,0 +1,102 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.ImageView +import android.widget.RelativeLayout +import net.mullvad.mullvadvpn.R + +class AccountLoginBorder : RelativeLayout { + enum class BorderState { + UNFOCUSED, + FOCUSED, + ERROR + } + + // The horizontal and vertical drawables are identical, but they must be separate objects + // because the view that uses them changes the bounds of the drawable. If they are shared + // between the horizontal and vertical views either the drawable becomes a vertical line or a + // horizontal line, and as a consequence either the horizontal or the vertical borders don't + // show correctly, respectively. + private class StateDrawables( + val corner: Drawable, + val horizontalBorder: Drawable, + val verticalBorder: Drawable + ) + + private val unfocusedDrawables = StateDrawables( + resources.getDrawable(R.drawable.account_login_corner, null), + resources.getDrawable(R.drawable.account_login_border, null), + resources.getDrawable(R.drawable.account_login_border, null) + ) + + private val focusedDrawables = StateDrawables( + resources.getDrawable(R.drawable.account_login_corner_focused, null), + resources.getDrawable(R.drawable.account_login_border_focused, null), + resources.getDrawable(R.drawable.account_login_border_focused, null) + ) + + private val errorDrawables = StateDrawables( + resources.getDrawable(R.drawable.account_login_corner_error, null), + resources.getDrawable(R.drawable.account_login_border_error, null), + resources.getDrawable(R.drawable.account_login_border_error, null) + ) + + private val container = + context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> + val inflater = service as LayoutInflater + + inflater.inflate(R.layout.account_login_border, this) + } + + private val topLeftCorner: ImageView = container.findViewById(R.id.top_left_corner) + private val topRightCorner: ImageView = container.findViewById(R.id.top_right_corner) + private val bottomLeftCorner: ImageView = container.findViewById(R.id.bottom_left_corner) + private val bottomRightCorner: ImageView = container.findViewById(R.id.bottom_right_corner) + + private val topBorder: ImageView = container.findViewById(R.id.top_border) + private val leftBorder: ImageView = container.findViewById(R.id.left_border) + private val rightBorder: ImageView = container.findViewById(R.id.right_border) + private val bottomBorder: ImageView = container.findViewById(R.id.bottom_border) + + var borderState = BorderState.UNFOCUSED + set(value) { + field = value + + when (value) { + BorderState.UNFOCUSED -> setBorder(unfocusedDrawables) + BorderState.FOCUSED -> setBorder(focusedDrawables) + BorderState.ERROR -> setBorder(errorDrawables) + } + } + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {} + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) {} + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + } + + private fun setBorder(drawables: StateDrawables) { + topLeftCorner.setImageDrawable(drawables.corner) + topRightCorner.setImageDrawable(drawables.corner) + bottomLeftCorner.setImageDrawable(drawables.corner) + bottomRightCorner.setImageDrawable(drawables.corner) + + leftBorder.setImageDrawable(drawables.verticalBorder) + rightBorder.setImageDrawable(drawables.verticalBorder) + + topBorder.setImageDrawable(drawables.horizontalBorder) + bottomBorder.setImageDrawable(drawables.horizontalBorder) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AppVersionCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AppVersionCell.kt new file mode 100644 index 0000000000..978a8b1505 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AppVersionCell.kt @@ -0,0 +1,72 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.graphics.Typeface +import android.net.Uri +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.widget.ImageView +import android.widget.TextView +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R + +class AppVersionCell : UrlCell { + private val warningIcon = ImageView(context).apply { + val iconSize = resources.getDimensionPixelSize(R.dimen.app_version_warning_icon_size) + + layoutParams = LayoutParams(iconSize, iconSize, 0.0f) + + resources.getDimensionPixelSize(R.dimen.cell_inner_spacing).let { padding -> + setPadding(0, 0, padding, 0) + } + + setImageResource(R.drawable.icon_alert) + } + + private val versionLabel = TextView(context).apply { + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, 0.0f) + gravity = Gravity.RIGHT + + resources.getDimensionPixelSize(R.dimen.cell_inner_spacing).let { padding -> + setPadding(padding, 0, padding, 0) + } + + setTextColor(context.getColor(R.color.white60)) + setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.text_small)) + setTypeface(null, Typeface.BOLD) + + text = "" + } + + var updateAvailable by observable(false) { _, _, updateAvailable -> + if (updateAvailable) { + warningIcon.visibility = VISIBLE + footer?.visibility = VISIBLE + } else { + warningIcon.visibility = GONE + footer?.visibility = GONE + } + } + + var version by observable("") { _, _, version -> + versionLabel.text = version + } + + @JvmOverloads + constructor( + context: Context, + attributes: AttributeSet? = null, + defaultStyleAttribute: Int = 0, + defaultStyleResource: Int = 0 + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) {} + + init { + cell.addView(warningIcon, 0) + cell.addView(versionLabel, cell.getChildCount() - 1) + + if (url == null) { + url = Uri.parse(context.getString(R.string.download_url)) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/BackButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/BackButton.kt new file mode 100644 index 0000000000..b0833fd49d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/BackButton.kt @@ -0,0 +1,72 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.view.LayoutInflater +import android.widget.LinearLayout +import android.widget.TextView +import net.mullvad.mullvadvpn.R + +class BackButton : LinearLayout { + private val container = + context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> + val inflater = service as LayoutInflater + + inflater.inflate(R.layout.settings_back_button, this) + } + + private val label = container.findViewById<TextView>(R.id.label) + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) { + loadAttributes(attributes) + } + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) { + loadAttributes(attributes) + } + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + loadAttributes(attributes) + } + + init { + setFocusable(true) + isClickable = true + gravity = Gravity.CENTER_VERTICAL or Gravity.LEFT + orientation = HORIZONTAL + + resources.getDimensionPixelSize(R.dimen.settings_back_button_padding).let { padding -> + setPadding(padding, padding, padding, padding) + } + + loadBackground() + } + + private fun loadAttributes(attributes: AttributeSet) { + context.theme.obtainStyledAttributes(attributes, R.styleable.TextAttribute, 0, 0).apply { + try { + label.text = getString(R.styleable.TextAttribute_text) ?: "" + } finally { + recycle() + } + } + } + + private fun loadBackground() { + val typedValue = TypedValue() + + context.theme.resolveAttribute(android.R.attr.selectableItemBackground, typedValue, true) + + setBackgroundResource(typedValue.resourceId) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Button.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Button.kt new file mode 100644 index 0000000000..307a2c65a4 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Button.kt @@ -0,0 +1,165 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.util.JobTracker + +open class Button : FrameLayout { + enum class ButtonColor { + Blue, + Green, + Red; + + companion object { + internal fun fromCode(code: Int): ButtonColor { + when (code) { + 0 -> return Blue + 1 -> return Green + 2 -> return Red + else -> throw Exception("Invalid buttonColor attribute value") + } + } + } + } + + private val container = + context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> + val inflater = service as LayoutInflater + + inflater.inflate(R.layout.button, this) + } + + private val button = container.findViewById<android.widget.Button>(R.id.button) + private val spinner: View = container.findViewById(R.id.spinner) + private val image: ImageView = container.findViewById(R.id.image) + + private var clickJobName: String? = null + private var onClickAction: (suspend () -> Unit)? = null + + protected var jobTracker: JobTracker? = null + + var buttonColor: ButtonColor = ButtonColor.Blue + set(value) { + field = value + + val backgroundResource = when (value) { + ButtonColor.Blue -> R.drawable.blue_button_background + ButtonColor.Green -> R.drawable.green_button_background + ButtonColor.Red -> R.drawable.red_button_background + } + + button.setBackgroundResource(backgroundResource) + } + + var detailImage: Drawable? = null + set(value) { + field = value + + image.apply { + if (value == null) { + visibility = GONE + } else { + visibility = VISIBLE + setImageDrawable(value) + } + } + } + + var label: CharSequence + get() = button.text + set(value) { button.text = value } + + var showSpinner = false + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) { + loadAttributes(attributes) + } + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) { + loadAttributes(attributes) + } + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + loadAttributes(attributes) + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + button.setEnabled(enabled) + + if (enabled) { + alpha = 1.0f + } else { + alpha = 0.5f + } + } + + init { + button.setOnClickListener { + jobTracker?.newUiJob(clickJobName!!) { + setEnabled(false) + + if (showSpinner) { + image.visibility = GONE + spinner.visibility = VISIBLE + } + + onClickAction!!.invoke() + + spinner.visibility = GONE + + if (detailImage != null) { + image.visibility = VISIBLE + } + + setEnabled(true) + } + } + } + + fun setOnClickAction(jobName: String, tracker: JobTracker, action: suspend () -> Unit) { + clickJobName = jobName + jobTracker = tracker + onClickAction = action + } + + fun setText(textResource: Int) { + button.setText(textResource) + } + + private fun loadAttributes(attributes: AttributeSet) { + var styleableId = R.styleable.Button + + context.theme.obtainStyledAttributes(attributes, styleableId, 0, 0).apply { + try { + buttonColor = ButtonColor.fromCode(getInteger(R.styleable.Button_buttonColor, 0)) + detailImage = getDrawable(R.styleable.Button_detailImage) + showSpinner = getBoolean(R.styleable.Button_showSpinner, false) + } finally { + recycle() + } + } + + context.theme.obtainStyledAttributes(attributes, R.styleable.TextAttribute, 0, 0).apply { + try { + button.text = getString(R.styleable.TextAttribute_text) ?: "" + } finally { + recycle() + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Cell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Cell.kt new file mode 100644 index 0000000000..54f35fd519 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Cell.kt @@ -0,0 +1,117 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.graphics.Typeface +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.widget.LinearLayout +import android.widget.TextView +import net.mullvad.mullvadvpn.R + +open class Cell : LinearLayout { + private val label = TextView(context).apply { + val rightPadding = resources.getDimensionPixelSize(R.dimen.cell_inner_spacing) + val verticalPadding = resources.getDimensionPixelSize(R.dimen.cell_label_vertical_padding) + + layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1.0f) + setPadding(0, verticalPadding, rightPadding, verticalPadding) + + setTextColor(context.getColor(R.color.white)) + setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.text_medium_plus)) + setTypeface(null, Typeface.BOLD) + } + + protected var footer: TextView? = null + set(value) { + field = value?.apply { + val horizontalPadding = + resources.getDimensionPixelSize(R.dimen.cell_footer_horizontal_padding) + val topPadding = resources.getDimensionPixelSize(R.dimen.cell_footer_top_padding) + + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + setPadding(horizontalPadding, topPadding, horizontalPadding, 0) + + setTextColor(context.getColor(R.color.white60)) + setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.text_small)) + } + } + + protected var cell: LinearLayout = this + set(value) { + field = value.apply { + val height = resources.getDimensionPixelSize(R.dimen.cell_height) + val leftPadding = resources.getDimensionPixelSize(R.dimen.cell_left_padding) + val rightPadding = resources.getDimensionPixelSize(R.dimen.cell_right_padding) + + setFocusable(true) + isClickable = true + gravity = Gravity.CENTER + orientation = HORIZONTAL + minimumHeight = height + + setBackgroundResource(R.drawable.cell_button_background) + setPadding(leftPadding, 0, rightPadding, 0) + + addView(label) + + setOnClickListener { onClickListener?.invoke() } + } + } + + var onClickListener: (() -> Unit)? = null + + @JvmOverloads + constructor( + context: Context, + attributes: AttributeSet? = null, + defaultStyleAttribute: Int = 0, + defaultStyleResource: Int = 0, + footer: TextView? = null + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + this.footer = footer + loadAttributes(attributes) + } + + private fun loadAttributes(attributes: AttributeSet?) { + context.theme.obtainStyledAttributes(attributes, R.styleable.TextAttribute, 0, 0).apply { + try { + label.text = getString(R.styleable.TextAttribute_text) ?: "" + } finally { + recycle() + } + } + + context.theme.obtainStyledAttributes(attributes, R.styleable.Cell, 0, 0).apply { + try { + getString(R.styleable.Cell_footer)?.let { footerText -> + if (footer == null) { + footer = TextView(context) + } + + footer?.text = footerText + } + } finally { + recycle() + } + } + + setUp() + } + + private fun setUp() { + if (footer != null) { + cell = LinearLayout(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + } + + isClickable = false + orientation = VERTICAL + + addView(cell) + addView(footer) + } else { + cell = this + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CellSwitch.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CellSwitch.kt new file mode 100644 index 0000000000..e3dfc81314 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CellSwitch.kt @@ -0,0 +1,236 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Paint.Style +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.OvalShape +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.GestureDetector.OnGestureListener +import android.view.Gravity +import android.view.MotionEvent +import android.widget.ImageView +import android.widget.LinearLayout +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R + +class CellSwitch : LinearLayout { + enum class State { + ON, + OFF + } + + var state by observable(State.OFF) { _, oldState, newState -> + animateToState() + + if (oldState != newState) { + listener?.invoke(newState) + } + } + + var listener: ((State) -> Unit)? = null + + private val onColor = context.getColor(R.color.green) + private val offColor = context.getColor(R.color.red) + + private val knobSize = resources.getDimensionPixelSize(R.dimen.cell_switch_knob_size) + private val knobImage = ShapeDrawable(OvalShape()).apply { + paint.apply { + color = offColor + style = Style.FILL + } + + intrinsicWidth = knobSize + intrinsicHeight = knobSize + } + + private val knobView = ImageView(context).apply { + setImageDrawable(knobImage) + } + + private val knobAnimationDuration = 200L + private val knobMaxTranslation = + resources.getDimensionPixelOffset(R.dimen.cell_switch_knob_max_translation).toFloat() + + private val knobPosition: Float + get() = knobView.translationX / knobMaxTranslation + + private var animationIsReversed = false + + private val positionAnimation = ValueAnimator.ofFloat(0f, knobMaxTranslation).apply { + addUpdateListener { animation -> + knobView.translationX = animation.animatedValue as Float + } + + duration = knobAnimationDuration + } + + private val colorAnimation = ValueAnimator.ofArgb(offColor, onColor).apply { + addUpdateListener { animation -> + knobImage.paint.color = animation.animatedValue as Int + knobImage.invalidateSelf() + } + + duration = knobAnimationDuration + } + + private val gestureListener = object : OnGestureListener { + private var isScrolling: Boolean = false + private var scrollPosition: Float = 0f + + override fun onDown(event: MotionEvent): Boolean { + scrollPosition = knobView.translationX + return true + } + + override fun onFling( + downEvent: MotionEvent, + upEvent: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + if (velocityX > 0f) { + state = State.ON + } else if (velocityX < 0f) { + state = State.OFF + } + + return true + } + + override fun onLongPress(event: MotionEvent) {} + + override fun onScroll( + downEvent: MotionEvent, + moveEvent: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + isScrolling = true + scrollPosition -= distanceX + + var fraction = scrollPosition / knobMaxTranslation + val playTime = (fraction * knobAnimationDuration).toLong() + + colorAnimation.pause() + positionAnimation.pause() + + colorAnimation.currentPlayTime = playTime + positionAnimation.currentPlayTime = playTime + + return true + } + + override fun onShowPress(event: MotionEvent) {} + + override fun onSingleTapUp(event: MotionEvent): Boolean { + when (state) { + State.ON -> state = State.OFF + State.OFF -> state = State.ON + } + + return true + } + + fun onUp(): Boolean { + if (!isScrolling) { + return false + } + + if (knobPosition <= 0.5f) { + state = State.OFF + } else { + state = State.ON + } + + isScrolling = false + scrollPosition = 0f + + return true + } + } + + private val gestureDetector = GestureDetector(context, gestureListener) + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {} + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) {} + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) {} + + init { + setBackground(resources.getDrawable(R.drawable.cell_switch_background, null)) + addView( + knobView, + LinearLayout.LayoutParams(knobSize, knobSize).apply { + gravity = Gravity.CENTER_VERTICAL + leftMargin = resources.getDimensionPixelSize(R.dimen.cell_switch_knob_margin) + } + ) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (gestureDetector.onTouchEvent(event)) { + return true + } else if (event.actionMasked == MotionEvent.ACTION_UP) { + return gestureListener.onUp() + } + + return super.onTouchEvent(event) + } + + fun toggle() { + when (state) { + State.ON -> state = State.OFF + State.OFF -> state = State.ON + } + } + + fun forcefullySetState(newState: State) { + when (newState) { + State.ON -> { + knobView.translationX = knobMaxTranslation + knobImage.paint.color = onColor + } + State.OFF -> { + knobView.translationX = 0f + knobImage.paint.color = offColor + } + } + + state = newState + } + + private fun animateToState() { + var playTime = (knobPosition * knobAnimationDuration).toLong() + + when (state) { + State.ON -> { + animationIsReversed = false + colorAnimation.start() + positionAnimation.start() + } + State.OFF -> { + if (!animationIsReversed || !colorAnimation.isRunning()) { + animationIsReversed = true + colorAnimation.reverse() + positionAnimation.reverse() + } + + playTime = knobAnimationDuration - playTime + } + } + + colorAnimation.currentPlayTime = playTime + positionAnimation.currentPlayTime = playTime + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CopyableInformationView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CopyableInformationView.kt new file mode 100644 index 0000000000..348c8a2530 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CopyableInformationView.kt @@ -0,0 +1,65 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.util.AttributeSet +import android.widget.Toast +import net.mullvad.mullvadvpn.R + +class CopyableInformationView : InformationView { + var clipboardLabel: String? = null + set(value) { + field = value + shouldEnable = value != null + } + + var copiedToast: String? = null + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) { + loadAttributes(attributes) + } + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) { + loadAttributes(attributes) + } + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + loadAttributes(attributes) + } + + init { + onClick = { copyToClipboard() } + } + + private fun loadAttributes(attributes: AttributeSet) { + val styleableId = R.styleable.CopyableInformationView + + context.theme.obtainStyledAttributes(attributes, styleableId, 0, 0).apply { + try { + clipboardLabel = getString(R.styleable.CopyableInformationView_clipboardLabel) + copiedToast = getString(R.styleable.CopyableInformationView_copiedToast) + } finally { + recycle() + } + } + } + + private fun copyToClipboard() { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipData = ClipData.newPlainText(clipboardLabel, information) + val toastMessage = copiedToast ?: context.getString(R.string.copied_to_clipboard) + + clipboard.setPrimaryClip(clipData) + + Toast.makeText(context, toastMessage, Toast.LENGTH_SHORT).show() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomItemAnimator.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomItemAnimator.kt new file mode 100644 index 0000000000..ef8caf7e7c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomItemAnimator.kt @@ -0,0 +1,43 @@ +package net.mullvad.mullvadvpn.ui.widget + +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView.LayoutManager +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import kotlin.math.round + +class CustomItemAnimator : DefaultItemAnimator() { + var layoutManager: LayoutManager? = null + + var onMove: ((Int, Int) -> Unit)? = null + + override fun animateMove( + holder: ViewHolder, + fromX: Int, + fromY: Int, + toX: Int, + toY: Int + ): Boolean { + if (super.animateMove(holder, fromX, fromY, toX, toY)) { + var view = holder.itemView + + if (view == layoutManager?.getChildAt(0)) { + var translationX = view.translationX + var translationY = view.translationY + + view.animate().setUpdateListener { _ -> + val deltaX = round(translationX - view.translationX) + val deltaY = round(translationY - view.translationY) + + onMove?.invoke(deltaX.toInt(), deltaY.toInt()) + + translationX -= deltaX + translationY -= deltaY + } + } + + return true + } else { + return false + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomRecyclerView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomRecyclerView.kt new file mode 100644 index 0000000000..4684d50871 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomRecyclerView.kt @@ -0,0 +1,58 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.util.AttributeSet +import androidx.recyclerview.widget.RecyclerView +import net.mullvad.mullvadvpn.util.ListenableScrollableView + +class CustomRecyclerView : RecyclerView, ListenableScrollableView { + private val customItemAnimator = CustomItemAnimator() + + override var horizontalScrollOffset = 0 + override var verticalScrollOffset = 0 + + override var onScrollListener: ((Int, Int, Int, Int) -> Unit)? = null + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {} + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) { + } + + init { + itemAnimator = customItemAnimator.apply { + onMove = { horizontalDelta, verticalDelta -> + dispatchScrollEvent(horizontalDelta, verticalDelta) + } + } + } + + override fun setLayoutManager(layoutManager: LayoutManager?) { + super.setLayoutManager(layoutManager) + + customItemAnimator.layoutManager = layoutManager + } + + override fun onScrolled(horizontalDelta: Int, verticalDelta: Int) { + super.onScrolled(horizontalDelta, verticalDelta) + + dispatchScrollEvent(horizontalDelta, verticalDelta) + } + + private fun dispatchScrollEvent(horizontalDelta: Int, verticalDelta: Int) { + val oldHorizontalScrollOffset = horizontalScrollOffset + val oldVerticalScrollOffset = verticalScrollOffset + + horizontalScrollOffset += horizontalDelta + verticalScrollOffset += verticalDelta + + onScrollListener?.invoke( + horizontalScrollOffset, + verticalScrollOffset, + oldHorizontalScrollOffset, + oldVerticalScrollOffset + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/HeaderBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/HeaderBar.kt new file mode 100644 index 0000000000..877fcd9c66 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/HeaderBar.kt @@ -0,0 +1,59 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import androidx.core.content.ContextCompat +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.mullvadvpn.ui.StatusBarPainter +import net.mullvad.mullvadvpn.ui.paintStatusBar + +class HeaderBar @JvmOverloads constructor( + context: Context, + attributes: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : LinearLayout(context, attributes, defStyleAttr, defStyleRes), StatusBarPainter { + private val container = LayoutInflater.from(context).inflate(R.layout.header_bar, this) + + private val disabledColor = ContextCompat.getColor(context, android.R.color.transparent) + private val securedColor = ContextCompat.getColor(context, R.color.green) + private val unsecuredColor = ContextCompat.getColor(context, R.color.red) + + var tunnelState by observable<TunnelState?>(null) { _, _, state -> + val backgroundColor = when (state) { + null -> disabledColor + is TunnelState.Disconnected -> unsecuredColor + is TunnelState.Connecting -> securedColor + is TunnelState.Connected -> securedColor + is TunnelState.Disconnecting -> securedColor + is TunnelState.Error -> { + if (state.errorState.isBlocking) { + securedColor + } else { + unsecuredColor + } + } + } + + container.setBackgroundColor(backgroundColor) + paintStatusBar(backgroundColor) + } + + init { + gravity = Gravity.CENTER_VERTICAL + orientation = HORIZONTAL + + findViewById<View>(R.id.settings).setOnClickListener { + (context as? MainActivity)?.openSettings() + } + + tunnelState = null + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/InformationView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/InformationView.kt new file mode 100644 index 0000000000..d80506a5dd --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/InformationView.kt @@ -0,0 +1,159 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R + +open class InformationView : LinearLayout { + enum class WhenMissing { + Nothing, + Hide, + ShowSpinner; + + companion object { + internal fun fromCode(code: Int): WhenMissing { + when (code) { + 0 -> return Nothing + 1 -> return Hide + 2 -> return ShowSpinner + else -> throw Exception("Invalid whenMissing attribute value") + } + } + } + } + + private val container: View = + context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> + val inflater = service as LayoutInflater + + inflater.inflate(R.layout.information_view, this).apply { + setOnClickListener { onClick?.invoke() } + setEnabled(false) + } + } + + private val description: TextView = findViewById(R.id.description) + private val informationDisplay: TextView = findViewById(R.id.information_display) + private val spinner: View = findViewById(R.id.spinner) + + var error by observable<String?>(null) { _, _, _ -> updateStatus() } + var information by observable<String?>(null) { _, _, _ -> updateStatus() } + + var errorColor by observable(context.getColor(R.color.red)) { _, _, _ -> updateStatus() } + var informationColor by observable(context.getColor(R.color.white)) { _, _, _ -> + updateStatus() + } + + var displayFormatter by observable<((String) -> String)?>(null) { _, _, _ -> updateStatus() } + var maxLength by observable(0) { _, _, _ -> updateStatus() } + var whenMissing by observable(WhenMissing.Nothing) { _, _, _ -> updateStatus() } + + var shouldEnable by observable(false) { _, _, _ -> updateEnabled() } + + var onClick by observable<(() -> Unit)?>(null) { _, _, callback -> + container.setFocusable(callback != null) + } + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) { + loadAttributes(attributes) + } + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) { + loadAttributes(attributes) + } + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + loadAttributes(attributes) + } + + init { + val backgroundResource = TypedValue() + + context.theme.resolveAttribute( + android.R.attr.selectableItemBackground, + backgroundResource, + true + ) + + orientation = VERTICAL + setBackgroundResource(backgroundResource.resourceId) + } + + private fun loadAttributes(attributes: AttributeSet) { + val styleableId = R.styleable.InformationView + + context.theme.obtainStyledAttributes(attributes, styleableId, 0, 0).apply { + try { + description.text = getString(R.styleable.InformationView_description) ?: "" + + errorColor = getInteger(R.styleable.InformationView_errorColor, errorColor) + maxLength = getInteger(R.styleable.InformationView_maxLength, 0) + + informationColor = getInteger( + R.styleable.InformationView_informationColor, + informationColor + ) + + whenMissing = WhenMissing.fromCode( + getInteger(R.styleable.InformationView_whenMissing, 0) + ) + } finally { + recycle() + } + } + } + + private fun updateStatus() { + val information = this.information + val hasText = information != null || error != null + + if (error != null) { + informationDisplay.setTextColor(errorColor) + informationDisplay.text = error + } else if (information != null) { + val formattedInformation = displayFormatter?.invoke(information) ?: information + + informationDisplay.setTextColor(informationColor) + + if (maxLength == 0 || formattedInformation.length <= maxLength) { + informationDisplay.text = formattedInformation + } else { + informationDisplay.text = formattedInformation.substring(0, maxLength) + "..." + } + } + + if (whenMissing == WhenMissing.Hide && !hasText) { + visibility = INVISIBLE + } else { + visibility = VISIBLE + } + + if (whenMissing == WhenMissing.ShowSpinner && !hasText) { + spinner.visibility = VISIBLE + informationDisplay.visibility = INVISIBLE + } else { + spinner.visibility = INVISIBLE + informationDisplay.visibility = VISIBLE + } + + updateEnabled() + } + + private fun updateEnabled() { + setEnabled(shouldEnable && error == null && information != null) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ListenableScrollView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ListenableScrollView.kt new file mode 100644 index 0000000000..b436df903a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ListenableScrollView.kt @@ -0,0 +1,36 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.widget.ScrollView +import net.mullvad.mullvadvpn.util.ListenableScrollableView + +class ListenableScrollView : ScrollView, ListenableScrollableView { + override val horizontalScrollOffset + get() = scrollX + override val verticalScrollOffset + get() = scrollY + + override var onScrollListener: ((Int, Int, Int, Int) -> Unit)? = null + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {} + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) { + } + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + } + + override fun onScrollChanged(left: Int, top: Int, oldLeft: Int, oldTop: Int) { + super.onScrollChanged(left, top, oldLeft, oldTop) + onScrollListener?.invoke(left, top, oldLeft, oldTop) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/MtuCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/MtuCell.kt new file mode 100644 index 0000000000..93daba0856 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/MtuCell.kt @@ -0,0 +1,85 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.text.Editable +import android.text.TextWatcher +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.EditText +import android.widget.TextView +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R + +private const val MIN_MTU_VALUE = 1280 +private const val MAX_MTU_VALUE = 1420 + +class MtuCell : Cell { + private val input = + (LayoutInflater.from(context).inflate(R.layout.mtu_edit_text, null) as EditText).apply { + val width = resources.getDimensionPixelSize(R.dimen.cell_input_width) + val height = resources.getDimensionPixelSize(R.dimen.cell_input_height) + + layoutParams = LayoutParams(width, height, 0.0f) + + addTextChangedListener(InputWatcher()) + setOnFocusChangeListener { _, newHasFocus -> hasFocus = newHasFocus } + } + + private val validInputColor = context.getColor(R.color.white) + private val invalidInputColor = context.getColor(R.color.red) + + var value: Int? + get() = input.text.toString().trim().toIntOrNull() + set(value) = input.setText(value?.toString() ?: "") + + var onSubmit: ((Int?) -> Unit)? = null + + var hasFocus by observable(false) { _, oldValue, newValue -> + if (oldValue && !newValue) { + val mtu = value + + if (mtu == null || (mtu in MIN_MTU_VALUE..MAX_MTU_VALUE)) { + onSubmit?.invoke(mtu) + } + } + } + + @JvmOverloads + constructor( + context: Context, + attributes: AttributeSet? = null, + defaultStyleAttribute: Int = 0, + defaultStyleResource: Int = 0 + ) : super( + context, + attributes, + defaultStyleAttribute, + defaultStyleResource, + TextView(context) + ) { + cell.apply { + setEnabled(false) + setFocusable(false) + addView(input) + } + + footer?.text = + context.getString(R.string.wireguard_mtu_footer, MIN_MTU_VALUE, MAX_MTU_VALUE) + } + + inner class InputWatcher : TextWatcher { + override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(text: CharSequence, start: Int, count: Int, after: Int) {} + + override fun afterTextChanged(text: Editable) { + val value = text.toString().trim().toIntOrNull() + + if (value != null && value >= MIN_MTU_VALUE && value <= MAX_MTU_VALUE) { + input.setTextColor(validInputColor) + } else { + input.setTextColor(invalidInputColor) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NavigateCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NavigateCell.kt new file mode 100644 index 0000000000..1080e4e3fb --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NavigateCell.kt @@ -0,0 +1,60 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.widget.ImageView +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import kotlin.reflect.KClass +import net.mullvad.mullvadvpn.R + +open class NavigateCell : Cell { + private val chevron = ImageView(context).apply { + val width = resources.getDimensionPixelSize(R.dimen.chevron_width) + val height = resources.getDimensionPixelSize(R.dimen.chevron_height) + + layoutParams = LayoutParams(width, height, 0.0f) + alpha = 0.6f + + setImageResource(R.drawable.icon_chevron) + } + + var targetFragment: KClass<out Fragment>? = null + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {} + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) {} + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) {} + + init { + cell.addView(chevron) + onClickListener = { openSubFragment() } + } + + private fun openSubFragment() { + targetFragment?.let { fragmentClass -> + val fragment = fragmentClass.java.getConstructor().newInstance() + + (context as? FragmentActivity)?.supportFragmentManager?.beginTransaction()?.apply { + setCustomAnimations( + R.anim.fragment_enter_from_right, + R.anim.fragment_half_exit_to_left, + R.anim.fragment_half_enter_from_left, + R.anim.fragment_exit_to_right + ) + replace(R.id.main_fragment, fragment) + addToBackStack(null) + commit() + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NotificationBanner.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NotificationBanner.kt new file mode 100644 index 0000000000..bd4974be1e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NotificationBanner.kt @@ -0,0 +1,173 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.animation.Animator +import android.animation.Animator.AnimatorListener +import android.animation.ObjectAnimator +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.ui.notification.InAppNotification +import net.mullvad.mullvadvpn.ui.notification.InAppNotificationController +import net.mullvad.mullvadvpn.ui.notification.StatusLevel +import net.mullvad.mullvadvpn.util.JobTracker + +class NotificationBanner : FrameLayout { + private val jobTracker = JobTracker() + + private val animationListener = object : AnimatorListener { + override fun onAnimationCancel(animation: Animator) {} + override fun onAnimationRepeat(animation: Animator) {} + + override fun onAnimationStart(animation: Animator) { + visibility = View.VISIBLE + } + + override fun onAnimationEnd(animation: Animator) { + synchronized(this@NotificationBanner) { + if (reversedAnimation) { + // Banner is now hidden + val notification = notifications.current + + visibility = View.INVISIBLE + + if (notification != null) { + // Notification changed, restart animation + update(notification) + reversedAnimation = false + animation.start() + } + } + } + } + } + + private val animation = ObjectAnimator.ofFloat(this, "translationY", 0.0f).apply { + addListener(animationListener) + setDuration(350) + + // Ensure there's time for the layout to finish before making the banner visible + setStartDelay(20) + } + + private val container = + context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> + val inflater = service as LayoutInflater + + inflater.inflate(R.layout.notification_banner, this) + } + + private val errorImage = resources.getDrawable(R.drawable.icon_notification_error, null) + private val warningImage = resources.getDrawable(R.drawable.icon_notification_warning, null) + + private val status: ImageView = container.findViewById(R.id.notification_status) + private val title: TextView = container.findViewById(R.id.notification_title) + private val message: TextView = container.findViewById(R.id.notification_message) + private val icon: View = container.findViewById(R.id.notification_icon) + + private var reversedAnimation = false + + val notifications = InAppNotificationController { _ -> + synchronized(this@NotificationBanner) { + animateChange() + } + } + + constructor(context: Context) : super(context) {} + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {} + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) {} + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) {} + + init { + setBackgroundResource(R.color.darkBlue) + + setOnClickListener { + jobTracker.newUiJob("click") { onClick() } + } + + visibility = View.INVISIBLE + } + + fun onResume() { + notifications.onResume() + } + + fun onPause() { + notifications.onPause() + } + + fun onDestroy() { + notifications.onDestroy() + jobTracker.cancelAllJobs() + } + + protected override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + animation.setFloatValues(-height.toFloat(), 0.0f) + } + + private suspend fun onClick() { + notifications.current?.onClick?.let { action -> + alpha = 0.5f + setClickable(false) + + jobTracker.runOnBackground(action) + + setClickable(true) + alpha = 1.0f + } + } + + private fun update(notification: InAppNotification) { + val notificationMessage = notification.message + val clickAction = notification.onClick + + when (notification.status) { + StatusLevel.Error -> status.setImageDrawable(errorImage) + StatusLevel.Warning -> status.setImageDrawable(warningImage) + } + + title.text = notification.title + + if (notificationMessage != null) { + message.text = notificationMessage + message.visibility = View.VISIBLE + } else { + message.visibility = View.GONE + } + + if (notification.showIcon) { + icon.visibility = View.VISIBLE + } else { + icon.visibility = View.GONE + } + + setClickable(clickAction != null) + } + + private fun animateChange() { + val notification = notifications.current + val hasOngoingHideAnimation = animation.isRunning && reversedAnimation + + if (isVisible.not() && notification != null) { + reversedAnimation = false + update(notification) + animation.start() + } else if (hasOngoingHideAnimation.not()) { + reversedAnimation = true + animation.reverse() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/RedeemVoucherButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/RedeemVoucherButton.kt new file mode 100644 index 0000000000..f1e27e7b1b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/RedeemVoucherButton.kt @@ -0,0 +1,37 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.util.AttributeSet +import androidx.fragment.app.FragmentManager +import net.mullvad.mullvadvpn.ui.RedeemVoucherDialogFragment +import net.mullvad.mullvadvpn.util.JobTracker + +class RedeemVoucherButton : Button { + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {} + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) {} + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) {} + + fun prepare( + fragmentManager: FragmentManager?, + jobTracker: JobTracker, + jobName: String = "openRedeemVoucherDialog" + ) { + setOnClickAction(jobName, jobTracker) { + fragmentManager?.beginTransaction()?.let { transaction -> + transaction.addToBackStack(null) + + RedeemVoucherDialogFragment().show(transaction, null) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SitePaymentButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SitePaymentButton.kt new file mode 100644 index 0000000000..0f377013d6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SitePaymentButton.kt @@ -0,0 +1,35 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.util.AttributeSet +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R + +class SitePaymentButton : UrlButton { + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {} + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) {} + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) {} + + var newAccount by observable(false) { _, _, isNewAccount -> + if (isNewAccount) { + label = context.getString(R.string.buy_credit) + } else { + label = context.getString(R.string.buy_more_credit) + } + } + + init { + url = context.getString(R.string.account_url) + withToken = true + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SwitchLocationButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SwitchLocationButton.kt new file mode 100644 index 0000000000..fb10816edc --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SwitchLocationButton.kt @@ -0,0 +1,89 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.talpid.tunnel.ActionAfterDisconnect + +class SwitchLocationButton : FrameLayout { + private val container = + context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> + val inflater = service as LayoutInflater + + inflater.inflate(R.layout.switch_location_button, this) + } + + private val buttonWithLabel = container.findViewById<View>(R.id.button_with_label).apply { + setOnClickListener { onClick?.invoke() } + } + + private val buttonWithLocation = + container.findViewById<TextView>(R.id.button_with_location).apply { + setOnClickListener { onClick?.invoke() } + } + + var onClick: (() -> Unit)? = null + + var location by observable<RelayItem?>(null) { _, _, location -> + buttonWithLocation.text = location?.locationName ?: "" + } + + var tunnelState by observable<TunnelState>(TunnelState.Disconnected) { _, _, state -> + when (state) { + is TunnelState.Disconnected -> showLocation() + is TunnelState.Disconnecting -> { + when (state.actionAfterDisconnect) { + ActionAfterDisconnect.Nothing -> showLocation() + ActionAfterDisconnect.Block -> showLocation() + ActionAfterDisconnect.Reconnect -> showLabel() + } + } + is TunnelState.Connecting -> showLabel() + is TunnelState.Connected -> showLabel() + is TunnelState.Error -> showLocation() + } + } + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {} + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) {} + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) {} + + private fun showLabel() { + updateButton(buttonWithLabel, true) + updateButton(buttonWithLocation, false) + } + + private fun showLocation() { + updateButton(buttonWithLabel, false) + updateButton(buttonWithLocation, true) + } + + private fun updateButton(button: View, show: Boolean) { + button.apply { + setEnabled(show) + + visibility = if (show) { + View.VISIBLE + } else { + View.INVISIBLE + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ToggleCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ToggleCell.kt new file mode 100644 index 0000000000..cde050bbc2 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ToggleCell.kt @@ -0,0 +1,45 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.util.AttributeSet + +class ToggleCell : Cell { + private val toggle = CellSwitch(context).apply { + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, 0.0f) + } + + var state + get() = toggle.state + set(value) { toggle.state = value } + + var listener + get() = toggle.listener + set(value) { toggle.listener = value } + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {} + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) {} + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) {} + + init { + onClickListener = { toggle() } + cell.addView(toggle) + } + + fun toggle() { + toggle.toggle() + } + + fun forcefullySetState(state: CellSwitch.State) { + toggle.forcefullySetState(state) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt new file mode 100644 index 0000000000..613f65524e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt @@ -0,0 +1,113 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.AttributeSet +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache +import net.mullvad.mullvadvpn.util.JobTracker + +open class UrlButton : Button { + private lateinit var authTokenCache: AuthTokenCache + + private var shouldEnable = true + + var url: String? = null + var withToken = false + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) { + loadAttributes(attributes) + } + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) { + loadAttributes(attributes) + } + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + loadAttributes(attributes) + } + + init { + super.setEnabled(false) + super.detailImage = context.getDrawable(R.drawable.icon_extlink) + super.showSpinner = true + } + + fun prepare( + authTokenCache: AuthTokenCache, + jobTracker: JobTracker, + jobName: String = "fetchUrl", + extraOnClickAction: (suspend () -> Unit)? = null + ) { + synchronized(this) { + super.setEnabled(shouldEnable) + + this.authTokenCache = authTokenCache + + setOnClickAction(jobName, jobTracker) { + super.setEnabled(false) + + context.startActivity(buildIntent(jobTracker)) + extraOnClickAction?.invoke() + + super.setEnabled(true) + } + } + } + + override fun setEnabled(enabled: Boolean) { + synchronized(this) { + shouldEnable = enabled + + if (!withToken || this::authTokenCache.isInitialized) { + super.setEnabled(enabled) + } + } + } + + private fun loadAttributes(attributes: AttributeSet) { + context.theme.obtainStyledAttributes(attributes, R.styleable.Url, 0, 0).apply { + try { + url = getString(R.styleable.Url_url) + } finally { + recycle() + } + } + + context.theme.obtainStyledAttributes(attributes, R.styleable.UrlButton, 0, 0).apply { + try { + withToken = getBoolean(R.styleable.UrlButton_withToken, false) + } finally { + recycle() + } + } + } + + private suspend fun buildIntent(jobTracker: JobTracker): Intent { + val buildIntent = GlobalScope.async(Dispatchers.Default) { + val uri = if (withToken) { + Uri.parse(url + "?token=" + authTokenCache.fetchAuthToken()) + } else { + Uri.parse(url) + } + + Intent(Intent.ACTION_VIEW, uri) + } + + jobTracker.newJob(buildIntent) + + return buildIntent.await() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlCell.kt new file mode 100644 index 0000000000..50727f74a6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlCell.kt @@ -0,0 +1,53 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.AttributeSet +import android.widget.ImageView +import net.mullvad.mullvadvpn.R + +open class UrlCell : Cell { + private val externalLinkIcon = ImageView(context).apply { + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, 0.0f) + alpha = 0.6f + + setImageResource(R.drawable.icon_extlink) + } + + var url: Uri? = null + + @JvmOverloads + constructor( + context: Context, + attributes: AttributeSet? = null, + defaultStyleAttribute: Int = 0, + defaultStyleResource: Int = 0 + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + loadAttributes(attributes) + + cell.addView(externalLinkIcon) + + onClickListener = { openLink() } + } + + private fun loadAttributes(attributes: AttributeSet?) { + context.theme.obtainStyledAttributes(attributes, R.styleable.Url, 0, 0).apply { + try { + getString(R.styleable.Url_url)?.let { urlString -> + url = Uri.parse(urlString) + } + } finally { + recycle() + } + } + } + + private fun openLink() { + url?.let { url -> + val intent = Intent(Intent.ACTION_VIEW, url) + + context.startActivity(intent) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/AdapterWithHeader.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/AdapterWithHeader.kt new file mode 100644 index 0000000000..cbf96457ef --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/AdapterWithHeader.kt @@ -0,0 +1,122 @@ +package net.mullvad.mullvadvpn.util + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView.Adapter +import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import kotlin.properties.Delegates.observable + +class AdapterWithHeader<H : ViewHolder>( + val adapter: Adapter<H>, + val headerLayoutId: Int +) : Adapter<HeaderOrHolder<H>>() { + private val observer = object : AdapterDataObserver() { + override fun onChanged() { + notifyDataSetChanged() + } + + override fun onItemRangeChanged(start: Int, count: Int) { + notifyItemRangeChanged(start + 1, count) + } + + override fun onItemRangeChanged(start: Int, count: Int, payload: Any?) { + notifyItemRangeChanged(start + 1, count, payload) + } + + override fun onItemRangeInserted(start: Int, count: Int) { + notifyItemRangeInserted(start + 1, count) + } + + override fun onItemRangeMoved(from: Int, to: Int, count: Int) { + if (from == to) { + notifyItemRangeChanged(from + 1, count) + } else { + val sourceStart = from + 1 + val sourceEnd = sourceStart + count + val destinationStart = to + 1 + val destinationEnd = destinationStart + count + + val ascendingIndices = + (sourceStart..sourceEnd).zip(destinationStart..destinationEnd) + + val indices = if (from < to) { + ascendingIndices.asReversed() + } else { + ascendingIndices + } + + for ((source, destination) in indices) { + notifyItemMoved(source, destination) + } + } + } + + override fun onItemRangeRemoved(start: Int, count: Int) { + notifyItemRangeRemoved(start + 1, count) + } + } + + private var headerView: View? by observable<View?>(null) { _, _, newView -> + newView?.let { view -> onHeaderAvailable?.invoke(view) } + } + + var onHeaderAvailable by observable<((View) -> Unit)?>(null) { _, _, listener -> + headerView?.let { header -> listener?.invoke(header) } + } + + init { + adapter.registerAdapterDataObserver(observer) + } + + override fun getItemCount() = adapter.itemCount + 1 + + override fun getItemId(position: Int): Long { + if (position == 0) { + return 0L + } else { + return adapter.getItemId(position - 1) + 1 + } + } + + override fun getItemViewType(position: Int): Int { + if (position == 0) { + return 0 + } else { + return adapter.getItemViewType(position - 1) + 1 + } + } + + override fun onBindViewHolder(holder: HeaderOrHolder<H>, position: Int) { + when (holder) { + is HeaderOrHolder.Header -> { + if (position != 0) { + throw IllegalArgumentException("Adapter position is not for the header") + } + } + is HeaderOrHolder.Holder -> { + if (position > 0) { + adapter.onBindViewHolder(holder.holder, position - 1) + } else { + throw IllegalArgumentException("Adapter position is for the header") + } + } + } + } + + override fun onCreateViewHolder(parentView: ViewGroup, viewType: Int): HeaderOrHolder<H> { + if (viewType == 0) { + val inflater = LayoutInflater.from(parentView.context) + val view = inflater.inflate(headerLayoutId, parentView, false) + + headerView = view + + return HeaderOrHolder.Header(view) + } else { + val holder = adapter.onCreateViewHolder(parentView, viewType - 1) + + return HeaderOrHolder.Holder(holder) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ChangeMonitor.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ChangeMonitor.kt new file mode 100644 index 0000000000..398177db99 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ChangeMonitor.kt @@ -0,0 +1,18 @@ +package net.mullvad.mullvadvpn.util + +import kotlin.properties.Delegates.observable + +class ChangeMonitor { + var changed = false + private set + + fun <T> monitor(initialValue: T) = observable(initialValue) { _, oldValue, newValue -> + if (oldValue != newValue) { + changed = true + } + } + + fun reset() { + changed = false + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Debouncer.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Debouncer.kt new file mode 100644 index 0000000000..7afb4c508f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Debouncer.kt @@ -0,0 +1,39 @@ +package net.mullvad.mullvadvpn.util + +import kotlin.properties.Delegates.observable +import kotlinx.coroutines.delay + +// Helper to filter out bursts of events so that only the latest event in an interval is notified. +// +// An interval of zero means that it will only debounce events that are sent before the job is +// started. If the events are coming from the UI thread, this means that this class will only send +// the last event received before the UI thread finishes its current task. +// +// This can be used for example to filter out focus events coming from different views. Android will +// first send a "focus lost" event from a view followed by a "focus gained" event from another view. +// If the only thing the listener is interested in is if any of a set of views has focus, this class +// can be used to debounce focus events from the set of views to obtain an event that represents a +// change from when the set contains a focused view to when the set contains no focused views (and +// an event for the reverse situation). +class Debouncer<T>(initialValue: T, val intervalInMs: Long = 0) { + private val jobTracker = JobTracker() + + var listener: ((T) -> Unit)? = null + + var debouncedValue = initialValue + private set + + var rawValue by observable(initialValue) { _, oldValue, newValue -> + if (newValue != oldValue) { + jobTracker.cancelJob("notifyNewValue") + + if (newValue != debouncedValue) { + jobTracker.newUiJob("notifyNewValue") { + delay(intervalInMs) + listener?.invoke(newValue) + debouncedValue = newValue + } + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/DispatchingFlow.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/DispatchingFlow.kt new file mode 100644 index 0000000000..af66a092ba --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/DispatchingFlow.kt @@ -0,0 +1,49 @@ +package net.mullvad.mullvadvpn.util + +import java.util.concurrent.ConcurrentHashMap +import kotlin.reflect.KClass +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedSendChannelException +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.consumeAsFlow + +class DispatchingFlow<T : Any>(private val upstream: Flow<T>) : Flow<T> { + private val subscribers = ConcurrentHashMap<KClass<out T>, SendChannel<T>>() + + fun <V : T> subscribe( + variant: KClass<V>, + capacity: Int = Channel.CONFLATED + ): Flow<V> { + val channel = Channel<V>(capacity) + + // This is safe because `collect` will only send to this channel if the instance class is V + @Suppress("UNCHECKED_CAST") + subscribers[variant] = channel as SendChannel<T> + + return channel.consumeAsFlow() + } + + fun <V : T> unsubscribe(variant: KClass<V>) = subscribers.remove(variant) + + @InternalCoroutinesApi + override suspend fun collect(collector: FlowCollector<T>) { + upstream.collect { event -> + try { + subscribers[event::class]?.send(event) + } catch (closedException: ClosedSendChannelException) { + subscribers.remove(event::class) + } + + collector.emit(event) + } + + subscribers.clear() + } +} + +fun <T : Any> Flow<T>.dispatchTo(configureSubscribers: DispatchingFlow<T>.() -> Unit) = + DispatchingFlow(this).also(configureSubscribers) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/EditTextExt.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/EditTextExt.kt new file mode 100644 index 0000000000..b90201edfe --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/EditTextExt.kt @@ -0,0 +1,15 @@ +package net.mullvad.mullvadvpn.util + +import android.view.KeyEvent +import android.view.inputmethod.EditorInfo +import android.widget.EditText + +fun EditText.setOnEnterOrDoneAction(callback: () -> Unit) { + setOnEditorActionListener { _, action, event -> + if (action == EditorInfo.IME_ACTION_DONE || event?.keyCode == KeyEvent.KEYCODE_ENTER) { + callback() + } + + false + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ExponentialBackoff.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ExponentialBackoff.kt new file mode 100644 index 0000000000..1117b0b749 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ExponentialBackoff.kt @@ -0,0 +1,52 @@ +package net.mullvad.mullvadvpn.util + +// Calculates a series of delays that increase exponentially. +// +// The delays follow the formula: +// +// (base ^ retryAttempt) * scale +// +// but it is never larger than the specified cap value. +class ExponentialBackoff : Iterator<Long> { + private var unscaledValue = 1L + private var current = 1L + + var iteration = 1 + private set + + var base = 2L + var scale = 1000L + var cap = Long.MAX_VALUE + var count: Int? = null + + override fun hasNext(): Boolean { + val maxIterations = count + + if (maxIterations != null) { + return iteration < maxIterations + } else { + return true + } + } + + override fun next(): Long { + iteration += 1 + + if (current >= cap) { + return cap + } else { + val value = current + + unscaledValue *= base + current = Math.min(cap, scale * unscaledValue) + + return value + } + } + + fun reset() { + unscaledValue = 1L + current = 1L + iteration = 1 + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt new file mode 100644 index 0000000000..588f2ecfd1 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt @@ -0,0 +1,57 @@ +package net.mullvad.mullvadvpn.util + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.view.animation.Animation +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.take +import net.mullvad.mullvadvpn.model.ServiceResult + +fun <T> SendChannel<T>.safeOffer(element: T): Boolean { + return runCatching { offer(element) }.getOrDefault(false) +} + +fun Animation.transitionFinished(): Flow<Unit> = callbackFlow<Unit> { + val transitionAnimationListener = object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + override fun onAnimationEnd(animation: Animation?) { safeOffer(Unit) } + override fun onAnimationRepeat(animation: Animation?) {} + } + setAnimationListener(transitionAnimationListener) + awaitClose { + Dispatchers.Main.dispatch(EmptyCoroutineContext) { + setAnimationListener(null) + } + } +}.take(1) + +fun Context.bindServiceFlow(intent: Intent, flags: Int = 0): Flow<ServiceResult> = callbackFlow { + val connectionCallback = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, binder: IBinder) { + safeOffer(ServiceResult(binder)) + } + + override fun onServiceDisconnected(className: ComponentName) { + safeOffer(ServiceResult.NOT_CONNECTED) + bindService(intent, this, flags) + } + } + + bindService(intent, connectionCallback, flags) + + awaitClose { + safeOffer(ServiceResult.NOT_CONNECTED) + + Dispatchers.Default.dispatch(EmptyCoroutineContext) { + unbindService(connectionCallback) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/HeaderOrHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/HeaderOrHolder.kt new file mode 100644 index 0000000000..308298443a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/HeaderOrHolder.kt @@ -0,0 +1,9 @@ +package net.mullvad.mullvadvpn.util + +import android.view.View +import androidx.recyclerview.widget.RecyclerView.ViewHolder + +sealed class HeaderOrHolder<H : ViewHolder>(itemView: View) : ViewHolder(itemView) { + class Header<H : ViewHolder>(headerView: View) : HeaderOrHolder<H>(headerView) + class Holder<H : ViewHolder>(val holder: H) : HeaderOrHolder<H>(holder.itemView) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Intermittent.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Intermittent.kt new file mode 100644 index 0000000000..864667700c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/Intermittent.kt @@ -0,0 +1,88 @@ +package net.mullvad.mullvadvpn.util + +import kotlin.properties.Delegates.observable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import net.mullvad.talpid.util.EventNotifier + +// Wrapper to allow awaiting for intermittent values. +// +// Wraps a property that is changed from time to time and that can become unavailable (null). This +// behaves in a way similar to `CompletableDeferred`, but the value can be set and reset multiple +// times. +// +// Calling `await` will either provide the value if it's available, or suspend until it becomes +// available and then return it. +// +// Calling `update` will set the internal value after it guarantees that no other coroutine is +// currently reading the value (through a permit from the semaphore). After the value is set, it +// provides a permit to the semaphore so that suspended coroutines can use the new value. +// +// Extra initialization can be done on the intermittent value when it becomes available and before +// it is provided to the awaiting coroutines, through the use of listener callbacks. These are +// called after the value is updated but before it is made available to the coroutines. +class Intermittent<T> { + private val notifier = EventNotifier<T?>(null) + private val semaphore = Semaphore(1, 1) + private val writeLock = Mutex() + + private var updateJob: Job? = null + private var value by notifier.notifiable() + + // When the internal value is updated, listeners can be notified before the awaiting coroutines + // resume execution. This allows performing any extra initialization before the value is made + // available for usage. + fun registerListener(id: Any, listener: (T?) -> Unit) = notifier.subscribe(id, listener) + fun unregisterListener(id: Any) = notifier.unsubscribe(id) + + suspend fun await(): T { + return semaphore.withPermit { value!! } + } + + suspend fun update(newValue: T?) { + writeLock.withLock { + if (newValue != value) { + if (value != null) { + semaphore.acquire() + } + + // This will trigger the listeners to run before the awaiting coroutines resume + value = newValue + + if (newValue != null) { + semaphore.release() + } + } + } + } + + // Helper method that spawns a coroutine to update the value. + fun spawnUpdate(newValue: T?) { + synchronized(this@Intermittent) { + val previousUpdate = updateJob + + updateJob = GlobalScope.launch(Dispatchers.Default) { + previousUpdate?.join() + update(newValue) + } + } + } + + // Helper method that provides a simple way to change the wrapped value. + // + // The method returns a property delegate that will spawn a coroutine to update the wrapped + // value every time the property is written to. + fun source() = observable<T?>(null) { _, _, newValue -> + spawnUpdate(newValue) + } + + fun onDestroy() { + notifier.unsubscribeAll() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/JobTracker.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/JobTracker.kt new file mode 100644 index 0000000000..0b950d9a55 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/JobTracker.kt @@ -0,0 +1,97 @@ +package net.mullvad.mullvadvpn.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch + +class JobTracker { + private val jobs = HashMap<Long, Job>() + private val reaperJobs = HashMap<Long, Job>() + private val namedJobs = HashMap<String, Long>() + + private var jobIdCounter = 0L + + fun newJob(job: Job): Long { + synchronized(jobs) { + val jobId = jobIdCounter + + jobIdCounter += 1 + + jobs.put(jobId, job) + + reaperJobs.put( + jobId, + GlobalScope.launch(Dispatchers.Default) { + job.join() + + synchronized(jobs) { + jobs.remove(jobId) + } + } + ) + + return jobId + } + } + + fun newJob(name: String, job: Job): Long { + synchronized(namedJobs) { + cancelJob(name) + + val newJobId = newJob(job) + + namedJobs.put(name, newJobId) + + return newJobId + } + } + + fun newBackgroundJob(name: String, jobBody: suspend () -> Unit): Long { + return newJob(name, GlobalScope.launch(Dispatchers.Default) { jobBody() }) + } + + fun newUiJob(name: String, jobBody: suspend () -> Unit): Long { + return newJob(name, GlobalScope.launch(Dispatchers.Main) { jobBody() }) + } + + suspend fun <T> runOnBackground(jobBody: suspend () -> T): T { + val job = GlobalScope.async(Dispatchers.Default) { jobBody() } + + newJob(job) + + return job.await() + } + + fun cancelJob(name: String) { + synchronized(namedJobs) { + namedJobs.remove(name)?.let { oldJobId -> + cancelJob(oldJobId) + } + } + } + + fun cancelJob(jobId: Long) { + synchronized(jobs) { + jobs.remove(jobId)?.cancel() + reaperJobs.remove(jobId)?.cancel() + } + } + + fun cancelAllJobs() { + synchronized(jobs) { + for (job in jobs.values) { + job.cancel() + } + + for (job in reaperJobs.values) { + job.cancel() + } + + jobs.clear() + reaperJobs.clear() + namedJobs.clear() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LinearInterpolation.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LinearInterpolation.kt new file mode 100644 index 0000000000..750780aa6a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LinearInterpolation.kt @@ -0,0 +1,46 @@ +package net.mullvad.mullvadvpn.util + +import kotlin.properties.Delegates.observable +import kotlin.reflect.KProperty + +class LinearInterpolation { + private val observer = { _: KProperty<*>, oldValue: Float, newValue: Float -> + if (!updated && oldValue != newValue) { + updated = true + } + } + + private val realStart + get() = start - reference + + private val realEnd + get() = end - reference + + var reference by observable(0.0f, observer) + var start by observable(0.0f, observer) + var end by observable(0.0f, observer) + + var updated = true + get() { + if (field == true) { + field = false + return true + } else { + return false + } + } + + fun interpolate(progress: Float): Float { + return progress * (realEnd - realStart) + realStart + } + + fun progress(interpolation: Float): Float { + val length = realEnd - realStart + + if (length == 0.0f) { + return 0.0f + } + + return (interpolation - realStart) / length + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ListenableScrollableView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ListenableScrollableView.kt new file mode 100644 index 0000000000..61deecacb2 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ListenableScrollableView.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.util + +interface ListenableScrollableView { + val horizontalScrollOffset: Int + val verticalScrollOffset: Int + + var onScrollListener: ((Int, Int, Int, Int) -> Unit)? +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/SegmentedInputFormatter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/SegmentedInputFormatter.kt new file mode 100644 index 0000000000..9154f102ef --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/SegmentedInputFormatter.kt @@ -0,0 +1,144 @@ +package net.mullvad.mullvadvpn.util + +import android.text.Editable +import android.text.TextWatcher +import android.widget.EditText + +class SegmentedInputFormatter(val input: EditText, var separator: Char) : TextWatcher { + private var editing = false + private var removing = false + private var separatorSkipCount = 5 + + var allCaps = false + var isValidInputCharacter: (Char) -> Boolean = { _ -> true } + + var segmentSize = 4 + set(value) { + field = value + separatorSkipCount = value + 1 + } + + init { + input.addTextChangedListener(this) + } + + override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) { + if (!editing) { + editing = true + removing = after < count + } + } + + override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(text: Editable) { + val string = text.toString() + + if (isValidInput(string)) { + editing = false + maybeUpdateSelection(text) + } else { + formatInput(text) + } + } + + private fun maybeUpdateSelection(text: Editable) { + if (removing) { + var start = input.selectionStart + var end = input.selectionEnd + var changed = false + + if (start % separatorSkipCount == 0 && start > 0) { + start -= 1 + changed = true + } + + if (end % separatorSkipCount == 0 && end > 0) { + end -= 1 + changed = true + } + + if (changed) { + input.setSelection(start, end) + + if (start == end && end == text.length - 1) { + // The cursor was previously at the last character, and now after the character + // was removed it has been moved to before the separator. It's best now to + // remove the unnecessary trailing separator + text.delete(text.length - 1, text.length) + } + } + } + } + + private fun isValidInput(string: String): Boolean { + return string + .asSequence() + .withIndex() + .all { item -> + val index = item.index + val character = item.value + + if ((index + 1) % separatorSkipCount == 0) { + character == separator + } else { + isValidInputCharacter(character) + } + } + } + + private fun formatInput(input: Editable) { + var index = 0 + val length = input.length + var changed = false + + while (index < length && !changed) { + val segmentStart = index + val segmentEnd = index + segmentSize - 1 + val separatorPosition = segmentEnd + 1 + + changed = formatSegment(input, segmentStart..segmentEnd) || + formatSeparator(input, separatorPosition) + + index = separatorPosition + 1 + } + } + + private fun formatSegment(input: Editable, range: IntRange): Boolean { + val length = input.length + val start = range.start + var end = range.endInclusive + + if (start < length) { + end = minOf(end, length - 1) + + for (index in start..end) { + val character = input[index] + + if (allCaps && character >= 'a' && character <= 'z') { + input.replace(index, index + 1, character.toString().toUpperCase()) + } else if (!isValidInputCharacter(character)) { + input.delete(index, index + 1) + } else { + // Only continue looping if no changes were made to the string + continue + } + + // Abort loop because the input was edited and `afterTextChanged` will be called + // again + return true + } + } + + return false + } + + private fun formatSeparator(input: Editable, index: Int): Boolean { + if (index < input.length && input[index] != separator) { + input.insert(index, "$separator") + return true + } else { + return false + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/SegmentedTextFormatter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/SegmentedTextFormatter.kt new file mode 100644 index 0000000000..8e08f3c742 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/SegmentedTextFormatter.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.util + +class SegmentedTextFormatter(var separator: Char) { + var isValidInputCharacter: (Char) -> Boolean = { _ -> true } + var segmentSize = 4 + + fun format(string: String) = string + .asSequence() + .filter(isValidInputCharacter) + .chunked(segmentSize) + .map { segmentCharacters -> segmentCharacters.joinToString("") } + .joinToString("$separator") +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/SmartDeferred.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/SmartDeferred.kt new file mode 100644 index 0000000000..61050bc894 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/SmartDeferred.kt @@ -0,0 +1,33 @@ +package net.mullvad.mullvadvpn.util + +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +class SmartDeferred<T>(private val deferred: Deferred<T>) { + private val jobTracker = JobTracker() + + private var active = true + + fun awaitThen(action: T.() -> Unit): Long? { + if (active) { + return jobTracker.newJob( + GlobalScope.launch(Dispatchers.Default) { + deferred.await().action() + } + ) + } else { + return null + } + } + + fun cancelJob(jobId: Long) { + jobTracker.cancelJob(jobId) + } + + fun cancel() { + active = false + jobTracker.cancelAllJobs() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TimeAgoFormatter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TimeAgoFormatter.kt new file mode 100644 index 0000000000..1136a21814 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TimeAgoFormatter.kt @@ -0,0 +1,36 @@ +package net.mullvad.mullvadvpn.util + +import android.content.res.Resources +import net.mullvad.mullvadvpn.R +import org.joda.time.DateTime +import org.joda.time.Duration +import org.joda.time.PeriodType + +class TimeAgoFormatter(val resources: Resources) { + private val periodType = PeriodType.standard() + .withMillisRemoved() + .withSecondsRemoved() + + fun format(instant: DateTime): String { + val elapsedTime = Duration(instant, DateTime.now()) + val elapsedTimeInfo = elapsedTime.toPeriodTo(instant, periodType) + + if (elapsedTimeInfo.years > 0) { + return getRemainingText(R.plurals.years_ago, elapsedTimeInfo.years) + } else if (elapsedTimeInfo.months > 0) { + return getRemainingText(R.plurals.months_ago, elapsedTimeInfo.months) + } else if (elapsedTimeInfo.days > 0) { + return getRemainingText(R.plurals.days_ago, elapsedTimeInfo.days) + } else if (elapsedTimeInfo.hours > 0) { + return getRemainingText(R.plurals.hours_ago, elapsedTimeInfo.hours) + } else if (elapsedTimeInfo.minutes > 0) { + return getRemainingText(R.plurals.minutes_ago, elapsedTimeInfo.minutes) + } else { + return resources.getString(R.string.less_than_a_minute_ago) + } + } + + private fun getRemainingText(pluralId: Int, quantity: Int): String { + return resources.getQuantityString(pluralId, quantity, quantity) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TimeLeftFormatter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TimeLeftFormatter.kt new file mode 100644 index 0000000000..c3a6aaa1cb --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TimeLeftFormatter.kt @@ -0,0 +1,38 @@ +package net.mullvad.mullvadvpn.util + +import android.content.res.Resources +import net.mullvad.mullvadvpn.R +import org.joda.time.DateTime +import org.joda.time.Duration +import org.joda.time.PeriodType + +class TimeLeftFormatter(val resources: Resources) { + fun format(accountExpiry: DateTime): String { + val remainingTime = Duration(DateTime.now(), accountExpiry) + + return format(accountExpiry, remainingTime) + } + + fun format(accountExpiry: DateTime, remainingTime: Duration): String { + if (remainingTime.isShorterThan(Duration.ZERO)) { + return resources.getString(R.string.out_of_time) + } else { + val remainingTimeInfo = + remainingTime.toPeriodTo(accountExpiry, PeriodType.yearMonthDayTime()) + + if (remainingTimeInfo.years > 0) { + return getRemainingText(R.plurals.years_left, remainingTimeInfo.years) + } else if (remainingTimeInfo.months >= 3) { + return getRemainingText(R.plurals.months_left, remainingTimeInfo.months) + } else if (remainingTimeInfo.months > 0 || remainingTimeInfo.days >= 1) { + return getRemainingText(R.plurals.days_left, remainingTime.standardDays.toInt()) + } else { + return resources.getString(R.string.less_than_a_day_left) + } + } + } + + private fun getRemainingText(pluralId: Int, quantity: Int): String { + return resources.getQuantityString(pluralId, quantity, quantity) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ViewKtx.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ViewKtx.kt new file mode 100644 index 0000000000..fb4a4c65b6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ViewKtx.kt @@ -0,0 +1,15 @@ +package net.mullvad.mullvadvpn.util + +import android.util.Log +import android.view.View +import android.view.ViewGroup.MarginLayoutParams + +fun View.setMargins(l: Int? = null, t: Int? = null, r: Int? = null, b: Int? = null) { + if (this.layoutParams is MarginLayoutParams) { + val p = this.layoutParams as MarginLayoutParams + p.setMargins(l ?: p.leftMargin, t ?: p.topMargin, r ?: p.rightMargin, b ?: p.bottomMargin) + this.requestLayout() + } else { + Log.w("mullvad", "setMargins is not supported") + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt new file mode 100644 index 0000000000..71db6686ca --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt @@ -0,0 +1,172 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.applist.AppData +import net.mullvad.mullvadvpn.applist.ApplicationsProvider +import net.mullvad.mullvadvpn.applist.ViewIntent +import net.mullvad.mullvadvpn.model.ListItemData +import net.mullvad.mullvadvpn.model.WidgetState +import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling + +class SplitTunnelingViewModel( + private val appsProvider: ApplicationsProvider, + private val splitTunneling: SplitTunneling, + dispatcher: CoroutineDispatcher +) : ViewModel() { + private val listItemsSink = MutableSharedFlow<List<ListItemData>>(replay = 1) + // read-only public view + val listItems: SharedFlow<List<ListItemData>> = listItemsSink.asSharedFlow() + + private val intentFlow = MutableSharedFlow<ViewIntent>() + private val isUIReady = CompletableDeferred<Unit>() + private val excludedApps: MutableMap<String, AppData> = mutableMapOf() + private val notExcludedApps: MutableMap<String, AppData> = mutableMapOf() + + private val defaultListItems: List<ListItemData> = listOf( + createTextItem(R.string.split_tunneling_description) + // We will have search item in future + ) + private var isSystemAppsVisible = false + + init { + viewModelScope.launch(dispatcher) { + listItemsSink.emit(defaultListItems + createDivider(0) + createProgressItem()) + // this will be removed after changes on native to ignore enable parameter + if (!splitTunneling.enabled) + splitTunneling.enabled = true + fetchData() + } + viewModelScope.launch(dispatcher) { + intentFlow.shareIn(viewModelScope, SharingStarted.WhileSubscribed()) + .collect(::handleIntents) + } + } + + suspend fun processIntent(intent: ViewIntent) = intentFlow.emit(intent) + + override fun onCleared() { + splitTunneling.persist() + super.onCleared() + } + + private suspend fun handleIntents(viewIntent: ViewIntent) { + when (viewIntent) { + is ViewIntent.ChangeApplicationGroup -> { + viewIntent.item.action?.let { + if (excludedApps.containsKey(it.identifier)) { + removeFromExcluded(it.identifier) + } else { + addToExcluded(it.identifier) + } + publishList() + } + } + is ViewIntent.ViewIsReady -> isUIReady.complete(Unit) + is ViewIntent.ShowSystemApps -> { + isSystemAppsVisible = viewIntent.show + publishList() + } + } + } + + private fun removeFromExcluded(packageName: String) { + excludedApps.remove(packageName)?.let { appInfo -> + notExcludedApps[packageName] = appInfo + splitTunneling.includeApp(packageName) + } + } + + private fun addToExcluded(packageName: String) { + notExcludedApps.remove(packageName)?.let { appInfo -> + excludedApps[packageName] = appInfo + splitTunneling.excludeApp(packageName) + } + } + + private suspend fun fetchData() { + appsProvider.getAppsList() + .partition { app -> splitTunneling.isAppExcluded(app.packageName) } + .let { (excludedAppsList, notExcludedAppsList) -> + // TODO: remove potential package names from splitTunneling list + // if they already uninstalled or filtered; but not in ViewModel + excludedAppsList.map { it.packageName to it }.toMap(excludedApps) + notExcludedAppsList.map { it.packageName to it }.toMap(notExcludedApps) + } + isUIReady.await() + publishList() + } + + private suspend fun publishList() { + val listItems = ArrayList(defaultListItems) + if (excludedApps.isNotEmpty()) { + listItems += createDivider(0) + listItems += createMainItem(R.string.exclude_applications) + listItems += excludedApps.values.sortedBy { it.name }.map { info -> + createApplicationItem(info, true) + } + } + val shownNotExcludedApps = + notExcludedApps.filter { app -> !app.value.isSystemApp || isSystemAppsVisible } + if (shownNotExcludedApps.isNotEmpty()) { + listItems += createDivider(1) + listItems += createSwitchItem(R.string.show_system_apps, isSystemAppsVisible) + listItems += createMainItem(R.string.all_applications) + listItems += shownNotExcludedApps.values.sortedBy { it.name }.map { info -> + createApplicationItem(info, false) + } + } + listItemsSink.emit(listItems) + } + + private fun createApplicationItem(appData: AppData, checked: Boolean): ListItemData = + ListItemData.build(appData.packageName) { + type = ListItemData.APPLICATION + text = appData.name + iconRes = appData.iconRes + action = ListItemData.ItemAction(appData.packageName) + widget = WidgetState.ImageState( + if (checked) R.drawable.ic_icons_remove else R.drawable.ic_icons_add + ) + } + + private fun createDivider(id: Int): ListItemData = ListItemData.build("space_$id") { + type = ListItemData.DIVIDER + } + + private fun createMainItem(@StringRes text: Int): ListItemData = + ListItemData.build("header_$text") { + type = ListItemData.ACTION + textRes = text + } + + private fun createTextItem(@StringRes text: Int): ListItemData = + ListItemData.build("text_$text") { + type = ListItemData.PLAIN + textRes = text + action = ListItemData.ItemAction(text.toString()) + } + + private fun createProgressItem(): ListItemData = ListItemData.build(identifier = "progress") { + type = ListItemData.PROGRESS + } + + private fun createSwitchItem(@StringRes text: Int, checked: Boolean): ListItemData = + ListItemData.build(identifier = "switch_$text") { + type = ListItemData.ACTION + textRes = text + action = ListItemData.ItemAction(text.toString()) + widget = WidgetState.SwitchState(checked) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt b/android/app/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt new file mode 100644 index 0000000000..51ed63bd0a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt @@ -0,0 +1,66 @@ +package net.mullvad.talpid + +import android.content.Context +import android.net.ConnectivityManager +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlin.properties.Delegates.observable +import net.mullvad.talpid.util.EventNotifier + +class ConnectivityListener { + private val availableNetworks = HashSet<Network>() + + private val callback = object : NetworkCallback() { + override fun onAvailable(network: Network) { + availableNetworks.add(network) + isConnected = true + } + + override fun onLost(network: Network) { + availableNetworks.remove(network) + isConnected = !availableNetworks.isEmpty() + } + } + + private lateinit var connectivityManager: ConnectivityManager + + val connectivityNotifier = EventNotifier(false) + + var isConnected by observable(false) { _, oldValue, newValue -> + if (newValue != oldValue) { + if (senderAddress != 0L) { + notifyConnectivityChange(newValue, senderAddress) + } + + connectivityNotifier.notify(newValue) + } + } + + var senderAddress = 0L + + fun register(context: Context) { + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + .build() + + connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + connectivityManager.registerNetworkCallback(request, callback) + } + + fun unregister() { + connectivityManager.unregisterNetworkCallback(callback) + } + + private fun finalize() { + destroySender(senderAddress) + senderAddress = 0L + } + + private external fun notifyConnectivityChange(isConnected: Boolean, senderAddress: Long) + private external fun destroySender(senderAddress: Long) +} diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/CreateTunResult.kt b/android/app/src/main/kotlin/net/mullvad/talpid/CreateTunResult.kt new file mode 100644 index 0000000000..150382bb1a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/CreateTunResult.kt @@ -0,0 +1,25 @@ +package net.mullvad.talpid + +import java.net.InetAddress + +sealed class CreateTunResult { + open val isOpen + get() = false + + class Success(val tunFd: Int) : CreateTunResult() { + override val isOpen + get() = true + } + + class InvalidDnsServers( + val addresses: ArrayList<InetAddress>, + val tunFd: Int + ) : CreateTunResult() { + override val isOpen + get() = true + } + + object PermissionDenied : CreateTunResult() + + object TunnelDeviceError : CreateTunResult() +} diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt b/android/app/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt new file mode 100644 index 0000000000..0dd94749c5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt @@ -0,0 +1,167 @@ +package net.mullvad.talpid + +import android.net.VpnService +import android.os.Build +import android.os.ParcelFileDescriptor +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import kotlin.properties.Delegates.observable +import net.mullvad.talpid.tun_provider.TunConfig + +open class TalpidVpnService : VpnService() { + private var activeTunStatus by observable<CreateTunResult?>(null) { _, oldTunStatus, _ -> + val oldTunFd = when (oldTunStatus) { + is CreateTunResult.Success -> oldTunStatus.tunFd + is CreateTunResult.InvalidDnsServers -> oldTunStatus.tunFd + else -> null + } + + if (oldTunFd != null) { + ParcelFileDescriptor.adoptFd(oldTunFd).close() + } + } + + private val tunIsOpen + get() = activeTunStatus?.isOpen ?: false + + private var currentTunConfig = defaultTunConfig() + private var tunIsStale = false + + protected var disallowedApps: List<String>? = null + + val connectivityListener = ConnectivityListener() + + override fun onCreate() { + connectivityListener.register(this) + } + + override fun onDestroy() { + connectivityListener.unregister() + } + + fun getTun(config: TunConfig): CreateTunResult { + synchronized(this) { + val tunStatus = activeTunStatus + + if (config == currentTunConfig && tunIsOpen && !tunIsStale) { + return tunStatus!! + } else { + val newTunStatus = createTun(config) + + currentTunConfig = config + activeTunStatus = newTunStatus + tunIsStale = false + + return newTunStatus + } + } + } + + fun createTun() { + synchronized(this) { + activeTunStatus = createTun(currentTunConfig) + } + } + + fun createTunIfClosed(): Boolean { + synchronized(this) { + if (!tunIsOpen) { + activeTunStatus = createTun(currentTunConfig) + } + + return tunIsOpen + } + } + + fun recreateTunIfOpen(config: TunConfig) { + synchronized(this) { + if (tunIsOpen) { + currentTunConfig = config + activeTunStatus = createTun(config) + } + } + } + + fun closeTun() { + synchronized(this) { + activeTunStatus = null + } + } + + fun markTunAsStale() { + synchronized(this) { + tunIsStale = true + } + } + + private fun createTun(config: TunConfig): CreateTunResult { + if (VpnService.prepare(this) != null) { + // VPN permission wasn't granted + return CreateTunResult.PermissionDenied + } + + var invalidDnsServerAddresses = ArrayList<InetAddress>() + + val builder = Builder().apply { + for (address in config.addresses) { + addAddress(address, prefixForAddress(address)) + } + + for (dnsServer in config.dnsServers) { + try { + addDnsServer(dnsServer) + } catch (exception: IllegalArgumentException) { + invalidDnsServerAddresses.add(dnsServer) + } + } + + for (route in config.routes) { + addRoute(route.address, route.prefixLength.toInt()) + } + + disallowedApps?.let { apps -> + for (app in apps) { + addDisallowedApplication(app) + } + } + + if (Build.VERSION.SDK_INT >= 29) { + setMetered(false) + } + + setMtu(config.mtu) + setBlocking(false) + } + + val vpnInterface = builder.establish() + val tunFd = vpnInterface?.detachFd() + + if (tunFd == null) { + return CreateTunResult.TunnelDeviceError + } + + waitForTunnelUp(tunFd, config.routes.any { route -> route.isIpv6 }) + + if (!invalidDnsServerAddresses.isEmpty()) { + return CreateTunResult.InvalidDnsServers(invalidDnsServerAddresses, tunFd) + } + + return CreateTunResult.Success(tunFd) + } + + fun bypass(socket: Int): Boolean { + return protect(socket) + } + + private fun prefixForAddress(address: InetAddress): Int { + when (address) { + is Inet4Address -> return 32 + is Inet6Address -> return 128 + else -> throw RuntimeException("Invalid IP address (not IPv4 nor IPv6)") + } + } + + private external fun defaultTunConfig(): TunConfig + private external fun waitForTunnelUp(tunFd: Int, isIpv6Enabled: Boolean) +} diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/net/Endpoint.kt b/android/app/src/main/kotlin/net/mullvad/talpid/net/Endpoint.kt new file mode 100644 index 0000000000..8937bd0122 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/net/Endpoint.kt @@ -0,0 +1,8 @@ +package net.mullvad.talpid.net + +import android.os.Parcelable +import java.net.InetSocketAddress +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Endpoint(val address: InetSocketAddress, val protocol: TransportProtocol) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/net/TransportProtocol.kt b/android/app/src/main/kotlin/net/mullvad/talpid/net/TransportProtocol.kt new file mode 100644 index 0000000000..5efb1bcb1c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/net/TransportProtocol.kt @@ -0,0 +1,9 @@ +package net.mullvad.talpid.net + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class TransportProtocol : Parcelable { + Tcp, Udp +} diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/net/TunnelEndpoint.kt b/android/app/src/main/kotlin/net/mullvad/talpid/net/TunnelEndpoint.kt new file mode 100644 index 0000000000..db9c2c4391 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/net/TunnelEndpoint.kt @@ -0,0 +1,7 @@ +package net.mullvad.talpid.net + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class TunnelEndpoint(val endpoint: Endpoint) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/net/wireguard/TunnelOptions.kt b/android/app/src/main/kotlin/net/mullvad/talpid/net/wireguard/TunnelOptions.kt new file mode 100644 index 0000000000..f5c5811c67 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/net/wireguard/TunnelOptions.kt @@ -0,0 +1,7 @@ +package net.mullvad.talpid.net.wireguard + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class TunnelOptions(val mtu: Int?) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/tun_provider/InetNetwork.kt b/android/app/src/main/kotlin/net/mullvad/talpid/tun_provider/InetNetwork.kt new file mode 100644 index 0000000000..a8490b48bf --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/tun_provider/InetNetwork.kt @@ -0,0 +1,8 @@ +package net.mullvad.talpid.tun_provider + +import java.net.Inet6Address +import java.net.InetAddress + +data class InetNetwork(val address: InetAddress, val prefixLength: Short) { + val isIpv6 = address is Inet6Address +} diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/tun_provider/TunConfig.kt b/android/app/src/main/kotlin/net/mullvad/talpid/tun_provider/TunConfig.kt new file mode 100644 index 0000000000..761462013e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/tun_provider/TunConfig.kt @@ -0,0 +1,11 @@ +package net.mullvad.talpid.tun_provider + +import java.net.InetAddress +import java.util.ArrayList + +data class TunConfig( + val addresses: ArrayList<InetAddress>, + val dnsServers: ArrayList<InetAddress>, + val routes: ArrayList<InetNetwork>, + val mtu: Int +) diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/tunnel/ActionAfterDisconnect.kt b/android/app/src/main/kotlin/net/mullvad/talpid/tunnel/ActionAfterDisconnect.kt new file mode 100644 index 0000000000..365ac0811b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/tunnel/ActionAfterDisconnect.kt @@ -0,0 +1,9 @@ +package net.mullvad.talpid.tunnel + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class ActionAfterDisconnect : Parcelable { + Nothing, Block, Reconnect +} diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/tunnel/ErrorState.kt b/android/app/src/main/kotlin/net/mullvad/talpid/tunnel/ErrorState.kt new file mode 100644 index 0000000000..2c5ba00bf5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/tunnel/ErrorState.kt @@ -0,0 +1,7 @@ +package net.mullvad.talpid.tunnel + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ErrorState(val cause: ErrorStateCause, val isBlocking: Boolean) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/tunnel/ErrorStateCause.kt b/android/app/src/main/kotlin/net/mullvad/talpid/tunnel/ErrorStateCause.kt new file mode 100644 index 0000000000..f5b79bdfd5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/tunnel/ErrorStateCause.kt @@ -0,0 +1,34 @@ +package net.mullvad.talpid.tunnel + +import android.os.Parcelable +import java.net.InetAddress +import kotlinx.parcelize.Parcelize + +sealed class ErrorStateCause : Parcelable { + @Parcelize + class AuthFailed(val reason: String?) : ErrorStateCause() + + @Parcelize + object Ipv6Unavailable : ErrorStateCause() + + @Parcelize + object SetFirewallPolicyError : ErrorStateCause() + + @Parcelize + object SetDnsError : ErrorStateCause() + + @Parcelize + class InvalidDnsServers(val addresses: ArrayList<InetAddress>) : ErrorStateCause() + + @Parcelize + object StartTunnelError : ErrorStateCause() + + @Parcelize + class TunnelParameterError(val error: ParameterGenerationError) : ErrorStateCause() + + @Parcelize + object IsOffline : ErrorStateCause() + + @Parcelize + object VpnPermissionDenied : ErrorStateCause() +} diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/tunnel/ParameterGenerationError.kt b/android/app/src/main/kotlin/net/mullvad/talpid/tunnel/ParameterGenerationError.kt new file mode 100644 index 0000000000..51fa8ac461 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/tunnel/ParameterGenerationError.kt @@ -0,0 +1,5 @@ +package net.mullvad.talpid.tunnel + +enum class ParameterGenerationError { + NoMatchingRelay, NoMatchingBridgeRelay, NoWireguardKey, CustomTunnelHostResultionError +} diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/util/EventNotifier.kt b/android/app/src/main/kotlin/net/mullvad/talpid/util/EventNotifier.kt new file mode 100644 index 0000000000..444dd54f42 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/util/EventNotifier.kt @@ -0,0 +1,78 @@ +package net.mullvad.talpid.util + +import kotlin.properties.Delegates.observable + +// Manages listeners interested in receiving events of type T +// +// The listeners subscribe using an ID object. This ID is used later on for unsubscribing. The only +// requirement is that the object uses the default implementation of the `hashCode` and `equals` +// methods inherited from `Any` (or `Object` in Java). +// +// If the ID object class (or any of its super-classes) overrides `hashCode` or `equals`, +// unsubscribe might not work correctly. +class EventNotifier<T>(private val initialValue: T) { + private val listeners = LinkedHashMap<Any, (T) -> Unit>() + + var latestEvent = initialValue + private set + + fun notify(event: T) { + synchronized(this) { + latestEvent = event + + for (listener in listeners.values) { + listener(event) + } + } + } + + fun notifyIfChanged(event: T) { + synchronized(this) { + if (latestEvent != event) { + notify(event) + } + } + } + + fun subscribe(id: Any, listener: (T) -> Unit) { + synchronized(this) { + listeners.put(id, listener) + listener(latestEvent) + } + } + + fun hasListeners(): Boolean { + synchronized(this) { + return !listeners.isEmpty() + } + } + + fun unsubscribe(id: Any) { + synchronized(this) { + listeners.remove(id) + } + } + + fun unsubscribeAll() { + synchronized(this) { + listeners.clear() + } + } + + fun notifiable() = observable(latestEvent) { _, _, newValue -> + notify(newValue) + } +} + +fun <T> autoSubscribable(id: Any, fallback: T, listener: (T) -> Unit) = + observable<EventNotifier<T>?>(null) { _, old, new -> + if (old != new) { + old?.unsubscribe(id) + + if (new == null) { + listener.invoke(fallback) + } else { + new.subscribe(id, listener) + } + } + } diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/util/InetAddressExt.kt b/android/app/src/main/kotlin/net/mullvad/talpid/util/InetAddressExt.kt new file mode 100644 index 0000000000..d310deb884 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/util/InetAddressExt.kt @@ -0,0 +1,10 @@ +package net.mullvad.talpid.util + +import java.net.InetAddress + +fun InetAddress.addressString(): String { + val hostNameAndAddress = this.toString().split('/', limit = 2) + val address = hostNameAndAddress[1] + + return address +} diff --git a/android/app/src/main/play/contact-email.txt b/android/app/src/main/play/contact-email.txt new file mode 100644 index 0000000000..001a8c0cf1 --- /dev/null +++ b/android/app/src/main/play/contact-email.txt @@ -0,0 +1 @@ +support@mullvad.net diff --git a/android/app/src/main/play/contact-website.txt b/android/app/src/main/play/contact-website.txt new file mode 100644 index 0000000000..08ee598e0f --- /dev/null +++ b/android/app/src/main/play/contact-website.txt @@ -0,0 +1 @@ +https://mullvad.net/ diff --git a/android/app/src/main/play/default-language.txt b/android/app/src/main/play/default-language.txt new file mode 100644 index 0000000000..beb9970be0 --- /dev/null +++ b/android/app/src/main/play/default-language.txt @@ -0,0 +1 @@ +en-US diff --git a/android/app/src/main/play/listings/en-US/full-description.txt b/android/app/src/main/play/listings/en-US/full-description.txt new file mode 100644 index 0000000000..63b51cf557 --- /dev/null +++ b/android/app/src/main/play/listings/en-US/full-description.txt @@ -0,0 +1,35 @@ +<b>Get started</b> + +1. Install the app. +2. Click “Create account” to generate an account number. +3. Add time to your account on our website. Just €5/month. + +<b>Why use Mullvad VPN?</b> + +Maintain your anonymity: + +• Creating an account requires no personal info — not even an email address. +• We keep no activity logs. +• Pay anonymously with cash or cryptocurrency. + +Mitigate ISP blocking and throttling. + +Bypass geographical restrictions with our global network of VPN servers. + +Our Android app uses WireGuard, a superior VPN protocol that connects fast and doesn’t drain your battery. + +<b>How does Mullvad VPN work?</b> + +Mullvad VPN allows you to browse the web securely and privately. + +With Mullvad, your traffic travels through an encrypted tunnel to one of our VPN servers and then onward to the website you are visiting. In this way, websites will only see our server’s identity instead of yours. In addition, any information that your internet service provider (ISP) saves cannot be tied specifically to you. + +Using a VPN is a great first step toward protecting your privacy. We believe that privacy is a universal right. + +<b>For your right to privacy</b> + +Mullvad was founded in 2009 purely with the ambition of upholding the universal right to privacy — for you, for us, for everyone. And not only that, we want to make Internet censorship and mass surveillance ineffective. + +That's a tall order, but if you want to make a change, you've gotta start somewhere. + +Since our humble beginning, our VPN service has helped to keep users' online activity, identity, and location private. Over the years, we've been blazing a trail forward to provide the most secure and anonymous VPN out there for everyone, for us, for you. diff --git a/android/app/src/main/play/listings/en-US/graphics/feature-graphic/Android-feature-graphics.png b/android/app/src/main/play/listings/en-US/graphics/feature-graphic/Android-feature-graphics.png Binary files differnew file mode 100644 index 0000000000..14504d0f5a --- /dev/null +++ b/android/app/src/main/play/listings/en-US/graphics/feature-graphic/Android-feature-graphics.png diff --git a/android/app/src/main/play/listings/en-US/graphics/icon/icon.png b/android/app/src/main/play/listings/en-US/graphics/icon/icon.png Binary files differnew file mode 100644 index 0000000000..23a1bb5fe3 --- /dev/null +++ b/android/app/src/main/play/listings/en-US/graphics/icon/icon.png diff --git a/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/01_connect.png b/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/01_connect.png Binary files differnew file mode 100644 index 0000000000..77e1011acd --- /dev/null +++ b/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/01_connect.png diff --git a/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/02_settings.png b/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/02_settings.png Binary files differnew file mode 100644 index 0000000000..66112856cf --- /dev/null +++ b/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/02_settings.png diff --git a/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/03_wireguard_key.png b/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/03_wireguard_key.png Binary files differnew file mode 100644 index 0000000000..3b39482ad1 --- /dev/null +++ b/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/03_wireguard_key.png diff --git a/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/04_report_a_problem.png b/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/04_report_a_problem.png Binary files differnew file mode 100644 index 0000000000..4f01b14a20 --- /dev/null +++ b/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/04_report_a_problem.png diff --git a/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/05_login.png b/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/05_login.png Binary files differnew file mode 100644 index 0000000000..85272d086a --- /dev/null +++ b/android/app/src/main/play/listings/en-US/graphics/phone-screenshots/05_login.png diff --git a/android/app/src/main/play/listings/en-US/short-description.txt b/android/app/src/main/play/listings/en-US/short-description.txt new file mode 100644 index 0000000000..69cbdafac8 --- /dev/null +++ b/android/app/src/main/play/listings/en-US/short-description.txt @@ -0,0 +1 @@ +Protect your online privacy with a fast, trustworthy, and easy-to-use VPN. diff --git a/android/app/src/main/play/listings/en-US/title.txt b/android/app/src/main/play/listings/en-US/title.txt new file mode 100644 index 0000000000..ece3daab0a --- /dev/null +++ b/android/app/src/main/play/listings/en-US/title.txt @@ -0,0 +1 @@ +Mullvad VPN: privacy is a universal right diff --git a/android/app/src/main/play/release-notes/en-US/default.txt b/android/app/src/main/play/release-notes/en-US/default.txt new file mode 100644 index 0000000000..528895e331 --- /dev/null +++ b/android/app/src/main/play/release-notes/en-US/default.txt @@ -0,0 +1 @@ +- First stable release. Identical to last beta release. diff --git a/android/app/src/main/res/anim/do_nothing.xml b/android/app/src/main/res/anim/do_nothing.xml new file mode 100644 index 0000000000..8cb6866d6d --- /dev/null +++ b/android/app/src/main/res/anim/do_nothing.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <translate android:fromYDelta="0" + android:toYDelta="0" + android:duration="@integer/transition_animation_duration" /> +</set> diff --git a/android/app/src/main/res/anim/fade_in.xml b/android/app/src/main/res/anim/fade_in.xml new file mode 100644 index 0000000000..d9b78f9197 --- /dev/null +++ b/android/app/src/main/res/anim/fade_in.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <alpha android:fromAlpha="0.0" + android:toAlpha="1.0" + android:duration="@integer/transition_animation_duration" /> +</set> diff --git a/android/app/src/main/res/anim/fade_out.xml b/android/app/src/main/res/anim/fade_out.xml new file mode 100644 index 0000000000..7c164cb338 --- /dev/null +++ b/android/app/src/main/res/anim/fade_out.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <alpha android:fromAlpha="1.0" + android:toAlpha="0.0" + android:duration="@integer/transition_animation_duration" /> +</set> diff --git a/android/app/src/main/res/anim/fragment_enter_from_bottom.xml b/android/app/src/main/res/anim/fragment_enter_from_bottom.xml new file mode 100644 index 0000000000..337392e881 --- /dev/null +++ b/android/app/src/main/res/anim/fragment_enter_from_bottom.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <translate android:fromYDelta="100%p" + android:toYDelta="0" + android:duration="@integer/transition_animation_duration" /> +</set> diff --git a/android/app/src/main/res/anim/fragment_enter_from_right.xml b/android/app/src/main/res/anim/fragment_enter_from_right.xml new file mode 100644 index 0000000000..5ba3b5c3f8 --- /dev/null +++ b/android/app/src/main/res/anim/fragment_enter_from_right.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <translate android:fromXDelta="100%p" + android:toXDelta="0" + android:duration="@integer/transition_animation_duration" /> +</set> diff --git a/android/app/src/main/res/anim/fragment_exit_to_bottom.xml b/android/app/src/main/res/anim/fragment_exit_to_bottom.xml new file mode 100644 index 0000000000..dc1261114a --- /dev/null +++ b/android/app/src/main/res/anim/fragment_exit_to_bottom.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <translate android:fromYDelta="0" + android:toYDelta="100%p" + android:duration="@integer/transition_animation_duration" /> +</set> diff --git a/android/app/src/main/res/anim/fragment_exit_to_right.xml b/android/app/src/main/res/anim/fragment_exit_to_right.xml new file mode 100644 index 0000000000..d794200982 --- /dev/null +++ b/android/app/src/main/res/anim/fragment_exit_to_right.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <translate android:fromXDelta="0" + android:toXDelta="100%p" + android:duration="@integer/transition_animation_duration" /> +</set> diff --git a/android/app/src/main/res/anim/fragment_half_enter_from_left.xml b/android/app/src/main/res/anim/fragment_half_enter_from_left.xml new file mode 100644 index 0000000000..67e7b7364e --- /dev/null +++ b/android/app/src/main/res/anim/fragment_half_enter_from_left.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <translate android:fromXDelta="-50%p" + android:toXDelta="0" + android:duration="@integer/transition_animation_duration" /> +</set> diff --git a/android/app/src/main/res/anim/fragment_half_exit_to_left.xml b/android/app/src/main/res/anim/fragment_half_exit_to_left.xml new file mode 100644 index 0000000000..bfac81df2e --- /dev/null +++ b/android/app/src/main/res/anim/fragment_half_exit_to_left.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <translate android:fromXDelta="0%p" + android:toXDelta="-50%p" + android:duration="@integer/transition_animation_duration" /> +</set> diff --git a/android/app/src/main/res/color/switch_thumb_fill_selector.xml b/android/app/src/main/res/color/switch_thumb_fill_selector.xml new file mode 100644 index 0000000000..b294ee1038 --- /dev/null +++ b/android/app/src/main/res/color/switch_thumb_fill_selector.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector + xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:color="@color/switch_thumb_fill_checked" + android:state_checked="true" /> + <item android:color="@color/switch_thumb_fill_unchecked"/> +</selector> diff --git a/android/app/src/main/res/drawable-hdpi/launch_logo.png b/android/app/src/main/res/drawable-hdpi/launch_logo.png Binary files differnew file mode 100644 index 0000000000..4b9b739061 --- /dev/null +++ b/android/app/src/main/res/drawable-hdpi/launch_logo.png diff --git a/android/app/src/main/res/drawable-hdpi/logo_icon.png b/android/app/src/main/res/drawable-hdpi/logo_icon.png Binary files differnew file mode 100644 index 0000000000..fef2b8ce1f --- /dev/null +++ b/android/app/src/main/res/drawable-hdpi/logo_icon.png diff --git a/android/app/src/main/res/drawable-hdpi/small_logo_black.png b/android/app/src/main/res/drawable-hdpi/small_logo_black.png Binary files differnew file mode 100644 index 0000000000..f335f411f8 --- /dev/null +++ b/android/app/src/main/res/drawable-hdpi/small_logo_black.png diff --git a/android/app/src/main/res/drawable-hdpi/small_logo_white.png b/android/app/src/main/res/drawable-hdpi/small_logo_white.png Binary files differnew file mode 100644 index 0000000000..b975e81ede --- /dev/null +++ b/android/app/src/main/res/drawable-hdpi/small_logo_white.png diff --git a/android/app/src/main/res/drawable-mdpi/launch_logo.png b/android/app/src/main/res/drawable-mdpi/launch_logo.png Binary files differnew file mode 100644 index 0000000000..bb9d166f6c --- /dev/null +++ b/android/app/src/main/res/drawable-mdpi/launch_logo.png diff --git a/android/app/src/main/res/drawable-mdpi/logo_icon.png b/android/app/src/main/res/drawable-mdpi/logo_icon.png Binary files differnew file mode 100644 index 0000000000..08f807d7a3 --- /dev/null +++ b/android/app/src/main/res/drawable-mdpi/logo_icon.png diff --git a/android/app/src/main/res/drawable-mdpi/small_logo_black.png b/android/app/src/main/res/drawable-mdpi/small_logo_black.png Binary files differnew file mode 100644 index 0000000000..0613f65cc6 --- /dev/null +++ b/android/app/src/main/res/drawable-mdpi/small_logo_black.png diff --git a/android/app/src/main/res/drawable-mdpi/small_logo_white.png b/android/app/src/main/res/drawable-mdpi/small_logo_white.png Binary files differnew file mode 100644 index 0000000000..4d05b81186 --- /dev/null +++ b/android/app/src/main/res/drawable-mdpi/small_logo_white.png diff --git a/android/app/src/main/res/drawable-xhdpi/banner.png b/android/app/src/main/res/drawable-xhdpi/banner.png Binary files differnew file mode 100644 index 0000000000..da8eee678d --- /dev/null +++ b/android/app/src/main/res/drawable-xhdpi/banner.png diff --git a/android/app/src/main/res/drawable-xhdpi/launch_logo.png b/android/app/src/main/res/drawable-xhdpi/launch_logo.png Binary files differnew file mode 100644 index 0000000000..876cb32f32 --- /dev/null +++ b/android/app/src/main/res/drawable-xhdpi/launch_logo.png diff --git a/android/app/src/main/res/drawable-xhdpi/logo_icon.png b/android/app/src/main/res/drawable-xhdpi/logo_icon.png Binary files differnew file mode 100644 index 0000000000..28ed381da3 --- /dev/null +++ b/android/app/src/main/res/drawable-xhdpi/logo_icon.png diff --git a/android/app/src/main/res/drawable-xhdpi/small_logo_black.png b/android/app/src/main/res/drawable-xhdpi/small_logo_black.png Binary files differnew file mode 100644 index 0000000000..fd681d9c6a --- /dev/null +++ b/android/app/src/main/res/drawable-xhdpi/small_logo_black.png diff --git a/android/app/src/main/res/drawable-xhdpi/small_logo_white.png b/android/app/src/main/res/drawable-xhdpi/small_logo_white.png Binary files differnew file mode 100644 index 0000000000..b40c5b59dd --- /dev/null +++ b/android/app/src/main/res/drawable-xhdpi/small_logo_white.png diff --git a/android/app/src/main/res/drawable-xxhdpi/launch_logo.png b/android/app/src/main/res/drawable-xxhdpi/launch_logo.png Binary files differnew file mode 100644 index 0000000000..3ea8cbe15a --- /dev/null +++ b/android/app/src/main/res/drawable-xxhdpi/launch_logo.png diff --git a/android/app/src/main/res/drawable-xxhdpi/logo_icon.png b/android/app/src/main/res/drawable-xxhdpi/logo_icon.png Binary files differnew file mode 100644 index 0000000000..0be79bef01 --- /dev/null +++ b/android/app/src/main/res/drawable-xxhdpi/logo_icon.png diff --git a/android/app/src/main/res/drawable-xxhdpi/small_logo_black.png b/android/app/src/main/res/drawable-xxhdpi/small_logo_black.png Binary files differnew file mode 100644 index 0000000000..b012f609ec --- /dev/null +++ b/android/app/src/main/res/drawable-xxhdpi/small_logo_black.png diff --git a/android/app/src/main/res/drawable-xxhdpi/small_logo_white.png b/android/app/src/main/res/drawable-xxhdpi/small_logo_white.png Binary files differnew file mode 100644 index 0000000000..79c8003c09 --- /dev/null +++ b/android/app/src/main/res/drawable-xxhdpi/small_logo_white.png diff --git a/android/app/src/main/res/drawable-xxxhdpi/launch_logo.png b/android/app/src/main/res/drawable-xxxhdpi/launch_logo.png Binary files differnew file mode 100644 index 0000000000..7d3ec88895 --- /dev/null +++ b/android/app/src/main/res/drawable-xxxhdpi/launch_logo.png diff --git a/android/app/src/main/res/drawable-xxxhdpi/logo_icon.png b/android/app/src/main/res/drawable-xxxhdpi/logo_icon.png Binary files differnew file mode 100644 index 0000000000..eb7a150208 --- /dev/null +++ b/android/app/src/main/res/drawable-xxxhdpi/logo_icon.png diff --git a/android/app/src/main/res/drawable-xxxhdpi/small_logo_black.png b/android/app/src/main/res/drawable-xxxhdpi/small_logo_black.png Binary files differnew file mode 100644 index 0000000000..a2d59c953b --- /dev/null +++ b/android/app/src/main/res/drawable-xxxhdpi/small_logo_black.png diff --git a/android/app/src/main/res/drawable-xxxhdpi/small_logo_white.png b/android/app/src/main/res/drawable-xxxhdpi/small_logo_white.png Binary files differnew file mode 100644 index 0000000000..89c5de1385 --- /dev/null +++ b/android/app/src/main/res/drawable-xxxhdpi/small_logo_white.png diff --git a/android/app/src/main/res/drawable/account_history_entry_background.xml b/android/app/src/main/res/drawable/account_history_entry_background.xml new file mode 100644 index 0000000000..ea25d2b72a --- /dev/null +++ b/android/app/src/main/res/drawable/account_history_entry_background.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <item android:state_pressed="false" + android:state_focused="false"> + <shape android:shape="rectangle"> + <solid android:color="@color/white60" /> + </shape> + </item> + <item android:state_pressed="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/white40" /> + </shape> + </item> + <item android:state_focused="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/white40" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/account_history_remove.xml b/android/app/src/main/res/drawable/account_history_remove.xml new file mode 100644 index 0000000000..6c7f52fcba --- /dev/null +++ b/android/app/src/main/res/drawable/account_history_remove.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <item android:state_pressed="false" + android:drawable="@drawable/account_history_remove_normal" /> + <item android:state_pressed="true" + android:drawable="@drawable/account_history_remove_pressed" /> +</selector> diff --git a/android/app/src/main/res/drawable/account_history_remove_normal.xml b/android/app/src/main/res/drawable/account_history_remove_normal.xml new file mode 100644 index 0000000000..532d6cd9d7 --- /dev/null +++ b/android/app/src/main/res/drawable/account_history_remove_normal.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="16dp" + android:height="16dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + <path android:fillColor="#66294D73" + android:pathData=" + M 12,24 + C 5.37312,24 0,18.62688 0,12 + C 0,5.37312 5.37312,0 12,0 + C 18.62688,0 24,5.37312 24,12 + C 24,18.62688 18.62688,24 12,24 + Z + M 13.5,12 + L 17.2947612,8.20523878 + C 17.6857559,7.81424414 17.6838785,7.18387854 17.293923,6.79392296 + L 17.206077,6.70607704 + C 16.8181114,6.31811142 16.1842538,6.31574616 15.7947612,6.70523878 + L 12,10.5 + L 8.20523878,6.70523878 + C 7.81574616,6.31574616 7.18188858,6.31811142 6.79392296,6.70607704 + L 6.70607704,6.79392296 + C 6.31612146,7.18387854 6.31424414,7.81424414 6.70523878,8.20523878 + L 10.5,12 + L 6.70523878,15.7947612 + C 6.31424414,16.1857559 6.31612146,16.8161215 6.70607704,17.206077 + L 6.79392296,17.293923 + C 7.18188858,17.6818886 7.81574616,17.6842538 8.20523878,17.2947612 + L 12,13.5 + L 15.7947612,17.2947612 + C 16.1842538,17.6842538 16.8181114,17.6818886 17.206077,17.293923 + L 17.293923,17.206077 + C 17.6838785,16.8161215 17.6857559,16.1857559 17.2947612,15.7947612 + L 13.5,12 + L13.5,12 + Z" /> +</vector> diff --git a/android/app/src/main/res/drawable/account_history_remove_pressed.xml b/android/app/src/main/res/drawable/account_history_remove_pressed.xml new file mode 100644 index 0000000000..49d3484460 --- /dev/null +++ b/android/app/src/main/res/drawable/account_history_remove_pressed.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="16dp" + android:height="16dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + <path android:fillColor="#FF294D73" + android:pathData=" + M 12,24 + C 5.37312,24 0,18.62688 0,12 + C 0,5.37312 5.37312,0 12,0 + C 18.62688,0 24,5.37312 24,12 + C 24,18.62688 18.62688,24 12,24 + Z + M 13.5,12 + L 17.2947612,8.20523878 + C 17.6857559,7.81424414 17.6838785,7.18387854 17.293923,6.79392296 + L 17.206077,6.70607704 + C 16.8181114,6.31811142 16.1842538,6.31574616 15.7947612,6.70523878 + L 12,10.5 + L 8.20523878,6.70523878 + C 7.81574616,6.31574616 7.18188858,6.31811142 6.79392296,6.70607704 + L 6.70607704,6.79392296 + C 6.31612146,7.18387854 6.31424414,7.81424414 6.70523878,8.20523878 + L 10.5,12 + L 6.70523878,15.7947612 + C 6.31424414,16.1857559 6.31612146,16.8161215 6.70607704,17.206077 + L 6.79392296,17.293923 + C 7.18188858,17.6818886 7.81574616,17.6842538 8.20523878,17.2947612 + L 12,13.5 + L 15.7947612,17.2947612 + C 16.1842538,17.6842538 16.8181114,17.6818886 17.206077,17.293923 + L 17.293923,17.206077 + C 17.6838785,16.8161215 17.6857559,16.1857559 17.2947612,15.7947612 + L 13.5,12 + L13.5,12 + Z" /> +</vector> diff --git a/android/app/src/main/res/drawable/account_input_background.xml b/android/app/src/main/res/drawable/account_input_background.xml new file mode 100644 index 0000000000..d31775f404 --- /dev/null +++ b/android/app/src/main/res/drawable/account_input_background.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <item android:state_enabled="false"> + <shape android:shape="rectangle"> + <solid android:color="@color/white20" /> + </shape> + </item> + <item android:state_enabled="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/white" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/account_login_border.xml b/android/app/src/main/res/drawable/account_login_border.xml new file mode 100644 index 0000000000..7aa3362f35 --- /dev/null +++ b/android/app/src/main/res/drawable/account_login_border.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <solid android:color="@color/blue" /> +</shape> diff --git a/android/app/src/main/res/drawable/account_login_border_error.xml b/android/app/src/main/res/drawable/account_login_border_error.xml new file mode 100644 index 0000000000..7b0b225c85 --- /dev/null +++ b/android/app/src/main/res/drawable/account_login_border_error.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <solid android:color="@color/red" /> +</shape> diff --git a/android/app/src/main/res/drawable/account_login_border_focused.xml b/android/app/src/main/res/drawable/account_login_border_focused.xml new file mode 100644 index 0000000000..fa32039e1d --- /dev/null +++ b/android/app/src/main/res/drawable/account_login_border_focused.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <solid android:color="@color/darkBlue" /> +</shape> diff --git a/android/app/src/main/res/drawable/account_login_corner.xml b/android/app/src/main/res/drawable/account_login_corner.xml new file mode 100644 index 0000000000..e4640e498d --- /dev/null +++ b/android/app/src/main/res/drawable/account_login_corner.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="@dimen/account_login_corner_radius" + android:height="@dimen/account_login_corner_radius" + android:viewportWidth="4.0" + android:viewportHeight="4.0"> + <path android:fillColor="@color/blue" + android:pathData="M 0 4 H 2 A 2 2 0 0 1 4 2 V 0 H 0 Z" /> +</vector> diff --git a/android/app/src/main/res/drawable/account_login_corner_error.xml b/android/app/src/main/res/drawable/account_login_corner_error.xml new file mode 100644 index 0000000000..c19e1be609 --- /dev/null +++ b/android/app/src/main/res/drawable/account_login_corner_error.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="@dimen/account_login_corner_radius" + android:height="@dimen/account_login_corner_radius" + android:viewportWidth="4.0" + android:viewportHeight="4.0"> + <path android:fillColor="@color/blue" + android:pathData="M 0 4 H 1 A 3 3 0 0 1 4 1 V 0 H 0 Z" /> + <path android:fillColor="@color/red" + android:pathData="M 0 4 A 4 4 0 0 1 4 0 V 2 A 2 2 0 0 0 2 4 Z" /> +</vector> diff --git a/android/app/src/main/res/drawable/account_login_corner_focused.xml b/android/app/src/main/res/drawable/account_login_corner_focused.xml new file mode 100644 index 0000000000..a02110b51d --- /dev/null +++ b/android/app/src/main/res/drawable/account_login_corner_focused.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="@dimen/account_login_corner_radius" + android:height="@dimen/account_login_corner_radius" + android:viewportWidth="4.0" + android:viewportHeight="4.0"> + <path android:fillColor="@color/blue" + android:pathData="M 0 4 H 1 A 3 3 0 0 1 4 1 V 0 H 0 Z" /> + <path android:fillColor="@color/darkBlue" + android:pathData="M 0 4 A 4 4 0 0 1 4 0 V 2 A 2 2 0 0 0 2 4 Z" /> +</vector> diff --git a/android/app/src/main/res/drawable/app_list_item_background.xml b/android/app/src/main/res/drawable/app_list_item_background.xml new file mode 100644 index 0000000000..a55c1e6d01 --- /dev/null +++ b/android/app/src/main/res/drawable/app_list_item_background.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_pressed="false" + android:state_focused="false"> + <shape android:shape="rectangle"> + <solid android:color="@color/blue40" /> + </shape> + </item> + <item android:state_pressed="false" + android:state_focused="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/blue80" /> + </shape> + </item> + <item android:state_pressed="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/blue60" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/blue_button_background.xml b/android/app/src/main/res/drawable/blue_button_background.xml new file mode 100644 index 0000000000..e87b080bee --- /dev/null +++ b/android/app/src/main/res/drawable/blue_button_background.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_pressed="false" + android:state_focused="false"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/blue80" /> + </shape> + </item> + <item android:state_pressed="false" + android:state_focused="true"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/blue40" /> + </shape> + </item> + <item android:state_pressed="true"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/blue60" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/cell_button_background.xml b/android/app/src/main/res/drawable/cell_button_background.xml new file mode 100644 index 0000000000..857a8386e1 --- /dev/null +++ b/android/app/src/main/res/drawable/cell_button_background.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_pressed="false" + android:state_focused="false"> + <shape android:shape="rectangle"> + <solid android:color="@color/blue" /> + </shape> + </item> + <item android:state_pressed="false" + android:state_focused="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/blue60" /> + </shape> + </item> + <item android:state_pressed="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/blue80" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/cell_input_background.xml b/android/app/src/main/res/drawable/cell_input_background.xml new file mode 100644 index 0000000000..436b3adb6e --- /dev/null +++ b/android/app/src/main/res/drawable/cell_input_background.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + <corners android:radius="4dp" /> + <solid android:color="@color/white10" /> +</shape> diff --git a/android/app/src/main/res/drawable/cell_input_cursor.xml b/android/app/src/main/res/drawable/cell_input_cursor.xml new file mode 100644 index 0000000000..781c1d9b87 --- /dev/null +++ b/android/app/src/main/res/drawable/cell_input_cursor.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + <solid android:color="@color/white" /> + <size android:width="1sp" + android:height="24sp" /> +</shape> diff --git a/android/app/src/main/res/drawable/cell_switch_background.xml b/android/app/src/main/res/drawable/cell_switch_background.xml new file mode 100644 index 0000000000..c7b44ce746 --- /dev/null +++ b/android/app/src/main/res/drawable/cell_switch_background.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <item android:state_enabled="false"> + <shape android:shape="rectangle"> + <corners android:radius="@dimen/cell_switch_border_radius" /> + <stroke android:color="@color/white20" + android:width="2dp" /> + <size android:width="@dimen/cell_switch_width" + android:height="@dimen/cell_switch_height" /> + </shape> + </item> + <item android:state_enabled="true"> + <shape android:shape="rectangle"> + <corners android:radius="@dimen/cell_switch_border_radius" /> + <stroke android:color="@color/white" + android:width="2dp" /> + <size android:width="@dimen/cell_switch_width" + android:height="@dimen/cell_switch_height" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/dialog_background.xml b/android/app/src/main/res/drawable/dialog_background.xml new file mode 100644 index 0000000000..a552adc351 --- /dev/null +++ b/android/app/src/main/res/drawable/dialog_background.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<inset xmlns:android="http://schemas.android.com/apk/res/android" + android:insetTop="@dimen/dialog_margin" + android:insetLeft="@dimen/dialog_margin" + android:insetRight="@dimen/dialog_margin" + android:insetBottom="@dimen/dialog_margin"> + <shape android:shape="rectangle"> + <corners android:radius="11dp" /> + <solid android:color="@color/darkBlue" /> + </shape> +</inset> diff --git a/android/app/src/main/res/drawable/edit_text_background.xml b/android/app/src/main/res/drawable/edit_text_background.xml new file mode 100644 index 0000000000..06252ac37c --- /dev/null +++ b/android/app/src/main/res/drawable/edit_text_background.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <item android:state_enabled="false"> + <inset android:insetTop="1dp" + android:insetBottom="1dp" + android:insetLeft="1dp" + android:insetRight="1dp"> + <shape android:shape="rectangle"> + <corners android:radius="@dimen/edit_text_corner_radius" /> + <solid android:color="@color/white20" /> + </shape> + </inset> + </item> + <item android:state_enabled="true"> + <inset android:insetTop="1dp" + android:insetBottom="1dp" + android:insetLeft="1dp"> + <shape android:shape="rectangle"> + <corners android:radius="@dimen/edit_text_corner_radius" /> + <solid android:color="@color/white" /> + </shape> + </inset> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/green_button_background.xml b/android/app/src/main/res/drawable/green_button_background.xml new file mode 100644 index 0000000000..b2a50d5678 --- /dev/null +++ b/android/app/src/main/res/drawable/green_button_background.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_pressed="false" + android:state_focused="false"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/green" /> + </shape> + </item> + <item android:state_pressed="false" + android:state_focused="true"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/green80" /> + </shape> + </item> + <item android:state_pressed="true"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/green90" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/ic_icons_add.xml b/android/app/src/main/res/drawable/ic_icons_add.xml new file mode 100644 index 0000000000..97f0ca7fc7 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_icons_add.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path android:pathData="M13.05,5.66v5.29h5.29c0.513,0 0.99,0.398 0.99,0.99v0.12c0,0.578 -0.477,0.99 -0.99,0.99h-5.29v5.29c0,0.522 -0.412,0.989 -0.99,0.99l-0.12,-0.001c-0.59,0 -0.99,-0.467 -0.99,-0.989v-5.29H5.66c-0.534,0 -0.99,-0.427 -0.99,-0.99v-0.12c0,-0.559 0.456,-0.99 0.99,-0.99h5.29V5.66c0,-0.512 0.407,-0.99 0.99,-0.99h0.12c0.584,0 0.99,0.478 0.99,0.99zM12,24C5.373,24 0,18.627 0,12S5.373,0 12,0s12,5.373 12,12 -5.373,12 -12,12z" + android:fillColor="@android:color/white" + android:fillType="evenOdd" /> +</vector> diff --git a/android/app/src/main/res/drawable/ic_icons_missing.xml b/android/app/src/main/res/drawable/ic_icons_missing.xml new file mode 100644 index 0000000000..726a5c7f74 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_icons_missing.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path android:fillColor="#D8D8D8" + android:fillType="nonZero" + android:pathData="M16.327,10.25l1.422,-2.519c0.124,-0.246 0.031,-0.547 -0.2,-0.673 -0.225,-0.12 -0.503,-0.048 -0.642,0.174l-1.453,2.567c-2.21,-0.959 -4.698,-0.959 -6.908,0L7.093,7.232c-0.147,-0.23 -0.448,-0.301 -0.672,-0.159 -0.216,0.143 -0.286,0.428 -0.17,0.658l1.422,2.52C5.277,11.652 3.716,14.18 3.5,17h17c-0.216,-2.82 -1.777,-5.347 -4.173,-6.75zM8.137,14.821c-0.534,0 -0.967,-0.443 -0.967,-0.99 0,-0.547 0.433,-0.99 0.966,-0.99 0.534,0 0.966,0.443 0.966,0.99 0,0.547 -0.432,0.99 -0.966,0.99zM15.864,14.821c-0.534,0 -0.966,-0.443 -0.966,-0.99 0,-0.547 0.432,-0.99 0.966,-0.99 0.533,0 0.966,0.443 0.966,0.99 0,0.547 -0.433,0.99 -0.966,0.99zM12,24C5.373,24 0,18.627 0,12S5.373,0 12,0s12,5.373 12,12 -5.373,12 -12,12z" /> +</vector> diff --git a/android/app/src/main/res/drawable/ic_icons_remove.xml b/android/app/src/main/res/drawable/ic_icons_remove.xml new file mode 100644 index 0000000000..50b84ad42c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_icons_remove.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path android:pathData="M13.05,10.95h5.29c0.513,0 0.99,0.398 0.99,0.99v0.12c0,0.578 -0.477,0.99 -0.99,0.99H5.66c-0.534,0 -0.99,-0.427 -0.99,-0.99v-0.12c0,-0.559 0.456,-0.99 0.99,-0.99H13.05zM12,24C5.373,24 0,18.627 0,12S5.373,0 12,0s12,5.373 12,12 -5.373,12 -12,12z" + android:fillColor="@android:color/white" + android:fillType="evenOdd" /> +</vector> diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..a776994b25 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,21 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path android:pathData="M23.566,50.511L26.214,46.803C26.214,46.83 26.049,52.143 26.049,52.143L26.793,48.131C29,52.614 34.407,58.812 39.345,62.133C39.869,62.493 40.31,62.88 40.614,63.267C41.248,63.516 41.883,63.655 42.517,63.765C42.848,63.821 43.207,63.848 43.538,63.876C43.869,63.904 44.227,63.904 44.558,63.904C44.889,63.904 45.22,63.876 45.551,63.848C45.882,63.821 46.213,63.765 46.544,63.71C46.875,63.655 47.206,63.599 47.51,63.489C47.841,63.406 48.144,63.323 48.475,63.212C48.779,63.129 49.11,62.991 49.413,62.88C49.717,62.742 50.02,62.631 50.324,62.465C50.627,62.299 50.93,62.16 51.206,61.994C51.51,61.856 51.786,61.662 52.089,61.496C52.392,61.33 52.668,61.137 52.972,60.971C53.275,60.805 53.551,60.611 53.827,60.445C54.103,60.251 54.406,60.085 54.682,59.891C54.958,59.698 55.261,59.532 55.565,59.338L55.841,59.172L55.978,59.255L57.965,60.583L55.951,60.057C55.758,60.279 55.565,60.5 55.344,60.722C55.096,60.971 54.82,61.22 54.572,61.469C54.296,61.69 54.02,61.939 53.716,62.133C53.413,62.354 53.137,62.548 52.806,62.742C52.199,63.129 51.537,63.461 50.848,63.738C50.517,63.876 50.158,64.014 49.827,64.125C49.468,64.236 49.137,64.346 48.779,64.43C48.42,64.513 48.062,64.596 47.703,64.651C47.344,64.706 46.986,64.734 46.627,64.789C45.91,64.817 45.165,64.817 44.448,64.706C44.089,64.651 43.731,64.596 43.372,64.513C43.013,64.43 42.682,64.319 42.351,64.208C41.772,63.987 41.193,63.71 40.669,63.378C40.669,63.378 38.765,63.655 39.538,65.094C40.31,66.533 41.469,66.394 40.917,68.082C40.531,68.995 39.979,69.881 39.372,70.711C38.103,72.427 36.117,73.948 36.31,74.862C45.331,86.013 65.661,84.464 73.385,74.502C73.274,73.063 71.012,72.371 69.44,68.857C69.881,68.995 70.543,69.189 70.543,69.161C70.543,69.134 68.668,66.09 68.585,65.785L69.799,65.868C69.799,65.868 68.199,63.876 68.143,63.682L69.771,63.461C69.771,63.461 67.73,61.109 67.702,60.915L69.771,61.247L67.509,58.508L68.585,58.508L67.316,56.654C67.095,56.571 66.875,56.515 66.654,56.46C66.378,56.377 66.102,56.294 65.826,56.211C62.737,55.243 59.813,54.357 56.999,52.586C53.054,50.123 49.524,47.107 46.875,44.755L41.551,42.154C36.448,41.767 31.648,41.905 28.724,42.486L30.6,39.276L27.731,42.735C27.538,42.68 27.483,42.569 27.483,42.569L27.676,38.308L26.766,42.154C26.49,42.016 26.159,41.96 25.828,41.96C24.559,41.96 23.538,42.984 23.538,44.257C23.538,45.419 24.394,46.388 25.525,46.526L23.566,50.511L23.566,50.511L23.566,50.511L23.566,50.511Z" + android:fillColor="#D0933A" + android:fillType="evenOdd" /> + <path android:pathData="M26.668,40.389C26.398,40.255 26.075,40.154 25.778,40.154C24.537,40.154 23.538,41.396 23.538,42.94C23.538,44.283 24.321,45.424 25.346,45.692C25.373,45.692 25.373,45.692 25.4,45.692C26.075,45.424 27.423,43.175 27.208,41.765C27.127,41.262 26.938,40.792 26.668,40.389L26.668,40.389L26.668,40.389L26.668,40.389Z" + android:fillColor="#FFCC86" + android:fillType="evenOdd" /> + <path android:pathData="M46.765,39.239C46.34,38.031 46.454,36.469 47.048,34.996C47.896,32.963 49.508,31.608 51.036,31.608C51.347,31.608 51.63,31.667 51.912,31.785C52.789,30.96 53.808,30.282 54.939,29.811C61.19,27.218 70.326,31.844 72.673,38.237C73.805,41.331 73.465,44.719 72.503,47.813C71.711,50.346 68.826,54 69.901,56.769C69.477,56.651 60.539,53.764 58.05,52.114C54.062,49.521 50.47,46.339 47.783,43.864L47.698,43.776L38.619,39.298C38.506,39.239 38.393,39.18 38.308,39.121C39.609,39.121 44.559,39.74 46.765,39.239" + android:fillColor="#FDD321" + android:fillType="evenOdd" /> + <path android:pathData="M50.184,37.688C49.907,37.688 49.661,37.641 49.476,37.569C48.984,37.402 48.615,37.093 48.369,36.616C47.938,35.806 48.061,34.687 48.646,33.639C49.415,32.281 50.861,31.328 52.184,31.328C52.43,31.328 52.676,31.376 52.923,31.447C53.569,31.661 54.03,32.162 54.184,32.876C54.369,33.639 54.215,34.52 53.723,35.354C52.953,36.712 51.476,37.688 50.184,37.688Z" + android:fillColor="#FFF" + android:fillType="nonZero" /> + <path android:pathData="M52.153,31.709C52.338,31.709 52.523,31.733 52.707,31.804C53.2,31.971 53.569,32.4 53.692,32.972C53.846,33.662 53.723,34.472 53.261,35.235C52.584,36.45 51.261,37.331 50.153,37.331C49.969,37.331 49.784,37.307 49.63,37.259L49.63,37.259L49.63,37.259C49.169,37.117 48.923,36.783 48.8,36.521C48.43,35.806 48.523,34.758 49.046,33.805C49.753,32.59 51.046,31.709 52.153,31.709M52.153,30.994C50.676,30.994 49.046,32.043 48.184,33.543C47.538,34.663 47.446,35.878 47.907,36.807C48.184,37.355 48.646,37.736 49.23,37.95C49.507,38.046 49.815,38.093 50.153,38.093C51.63,38.093 53.261,37.045 54.092,35.544C54.615,34.639 54.769,33.686 54.584,32.853C54.4,32.019 53.846,31.423 53.046,31.161C52.8,31.042 52.492,30.994 52.153,30.994L52.153,30.994Z" + android:fillColor="#1D2A3A" + android:fillType="nonZero" /> +</vector> diff --git a/android/app/src/main/res/drawable/icon_add.xml b/android/app/src/main/res/drawable/icon_add.xml new file mode 100644 index 0000000000..f44a660a95 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_add.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<rotate xmlns:android="http://schemas.android.com/apk/res/android" + android:fromDegrees="45" + android:toDegrees="45" + android:pivotX="50%" + android:pivotY="50%" + android:drawable="@drawable/icon_close" /> diff --git a/android/app/src/main/res/drawable/icon_alert.xml b/android/app/src/main/res/drawable/icon_alert.xml new file mode 100644 index 0000000000..f8e4a2c0b0 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_alert.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + <path android:fillColor="#E34039" + android:pathData=" + m 12,24 + c -6.627417,0 -12,-5.372583 -12,-12 + s 5.372583,-12 12,-12 12,5.372583 12,12 -5.372583,12 -12,12 + z + m 0,-19.5 + c -0.8284271,0 -1.5,0.67157288 -1.5,1.5 + v 7.5 + c 0,0.8284271 0.6715729,1.5 1.5,1.5 + s 1.5,-0.6715729 1.5,-1.5 + v -7.5 + c 0,-0.82842712 -0.6715729,-1.5 -1.5,-1.5 + z + m 0,12 + c -0.8284271,0 -1.5,0.6715729 -1.5,1.5 + s 0.6715729,1.5 1.5,1.5 1.5,-0.6715729 1.5,-1.5 -0.6715729,-1.5 -1.5,-1.5 + z + " /> +</vector> diff --git a/android/app/src/main/res/drawable/icon_arrow_blue20.xml b/android/app/src/main/res/drawable/icon_arrow_blue20.xml new file mode 100644 index 0000000000..1fc5f8c1a1 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_arrow_blue20.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="16dp" + android:viewportWidth="24.0" + android:viewportHeight="16.0"> + <group> + <path android:fillColor="#33294D73" + android:pathData="M18.7015867,9 L14.4331381,12.762659 C13.851665,13.2752305 13.8579999,14.1003943 14.4392669,14.612784 C15.0245863,15.1287461 15.9602099,15.1275926 16.5380921,14.6181865 L23.5668627,8.42228969 C23.8565791,8.16690324 24.000373,7.83391619 23.999837,7.50067932 L24,7.4966702 C23.999589,7.16348359 23.8547954,6.83138119 23.5668627,6.57756713 L16.5380921,0.381670278 C15.956619,-0.130901228 15.0205338,-0.125317014 14.4392669,0.387072772 C13.8539474,0.903034846 13.8552559,1.72779176 14.4331381,2.23719784 L18.7017491,6 L1.50909424,6 C0.66354084,6 0,6.67157288 0,7.5 C0,8.33420277 0.675644504,9 1.50909424,9 L18.7015867,9 Z" /> + </group> +</vector> diff --git a/android/app/src/main/res/drawable/icon_arrow_white.xml b/android/app/src/main/res/drawable/icon_arrow_white.xml new file mode 100644 index 0000000000..8b0a0e5f20 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_arrow_white.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="16dp" + android:viewportWidth="24.0" + android:viewportHeight="16.0"> + <group> + <path android:fillColor="#FFFFFF" + android:pathData="M18.7015867,9 L14.4331381,12.762659 C13.851665,13.2752305 13.8579999,14.1003943 14.4392669,14.612784 C15.0245863,15.1287461 15.9602099,15.1275926 16.5380921,14.6181865 L23.5668627,8.42228969 C23.8565791,8.16690324 24.000373,7.83391619 23.999837,7.50067932 L24,7.4966702 C23.999589,7.16348359 23.8547954,6.83138119 23.5668627,6.57756713 L16.5380921,0.381670278 C15.956619,-0.130901228 15.0205338,-0.125317014 14.4392669,0.387072772 C13.8539474,0.903034846 13.8552559,1.72779176 14.4331381,2.23719784 L18.7017491,6 L1.50909424,6 C0.66354084,6 0,6.67157288 0,7.5 C0,8.33420277 0.675644504,9 1.50909424,9 L18.7015867,9 Z" /> + </group> +</vector> diff --git a/android/app/src/main/res/drawable/icon_back.xml b/android/app/src/main/res/drawable/icon_back.xml new file mode 100644 index 0000000000..7b5534c928 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_back.xml @@ -0,0 +1,12 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path android:pathData="M12,24C5.3731,24 -0,18.6269 -0,12C-0,5.3731 5.3731,0 12,0C18.6269,0 24,5.3731 24,12C24,18.6269 18.6269,24 12,24ZM7.0055,11.9979C6.9755,12.2732 7.0685,12.5604 7.2852,12.7774L13.2129,18.7118C13.5936,19.0929 14.2231,19.0908 14.6233,18.7027L14.6942,18.634C15.0925,18.2478 15.1055,17.6196 14.7109,17.218L9.5805,11.9979L14.7109,6.7777C15.1055,6.3762 15.0925,5.7479 14.6942,5.3618L14.6233,5.293C14.2231,4.9049 13.5936,4.9028 13.2129,5.2839L7.2852,11.2184C7.0685,11.4353 6.9755,11.7225 7.0055,11.9979L7.0055,11.9979Z" + android:strokeWidth="1" + android:fillColor="#FFFFFF" + android:fillAlpha="0.6" + android:fillType="evenOdd" + android:strokeColor="#00000000" /> +</vector> diff --git a/android/app/src/main/res/drawable/icon_chevron.xml b/android/app/src/main/res/drawable/icon_chevron.xml new file mode 100644 index 0000000000..8c0fc11d10 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_chevron.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="14dp" + android:height="24dp" + android:viewportWidth="14.0" + android:viewportHeight="24.0"> + <group android:translateX="4.0" + android:translateY="6.0"> + <path android:fillColor="#FFFFFF" + android:pathData="M0.335204989,1.95371785 L4.23669259,6 L0.335204989,10.0462822 C-0.111734996,10.4932221 -0.111734996,11.217855 0.335204989,11.664795 C0.782144974,12.111735 1.49826561,12.111735 1.9452056,11.664795 L6.66818642,6.80553188 C6.88657769,6.58714061 6.99779844,6.29559541 6.99881099,6.00303766 C6.99779844,5.70440459 6.88657769,5.41285939 6.66818642,5.19446812 L1.9452056,0.335204989 C1.49826561,-0.111734996 0.782144974,-0.111734996 0.335204989,0.335204989 C-0.111734996,0.782144974 -0.111734996,1.50677786 0.335204989,1.95371785 Z" /> + </group> +</vector> diff --git a/android/app/src/main/res/drawable/icon_chevron_expand.xml b/android/app/src/main/res/drawable/icon_chevron_expand.xml new file mode 100644 index 0000000000..f85e172a00 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_chevron_expand.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<rotate xmlns:android="http://schemas.android.com/apk/res/android" + android:fromDegrees="90" + android:toDegrees="90" + android:pivotX="50%" + android:pivotY="50%" + android:drawable="@drawable/icon_chevron" /> diff --git a/android/app/src/main/res/drawable/icon_close.xml b/android/app/src/main/res/drawable/icon_close.xml new file mode 100644 index 0000000000..7de0a4ac04 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_close.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + <path android:fillColor="#99FFFFFF" + android:pathData=" + M 12,24 + C 5.37312,24 0,18.62688 0,12 + C 0,5.37312 5.37312,0 12,0 + C 18.62688,0 24,5.37312 24,12 + C 24,18.62688 18.62688,24 12,24 + Z + M 13.5,12 + L 17.2947612,8.20523878 + C 17.6857559,7.81424414 17.6838785,7.18387854 17.293923,6.79392296 + L 17.206077,6.70607704 + C 16.8181114,6.31811142 16.1842538,6.31574616 15.7947612,6.70523878 + L 12,10.5 + L 8.20523878,6.70523878 + C 7.81574616,6.31574616 7.18188858,6.31811142 6.79392296,6.70607704 + L 6.70607704,6.79392296 + C 6.31612146,7.18387854 6.31424414,7.81424414 6.70523878,8.20523878 + L 10.5,12 + L 6.70523878,15.7947612 + C 6.31424414,16.1857559 6.31612146,16.8161215 6.70607704,17.206077 + L 6.79392296,17.293923 + C 7.18188858,17.6818886 7.81574616,17.6842538 8.20523878,17.2947612 + L 12,13.5 + L 15.7947612,17.2947612 + C 16.1842538,17.6842538 16.8181114,17.6818886 17.206077,17.293923 + L 17.293923,17.206077 + C 17.6838785,16.8161215 17.6857559,16.1857559 17.2947612,15.7947612 + L 13.5,12 + L13.5,12 + Z" /> +</vector> diff --git a/android/app/src/main/res/drawable/icon_extlink.xml b/android/app/src/main/res/drawable/icon_extlink.xml new file mode 100644 index 0000000000..3d31a523b2 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_extlink.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="16dp" + android:height="16dp" + android:viewportWidth="16" + android:viewportHeight="16"> + <path android:pathData="M12.5858,2L8.9908,2C8.451,2 8,1.5523 8,1C8,0.4439 8.4464,0 8.997,0L15.003,0C15.547,0 16,0.4464 16,0.997L16,7.003C16,7.547 15.5523,8 15,8C14.4439,8 14,7.5564 14,7.0092L14,3.4142L6.7071,10.7071C6.3166,11.0976 5.6834,11.0976 5.2929,10.7071C4.9024,10.3166 4.9024,9.6834 5.2929,9.2929L12.5858,2ZM8.4645,4L6.4645,6L2,6L2,14L10,14L10,9.5355L12,7.5355L12,14.9975C12,15.5512 11.5442,16 10.9975,16L1.0025,16C0.4488,16 0,15.5442 0,14.9975L0,5.0025C0,4.4488 0.4558,4 1.0025,4L8.4645,4Z" + android:fillColor="#FFFFFF" + android:fillType="evenOdd" /> +</vector> diff --git a/android/app/src/main/res/drawable/icon_fail.xml b/android/app/src/main/res/drawable/icon_fail.xml new file mode 100644 index 0000000000..b3bb63843b --- /dev/null +++ b/android/app/src/main/res/drawable/icon_fail.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="60dp" + android:height="60dp" + android:viewportWidth="60.0" + android:viewportHeight="60.0"> + <group> + <path android:fillColor="#FFFFFF" + android:pathData="M8 30 a 22,22 0 1,0 44,0 a 22,22 0 1,0 -44,0 Z" /> + <path android:fillColor="#E34039" + android:pathData="M33.2371523,30 L41.337119,21.9033278 C42.2203329,21.020473 42.223948,19.5681264 41.3300331,18.6745751 C40.429886,17.774794 38.9899682,17.7778525 38.0999667,18.6674921 L30,26.7641643 L21.9000333,18.6674921 C21.0100318,17.7778525 19.570114,17.774794 18.6699669,18.6745751 C17.776052,19.5681264 17.7796671,21.020473 18.662881,21.9033278 L26.7628477,30 L18.662881,38.0966722 C17.7796671,38.979527 17.776052,40.4318736 18.6699669,41.3254249 C19.570114,42.225206 21.0100318,42.2221475 21.9000333,41.3325079 L30,33.2358357 L38.0999667,41.3325079 C38.9899682,42.2221475 40.429886,42.225206 41.3300331,41.3254249 C42.223948,40.4318736 42.2203329,38.979527 41.337119,38.0966722 L33.2371523,30 Z" /> + </group> +</vector> diff --git a/android/app/src/main/res/drawable/icon_notification_connect.xml b/android/app/src/main/res/drawable/icon_notification_connect.xml new file mode 100644 index 0000000000..85f7bc9da0 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_notification_connect.xml @@ -0,0 +1,36 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="32dp" + android:height="32dp" + android:viewportWidth="32.0" + android:viewportHeight="32.0" + android:tint="?attr/colorControlNormal"> + <group android:translateX="16.0" + android:translateY="16.0" + android:scaleX="1.25" + android:scaleY="1.25"> + <path android:fillColor="#000000" + android:fillType="evenOdd" + android:pathData=" + M -4,-1 + v -6 + a 1,1 0 0,1 1,-1 + h 6 + a 1,1 0 0,1 1,1 + v 6 + h 1 + a 1,1 0 0,1 1,1 + v 7 + a 1,1 0 0,1 -1,1 + h -10 + a 1,1 0 0,1 -1,-1 + v -7 + a 1,1 0 0,1 1,-1 + z + M -2,-1 + v -5 + h 4 + v 5 + z + " /> + </group> +</vector> diff --git a/android/app/src/main/res/drawable/icon_notification_disconnect.xml b/android/app/src/main/res/drawable/icon_notification_disconnect.xml new file mode 100644 index 0000000000..e90330cdaf --- /dev/null +++ b/android/app/src/main/res/drawable/icon_notification_disconnect.xml @@ -0,0 +1,34 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="32dp" + android:height="32dp" + android:viewportWidth="32.0" + android:viewportHeight="32.0" + android:tint="?attr/colorControlNormal"> + <group android:translateX="16.0" + android:translateY="16.0" + android:scaleX="1.25" + android:scaleY="1.25"> + <path android:fillColor="#000000" + android:pathData=" + M 0,-1 + v -6 + a 1,1 0 0,1 1,-1 + h 6 + a 1,1 0 0,1 1,1 + v 6 + a 1,1 0 0,1 -2,0 + v -5 + h -4 + v 5 + h 1 + a 1,1 0 0,1 1,1 + v 7 + a 1,1 0 0,1 -1,1 + h -10 + a 1,1 0 0,1 -1,-1 + v -7 + a 1,1 0 0,1 1,-1 + z + " /> + </group> +</vector> diff --git a/android/app/src/main/res/drawable/icon_notification_error.xml b/android/app/src/main/res/drawable/icon_notification_error.xml new file mode 100644 index 0000000000..7574392129 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_notification_error.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="oval"> + <solid android:color="@color/red" /> + <size android:width="10dp" + android:height="10dp" /> +</shape> diff --git a/android/app/src/main/res/drawable/icon_notification_warning.xml b/android/app/src/main/res/drawable/icon_notification_warning.xml new file mode 100644 index 0000000000..c6baa04c1c --- /dev/null +++ b/android/app/src/main/res/drawable/icon_notification_warning.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="oval"> + <solid android:color="@color/yellow" /> + <size android:width="10dp" + android:height="10dp" /> +</shape> diff --git a/android/app/src/main/res/drawable/icon_relay_active.xml b/android/app/src/main/res/drawable/icon_relay_active.xml new file mode 100644 index 0000000000..68b77b0641 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_relay_active.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="oval"> + <solid android:color="@color/green" /> + <size android:width="16dp" + android:height="16dp" /> +</shape> diff --git a/android/app/src/main/res/drawable/icon_relay_inactive.xml b/android/app/src/main/res/drawable/icon_relay_inactive.xml new file mode 100644 index 0000000000..d01dc83f11 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_relay_inactive.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="oval"> + <solid android:color="@color/red" /> + <size android:width="16dp" + android:height="16dp" /> +</shape> diff --git a/android/app/src/main/res/drawable/icon_reload.xml b/android/app/src/main/res/drawable/icon_reload.xml new file mode 100644 index 0000000000..0800d557ff --- /dev/null +++ b/android/app/src/main/res/drawable/icon_reload.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="22dp" + android:height="22dp" + android:viewportWidth="512.0" + android:viewportHeight="512.0"> + <path android:fillColor="#FFFFFFFF" + android:pathData="M 256.455 8 c 66.269 0.119 126.437 26.233 170.859 68.685 l 35.715 -35.715 C 478.149 25.851 504 36.559 504 57.941 V 192 c 0 13.255 -10.745 24 -24 24 H 345.941 c -21.382 0 -32.09 -25.851 -16.971 -40.971 l 41.75 -41.75 c -30.864 -28.899 -70.801 -44.907 -113.23 -45.273 -92.398 -0.798 -170.283 73.977 -169.484 169.442 C 88.764 348.009 162.184 424 256 424 c 41.127 0 79.997 -14.678 110.629 -41.556 4.743 -4.161 11.906 -3.908 16.368 0.553 l 39.662 39.662 c 4.872 4.872 4.631 12.815 -0.482 17.433 C 378.202 479.813 319.926 504 256 504 119.034 504 8.001 392.967 8 256.002 7.999 119.193 119.646 7.755 256.455 8 z" /> +</vector> diff --git a/android/app/src/main/res/drawable/icon_settings.xml b/android/app/src/main/res/drawable/icon_settings.xml new file mode 100644 index 0000000000..3d670e5124 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_settings.xml @@ -0,0 +1,12 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path android:pathData="M21.2552,12C21.2552,12.408 21.2182,12.792 21.1688,13.176L23.7719,15.156C24.0063,15.336 24.068,15.66 23.9199,15.924L21.4526,20.076C21.3045,20.34 20.9838,20.448 20.7001,20.34L17.6282,19.14C16.9867,19.608 16.2959,20.016 15.5433,20.316L15.0745,23.496C15.0375,23.784 14.7785,24 14.4701,24L9.5354,24C9.227,24 8.9679,23.784 8.9309,23.496L8.4621,20.316C7.7096,20.016 7.0187,19.62 6.3772,19.14L3.3054,20.34C3.034,20.436 2.7009,20.34 2.5529,20.076L0.0855,15.924C-0.0625,15.66 -0.0008,15.336 0.2336,15.156L2.8366,13.176C2.7873,12.792 2.7502,12.396 2.7502,12C2.7502,11.604 2.7873,11.208 2.8366,10.824L0.2336,8.844C-0.0008,8.664 -0.0748,8.34 0.0855,8.076L2.5529,3.924C2.7009,3.66 3.0217,3.552 3.3054,3.66L6.3772,4.86C7.0187,4.392 7.7096,3.984 8.4621,3.684L8.9309,0.504C8.9679,0.216 9.227,0 9.5354,0L14.4701,0C14.7785,0 15.0375,0.216 15.0745,0.504L15.5433,3.684C16.2959,3.984 16.9867,4.38 17.6282,4.86L20.7001,3.66C20.9715,3.564 21.3046,3.66 21.4526,3.924L23.9199,8.076C24.068,8.34 24.0063,8.664 23.7719,8.844L21.1688,10.824C21.2182,11.208 21.2552,11.592 21.2552,12ZM12,17C14.7571,17 17,14.7571 17,12C17,9.2429 14.7571,7 12,7C9.2429,7 7,9.2429 7,12C7,14.7571 9.2429,17 12,17Z" + android:strokeWidth="1" + android:fillColor="#FFFFFF" + android:fillAlpha="0.8" + android:fillType="evenOdd" + android:strokeColor="#00000000" /> +</vector> diff --git a/android/app/src/main/res/drawable/icon_spinner.xml b/android/app/src/main/res/drawable/icon_spinner.xml new file mode 100644 index 0000000000..2475c4ef3c --- /dev/null +++ b/android/app/src/main/res/drawable/icon_spinner.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<rotate xmlns:android="http://schemas.android.com/apk/res/android" + android:fromDegrees="0" + android:toDegrees="360" + android:pivotX="50%" + android:pivotY="50%"> + <vector android:width="60dp" + android:height="60dp" + android:viewportWidth="60.0" + android:viewportHeight="60.0"> + <group> + <path android:fillColor="#33FFFFFF" + android:pathData="M27.6038221,6.11991768 C40.7924274,4.79654517 52.5567098,14.4152168 53.8800823,27.6038221 C55.2034548,40.7924274 45.5847832,52.5567098 32.3961779,53.8800823 C19.2075726,55.2034548 7.4432902,45.5847832 6.11991768,32.3961779 C4.79654517,19.2075726 14.4152168,7.4432902 27.6038221,6.11991768 Z M28.4025481,14.0799451 C19.6101445,14.9621935 13.1976968,22.8050484 14.0799451,31.5974519 C14.9621935,40.3898555 22.8050484,46.8023032 31.5974519,45.9200549 C40.3898555,45.0378065 46.8023032,37.1949516 45.9200549,28.4025481 C45.0378065,19.6101445 37.1949516,13.1976968 28.4025481,14.0799451 Z" /> + <path android:fillColor="#FFFFFF" + android:pathData="M25.2028561,6.48431564 C12.2155023,9.13370504 3.83492624,21.80979 6.48431564,34.7971439 C9.13370504,47.7844977 21.80979,56.1650738 34.7971439,53.5156844 C44.2988591,51.577357 51.5941458,44.163762 53.514681,34.8276709 C53.9598043,32.6638409 52.5665172,30.5488664 50.4026872,30.1037431 C48.2388572,29.6586198 46.1238826,31.0519068 45.6787593,33.2157369 C44.3979534,39.441981 39.5342463,44.3845633 33.1980959,45.6771229 C24.53986,47.4433825 16.0891367,41.8563318 14.3228771,33.1980959 C12.5566175,24.53986 18.1436682,16.0891367 26.8019041,14.3228771 C28.9664631,13.8813122 30.3632257,11.7686314 29.9216608,9.60407239 C29.4800959,7.43951342 27.3674151,6.04275074 25.2028561,6.48431564 Z" /> + </group> + </vector> +</rotate> diff --git a/android/app/src/main/res/drawable/icon_success.xml b/android/app/src/main/res/drawable/icon_success.xml new file mode 100644 index 0000000000..4f5fdaae34 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_success.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="60dp" + android:height="60dp" + android:viewportWidth="60.0" + android:viewportHeight="60.0"> + <group> + <path android:fillColor="#FFFFFF" + android:pathData="M8 30 a 22,22 0 1,0 44,0 a 22,22 0 1,0 -44,0 Z" /> + <path android:fillColor="#44AD4D" + android:pathData="M19.4142136,28.5857864 C18.633165,27.8047379 17.366835,27.8047379 16.5857864,28.5857864 C15.8047379,29.366835 15.8047379,30.633165 16.5857864,31.4142136 L24.5857864,39.4142136 C25.366835,40.1952621 26.633165,40.1952621 27.4142136,39.4142136 L43.4142136,23.4142136 C44.1952621,22.633165 44.1952621,21.366835 43.4142136,20.5857864 C42.633165,19.8047379 41.366835,19.8047379 40.5857864,20.5857864 L26,35.1715729 L19.4142136,28.5857864 Z" /> + </group> +</vector> diff --git a/android/app/src/main/res/drawable/icon_tick.xml b/android/app/src/main/res/drawable/icon_tick.xml new file mode 100644 index 0000000000..8185727a1c --- /dev/null +++ b/android/app/src/main/res/drawable/icon_tick.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + <group> + <path android:fillColor="#FFFFFF" + android:pathData="M2.92646877,10.7979185 C2.25699855,10.1340272 1.17157288,10.1340272 0.502102661,10.7979185 C-0.167367554,11.4618098 -0.167367554,12.5381902 0.502102661,13.2020815 L7.35924552,20.0020815 C8.02871573,20.6659728 9.11414141,20.6659728 9.78361162,20.0020815 L23.4978973,6.40208153 C24.1673676,5.73819023 24.1673676,4.66180977 23.4978973,3.99791847 C22.8284271,3.33402718 21.7430014,3.33402718 21.0735312,3.99791847 L8.57142857,16.3958369 L2.92646877,10.7979185 Z" /> + </group> +</vector> diff --git a/android/app/src/main/res/drawable/icon_tick_green.xml b/android/app/src/main/res/drawable/icon_tick_green.xml new file mode 100644 index 0000000000..a761a863ba --- /dev/null +++ b/android/app/src/main/res/drawable/icon_tick_green.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + <group> + <path android:fillColor="@color/green" + android:pathData="M2.92646877,10.7979185 C2.25699855,10.1340272 1.17157288,10.1340272 0.502102661,10.7979185 C-0.167367554,11.4618098 -0.167367554,12.5381902 0.502102661,13.2020815 L7.35924552,20.0020815 C8.02871573,20.6659728 9.11414141,20.6659728 9.78361162,20.0020815 L23.4978973,6.40208153 C24.1673676,5.73819023 24.1673676,4.66180977 23.4978973,3.99791847 C22.8284271,3.33402718 21.7430014,3.33402718 21.0735312,3.99791847 L8.57142857,16.3958369 L2.92646877,10.7979185 Z" /> + </group> +</vector> diff --git a/android/app/src/main/res/drawable/input_text_background.xml b/android/app/src/main/res/drawable/input_text_background.xml new file mode 100644 index 0000000000..d4b4b3c595 --- /dev/null +++ b/android/app/src/main/res/drawable/input_text_background.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/white" /> +</shape> diff --git a/android/app/src/main/res/drawable/login_button_arrow.xml b/android/app/src/main/res/drawable/login_button_arrow.xml new file mode 100644 index 0000000000..1909b78fe7 --- /dev/null +++ b/android/app/src/main/res/drawable/login_button_arrow.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <item android:state_enabled="false" + android:drawable="@drawable/icon_arrow_blue20" /> + <item android:state_enabled="true" + android:drawable="@drawable/icon_arrow_white" /> +</selector> diff --git a/android/app/src/main/res/drawable/login_button_background.xml b/android/app/src/main/res/drawable/login_button_background.xml new file mode 100644 index 0000000000..c1041ef523 --- /dev/null +++ b/android/app/src/main/res/drawable/login_button_background.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <item android:state_enabled="false"> + <shape android:shape="rectangle"> + <solid android:color="@color/white" /> + </shape> + </item> + <item android:state_enabled="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/green" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/red_button_background.xml b/android/app/src/main/res/drawable/red_button_background.xml new file mode 100644 index 0000000000..e41121638f --- /dev/null +++ b/android/app/src/main/res/drawable/red_button_background.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_pressed="false" + android:state_focused="false"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/red" /> + </shape> + </item> + <item android:state_pressed="false" + android:state_focused="true"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/red80" /> + </shape> + </item> + <item android:state_pressed="true"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/red95" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/switch_thumb.xml b/android/app/src/main/res/drawable/switch_thumb.xml new file mode 100644 index 0000000000..1b32766d34 --- /dev/null +++ b/android/app/src/main/res/drawable/switch_thumb.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:dither="true" + android:shape="oval" + android:useLevel="false" + android:visible="true"> + <size android:width="@dimen/switch_thumb_size" + android:height="@dimen/switch_thumb_size" /> + <padding android:bottom="@dimen/switch_thumb_padding" + android:left="@dimen/switch_thumb_padding" + android:right="@dimen/switch_thumb_padding" + android:top="@dimen/switch_thumb_padding" /> + <solid android:color="@color/switch_thumb_fill" /> + <stroke android:width="@dimen/switch_thumb_padding" + android:color="@color/switch_thumb_border" /> +</shape> diff --git a/android/app/src/main/res/drawable/switch_track.xml b/android/app/src/main/res/drawable/switch_track.xml new file mode 100644 index 0000000000..eb287d3316 --- /dev/null +++ b/android/app/src/main/res/drawable/switch_track.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:dither="true" + android:shape="rectangle" + android:useLevel="false" + android:visible="true"> + <size android:width="@dimen/switch_width" + android:height="@dimen/switch_height" /> + <padding android:bottom="@dimen/switch_thumb_padding" + android:left="@dimen/switch_thumb_padding" + android:right="@dimen/switch_thumb_padding" + android:top="@dimen/switch_thumb_padding" /> + <solid android:color="@color/switch_track_fill" /> + <stroke android:width="@dimen/switch_track_stroke" + android:color="@color/switch_track_border" /> + <corners android:radius="@dimen/switch_track_radius" /> +</shape> diff --git a/android/app/src/main/res/drawable/text_input_cursor.xml b/android/app/src/main/res/drawable/text_input_cursor.xml new file mode 100644 index 0000000000..56b2895c88 --- /dev/null +++ b/android/app/src/main/res/drawable/text_input_cursor.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + <solid android:color="@color/darkBlue" /> + <size android:width="2sp" + android:height="24sp" /> +</shape> diff --git a/android/app/src/main/res/drawable/transparent_red_button_background.xml b/android/app/src/main/res/drawable/transparent_red_button_background.xml new file mode 100644 index 0000000000..84a3a77c38 --- /dev/null +++ b/android/app/src/main/res/drawable/transparent_red_button_background.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_pressed="false" + android:state_focused="false"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/red60" /> + </shape> + </item> + <item android:state_pressed="false" + android:state_focused="true"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/red95" /> + </shape> + </item> + <item android:state_pressed="true"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/red80" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/transparent_red_left_half_button_background.xml b/android/app/src/main/res/drawable/transparent_red_left_half_button_background.xml new file mode 100644 index 0000000000..dab41c1f57 --- /dev/null +++ b/android/app/src/main/res/drawable/transparent_red_left_half_button_background.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_pressed="false" + android:state_focused="false"> + <shape android:shape="rectangle"> + <corners android:topLeftRadius="4dp" + android:bottomLeftRadius="4dp" /> + <solid android:color="@color/red60" /> + </shape> + </item> + <item android:state_pressed="false" + android:state_focused="true"> + <shape android:shape="rectangle"> + <corners android:topLeftRadius="4dp" + android:bottomLeftRadius="4dp" /> + <solid android:color="@color/red95" /> + </shape> + </item> + <item android:state_pressed="true"> + <shape android:shape="rectangle"> + <corners android:topLeftRadius="4dp" + android:bottomLeftRadius="4dp" /> + <solid android:color="@color/red80" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/transparent_red_right_half_button_background.xml b/android/app/src/main/res/drawable/transparent_red_right_half_button_background.xml new file mode 100644 index 0000000000..f23bde9841 --- /dev/null +++ b/android/app/src/main/res/drawable/transparent_red_right_half_button_background.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_pressed="false" + android:state_focused="false"> + <shape android:shape="rectangle"> + <corners android:topRightRadius="4dp" + android:bottomRightRadius="4dp" /> + <solid android:color="@color/red60" /> + </shape> + </item> + <item android:state_pressed="false" + android:state_focused="true"> + <shape android:shape="rectangle"> + <corners android:topRightRadius="4dp" + android:bottomRightRadius="4dp" /> + <solid android:color="@color/red95" /> + </shape> + </item> + <item android:state_pressed="true"> + <shape android:shape="rectangle"> + <corners android:topRightRadius="4dp" + android:bottomRightRadius="4dp" /> + <solid android:color="@color/red80" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/drawable/white20_button_background.xml b/android/app/src/main/res/drawable/white20_button_background.xml new file mode 100644 index 0000000000..f52c7cf182 --- /dev/null +++ b/android/app/src/main/res/drawable/white20_button_background.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_pressed="false" + android:state_focused="false"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/white20" /> + </shape> + </item> + <item android:state_pressed="false" + android:state_focused="true"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/white60" /> + </shape> + </item> + <item android:state_pressed="true"> + <shape android:shape="rectangle"> + <corners android:radius="4dp" /> + <solid android:color="@color/white40" /> + </shape> + </item> +</selector> diff --git a/android/app/src/main/res/layout/account.xml b/android/app/src/main/res/layout/account.xml new file mode 100644 index 0000000000..9ca5e6f75b --- /dev/null +++ b/android/app/src/main/res/layout/account.xml @@ -0,0 +1,77 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/darkBlue" + android:gravity="left"> + <TextView android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/settings_account" + style="@style/SettingsCollapsedHeader" /> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <FrameLayout android:layout_width="match_parent" + android:layout_height="wrap_content"> + <net.mullvad.mullvadvpn.ui.widget.BackButton android:id="@+id/back" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + mullvad:text="@string/settings" /> + <TextView android:id="@+id/collapsed_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="4dp" + android:layout_gravity="center" + android:text="@string/settings_account" + style="@style/SettingsCollapsedHeader" /> + </FrameLayout> + <net.mullvad.mullvadvpn.ui.widget.ListenableScrollView android:id="@+id/scroll_area" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:fillViewport="true"> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_marginTop="2dp" + android:orientation="vertical"> + <TextView android:id="@+id/expanded_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/half_vertical_space" + android:layout_marginHorizontal="@dimen/side_margin" + android:lines="1" + android:text="@string/settings_account" + style="@style/SettingsExpandedHeader" /> + <net.mullvad.mullvadvpn.ui.widget.CopyableInformationView android:id="@+id/account_number" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingHorizontal="@dimen/side_margin" + android:paddingVertical="@dimen/half_vertical_space" + mullvad:clipboardLabel="@string/mullvad_account_number" + mullvad:copiedToast="@string/copied_mullvad_account_number" + mullvad:description="@string/account_number" + mullvad:whenMissing="hide" /> + <net.mullvad.mullvadvpn.ui.widget.InformationView android:id="@+id/account_expiry" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/half_vertical_space" + android:paddingHorizontal="@dimen/side_margin" + android:paddingVertical="@dimen/half_vertical_space" + mullvad:description="@string/paid_until" + mullvad:whenMissing="hide" /> + <Space android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" /> + <include layout="@layout/payment_buttons" /> + <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/logout" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/side_margin" + android:layout_marginTop="@dimen/button_separation" + android:layout_marginBottom="@dimen/screen_vertical_margin" + mullvad:text="@string/log_out" + mullvad:buttonColor="red" /> + </LinearLayout> + </net.mullvad.mullvadvpn.ui.widget.ListenableScrollView> + </LinearLayout> +</FrameLayout> diff --git a/android/app/src/main/res/layout/account_history_entry.xml b/android/app/src/main/res/layout/account_history_entry.xml new file mode 100644 index 0000000000..c31782c0bd --- /dev/null +++ b/android/app/src/main/res/layout/account_history_entry.xml @@ -0,0 +1,22 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="@dimen/account_history_entry_height"> + <TextView android:id="@+id/label" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:focusable="true" + android:nextFocusRight="@id/remove" + android:background="@drawable/account_history_entry_background" + android:paddingHorizontal="12dp" + android:gravity="center_vertical" + android:textColor="@color/blue80" + android:textSize="@dimen/text_medium_plus" + android:textStyle="bold" /> + <ImageButton android:id="@+id/remove" + android:layout_width="@dimen/account_history_entry_height" + android:layout_height="@dimen/account_history_entry_height" + android:layout_gravity="right" + android:nextFocusLeft="@id/remove" + android:background="?android:attr/selectableItemBackground" + android:src="@drawable/account_history_remove" /> +</FrameLayout> diff --git a/android/app/src/main/res/layout/account_input.xml b/android/app/src/main/res/layout/account_input.xml new file mode 100644 index 0000000000..96aa3c7c46 --- /dev/null +++ b/android/app/src/main/res/layout/account_input.xml @@ -0,0 +1,22 @@ +<merge xmlns:android="http://schemas.android.com/apk/res/android"> + <EditText android:id="@+id/login_input" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_weight="1" + android:paddingHorizontal="12dp" + android:background="@drawable/account_input_background" + android:singleLine="true" + android:imeOptions="flagNoPersonalizedLearning" + android:textCursorDrawable="@drawable/text_input_cursor" + android:hint="@string/login_hint" + android:textColorHint="@color/blue40" + android:textColor="@color/blue" + android:textSize="@dimen/text_medium_plus" + android:textStyle="bold" /> + <ImageButton android:id="@+id/login_button" + android:layout_width="48dp" + android:layout_height="match_parent" + android:layout_weight="0" + android:background="@drawable/login_button_background" + android:src="@drawable/login_button_arrow" /> +</merge> diff --git a/android/app/src/main/res/layout/account_login.xml b/android/app/src/main/res/layout/account_login.xml new file mode 100644 index 0000000000..5ada635027 --- /dev/null +++ b/android/app/src/main/res/layout/account_login.xml @@ -0,0 +1,16 @@ +<merge xmlns:android="http://schemas.android.com/apk/res/android"> + <net.mullvad.mullvadvpn.ui.widget.AccountLoginBorder android:id="@+id/border" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentTop="true" + android:layout_alignParentBottom="true" /> + <net.mullvad.mullvadvpn.ui.widget.AccountInput android:id="@+id/input" + android:layout_width="match_parent" + android:layout_height="@dimen/account_login_input_height" + android:layout_alignParentTop="true" + android:orientation="horizontal" /> + <androidx.recyclerview.widget.RecyclerView android:id="@+id/history" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_below="@id/input" /> +</merge> diff --git a/android/app/src/main/res/layout/account_login_border.xml b/android/app/src/main/res/layout/account_login_border.xml new file mode 100644 index 0000000000..73f17980e3 --- /dev/null +++ b/android/app/src/main/res/layout/account_login_border.xml @@ -0,0 +1,59 @@ +<merge xmlns:android="http://schemas.android.com/apk/res/android"> + <!-- corners --> + <ImageView android:id="@+id/top_left_corner" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignParentTop="true" + android:layout_alignParentLeft="true" + android:src="@drawable/account_login_corner" /> + <ImageView android:id="@+id/top_right_corner" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignParentTop="true" + android:layout_alignParentRight="true" + android:rotation="90" + android:src="@drawable/account_login_corner" /> + <ImageView android:id="@+id/bottom_right_corner" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignParentBottom="true" + android:layout_alignParentRight="true" + android:rotation="180" + android:src="@drawable/account_login_corner" /> + <ImageView android:id="@+id/bottom_left_corner" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignParentBottom="true" + android:layout_alignParentLeft="true" + android:rotation="270" + android:src="@drawable/account_login_corner" /> + <!-- sides --> + <ImageView android:id="@+id/left_border" + android:layout_width="@dimen/account_login_border_width" + android:layout_height="wrap_content" + android:layout_alignParentLeft="true" + android:layout_below="@id/top_left_corner" + android:layout_above="@id/bottom_left_corner" + android:src="@drawable/account_login_border" /> + <ImageView android:id="@+id/right_border" + android:layout_width="@dimen/account_login_border_width" + android:layout_height="wrap_content" + android:layout_alignParentRight="true" + android:layout_below="@id/top_right_corner" + android:layout_above="@id/bottom_right_corner" + android:src="@drawable/account_login_border" /> + <ImageView android:id="@+id/top_border" + android:layout_width="wrap_content" + android:layout_height="@dimen/account_login_border_width" + android:layout_toLeftOf="@id/top_right_corner" + android:layout_toRightOf="@id/top_left_corner" + android:layout_alignParentTop="true" + android:src="@drawable/account_login_border" /> + <ImageView android:id="@+id/bottom_border" + android:layout_width="wrap_content" + android:layout_height="@dimen/account_login_border_width" + android:layout_toLeftOf="@id/bottom_right_corner" + android:layout_toRightOf="@id/bottom_left_corner" + android:layout_alignParentBottom="true" + android:src="@drawable/account_login_border" /> +</merge> diff --git a/android/app/src/main/res/layout/add_custom_dns_server.xml b/android/app/src/main/res/layout/add_custom_dns_server.xml new file mode 100644 index 0000000000..892b48a6fe --- /dev/null +++ b/android/app/src/main/res/layout/add_custom_dns_server.xml @@ -0,0 +1,31 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@color/blue40" + android:orientation="horizontal"> + <TextView android:layout_width="wrap_content" + android:layout_height="match_parent" + android:layout_marginLeft="54dp" + android:layout_marginVertical="14dp" + android:background="?android:attr/selectableItemBackground" + android:gravity="center_vertical" + android:textColor="@color/white" + android:textSize="@dimen/text_medium" + android:text="@string/add_a_server" /> + <View android:id="@+id/click_area" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_alignParentLeft="true" + android:layout_alignParentRight="true" + android:focusable="true" + android:clickable="true" + android:background="?android:attr/selectableItemBackground" /> + <ImageButton android:id="@+id/add" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:layout_gravity="right" + android:paddingHorizontal="16dp" + android:paddingVertical="14dp" + android:background="?android:attr/selectableItemBackground" + android:src="@drawable/icon_add" /> +</FrameLayout> diff --git a/android/app/src/main/res/layout/advanced.xml b/android/app/src/main/res/layout/advanced.xml new file mode 100644 index 0000000000..42f94b7b7f --- /dev/null +++ b/android/app/src/main/res/layout/advanced.xml @@ -0,0 +1,34 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/darkBlue" + android:gravity="left"> + <TextView android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/settings_advanced" + style="@style/SettingsCollapsedHeader" /> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <FrameLayout android:layout_width="match_parent" + android:layout_height="wrap_content"> + <net.mullvad.mullvadvpn.ui.widget.BackButton android:id="@+id/back" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + mullvad:text="@string/settings" /> + <TextView android:id="@+id/collapsed_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="4dp" + android:layout_gravity="center" + android:text="@string/settings_advanced" + style="@style/SettingsCollapsedHeader" /> + </FrameLayout> + <net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView android:id="@+id/contents" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:scrollbars="vertical" /> + </LinearLayout> +</FrameLayout> diff --git a/android/app/src/main/res/layout/advanced_header.xml b/android/app/src/main/res/layout/advanced_header.xml new file mode 100644 index 0000000000..eb04259b3d --- /dev/null +++ b/android/app/src/main/res/layout/advanced_header.xml @@ -0,0 +1,37 @@ +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:gravity="left"> + <TextView android:id="@+id/expanded_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="2dp" + android:layout_marginLeft="@dimen/side_margin" + android:lines="1" + android:text="@string/settings_advanced" + style="@style/SettingsExpandedHeader" /> + <net.mullvad.mullvadvpn.ui.widget.MtuCell android:id="@+id/wireguard_mtu" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/vertical_space" + android:focusable="true" + android:focusableInTouchMode="true" + mullvad:text="@string/wireguard_mtu" /> + <net.mullvad.mullvadvpn.ui.widget.NavigateCell android:id="@+id/wireguard_keys" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/vertical_space" + mullvad:text="@string/wireguard_key" /> + <net.mullvad.mullvadvpn.ui.widget.NavigateCell android:id="@+id/split_tunneling" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="1dp" + mullvad:text="@string/split_tunneling" /> + <net.mullvad.mullvadvpn.ui.widget.ToggleCell android:id="@+id/enable_custom_dns" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/vertical_space" + mullvad:text="@string/enable_custom_dns" /> +</LinearLayout> diff --git a/android/app/src/main/res/layout/app_list_item.xml b/android/app/src/main/res/layout/app_list_item.xml new file mode 100644 index 0000000000..eebfccf88e --- /dev/null +++ b/android/app/src/main/res/layout/app_list_item.xml @@ -0,0 +1,39 @@ +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingHorizontal="@dimen/side_margin" + android:background="@drawable/app_list_item_background" + android:orientation="horizontal" + android:gravity="center" + android:clickable="true" + android:focusable="true"> + <ProgressBar android:id="@+id/loading" + android:layout_width="@dimen/app_list_item_icon_size" + android:layout_height="@dimen/app_list_item_icon_size" + android:layout_gravity="center" + android:layout_marginRight="4dp" + android:indeterminate="true" + android:indeterminateOnly="true" + android:indeterminateDuration="600" + android:indeterminateDrawable="@drawable/icon_spinner" + android:visibility="visible" /> + <ImageView android:id="@+id/icon" + android:layout_width="@dimen/app_list_item_icon_size" + android:layout_height="@dimen/app_list_item_icon_size" + android:layout_gravity="center" + android:layout_marginRight="4dp" + android:visibility="gone" /> + <TextView android:id="@+id/name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="1" + android:layout_marginHorizontal="8dp" + android:layout_marginVertical="16dp" + android:textColor="@color/white" + android:textSize="@dimen/text_medium" + android:text="" /> + <net.mullvad.mullvadvpn.ui.widget.CellSwitch android:id="@+id/excluded" + android:layout_width="@dimen/cell_switch_width" + android:layout_height="@dimen/cell_switch_height" + android:layout_weight="0" /> +</LinearLayout> diff --git a/android/app/src/main/res/layout/button.xml b/android/app/src/main/res/layout/button.xml new file mode 100644 index 0000000000..51d273af97 --- /dev/null +++ b/android/app/src/main/res/layout/button.xml @@ -0,0 +1,23 @@ +<merge xmlns:android="http://schemas.android.com/apk/res/android"> + <Button android:id="@+id/button" + android:gravity="center" + android:text="" + style="@style/Button" /> + <ProgressBar android:id="@+id/spinner" + android:layout_width="20dp" + android:layout_height="20dp" + android:layout_marginHorizontal="9dp" + android:layout_gravity="right|center_vertical" + android:indeterminate="true" + android:indeterminateOnly="true" + android:indeterminateDuration="600" + android:indeterminateDrawable="@drawable/icon_spinner" + android:visibility="gone" /> + <ImageView android:id="@+id/image" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="9dp" + android:layout_gravity="right|center_vertical" + android:src="@android:color/transparent" + android:visibility="gone" /> +</merge> diff --git a/android/app/src/main/res/layout/collapsed_title_layout.xml b/android/app/src/main/res/layout/collapsed_title_layout.xml new file mode 100644 index 0000000000..64ad3ed2d6 --- /dev/null +++ b/android/app/src/main/res/layout/collapsed_title_layout.xml @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/main_content" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/darkBlue" + android:fitsSystemWindows="false"> + <com.google.android.material.appbar.AppBarLayout android:id="@+id/appbar" + android:layout_width="match_parent" + android:layout_height="@dimen/expanded_toolbar_height" + android:background="@color/darkBlue" + android:fitsSystemWindows="false" + android:padding="0dp" + android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"> + + <com.google.android.material.appbar.CollapsingToolbarLayout android:id="@+id/collapsing_toolbar" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:fitsSystemWindows="false" + app:collapsedTitleGravity="center" + app:collapsedTitleTextAppearance="@style/TextAppearance.Mullvad.CollapsingToolbar.Collapsed" + app:contentScrim="@color/darkBlue" + app:expandedTitleGravity="start|bottom" + app:expandedTitleMarginBottom="20dp" + app:expandedTitleMarginStart="22dp" + app:expandedTitleMarginTop="0dp" + app:expandedTitleTextAppearance="@style/TextAppearance.Mullvad.CollapsingToolbar.Expanded" + app:layout_scrollFlags="scroll|exitUntilCollapsed" + app:title="@string/split_tunneling"> + + <com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" + android:layout_width="match_parent" + android:layout_height="?attr/actionBarSize" + android:padding="0dp" + app:layout_collapseMode="pin" + app:layout_scrollFlags="scroll|enterAlways" + android:contentInsetLeft="0dp" + android:contentInsetStart="0dp" + app:contentInsetLeft="0dp" + app:contentInsetStart="0dp" + android:contentInsetRight="0dp" + android:contentInsetEnd="0dp" + app:contentInsetRight="0dp" + app:contentInsetEnd="0dp" + app:contentInsetStartWithNavigation="0dp" + app:popupTheme="@style/ThemeOverlay.AppCompat.Light" /> + <net.mullvad.mullvadvpn.ui.widget.BackButton android:id="@+id/back" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:layout_collapseMode="pin" + app:layout_collapseParallaxMultiplier="0" + app:layout_scrollFlags="enterAlwaysCollapsed|enterAlways" + app:text="@string/settings_advanced" /> + </com.google.android.material.appbar.CollapsingToolbarLayout> + </com.google.android.material.appbar.AppBarLayout> + <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView" + android:layout_width="match_parent" + android:layout_height="wrap_content" + tools:listitem="@layout/app_list_item" + tools:itemCount="15" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + app:layout_behavior="@string/appbar_scrolling_view_behavior" /> +</androidx.coordinatorlayout.widget.CoordinatorLayout> diff --git a/android/app/src/main/res/layout/confirm_dns.xml b/android/app/src/main/res/layout/confirm_dns.xml new file mode 100644 index 0000000000..6c7266eae9 --- /dev/null +++ b/android/app/src/main/res/layout/confirm_dns.xml @@ -0,0 +1,31 @@ +<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:scrollbars="none"> + <LinearLayout android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:padding="30dp" + android:background="@drawable/dialog_background" + android:orientation="vertical" + android:gravity="left"> + <ImageView android:layout_width="44dp" + android:layout_height="44dp" + android:layout_marginTop="8dp" + android:layout_gravity="center" + android:src="@drawable/icon_alert" /> + <TextView android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginTop="16dp" + android:textColor="@color/white80" + android:textSize="@dimen/text_small" + android:text="@string/confirm_local_dns" /> + <Button android:id="@+id/confirm_button" + android:layout_marginVertical="@dimen/button_separation" + android:text="@string/add_anyway" + style="@style/RedButton" /> + <Button android:id="@+id/back_button" + android:text="@string/back" + style="@style/BlueButton" /> + </LinearLayout> +</ScrollView> diff --git a/android/app/src/main/res/layout/confirm_no_email.xml b/android/app/src/main/res/layout/confirm_no_email.xml new file mode 100644 index 0000000000..ff538ff28b --- /dev/null +++ b/android/app/src/main/res/layout/confirm_no_email.xml @@ -0,0 +1,31 @@ +<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:scrollbars="none"> + <LinearLayout android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:padding="30dp" + android:background="@drawable/dialog_background" + android:orientation="vertical" + android:gravity="left"> + <ImageView android:layout_width="44dp" + android:layout_height="44dp" + android:layout_marginTop="8dp" + android:layout_gravity="center" + android:src="@drawable/icon_alert" /> + <TextView android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginTop="16dp" + android:textColor="@color/white80" + android:textSize="@dimen/text_small" + android:text="@string/confirm_no_email" /> + <Button android:id="@+id/send_button" + android:layout_marginVertical="@dimen/button_separation" + android:text="@string/send_anyway" + style="@style/RedButton" /> + <Button android:id="@+id/back_button" + android:text="@string/back" + style="@style/BlueButton" /> + </LinearLayout> +</ScrollView> diff --git a/android/app/src/main/res/layout/connect.xml b/android/app/src/main/res/layout/connect.xml new file mode 100644 index 0000000000..d6ac999d43 --- /dev/null +++ b/android/app/src/main/res/layout/connect.xml @@ -0,0 +1,151 @@ +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <net.mullvad.mullvadvpn.ui.widget.HeaderBar android:id="@+id/header_bar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:elevation="0.5dp" /> + <androidx.coordinatorlayout.widget.CoordinatorLayout android:layout_width="match_parent" + android:layout_height="match_parent"> + <net.mullvad.mullvadvpn.ui.widget.NotificationBanner android:id="@+id/notification_banner" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:elevation="0.25dp" /> + <ScrollView android:id="@+id/body" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:fillViewport="true" + app:layout_behavior="net.mullvad.mullvadvpn.ui.UnderNotificationBannerBehavior"> + + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="bottom" + android:orientation="vertical"> + <ProgressBar android:id="@+id/connecting_spinner" + android:layout_width="60dp" + android:layout_height="60dp" + android:layout_gravity="center" + android:layout_marginBottom="7dp" + android:indeterminate="true" + android:indeterminateDrawable="@drawable/icon_spinner" + android:indeterminateDuration="600" + android:indeterminateOnly="true" + android:visibility="invisible" /> + <LinearLayout android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="7dp" + android:layout_weight="0" + android:gravity="start" + android:orientation="vertical"> + <TextView android:id="@+id/connection_status" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/side_margin" + android:layout_marginBottom="2dp" + android:text="@string/unsecured_connection" + android:textAllCaps="true" + android:textColor="@color/red" + android:textSize="@dimen/text_medium" + android:textStyle="bold" /> + <TextView android:id="@+id/city" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/side_margin" + android:text="" + android:textColor="@color/white" + android:textSize="@dimen/text_huge" + android:textStyle="bold" /> + <TextView android:id="@+id/country" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/side_margin" + android:layout_marginBottom="2dp" + android:text="" + android:textColor="@color/white" + android:textSize="@dimen/text_huge" + android:textStyle="bold" /> + <LinearLayout android:id="@+id/tunnel_info" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:background="?android:attr/selectableItemBackground" + android:clickable="true" + android:focusable="true" + android:gravity="bottom" + android:orientation="vertical" + android:paddingHorizontal="@dimen/side_margin"> + <LinearLayout android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center" + android:orientation="horizontal"> + <TextView android:id="@+id/hostname" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="" + android:textColor="@color/white40" + android:textSize="@dimen/text_hostname" /> + <ImageView android:id="@+id/chevron" + android:layout_width="24dp" + android:layout_height="24dp" + android:layout_marginHorizontal="5dp" + android:alpha="0.4" + android:src="@drawable/icon_chevron_expand" /> + </LinearLayout> + <TextView android:id="@+id/tunnel_protocol" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="1dp" + android:text="" + android:textColor="@color/white" + android:textSize="@dimen/text_small" /> + <TextView android:id="@+id/in_address" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="1dp" + android:text="" + android:textColor="@color/white" + android:textSize="@dimen/text_small" /> + <TextView android:id="@+id/out_address" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="1dp" + android:text="" + android:textColor="@color/white" + android:textSize="@dimen/text_small" /> + </LinearLayout> + </LinearLayout> + <LinearLayout android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:orientation="vertical" + android:paddingHorizontal="@dimen/side_margin" + android:paddingTop="@dimen/button_separation" + android:paddingBottom="@dimen/screen_vertical_margin"> + <net.mullvad.mullvadvpn.ui.widget.SwitchLocationButton android:id="@+id/switch_location" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/button_separation" /> + <LinearLayout android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal"> + <Button android:id="@+id/action_button" + style="@style/GreenButton" + android:layout_weight="1" + android:text="@string/connect" /> + <ImageButton android:id="@+id/reconnect_button" + android:layout_width="50dp" + android:layout_height="match_parent" + android:layout_marginLeft="1dp" + android:layout_weight="0" + android:background="@drawable/transparent_red_right_half_button_background" + android:padding="9dp" + android:src="@drawable/icon_reload" + android:visibility="gone" /> + </LinearLayout> + </LinearLayout> + </LinearLayout> + </ScrollView> + </androidx.coordinatorlayout.widget.CoordinatorLayout> +</LinearLayout> diff --git a/android/app/src/main/res/layout/custom_dns_footer.xml b/android/app/src/main/res/layout/custom_dns_footer.xml new file mode 100644 index 0000000000..c939eebb7f --- /dev/null +++ b/android/app/src/main/res/layout/custom_dns_footer.xml @@ -0,0 +1,14 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center"> + <TextView android:id="@+id/name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:paddingHorizontal="@dimen/cell_footer_horizontal_padding" + android:paddingBottom="@dimen/screen_vertical_margin" + android:paddingTop="@dimen/cell_footer_top_padding" + android:textColor="@color/white60" + android:textSize="@dimen/text_small" + android:text="@string/custom_dns_footer" /> +</FrameLayout> diff --git a/android/app/src/main/res/layout/custom_dns_server.xml b/android/app/src/main/res/layout/custom_dns_server.xml new file mode 100644 index 0000000000..54d7e9f01e --- /dev/null +++ b/android/app/src/main/res/layout/custom_dns_server.xml @@ -0,0 +1,31 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@color/blue40" + android:orientation="horizontal"> + <TextView android:id="@+id/label" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:layout_marginLeft="54dp" + android:layout_marginVertical="14dp" + android:background="?android:attr/selectableItemBackground" + android:gravity="center_vertical" + android:textColor="@color/white" + android:textSize="@dimen/text_medium" /> + <View android:id="@+id/click_area" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_alignParentLeft="true" + android:layout_alignParentRight="true" + android:focusable="true" + android:clickable="true" + android:background="?android:attr/selectableItemBackground" /> + <ImageButton android:id="@+id/remove" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:layout_gravity="right" + android:paddingHorizontal="16dp" + android:paddingVertical="14dp" + android:background="?android:attr/selectableItemBackground" + android:src="@drawable/icon_close" /> +</FrameLayout> diff --git a/android/app/src/main/res/layout/edit_custom_dns_server.xml b/android/app/src/main/res/layout/edit_custom_dns_server.xml new file mode 100644 index 0000000000..17008c0d86 --- /dev/null +++ b/android/app/src/main/res/layout/edit_custom_dns_server.xml @@ -0,0 +1,30 @@ +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@color/white" + android:orientation="horizontal"> + <EditText android:id="@+id/input" + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_weight="1" + android:layout_marginLeft="54dp" + android:layout_marginVertical="14dp" + android:gravity="center_vertical" + android:background="@android:color/transparent" + android:singleLine="true" + android:imeOptions="flagNoPersonalizedLearning" + android:textCursorDrawable="@drawable/text_input_cursor" + android:textColorHint="@color/blue60" + android:textColor="@color/blue" + android:textSize="@dimen/text_medium" + android:hint="@string/custom_dns_hint" /> + <ImageButton android:id="@+id/save" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:layout_weight="0" + android:layout_gravity="right" + android:paddingHorizontal="16dp" + android:paddingVertical="14dp" + android:background="?android:attr/selectableItemBackground" + android:src="@drawable/icon_tick_green" /> +</LinearLayout> diff --git a/android/app/src/main/res/layout/header_bar.xml b/android/app/src/main/res/layout/header_bar.xml new file mode 100644 index 0000000000..a5965596a3 --- /dev/null +++ b/android/app/src/main/res/layout/header_bar.xml @@ -0,0 +1,25 @@ +<merge xmlns:android="http://schemas.android.com/apk/res/android"> + <ImageView android:layout_width="44dp" + android:layout_height="44dp" + android:layout_marginLeft="16dp" + android:layout_marginVertical="12dp" + android:layout_weight="0" + android:src="@drawable/logo_icon" /> + <TextView android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="9dp" + android:layout_marginVertical="12dp" + android:layout_weight="1" + android:textColor="@color/white80" + android:textSize="@dimen/text_big" + android:textStyle="bold" + android:text="@string/app_name" + android:textAllCaps="true" /> + <ImageButton android:id="@+id/settings" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:layout_weight="0" + android:paddingHorizontal="16dp" + android:background="?android:attr/selectableItemBackground" + android:src="@drawable/icon_settings" /> +</merge> diff --git a/android/app/src/main/res/layout/information_view.xml b/android/app/src/main/res/layout/information_view.xml new file mode 100644 index 0000000000..7ac89aee62 --- /dev/null +++ b/android/app/src/main/res/layout/information_view.xml @@ -0,0 +1,28 @@ +<merge xmlns:android="http://schemas.android.com/apk/res/android"> + <TextView android:id="@+id/description" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="9dp" + android:textColor="@color/white60" + android:textSize="@dimen/text_small" + android:textStyle="bold" + android:text="" /> + <FrameLayout android:layout_width="wrap_content" + android:layout_height="wrap_content"> + <TextView android:id="@+id/information_display" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="@color/white" + android:textSize="@dimen/text_medium" + android:textStyle="bold" + android:text="" /> + <ProgressBar android:id="@+id/spinner" + android:layout_width="20dp" + android:layout_height="20dp" + android:indeterminate="true" + android:indeterminateOnly="true" + android:indeterminateDuration="600" + android:indeterminateDrawable="@drawable/icon_spinner" + android:visibility="invisible" /> + </FrameLayout> +</merge> diff --git a/android/app/src/main/res/layout/launch.xml b/android/app/src/main/res/layout/launch.xml new file mode 100644 index 0000000000..a6ae06f1ca --- /dev/null +++ b/android/app/src/main/res/layout/launch.xml @@ -0,0 +1,36 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <ImageButton android:id="@+id/settings" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="top | right" + android:paddingHorizontal="16dp" + android:paddingVertical="25dp" + android:background="?android:attr/selectableItemBackground" + android:src="@drawable/icon_settings" /> + <LinearLayout android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:paddingHorizontal="@dimen/side_margin" + android:orientation="vertical" + android:gravity="center"> + <ImageView android:layout_width="120dp" + android:layout_height="120dp" + android:layout_marginBottom="5dp" + android:src="@drawable/launch_logo" /> + <TextView android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="4dp" + android:textColor="@color/white60" + android:textSize="@dimen/text_big" + android:textStyle="bold" + android:text="@string/app_name" + android:textAllCaps="true" /> + <TextView android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="@color/white40" + android:textSize="@dimen/text_small" + android:text="@string/connecting_to_daemon" /> + </LinearLayout> +</FrameLayout> diff --git a/android/app/src/main/res/layout/list_item_action.xml b/android/app/src/main/res/layout/list_item_action.xml new file mode 100644 index 0000000000..9b9fc806f0 --- /dev/null +++ b/android/app/src/main/res/layout/list_item_action.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<merge xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + style="@style/ListItem.Action" + tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> + <include layout="@layout/list_item_base" /> + <FrameLayout android:id="@+id/widgetContainer" + android:layout_width="wrap_content" + android:layout_height="0dp" + android:paddingStart="@dimen/widget_padding" + android:paddingEnd="@dimen/widget_padding" + android:visibility="invisible" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@id/itemText" + app:layout_constraintTop_toTopOf="parent" /> + <androidx.constraintlayout.widget.Guideline android:id="@+id/endGuideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_end="@dimen/cell_right_padding" /> + <androidx.constraintlayout.widget.Barrier android:id="@+id/widgetBarrier" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:barrierDirection="start" + app:constraint_referenced_ids="widgetContainer,endGuideline" /> +</merge> diff --git a/android/app/src/main/res/layout/list_item_base.xml b/android/app/src/main/res/layout/list_item_base.xml new file mode 100644 index 0000000000..0c22feef21 --- /dev/null +++ b/android/app/src/main/res/layout/list_item_base.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="utf-8"?> +<merge xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="@dimen/cell_height" + tools:background="@color/green" + tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> + <androidx.appcompat.widget.AppCompatImageView android:id="@+id/itemIcon" + android:layout_width="@dimen/icon_size" + android:layout_height="@dimen/icon_size" + android:layout_marginEnd="@dimen/cell_inner_spacing" + android:background="@null" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/itemText" + app:layout_constraintStart_toStartOf="@id/startGuideline" + app:layout_constraintTop_toTopOf="parent" + tools:visibility="gone" + tools:src="@drawable/launch_logo" /> + <androidx.appcompat.widget.AppCompatTextView android:id="@+id/itemText" + android:layout_width="0dp" + android:layout_height="match_parent" + android:gravity="center_vertical" + android:textAppearance="@style/TextAppearance.Mullvad.Title1" + android:textStyle="bold" + android:background="@null" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/widgetBarrier" + app:layout_constraintStart_toEndOf="@id/itemIcon" + app:layout_constraintTop_toTopOf="parent" + tools:background="@color/white20" + tools:text="WireGuard MTU" /> + <androidx.constraintlayout.widget.Guideline android:id="@+id/startGuideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_begin="@dimen/screen_vertical_margin" /> + <androidx.constraintlayout.widget.Barrier android:id="@+id/widgetBarrier" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:tag="base" + app:barrierDirection="start" + app:constraint_referenced_ids="parent" /> +</merge> diff --git a/android/app/src/main/res/layout/list_item_group_divider.xml b/android/app/src/main/res/layout/list_item_group_divider.xml new file mode 100644 index 0000000000..9546d55c98 --- /dev/null +++ b/android/app/src/main/res/layout/list_item_group_divider.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<merge xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="@dimen/vertical_space" + tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout" /> diff --git a/android/app/src/main/res/layout/list_item_plain_text.xml b/android/app/src/main/res/layout/list_item_plain_text.xml new file mode 100644 index 0000000000..f17bc6ed5e --- /dev/null +++ b/android/app/src/main/res/layout/list_item_plain_text.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<merge xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> + <androidx.appcompat.widget.AppCompatTextView android:id="@+id/plain_text" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:paddingTop="0dp" + android:paddingBottom="0dp" + android:textAppearance="@style/TextAppearance.Mullvad.Small" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="@id/endGuideline" + app:layout_constraintStart_toStartOf="@id/startGuideline" + app:layout_constraintTop_toTopOf="parent" + tools:text="Choose the apps you want to exclude from the VPN tunnel." /> + <androidx.constraintlayout.widget.Guideline android:id="@+id/startGuideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_begin="@dimen/screen_vertical_margin" /> + <androidx.constraintlayout.widget.Guideline android:id="@+id/endGuideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_end="@dimen/screen_vertical_margin" /> +</merge> diff --git a/android/app/src/main/res/layout/list_item_progress.xml b/android/app/src/main/res/layout/list_item_progress.xml new file mode 100644 index 0000000000..221947ea85 --- /dev/null +++ b/android/app/src/main/res/layout/list_item_progress.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<merge xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> + <ProgressBar android:id="@+id/loading_spinner" + android:layout_width="@dimen/progress_size" + android:layout_height="@dimen/progress_size" + android:indeterminate="true" + android:indeterminateDrawable="@drawable/icon_spinner" + android:indeterminateDuration="600" + android:indeterminateOnly="true" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> +</merge> diff --git a/android/app/src/main/res/layout/list_item_two_action.xml b/android/app/src/main/res/layout/list_item_two_action.xml new file mode 100644 index 0000000000..81e6a5c652 --- /dev/null +++ b/android/app/src/main/res/layout/list_item_two_action.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="utf-8"?> +<merge xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="@dimen/cell_height" + tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> + <androidx.constraintlayout.widget.ConstraintLayout android:id="@+id/container_without_widget" + android:layout_width="0dp" + android:layout_height="0dp" + android:background="?android:attr/selectableItemBackground" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/widgetBarrier" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + <include layout="@layout/list_item_base" /> + </androidx.constraintlayout.widget.ConstraintLayout> + <FrameLayout android:id="@+id/widgetContainer" + android:layout_width="wrap_content" + android:layout_height="0dp" + android:background="?android:attr/selectableItemBackground" + android:paddingStart="@dimen/widget_padding" + android:paddingEnd="@dimen/widget_padding" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + android:visibility="visible" + app:layout_constraintStart_toEndOf="@id/container_without_widget" + app:layout_constraintTop_toTopOf="parent" /> + <androidx.constraintlayout.widget.Guideline android:id="@+id/endGuideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_end="@dimen/cell_right_padding" /> + <androidx.constraintlayout.widget.Barrier android:id="@+id/widgetBarrier" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:barrierDirection="start" + app:constraint_referenced_ids="widgetContainer,endGuideline" /> +</merge> diff --git a/android/app/src/main/res/layout/list_item_widget_image.xml b/android/app/src/main/res/layout/list_item_widget_image.xml new file mode 100644 index 0000000000..95034e46e3 --- /dev/null +++ b/android/app/src/main/res/layout/list_item_widget_image.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.appcompat.widget.AppCompatImageView xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/widgetImage" + android:layout_width="@dimen/icon_size" + android:layout_height="@dimen/icon_size" + android:layout_gravity="center" + android:tint="@color/white40" + android:tintMode="multiply" + tools:src="@drawable/icon_extlink" /> diff --git a/android/app/src/main/res/layout/list_item_widget_switch.xml b/android/app/src/main/res/layout/list_item_widget_switch.xml new file mode 100644 index 0000000000..9c4e342660 --- /dev/null +++ b/android/app/src/main/res/layout/list_item_widget_switch.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.appcompat.widget.SwitchCompat xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/widgetSwitch" + style="@style/AppTheme.Switch" + android:layout_gravity="center" + android:clickable="false" + android:focusable="false" + android:background="@null" + android:focusableInTouchMode="false" + tools:checked="false" /> diff --git a/android/app/src/main/res/layout/login.xml b/android/app/src/main/res/layout/login.xml new file mode 100644 index 0000000000..526dab3ca1 --- /dev/null +++ b/android/app/src/main/res/layout/login.xml @@ -0,0 +1,103 @@ +<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:id="@+id/scroll_area" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:fillViewport="true"> + <LinearLayout android:id="@+id/contents" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" + android:focusable="true" + android:focusableInTouchMode="true" + android:nextFocusForward="@id/login_input" + android:nextFocusDown="@id/login_input" + android:descendantFocusability="beforeDescendants"> + <requestFocus /> + <net.mullvad.mullvadvpn.ui.widget.HeaderBar android:id="@+id/header_bar" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> + <LinearLayout android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="1" + android:orientation="vertical" + android:paddingHorizontal="@dimen/side_margin" + android:paddingVertical="24dp"> + <Space android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" /> + <FrameLayout android:layout_width="48dp" + android:layout_height="48dp" + android:layout_gravity="center_horizontal" + android:layout_marginBottom="30dp"> + <ProgressBar android:id="@+id/logging_in_status" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:indeterminate="true" + android:indeterminateOnly="true" + android:indeterminateDuration="600" + android:indeterminateDrawable="@drawable/icon_spinner" + android:visibility="gone" /> + <ImageView android:id="@+id/logged_in_status" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:src="@drawable/icon_success" + android:visibility="gone" /> + <ImageView android:id="@+id/login_fail_status" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:src="@drawable/icon_fail" + android:visibility="gone" /> + </FrameLayout> + <TextView android:id="@+id/title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginBottom="7dp" + android:gravity="start" + android:textColor="@color/white" + android:textSize="@dimen/text_huge" + android:textStyle="bold" + android:text="@string/login_title" /> + <TextView android:id="@+id/subtitle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginBottom="8dp" + android:gravity="start" + android:textColor="@color/white80" + android:textSize="@dimen/text_small" + android:text="@string/login_description" /> + <net.mullvad.mullvadvpn.ui.widget.AccountLogin android:id="@+id/account_login" + android:layout_width="match_parent" + android:layout_height="@dimen/account_history_entry_height" /> + <Space android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="3" /> + </LinearLayout> + <LinearLayout android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:orientation="vertical" + android:paddingHorizontal="@dimen/side_margin" + android:paddingBottom="@dimen/screen_vertical_margin" + android:paddingTop="@dimen/button_separation" + android:background="@color/darkBlue"> + <TextView android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="8dp" + android:gravity="start" + android:textColor="@color/white80" + android:textSize="@dimen/text_small" + android:text="@string/dont_have_an_account" /> + <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/create_account" + android:layout_width="match_parent" + android:layout_height="wrap_content" + mullvad:buttonColor="blue" + mullvad:text="@string/create_account" /> + </LinearLayout> + </LinearLayout> +</ScrollView> diff --git a/android/app/src/main/res/layout/main.xml b/android/app/src/main/res/layout/main.xml new file mode 100644 index 0000000000..7839409631 --- /dev/null +++ b/android/app/src/main/res/layout/main.xml @@ -0,0 +1,5 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/main_fragment" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:filterTouchesWhenObscured="true" /> diff --git a/android/app/src/main/res/layout/missing_service.xml b/android/app/src/main/res/layout/missing_service.xml new file mode 100644 index 0000000000..9e3f21fde7 --- /dev/null +++ b/android/app/src/main/res/layout/missing_service.xml @@ -0,0 +1,3 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"></FrameLayout> diff --git a/android/app/src/main/res/layout/mtu_edit_text.xml b/android/app/src/main/res/layout/mtu_edit_text.xml new file mode 100644 index 0000000000..11334cf4c1 --- /dev/null +++ b/android/app/src/main/res/layout/mtu_edit_text.xml @@ -0,0 +1,14 @@ +<EditText xmlns:android="http://schemas.android.com/apk/res/android" + android:paddingHorizontal="8dp" + android:paddingVertical="4dp" + android:background="@drawable/cell_input_background" + android:digits="0123456789" + android:inputType="number" + android:singleLine="true" + android:imeOptions="flagNoPersonalizedLearning" + android:textCursorDrawable="@drawable/cell_input_cursor" + android:gravity="center" + android:hint="@string/hint_default" + android:textColorHint="@color/white80" + android:textColor="@color/white" + android:textSize="@dimen/text_medium_plus" /> diff --git a/android/app/src/main/res/layout/notification_banner.xml b/android/app/src/main/res/layout/notification_banner.xml new file mode 100644 index 0000000000..3fb5ef4d10 --- /dev/null +++ b/android/app/src/main/res/layout/notification_banner.xml @@ -0,0 +1,52 @@ +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingVertical="8dp" + android:paddingLeft="16dp" + android:paddingRight="12dp" + android:focusable="true" + android:background="?android:attr/selectableItemBackground"> + <RelativeLayout android:id="@+id/notification_status_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignParentTop="true" + android:layout_alignParentLeft="true" + android:layout_alignBottom="@id/notification_title"> + <ImageView android:id="@+id/notification_status" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_centerInParent="true" + android:src="@drawable/icon_notification_error" /> + </RelativeLayout> + <TextView android:id="@+id/notification_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignParentTop="true" + android:layout_toLeftOf="@id/notification_icon" + android:layout_toRightOf="@id/notification_status_container" + android:layout_marginLeft="7dp" + android:textSize="@dimen/text_small" + android:textStyle="bold" + android:text="@string/blocking_internet" + android:textAllCaps="true" /> + <TextView android:id="@+id/notification_message" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignWithParentIfMissing="true" + android:layout_toLeftOf="@id/notification_icon" + android:layout_alignLeft="@id/notification_title" + android:layout_below="@id/notification_title" + android:textSize="@dimen/text_small" + android:textColor="@color/white60" + android:text="" + android:visibility="gone" /> + <ImageView android:id="@+id/notification_icon" + android:layout_width="20dp" + android:layout_height="20dp" + android:layout_alignParentRight="true" + android:layout_centerVertical="true" + android:alpha="0.6" + android:padding="4dp" + android:src="@drawable/icon_extlink" + android:visibility="gone" /> +</RelativeLayout> diff --git a/android/app/src/main/res/layout/out_of_time.xml b/android/app/src/main/res/layout/out_of_time.xml new file mode 100644 index 0000000000..791b2d8a77 --- /dev/null +++ b/android/app/src/main/res/layout/out_of_time.xml @@ -0,0 +1,59 @@ +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <net.mullvad.mullvadvpn.ui.widget.HeaderBar android:id="@+id/header_bar" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> + <ScrollView android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_alignParentBottom="true" + android:layout_below="@id/header_bar" + android:fillViewport="true"> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <ImageView android:layout_width="60dp" + android:layout_height="60dp" + android:layout_gravity="center" + android:layout_marginTop="@dimen/screen_vertical_margin" + android:layout_marginBottom="18dp" + android:src="@drawable/icon_fail" /> + <TextView android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/side_margin" + android:textColor="@color/white" + android:textSize="@dimen/text_huge" + android:textStyle="bold" + android:text="@string/out_of_time" /> + <TextView android:id="@+id/account_credit_has_expired" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/side_margin" + android:layout_marginTop="8dp" + android:layout_marginBottom="@dimen/vertical_space" + android:textColor="@color/white" + android:textSize="@dimen/text_small" /> + <Space android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" /> + <LinearLayout android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:orientation="vertical" + android:paddingTop="@dimen/button_separation" + android:paddingBottom="@dimen/screen_vertical_margin" + android:background="@color/darkBlue"> + <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/disconnect" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/side_margin" + android:layout_marginBottom="@dimen/button_separation" + android:visibility="gone" + mullvad:buttonColor="red" + mullvad:text="@string/disconnect" /> + <include layout="@layout/payment_buttons" /> + </LinearLayout> + </LinearLayout> + </ScrollView> +</RelativeLayout> diff --git a/android/app/src/main/res/layout/payment_buttons.xml b/android/app/src/main/res/layout/payment_buttons.xml new file mode 100644 index 0000000000..c617bb1571 --- /dev/null +++ b/android/app/src/main/res/layout/payment_buttons.xml @@ -0,0 +1,15 @@ +<merge xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto"> + <net.mullvad.mullvadvpn.ui.widget.SitePaymentButton android:id="@+id/site_payment" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/side_margin" + mullvad:buttonColor="green" /> + <net.mullvad.mullvadvpn.ui.widget.RedeemVoucherButton android:id="@+id/redeem_voucher" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/button_separation" + android:layout_marginHorizontal="@dimen/side_margin" + mullvad:buttonColor="green" + mullvad:text="@string/redeem_voucher" /> +</merge> diff --git a/android/app/src/main/res/layout/preferences.xml b/android/app/src/main/res/layout/preferences.xml new file mode 100644 index 0000000000..70489f4429 --- /dev/null +++ b/android/app/src/main/res/layout/preferences.xml @@ -0,0 +1,60 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/darkBlue" + android:gravity="left"> + <TextView android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/settings_preferences" + style="@style/SettingsCollapsedHeader" /> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <FrameLayout android:layout_width="match_parent" + android:layout_height="wrap_content"> + <net.mullvad.mullvadvpn.ui.widget.BackButton android:id="@+id/back" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + mullvad:text="@string/settings" /> + <TextView android:id="@+id/collapsed_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="4dp" + android:layout_gravity="center" + android:text="@string/settings_preferences" + style="@style/SettingsCollapsedHeader" /> + </FrameLayout> + <net.mullvad.mullvadvpn.ui.widget.ListenableScrollView android:id="@+id/scroll_area" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:paddingBottom="@dimen/screen_vertical_margin" + android:orientation="vertical"> + <TextView android:id="@+id/expanded_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="2dp" + android:layout_marginLeft="@dimen/side_margin" + android:lines="1" + android:text="@string/settings_preferences" + style="@style/SettingsExpandedHeader" /> + <net.mullvad.mullvadvpn.ui.widget.ToggleCell android:id="@+id/auto_connect" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/vertical_space" + mullvad:text="@string/auto_connect" + mullvad:footer="@string/auto_connect_footer" /> + <net.mullvad.mullvadvpn.ui.widget.ToggleCell android:id="@+id/allow_lan" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/vertical_space" + mullvad:text="@string/local_network_sharing" + mullvad:footer="@string/allow_lan_footer" /> + </LinearLayout> + </net.mullvad.mullvadvpn.ui.widget.ListenableScrollView> + </LinearLayout> +</FrameLayout> diff --git a/android/app/src/main/res/layout/problem_report.xml b/android/app/src/main/res/layout/problem_report.xml new file mode 100644 index 0000000000..614003054a --- /dev/null +++ b/android/app/src/main/res/layout/problem_report.xml @@ -0,0 +1,163 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/darkBlue" + android:gravity="left"> + <TextView android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/report_a_problem" + style="@style/SettingsCollapsedHeader" /> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <FrameLayout android:layout_width="match_parent" + android:layout_height="wrap_content"> + <net.mullvad.mullvadvpn.ui.widget.BackButton android:id="@+id/back" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + mullvad:text="@string/settings" /> + <TextView android:id="@+id/collapsed_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="4dp" + android:layout_gravity="center" + android:text="@string/report_a_problem" + style="@style/SettingsCollapsedHeader" /> + </FrameLayout> + <net.mullvad.mullvadvpn.ui.widget.ListenableScrollView android:id="@+id/scroll_area" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:fillViewport="true"> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <TextView android:id="@+id/expanded_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginTop="2dp" + android:layout_marginBottom="8dp" + android:layout_marginHorizontal="@dimen/side_margin" + android:lines="1" + android:text="@string/report_a_problem" + style="@style/SettingsExpandedHeader" /> + <ViewSwitcher android:id="@+id/body_container" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <TextView android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginBottom="@dimen/vertical_space" + android:layout_marginHorizontal="@dimen/side_margin" + android:textColor="@color/white80" + android:textSize="@dimen/text_small" + android:text="@string/problem_report_description" /> + <EditText android:id="@+id/user_email" + android:inputType="textEmailAddress" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginBottom="12dp" + android:layout_marginHorizontal="22dp" + android:singleLine="true" + android:hint="@string/user_email_hint" + style="@style/InputText" /> + <EditText android:id="@+id/user_message" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:layout_marginHorizontal="@dimen/side_margin" + android:singleLine="false" + android:hint="@string/user_message_hint" + android:gravity="top" + style="@style/InputText" /> + <Button android:id="@+id/view_logs" + android:layout_marginHorizontal="@dimen/side_margin" + android:layout_marginVertical="@dimen/button_separation" + android:enabled="true" + android:text="@string/view_logs" + style="@style/BlueButton" /> + <Button android:id="@+id/send_button" + android:layout_marginHorizontal="@dimen/side_margin" + android:layout_marginBottom="@dimen/screen_vertical_margin" + android:enabled="false" + android:text="@string/send" + style="@style/GreenButton" /> + </LinearLayout> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_marginTop="16dp" + android:layout_marginBottom="@dimen/screen_vertical_margin" + android:layout_marginHorizontal="@dimen/side_margin" + android:orientation="vertical"> + <FrameLayout android:layout_width="60dp" + android:layout_height="60dp" + android:layout_gravity="center_horizontal" + android:layout_marginBottom="32dp"> + <ProgressBar android:id="@+id/sending_spinner" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:indeterminate="true" + android:indeterminateOnly="true" + android:indeterminateDuration="600" + android:indeterminateDrawable="@drawable/icon_spinner" /> + <ImageView android:id="@+id/sent_successfully_icon" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:src="@drawable/icon_success" + android:visibility="gone" /> + <ImageView android:id="@+id/failed_to_send_icon" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:src="@drawable/icon_fail" + android:visibility="gone" /> + </FrameLayout> + <TextView android:id="@+id/send_status" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="4dp" + android:textColor="@color/white" + android:textSize="@dimen/text_huge" + android:textStyle="bold" + android:text="@string/sending" /> + <TextView android:id="@+id/send_details" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="@color/white60" + android:textSize="@dimen/text_small" + android:text="@string/sent_thanks" + android:visibility="gone" /> + <TextView android:id="@+id/response_message" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="@color/white60" + android:textSize="@dimen/text_small" + android:text="@string/sent_contact" + android:visibility="gone" /> + <Space android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" /> + <Button android:id="@+id/edit_message_button" + android:layout_marginTop="@dimen/button_separation" + android:text="@string/edit_message" + android:visibility="gone" + style="@style/BlueButton" /> + <Button android:id="@+id/try_again_button" + android:layout_marginTop="@dimen/button_separation" + android:text="@string/try_again" + android:visibility="gone" + style="@style/GreenButton" /> + </LinearLayout> + </ViewSwitcher> + </LinearLayout> + </net.mullvad.mullvadvpn.ui.widget.ListenableScrollView> + </LinearLayout> +</FrameLayout> diff --git a/android/app/src/main/res/layout/redeem_voucher.xml b/android/app/src/main/res/layout/redeem_voucher.xml new file mode 100644 index 0000000000..c3e081196e --- /dev/null +++ b/android/app/src/main/res/layout/redeem_voucher.xml @@ -0,0 +1,58 @@ +<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:scrollbars="none"> + <LinearLayout android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="30dp" + android:background="@drawable/dialog_background" + android:orientation="vertical" + android:gravity="left"> + <TextView android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginBottom="9dp" + android:textColor="@color/white" + android:textSize="@dimen/text_medium" + android:text="@string/enter_voucher_code" /> + <EditText android:id="@+id/voucher_code" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="14dp" + android:background="@drawable/edit_text_background" + android:singleLine="true" + android:imeActionLabel="@string/redeem" + android:imeOptions="flagNoPersonalizedLearning" + android:inputType="textCapCharacters" + android:textCursorDrawable="@drawable/text_input_cursor" + android:hint="@string/voucher_hint" + android:maxLength="19" + android:digits="0123456789-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + android:textAllCaps="true" + android:textColorHint="@color/blue40" + android:textColor="@color/blue" + android:textSize="@dimen/text_small" + android:textStyle="bold" /> + <TextView android:id="@+id/error" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="8dp" + android:textColor="@color/red" + android:textSize="@dimen/text_small" + android:textStyle="bold" + android:visibility="invisible" /> + <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/redeem" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginVertical="@dimen/button_separation" + mullvad:showSpinner="true" + mullvad:buttonColor="green" + mullvad:text="@string/redeem" /> + <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/cancel" + android:layout_width="match_parent" + android:layout_height="wrap_content" + mullvad:buttonColor="blue" + mullvad:text="@string/cancel" /> + </LinearLayout> +</ScrollView> diff --git a/android/app/src/main/res/layout/relay_list_item.xml b/android/app/src/main/res/layout/relay_list_item.xml new file mode 100644 index 0000000000..e0b084901c --- /dev/null +++ b/android/app/src/main/res/layout/relay_list_item.xml @@ -0,0 +1,53 @@ +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@color/blue" + android:orientation="horizontal"> + <RelativeLayout android:layout_width="wrap_content" + android:layout_height="match_parent" + android:layout_weight="1"> + <View android:id="@+id/click_area" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_alignParentLeft="true" + android:layout_alignParentRight="true" + android:focusable="true" + android:clickable="true" + android:background="?android:attr/selectableItemBackground" /> + <FrameLayout android:id="@+id/status" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignParentLeft="true" + android:layout_centerVertical="true" + android:layout_marginLeft="@dimen/country_row_padding"> + <ImageView android:id="@+id/relay_active" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:src="@drawable/icon_relay_active" /> + <ImageView android:id="@+id/selected" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:src="@drawable/icon_tick" + android:visibility="invisible" /> + </FrameLayout> + <TextView android:id="@+id/name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="8dp" + android:layout_marginVertical="14dp" + android:layout_alignParentRight="true" + android:layout_toRightOf="@id/status" + android:textColor="@color/white" + android:textSize="@dimen/text_medium_plus" + android:textStyle="bold" + android:text="" /> + </RelativeLayout> + <ImageButton android:id="@+id/chevron" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:layout_weight="0" + android:paddingHorizontal="16dp" + android:background="?android:attr/selectableItemBackground" + android:src="@drawable/icon_chevron_expand" /> +</LinearLayout> diff --git a/android/app/src/main/res/layout/select_location.xml b/android/app/src/main/res/layout/select_location.xml new file mode 100644 index 0000000000..25eebf7648 --- /dev/null +++ b/android/app/src/main/res/layout/select_location.xml @@ -0,0 +1,35 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/darkBlue" + android:gravity="left"> + <TextView android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/select_location" + style="@style/SettingsCollapsedHeader" /> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <FrameLayout android:layout_width="match_parent" + android:layout_height="wrap_content"> + <ImageButton android:id="@+id/close" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:padding="12dp" + android:background="?android:attr/selectableItemBackground" + android:src="@drawable/icon_close" /> + <TextView android:id="@+id/collapsed_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="4dp" + android:layout_gravity="center" + android:text="@string/select_location" + style="@style/SettingsCollapsedHeader" /> + </FrameLayout> + <net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView android:id="@+id/relay_list" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:scrollbars="vertical" /> + </LinearLayout> +</FrameLayout> diff --git a/android/app/src/main/res/layout/select_location_header.xml b/android/app/src/main/res/layout/select_location_header.xml new file mode 100644 index 0000000000..bd7ede2f3c --- /dev/null +++ b/android/app/src/main/res/layout/select_location_header.xml @@ -0,0 +1,32 @@ +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:gravity="left"> + <TextView android:id="@+id/expanded_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginVertical="4dp" + android:layout_marginHorizontal="@dimen/side_margin" + android:lines="1" + android:text="@string/select_location" + style="@style/SettingsExpandedHeader" /> + <TextView android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginHorizontal="@dimen/side_margin" + android:layout_marginBottom="@dimen/vertical_space" + android:textColor="@color/white80" + android:textSize="@dimen/text_small" + android:text="@string/select_location_description" /> + <ProgressBar android:id="@+id/loading_spinner" + android:layout_width="60dp" + android:layout_height="60dp" + android:layout_gravity="center" + android:indeterminate="true" + android:indeterminateOnly="true" + android:indeterminateDuration="600" + android:indeterminateDrawable="@drawable/icon_spinner" + android:visibility="visible" /> +</LinearLayout> diff --git a/android/app/src/main/res/layout/settings.xml b/android/app/src/main/res/layout/settings.xml new file mode 100644 index 0000000000..6e51960e88 --- /dev/null +++ b/android/app/src/main/res/layout/settings.xml @@ -0,0 +1,81 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/darkBlue" + android:gravity="left"> + <TextView android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/settings" + style="@style/SettingsCollapsedHeader" /> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <FrameLayout android:layout_width="match_parent" + android:layout_height="wrap_content"> + <ImageButton android:id="@+id/close" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:padding="12dp" + android:background="?android:attr/selectableItemBackground" + android:src="@drawable/icon_close" /> + <TextView android:id="@+id/collapsed_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="4dp" + android:layout_gravity="center" + android:text="@string/settings" + style="@style/SettingsCollapsedHeader" /> + </FrameLayout> + <net.mullvad.mullvadvpn.ui.widget.ListenableScrollView android:id="@+id/scroll_area" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <LinearLayout android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + <TextView android:id="@+id/expanded_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + android:layout_marginLeft="@dimen/side_margin" + android:lines="1" + android:text="@string/settings" + style="@style/SettingsExpandedHeader" /> + <net.mullvad.mullvadvpn.ui.widget.AccountCell android:id="@+id/account" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/vertical_space" + mullvad:text="@string/settings_account" /> + <net.mullvad.mullvadvpn.ui.widget.NavigateCell android:id="@+id/preferences" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="1dp" + mullvad:text="@string/settings_preferences" /> + <net.mullvad.mullvadvpn.ui.widget.NavigateCell android:id="@+id/advanced" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="1dp" + mullvad:text="@string/settings_advanced" /> + <net.mullvad.mullvadvpn.ui.widget.AppVersionCell android:id="@+id/app_version" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/vertical_space" + mullvad:text="@string/app_version" + mullvad:footer="@string/update_available_footer" /> + <net.mullvad.mullvadvpn.ui.widget.NavigateCell android:id="@+id/report_a_problem" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/vertical_space" + mullvad:text="@string/report_a_problem" /> + <net.mullvad.mullvadvpn.ui.widget.UrlCell android:id="@+id/faqs_and_guides" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="1dp" + mullvad:text="@string/faqs_and_guides" + mullvad:url="@string/faqs_and_guides_url" /> + </LinearLayout> + </net.mullvad.mullvadvpn.ui.widget.ListenableScrollView> + </LinearLayout> +</FrameLayout> diff --git a/android/app/src/main/res/layout/settings_back_button.xml b/android/app/src/main/res/layout/settings_back_button.xml new file mode 100644 index 0000000000..fc750132ac --- /dev/null +++ b/android/app/src/main/res/layout/settings_back_button.xml @@ -0,0 +1,12 @@ +<merge xmlns:android="http://schemas.android.com/apk/res/android"> + <ImageView android:layout_width="24dp" + android:layout_height="24dp" + android:layout_marginRight="8dp" + android:src="@drawable/icon_back" /> + <TextView android:id="@+id/label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="@color/white60" + android:textSize="@dimen/text_small" + android:textStyle="bold" /> +</merge> diff --git a/android/app/src/main/res/layout/split_tunneling.xml b/android/app/src/main/res/layout/split_tunneling.xml new file mode 100644 index 0000000000..9875a25774 --- /dev/null +++ b/android/app/src/main/res/layout/split_tunneling.xml @@ -0,0 +1,34 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/darkBlue" + android:gravity="left"> + <TextView android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/split_tunneling" + style="@style/SettingsCollapsedHeader" /> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <FrameLayout android:layout_width="match_parent" + android:layout_height="wrap_content"> + <net.mullvad.mullvadvpn.ui.widget.BackButton android:id="@+id/back" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + mullvad:text="@string/settings_advanced" /> + <TextView android:id="@+id/collapsed_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="4dp" + android:layout_gravity="center" + android:text="@string/split_tunneling" + style="@style/SettingsCollapsedHeader" /> + </FrameLayout> + <net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView android:id="@+id/app_list" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:scrollbars="vertical" /> + </LinearLayout> +</FrameLayout> diff --git a/android/app/src/main/res/layout/split_tunneling_header.xml b/android/app/src/main/res/layout/split_tunneling_header.xml new file mode 100644 index 0000000000..2f8bc681ce --- /dev/null +++ b/android/app/src/main/res/layout/split_tunneling_header.xml @@ -0,0 +1,58 @@ +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:gravity="left"> + <TextView android:id="@+id/expanded_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginLeft="@dimen/side_margin" + android:layout_marginTop="2dp" + android:layout_marginBottom="12dp" + android:lines="1" + android:text="@string/split_tunneling" + style="@style/SettingsExpandedHeader" /> + <TextView android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingTop="8dp" + android:paddingHorizontal="@dimen/side_margin" + android:text="@string/split_tunneling_description" + android:textColor="@color/white" + android:textSize="@dimen/text_small" /> + <net.mullvad.mullvadvpn.ui.widget.ToggleCell android:id="@+id/enabled" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/vertical_space" + mullvad:text="@string/enable" /> + <LinearLayout android:id="@+id/exclude_applications" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/vertical_space" + android:paddingLeft="@dimen/cell_left_padding" + android:paddingRight="@dimen/cell_right_padding" + android:background="@drawable/cell_button_background" + android:visibility="gone" + android:gravity="center"> + <TextView android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="1" + android:paddingRight="@dimen/cell_inner_spacing" + android:paddingVertical="@dimen/cell_label_vertical_padding" + android:textColor="@color/white" + android:textSize="@dimen/text_medium" + android:textStyle="bold" + android:text="@string/exclude_applications" /> + </LinearLayout> + <ProgressBar android:id="@+id/loading_spinner" + android:layout_width="60dp" + android:layout_height="60dp" + android:layout_gravity="center" + android:layout_marginTop="@dimen/vertical_space" + android:indeterminate="true" + android:indeterminateOnly="true" + android:indeterminateDuration="600" + android:indeterminateDrawable="@drawable/icon_spinner" + android:visibility="gone" /> +</LinearLayout> diff --git a/android/app/src/main/res/layout/switch_location_button.xml b/android/app/src/main/res/layout/switch_location_button.xml new file mode 100644 index 0000000000..d9ed79956f --- /dev/null +++ b/android/app/src/main/res/layout/switch_location_button.xml @@ -0,0 +1,14 @@ +<merge xmlns:android="http://schemas.android.com/apk/res/android"> + <Button android:id="@+id/button_with_label" + android:layout_gravity="bottom" + android:paddingHorizontal="9dp" + android:text="@string/switch_location" + style="@style/White20Button" /> + <Button android:id="@+id/button_with_location" + android:layout_gravity="bottom" + android:paddingHorizontal="9dp" + android:text="@string/switch_location" + android:drawableRight="@drawable/icon_chevron" + android:visibility="invisible" + style="@style/White20Button" /> +</merge> diff --git a/android/app/src/main/res/layout/view_logs.xml b/android/app/src/main/res/layout/view_logs.xml new file mode 100644 index 0000000000..3bf9e615fc --- /dev/null +++ b/android/app/src/main/res/layout/view_logs.xml @@ -0,0 +1,38 @@ +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/darkBlue" + android:gravity="left" + android:orientation="vertical"> + <net.mullvad.mullvadvpn.ui.widget.BackButton android:id="@+id/back" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + mullvad:text="@string/report_a_problem" /> + <TextView android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginTop="2dp" + android:layout_marginHorizontal="@dimen/side_margin" + android:text="@string/view_logs" + style="@style/SettingsExpandedHeader" /> + <ScrollView android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:layout_marginTop="@dimen/vertical_space" + android:layout_marginHorizontal="@dimen/side_margin" + android:layout_marginBottom="@dimen/screen_vertical_margin" + android:scrollbarThumbVertical="@color/blue" + android:background="@drawable/input_text_background"> + <EditText android:id="@+id/log_area" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:editable="false" + android:textIsSelectable="true" + android:singleLine="false" + android:gravity="top" + android:background="@null" + style="@style/InputText" /> + </ScrollView> +</LinearLayout> diff --git a/android/app/src/main/res/layout/welcome.xml b/android/app/src/main/res/layout/welcome.xml new file mode 100644 index 0000000000..e1c887ab96 --- /dev/null +++ b/android/app/src/main/res/layout/welcome.xml @@ -0,0 +1,66 @@ +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <net.mullvad.mullvadvpn.ui.widget.HeaderBar android:id="@+id/header_bar" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> + <ScrollView android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_alignParentBottom="true" + android:layout_below="@id/header_bar" + android:fillViewport="true"> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <TextView android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/side_margin" + android:layout_marginTop="@dimen/screen_vertical_margin" + android:textColor="@color/white" + android:textSize="@dimen/text_huge" + android:textStyle="bold" + android:text="@string/congrats" /> + <TextView android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/side_margin" + android:layout_marginTop="8dp" + android:layout_marginBottom="11dp" + android:textColor="@color/white" + android:textSize="@dimen/text_small" + android:text="@string/here_is_your_account_number" /> + <TextView android:id="@+id/account_number" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingHorizontal="@dimen/side_margin" + android:paddingVertical="11dp" + android:clickable="true" + android:focusable="true" + android:background="?android:attr/selectableItemBackground" + android:textColor="@color/white" + android:textSize="@dimen/text_big" + android:textStyle="bold" + android:text="" /> + <TextView android:id="@+id/pay_to_start_using" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/side_margin" + android:layout_marginTop="11dp" + android:layout_marginBottom="@dimen/vertical_space" + android:textColor="@color/white" + android:textSize="@dimen/text_small" /> + <Space android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" /> + <LinearLayout android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:orientation="vertical" + android:paddingTop="@dimen/button_separation" + android:paddingBottom="@dimen/screen_vertical_margin" + android:background="@color/darkBlue"> + <include layout="@layout/payment_buttons" /> + </LinearLayout> + </LinearLayout> + </ScrollView> +</RelativeLayout> diff --git a/android/app/src/main/res/layout/wireguard_key.xml b/android/app/src/main/res/layout/wireguard_key.xml new file mode 100644 index 0000000000..1300071d98 --- /dev/null +++ b/android/app/src/main/res/layout/wireguard_key.xml @@ -0,0 +1,121 @@ +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/darkBlue" + android:gravity="left"> + <TextView android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/wireguard_key" + style="@style/SettingsCollapsedHeader" /> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <FrameLayout android:layout_width="match_parent" + android:layout_height="wrap_content"> + <net.mullvad.mullvadvpn.ui.widget.BackButton android:id="@+id/back" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + mullvad:text="@string/settings_advanced" /> + <TextView android:id="@+id/collapsed_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="4dp" + android:layout_gravity="center" + android:text="@string/wireguard_key" + style="@style/SettingsCollapsedHeader" /> + </FrameLayout> + <net.mullvad.mullvadvpn.ui.widget.ListenableScrollView android:id="@+id/scroll_area" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:fillViewport="true"> + <LinearLayout android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <TextView android:id="@+id/expanded_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginLeft="@dimen/side_margin" + android:layout_marginTop="2dp" + android:layout_marginBottom="@dimen/half_vertical_space" + android:lines="1" + android:text="@string/wireguard_key" + style="@style/SettingsExpandedHeader" /> + <net.mullvad.mullvadvpn.ui.widget.CopyableInformationView android:id="@+id/public_key" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:paddingHorizontal="@dimen/side_margin" + android:paddingVertical="@dimen/half_vertical_space" + mullvad:clipboardLabel="@string/wireguard_public_key" + mullvad:copiedToast="@string/copied_wireguard_public_key" + mullvad:description="@string/public_key" + mullvad:maxLength="20" + mullvad:whenMissing="showSpinner" /> + <net.mullvad.mullvadvpn.ui.widget.InformationView android:id="@+id/key_age" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:paddingHorizontal="@dimen/side_margin" + android:paddingVertical="@dimen/half_vertical_space" + mullvad:description="@string/wireguard_key_generated" + mullvad:whenMissing="showSpinner" /> + <FrameLayout android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:paddingHorizontal="@dimen/side_margin" + android:paddingTop="@dimen/half_vertical_space" + android:paddingBottom="@dimen/button_separation"> + <TextView android:id="@+id/wireguard_key_status" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="@color/red" + android:textSize="@dimen/text_small" + android:textStyle="bold" + android:visibility="gone" /> + <ProgressBar android:id="@+id/verifying_key_spinner" + android:layout_width="20dp" + android:layout_height="20dp" + android:layout_gravity="center" + android:indeterminate="true" + android:indeterminateOnly="true" + android:indeterminateDuration="600" + android:indeterminateDrawable="@drawable/icon_spinner" + android:visibility="gone" /> + </FrameLayout> + <Space android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" /> + <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/generate_key" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginHorizontal="@dimen/side_margin" + mullvad:buttonColor="green" + mullvad:text="@string/wireguard_generate_key" + mullvad:showSpinner="true" /> + <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/verify_key" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginTop="@dimen/button_separation" + android:layout_marginHorizontal="@dimen/side_margin" + mullvad:buttonColor="blue" + mullvad:text="@string/wireguard_verify_key" + mullvad:showSpinner="true" /> + <net.mullvad.mullvadvpn.ui.widget.UrlButton android:id="@+id/manage_keys" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginTop="@dimen/button_separation" + android:layout_marginBottom="@dimen/screen_vertical_margin" + android:layout_marginHorizontal="@dimen/side_margin" + mullvad:text="@string/wireguard_manage_keys" + mullvad:buttonColor="blue" + mullvad:url="@string/wg_key_url" + mullvad:withToken="true" /> + </LinearLayout> + </net.mullvad.mullvadvpn.ui.widget.ListenableScrollView> + </LinearLayout> +</FrameLayout> diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..5ed0a2df70 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/ic_launcher_background"/> + <foreground android:drawable="@drawable/ic_launcher_foreground"/> +</adaptive-icon> diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..e69d754ff4 --- /dev/null +++ b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..1d53dd86ac --- /dev/null +++ b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..2164b759a6 --- /dev/null +++ b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..c2f5a200c2 --- /dev/null +++ b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..922f83b1db --- /dev/null +++ b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/values-da/plurals.xml b/android/app/src/main/res/values-da/plurals.xml new file mode 100644 index 0000000000..27da560202 --- /dev/null +++ b/android/app/src/main/res/values-da/plurals.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="one">1 dag tilbage</item> + <item quantity="other">%1$d dage tilbage</item> + </plurals> + <plurals name="months_left"> + <item quantity="one">1 måned tilbage</item> + <item quantity="other">%1$d måneder tilbage</item> + </plurals> + <plurals name="years_left"> + <item quantity="one">1 år tilbage</item> + <item quantity="other">%1$d år tilbage</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">en dag siden</item> + <item quantity="other">%1$d dage siden</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="one">et minut siden</item> + <item quantity="other">%1$d minutter siden</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">en måned siden</item> + <item quantity="other">%1$d måneder siden</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">et år siden</item> + <item quantity="other">%1$d år siden</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">en time siden</item> + <item quantity="other">%1$d timer siden</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="one">Kontokredit udløber om en dag</item> + <item quantity="other">Kontokredit udløber om %1$d dage</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="one">Kontokredit udløber om en time</item> + <item quantity="other">Kontokredit udløber om %1$d timer</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-da/strings.xml b/android/app/src/main/res/values-da/strings.xml new file mode 100644 index 0000000000..e522f75061 --- /dev/null +++ b/android/app/src/main/res/values-da/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">Konto oprettet</string> + <string name="account_credit_expires_in_a_few_minutes">Kontokredit udløber om få minutter</string> + <string name="account_credit_expires_soon">Kontokredit udløber snart</string> + <string name="account_credit_has_expired">Du har ikke mere VPN-tid tilbage på denne konto.</string> + <string name="account_number">Kontonummer</string> + <string name="account_time_notification_channel_description">Viser påmindelser, når kontotiden er ved at udløbe</string> + <string name="account_time_notification_channel_name">Påmindelser om kontotid</string> + <string name="add_a_server">Tilføj en server</string> + <string name="add_anyway">Tilføj alligevel</string> + <string name="add_time_to_account">Køb enten kredit på vores hjemmeside, eller indløs en kupon.</string> + <string name="all_applications">Alle applikationer</string> + <string name="allow_lan_footer">Giver adgang til andre enheder på det samme netværk til deling, udskrivning osv.</string> + <string name="app_version">App-version</string> + <string name="auth_failed">Kontogodkendelse mislykkedes.</string> + <string name="auto_connect">Auto-tilslutning</string> + <string name="auto_connect_footer">Opret automatisk forbindelse til en server, når appen starter.</string> + <string name="back">Tilbage</string> + <string name="blocked_connection">FORBINDELSE BLOKERET</string> + <string name="blocking_all_connections">Blokerer alle forbindelser</string> + <string name="blocking_internet">Blokerer internettet</string> + <string name="buy_credit">Køb kredit</string> + <string name="buy_more_credit">Køb mere kredit</string> + <string name="cancel">Annuller</string> + <string name="confirm_local_dns">Den lokale DNS-server fungerer ikke, medmindre du aktiverer \"Lokal netværksdeling\" under Indstillinger.</string> + <string name="confirm_no_email">Du er ved at sende rapporten om problemet, men har ikke angivet hvordan vi kan kontakte dig. Hvis du ønsker et svar på din rapport, skal du indtaste en e-mail-adresse.</string> + <string name="congrats">Tillykke!</string> + <string name="connect">Gør min forbindelse sikker</string> + <string name="connecting">Tilslutter</string> + <string name="connecting_to_daemon">Opretter forbindelse til Mullvad-systemtjeneste...</string> + <string name="copied_mullvad_account_number">Kopierede Mullvad-kontonummer til udklipsholder</string> + <string name="copied_to_clipboard">Kopieret til udklipsholder</string> + <string name="copied_wireguard_public_key">Kopierede offentlig WireGuard-nøgle til udklipsholder</string> + <string name="create_account">Opret konto</string> + <string name="creating_new_account">Opretter konto...</string> + <string name="creating_secure_connection">OPRETTER SIKKER FORBINDELSE</string> + <string name="critical_error">Kritisk fejl (som kræver din opmærksomhed)</string> + <string name="custom_dns_footer">Aktiver for at tilføje mindst én DNS-server.</string> + <string name="custom_dns_hint">Indtast IP</string> + <string name="custom_tunnel_host_resolution_error">Kunne ikke fortolke værtsnavnet på den tilpassede server</string> + <string name="disconnect">Afbryd forbindelse</string> + <string name="disconnecting">Afbryder</string> + <string name="dismiss">Afvis</string> + <string name="dont_have_an_account">Har du ikke noget kontonummer?</string> + <string name="edit_message">Rediger meddelelse</string> + <string name="enable">Aktiver</string> + <string name="enable_custom_dns">Brug brugerdefineret DNS-server</string> + <string name="enter_voucher_code">Indtast kuponkode</string> + <string name="error_occurred">Der opstod en fejl.</string> + <string name="error_state">KUNNE IKKE SIKRE FORBINDELSEN</string> + <string name="exclude_applications">Ekskluderede applikationer</string> + <string name="failed_to_block_internet">Kunne ikke blokere al netværkstrafik. Løs problemet selv, eller rapporter problemet til os.</string> + <string name="failed_to_create_account">Kunne ikke oprette konto</string> + <string name="failed_to_generate_key">Kunne ikke generere en nøgle</string> + <string name="failed_to_send">Kunne ikke sende</string> + <string name="failed_to_send_details">Du skal muligvis vende tilbage til appens hovedskærm og klikke på Afbryd forbindelse, før du prøver igen. Bare rolig, de angivne oplysninger bliver ikke slettet fra formularen.</string> + <string name="faqs_and_guides">Ofte stillede spørgsmål og vejledninger</string> + <string name="foreground_notification_channel_description">Viser den aktuelle VPN-tunnelstatus</string> + <string name="foreground_notification_channel_name">VPN-tunnelstatus</string> + <string name="here_is_your_account_number">Her er dit kontonummer. Gem det!</string> + <string name="hint_default">Standard</string> + <string name="in_address">Ind</string> + <string name="invalid_dns_servers">Tilpassede DNS-serveradresser %1$s er ugyldige</string> + <string name="invalid_voucher">Kuponkode er ugyldig.</string> + <string name="ipv6_unavailable">Kunne ikke konfigurere IPv6</string> + <string name="is_offline">Denne enhed er offline, ingen tunneler kan etableres</string> + <string name="less_than_a_day_left">mindre end én dag tilbage</string> + <string name="less_than_a_minute_ago">mindre end et minut siden</string> + <string name="local_network_sharing">Lokal netværksdeling</string> + <string name="log_out">Log af</string> + <string name="logged_in_title">Logget ind</string> + <string name="logging_in_description">Kontrollerer kontonummer</string> + <string name="logging_in_title">Logger ind...</string> + <string name="login_description">Indtast dit kontonummer</string> + <string name="login_fail_description">Ugyldigt kontonummer</string> + <string name="login_fail_title">Login mislykkedes</string> + <string name="login_title">Log ind</string> + <string name="mullvad_account_number">Mullvad-kontonummer</string> + <string name="no_matching_bridge_relay">Ingen bro-relæserver matcher de aktuelle indstillinger</string> + <string name="no_matching_relay">Ingen relæserver matcher de aktuelle indstillinger</string> + <string name="no_wireguard_key">Gyldig WireGuard-nøgle mangler. Administrer nøgler under Avancerede indstillinger.</string> + <string name="not_blocking_internet">DU LÆKKER MÅSKE NETVÆRKSTRAFIK</string> + <string name="out_address">Ud</string> + <string name="out_of_time">Tid udløbet</string> + <string name="paid_until">Betalt indtil</string> + <string name="pay_to_start_using">For at begynde at bruge appen skal du først føje tid til din konto.</string> + <string name="problem_report_description">For at vi bedre kan hjælpe dig, bedes du vedhæfte din apps logfil til denne meddelelse. Dine data vil forblive sikre og private, da de anonymiseres, før de sendes via en krypteret kanal.</string> + <string name="public_key">Offentlig nøgle</string> + <string name="reconnecting">Genopretter forbindelse</string> + <string name="redeem">Indløs</string> + <string name="redeem_voucher">Indløs kupon</string> + <string name="report_a_problem">Rapporter et problem</string> + <string name="secure_connection">SIKKER TILSLUTNING</string> + <string name="secured">Sikret</string> + <string name="select_location">Vælg placering</string> + <string name="select_location_description">Når du er tilsluttet, maskeres din virkelige placering med en privat og sikker placering i den valgte region.</string> + <string name="send">Send</string> + <string name="send_anyway">Send alligevel</string> + <string name="sending">Sender...</string> + <string name="sent">Sendt</string> + <string name="sent_contact">Hvis det er nødvendigt, kontakter vi dig via %1$s</string> + <string name="sent_thanks">Tak!</string> + <string name="set_dns_error">Kunne ikke indstille systemets DNS-server</string> + <string name="set_firewall_policy_error">Kunne ikke anvende firewall-regler. Enheden er muligvis ikke sikret</string> + <string name="settings">Indstillinger</string> + <string name="settings_account">Konto</string> + <string name="settings_advanced">Avanceret</string> + <string name="settings_preferences">Indstillinger</string> + <string name="show_system_apps">Vis systemapps</string> + <string name="split_tunneling">Split tunneling</string> + <string name="split_tunneling_description">Split tunneling gør det muligt at vælge, hvilke applikationer der ikke skal dirigeres gennem VPN-tunnelen.</string> + <string name="start_tunnel_error">Kunne ikke starte tunnelforbindelsen</string> + <string name="switch_location">Skift placering</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">For mange WireGuard-nøgler er registreret til kontoen</string> + <string name="try_again">Prøv igen</string> + <string name="udp">UDP</string> + <string name="unsecured">Ikke sikret</string> + <string name="unsecured_connection">IKKE-SIKRET FORBINDELSE</string> + <string name="unsupported_version">IKKE-UNDERSTØTTET VERSION</string> + <string name="unsupported_version_description">Du kører en ikke-understøttet appversion. Opgrader til %1$s nu for at styrke din sikkerhed</string> + <string name="unsupported_version_without_upgrade">Du kører en ikke-understøttet appversion.</string> + <string name="update_available">OPDATERING TILGÆNGELIG</string> + <string name="update_available_description">Installer Mullvad VPN (%1$s) for at holde dig opdateret</string> + <string name="update_available_footer">Opdatering tilgængelig, download den for at forblive sikker.</string> + <string name="user_email_hint">Din e-mail (valgfrit)</string> + <string name="user_message_hint">Beskriv dit problem på engelsk eller svensk.</string> + <string name="view_logs">Se app-logfiler</string> + <string name="virtual_adapter_problem">Fejl ved virtuel adapter</string> + <string name="voucher_already_used">Kuponkode er allerede brugt.</string> + <string name="vpn_permission_denied_error">VPN-tilladelse blev nægtet, da tunnelen blev oprettet. Prøv at oprette forbindelse igen.</string> + <string name="we_will_look_into_this">Vi vil undersøge dette.</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">WireGuard-fejl</string> + <string name="wireguard_generate_key">Generer nøgle</string> + <string name="wireguard_key">WireGuard-nøgle</string> + <string name="wireguard_key_generated">Nøgle genereret</string> + <string name="wireguard_key_invalid">Nøglen er ugyldig</string> + <string name="wireguard_key_reconnecting">Opret forbindelse igen med den nye WireGuard-nøgle...</string> + <string name="wireguard_key_valid">Nøglen er gyldig</string> + <string name="wireguard_key_verification_failure">Bekræftelse af nøgle mislykkedes</string> + <string name="wireguard_manage_keys">Administrer nøgler</string> + <string name="wireguard_mtu">WireGuard MTU</string> + <string name="wireguard_mtu_footer">Indstil WireGuard MTU-værdi. Gyldigt interval: %1$d - %2$d.</string> + <string name="wireguard_public_key">Offentlig WireGuard-nøgle</string> + <string name="wireguard_replace_key">Regenerer nøgle</string> + <string name="wireguard_verify_key">Bekræft nøgle</string> +</resources> diff --git a/android/app/src/main/res/values-de/plurals.xml b/android/app/src/main/res/values-de/plurals.xml new file mode 100644 index 0000000000..00d7d559f7 --- /dev/null +++ b/android/app/src/main/res/values-de/plurals.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="one">1 Tag übrig</item> + <item quantity="other">%1$d Tage übrig</item> + </plurals> + <plurals name="months_left"> + <item quantity="one">1 Monat übrig</item> + <item quantity="other">%1$d Monate übrig</item> + </plurals> + <plurals name="years_left"> + <item quantity="one">1 Jahr übrig</item> + <item quantity="other">%1$d Jahre übrig</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">vor einem Tag</item> + <item quantity="other">vor %1$d Tagen</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="one">vor einer Minute</item> + <item quantity="other">vor %1$d Minuten</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">vor einem Monat</item> + <item quantity="other">vor %1$d Monaten</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">vor einem Jahr</item> + <item quantity="other">vor %1$d Jahren</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">vor einer Stunde</item> + <item quantity="other">vor %1$d Stunden</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="one">Kontoguthaben läuft in einem Tag ab</item> + <item quantity="other">Kontoguthaben läuft in %1$d Tagen ab</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="one">Kontoguthaben läuft in einer Stunde ab</item> + <item quantity="other">Kontoguthaben läuft in %1$d Stunden ab</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000000..369c2f7aac --- /dev/null +++ b/android/app/src/main/res/values-de/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">Konto erstellt</string> + <string name="account_credit_expires_in_a_few_minutes">Kontoguthaben läuft in wenigen Minuten ab</string> + <string name="account_credit_expires_soon">Kontoguthaben läuft bald ab</string> + <string name="account_credit_has_expired">Sie haben keine VPN-Zeit mehr auf diesem Konto.</string> + <string name="account_number">Kontonummer</string> + <string name="account_time_notification_channel_description">Erinnerungen anzeigen, wenn die Kontozeit bald abläuft</string> + <string name="account_time_notification_channel_name">Erinnerungen an die Kontozeit</string> + <string name="add_a_server">Server hinzufügen</string> + <string name="add_anyway">Trotzdem hinzufügen</string> + <string name="add_time_to_account">Kaufen Sie entweder Guthaben über unsere Seite oder lösen Sie einen Gutschein ein.</string> + <string name="all_applications">Alle Anwendungen</string> + <string name="allow_lan_footer">Ermöglicht den Zugriff auf andere Geräte im selben Netzwerk zum Teilen von Dateien, Drucken etc.</string> + <string name="app_version">App-Version</string> + <string name="auth_failed">Konto-Authentifizierung fehlgeschlagen.</string> + <string name="auto_connect">Automatische Verbindung</string> + <string name="auto_connect_footer">Stellt automatisch eine Verbindung zum Server her, wenn die App startet.</string> + <string name="back">Zurück</string> + <string name="blocked_connection">GESPERRTE VERBINDUNG</string> + <string name="blocking_all_connections">Alle Verbindungen werden gesperrt</string> + <string name="blocking_internet">Internet wird gesperrt</string> + <string name="buy_credit">Guthaben erwerben</string> + <string name="buy_more_credit">Mehr Guthaben erwerben</string> + <string name="cancel">Abbrechen</string> + <string name="confirm_local_dns">Der lokale DNS-Server wird nicht funktionieren, solange „Teilen im lokalen Netzwerk“ nicht in den Einstellungen aktiviert ist.</string> + <string name="confirm_no_email">Sie wollen einen Problembericht senden, ohne uns die Möglichkeit zu geben, Sie zu erreichen. Wenn Sie sich eine Antwort zu Ihrem Problem wünschen, müssen Sie eine E-Mail-Adresse eingeben.</string> + <string name="congrats">Glückwunsch!</string> + <string name="connect">Meine Verbindung sichern</string> + <string name="connecting">Verbinden</string> + <string name="connecting_to_daemon">Verbindung zum Mullvad-Systemdienst wird hergestellt...</string> + <string name="copied_mullvad_account_number">Mullvad-Kontonummer wurde in die Zwischenablage kopiert</string> + <string name="copied_to_clipboard">In die Zwischenablage kopiert</string> + <string name="copied_wireguard_public_key">Der öffentliche WireGuard Schlüssel wurde in die Zwischenablage kopiert</string> + <string name="create_account">Konto erstellen</string> + <string name="creating_new_account">Konto wird erstellt ...</string> + <string name="creating_secure_connection">SICHERE VERBINDUNG WIRD ERSTELLT</string> + <string name="critical_error">Kritischer Fehler (Ihre Aufmerksamkeit wird erfordert)</string> + <string name="custom_dns_footer">Aktivieren, um mindestens einen DNS-Server hinzuzufügen.</string> + <string name="custom_dns_hint">IP eingeben</string> + <string name="custom_tunnel_host_resolution_error">Der Hostname des benutzerdefinierten Servers konnte nicht aufgelöst werden</string> + <string name="disconnect">Verbindung trennen</string> + <string name="disconnecting">Verbindung wird getrennt</string> + <string name="dismiss">Ausblenden</string> + <string name="dont_have_an_account">Sie haben keine Kontonummer?</string> + <string name="edit_message">Nachricht bearbeiten</string> + <string name="enable">Aktivieren</string> + <string name="enable_custom_dns">Benutzerdefinierten DNS-Server verwenden</string> + <string name="enter_voucher_code">Gutscheincode eingeben</string> + <string name="error_occurred">Ein Fehler ist aufgetreten.</string> + <string name="error_state">SICHERE VERBINDUNG KONNTE NICHT HERGESTELLT WERDEN</string> + <string name="exclude_applications">Ausgeschlossene Anwendungen</string> + <string name="failed_to_block_internet">Sperren des gesamten Netzwerk-Traffics fehlgeschlagen. Bitte beheben Sie den Fehler oder melden Sie uns das Problem.</string> + <string name="failed_to_create_account">Konto konnte nicht erstellt werden</string> + <string name="failed_to_generate_key">Fehler beim Generieren eines Schlüssels</string> + <string name="failed_to_send">Fehler beim Senden</string> + <string name="failed_to_send_details">Möglicherweise müssen Sie zum Hauptbildschirm der App zurückgehen und auf Trennen klicken, bevor Sie es erneut versuchen. Keine Sorge, die eingegebenen Informationen bleiben im Formular gespeichert.</string> + <string name="faqs_and_guides">Häufig gestellte Fragen & Anleitungen</string> + <string name="foreground_notification_channel_description">Zeigt den aktuellen Status des VPN Tunnels an</string> + <string name="foreground_notification_channel_name">Status des VPN-Tunnels</string> + <string name="here_is_your_account_number">Hier ist Ihre Kontonummer. Verlieren Sie sie nicht!</string> + <string name="hint_default">Standard</string> + <string name="in_address">Eingehend</string> + <string name="invalid_dns_servers">Eigene DNS-Server Adressen %1$s sind ungültig</string> + <string name="invalid_voucher">Der Gutscheincode ist ungültig.</string> + <string name="ipv6_unavailable">IPv6 konnte nicht konfiguriert werden</string> + <string name="is_offline">Diese Gerät ist offline, es können keine Tunnel aufgebaut werden</string> + <string name="less_than_a_day_left">weniger als ein Tag übrig</string> + <string name="less_than_a_minute_ago">vor weniger als einer Minute</string> + <string name="local_network_sharing">Teilen im lokalen Netzwerk</string> + <string name="log_out">Abmelden</string> + <string name="logged_in_title">Angemeldet</string> + <string name="logging_in_description">Ihre Kontonummer wird geprüft</string> + <string name="logging_in_title">Anmeldung läuft...</string> + <string name="login_description">Geben Sie Ihre Kontonummer ein</string> + <string name="login_fail_description">Ungültige Kontonummer</string> + <string name="login_fail_title">Anmeldung fehlgeschlagen</string> + <string name="login_title">Anmelden</string> + <string name="mullvad_account_number">Mullvad-Kontonummer</string> + <string name="no_matching_bridge_relay">Kein Bridge-Server stimmt mit den aktuellen Einstellungen überein</string> + <string name="no_matching_relay">Kein Relay-Server stimmt mit den aktuellen Einstellungen überein</string> + <string name="no_wireguard_key">Gültiger WireGuard-Schlüssel fehlt. Sie können Ihre Schlüssel in den erweiterten Einstellungen verwalten.</string> + <string name="not_blocking_internet">IHR NETZVERKEHR KÖNNTE UNSICHER SEIN</string> + <string name="out_address">Ausgehend</string> + <string name="out_of_time">Zeit abgelaufen</string> + <string name="paid_until">Bezahlt bis</string> + <string name="pay_to_start_using">Um mit der Nutzung dieser App zu beginnen, müssen Sie erst einmal Zeit zu Ihrem Konto hinzufügen.</string> + <string name="problem_report_description">Damit wir Ihnen besser helfen können, wird die Protokolldatei Ihrer App an diese Nachricht angehängt. Ihre Daten bleiben sicher und privat, da sie vor dem Senden über einen verschlüsselten Kanal anonymisiert werden.</string> + <string name="public_key">Öffentlicher Schlüssel</string> + <string name="reconnecting">Wiederherstellen der Verbindung</string> + <string name="redeem">Einlösen</string> + <string name="redeem_voucher">Gutschein einlösen</string> + <string name="report_a_problem">Problem melden</string> + <string name="secure_connection">SICHERE VERBINDUNG</string> + <string name="secured">Gesichert</string> + <string name="select_location">Ort auswählen</string> + <string name="select_location_description">Wenn Sie verbunden sind, wird Ihr tatsächlicher Standort durch einem privaten und sicheren Standort in der ausgewählten Region maskiert.</string> + <string name="send">Senden</string> + <string name="send_anyway">Trotzdem senden</string> + <string name="sending">Wird gesendet...</string> + <string name="sent">Gesendet</string> + <string name="sent_contact">Bei Bedarf werden wir Sie über %1$s kontaktieren</string> + <string name="sent_thanks">Danke!</string> + <string name="set_dns_error">Fehler beim Festlegen des System-DNS-Servers</string> + <string name="set_firewall_policy_error">Fehler beim Anwenden der Firewall-Regeln. Das Gerät ist derzeit möglicherweise ungesichert</string> + <string name="settings">Einstellungen</string> + <string name="settings_account">Konto</string> + <string name="settings_advanced">Erweitert</string> + <string name="settings_preferences">Präferenzen</string> + <string name="show_system_apps">Systemapps anzeigen</string> + <string name="split_tunneling">Split Tunneling</string> + <string name="split_tunneling_description">Split-Tunnel-Steuerung macht es möglich, auszuwählen, welche Anwendungen nicht durch den VPN-Tunnel geroutet werden.</string> + <string name="start_tunnel_error">Fehler beim Starten der Tunnel-Verbindung</string> + <string name="switch_location">Ort wechseln</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">Zu viele WireGuard-Schlüssel wurden auf das Konto registriert</string> + <string name="try_again">Erneut versuchen</string> + <string name="udp">UDP</string> + <string name="unsecured">Ungesichert</string> + <string name="unsecured_connection">UNGESICHERTE VERBINDUNG</string> + <string name="unsupported_version">NICHT UNTERSTÜTZTE VERSION</string> + <string name="unsupported_version_description">Sie führen eine nicht unterstützte App-Version aus. Bitte führen Sie jetzt ein Upgrade auf %1$s durch, um Ihre Sicherheit zu gewährleisten</string> + <string name="unsupported_version_without_upgrade">Sie verwenden eine nicht unterstützte Version der App. </string> + <string name="update_available">UPDATE VERFÜGBAR</string> + <string name="update_available_description">Installieren Sie Mullvad VPN (%1$s), um auf dem neuesten Stand zu bleiben</string> + <string name="update_available_footer">Update verfügbar, laden Sie es herunter, um sicher zu bleiben.</string> + <string name="user_email_hint">Ihre E-Mail-Adresse (optional)</string> + <string name="user_message_hint">Bitte beschreiben Sie Ihr Problem auf Englisch oder auf Schwedisch.</string> + <string name="view_logs">App-Protokolle anzeigen</string> + <string name="virtual_adapter_problem">Virtueller Adapterfehler</string> + <string name="voucher_already_used">Der Gutscheincode wurde bereits verwendet.</string> + <string name="vpn_permission_denied_error">VPN-Berechtigungen wurden beim Erstellen des Tunnels abgelehnt.</string> + <string name="we_will_look_into_this">Wir werden uns das anschauen.</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">WireGuard Fehler</string> + <string name="wireguard_generate_key">Schlüssel generieren</string> + <string name="wireguard_key">WireGuard-Schlüssel</string> + <string name="wireguard_key_generated">Schlüssel generiert</string> + <string name="wireguard_key_invalid">Schlüssel ist ungültig</string> + <string name="wireguard_key_reconnecting">Es wird mit einem neuem WireGuard-Schlüssel erneut verbunden ...</string> + <string name="wireguard_key_valid">Schlüssel ist gültig</string> + <string name="wireguard_key_verification_failure">Schlüsselverifikation fehlgeschlagen</string> + <string name="wireguard_manage_keys">Schlüssel verwalten</string> + <string name="wireguard_mtu">WireGuard-MTU</string> + <string name="wireguard_mtu_footer">WireGuard MTU-Wert einstellen. Gültiger Bereich: %1$d – %2$d.</string> + <string name="wireguard_public_key">Öffentlicher WireGuard Schlüssel</string> + <string name="wireguard_replace_key">Schlüssel neu generieren</string> + <string name="wireguard_verify_key">Schlüssel verifizieren</string> +</resources> diff --git a/android/app/src/main/res/values-es/plurals.xml b/android/app/src/main/res/values-es/plurals.xml new file mode 100644 index 0000000000..453d24c542 --- /dev/null +++ b/android/app/src/main/res/values-es/plurals.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="one">Queda 1 día</item> + <item quantity="other">Quedan %1$d días</item> + </plurals> + <plurals name="months_left"> + <item quantity="one">Queda 1 mes</item> + <item quantity="other">Quedan %1$d meses</item> + </plurals> + <plurals name="years_left"> + <item quantity="one">Queda 1 año</item> + <item quantity="other">Quedan %1$d años</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">hace un día</item> + <item quantity="other">hace %1$d días</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="one">hace un minuto</item> + <item quantity="other">hace %1$d minutos</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">hace un mes</item> + <item quantity="other">hace %1$d meses</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">hace un año</item> + <item quantity="other">hace %1$d años</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">hace una hora</item> + <item quantity="other">hace %1$d horas</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="one">El crédito de la cuenta caduca en un día</item> + <item quantity="other">El crédito de la cuenta caduca en %1$d días</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="one">El crédito de la cuenta caduca en una hora</item> + <item quantity="other">El crédito de la cuenta caduca en %1$d horas</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000000..89464c8ee0 --- /dev/null +++ b/android/app/src/main/res/values-es/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">Cuenta creada</string> + <string name="account_credit_expires_in_a_few_minutes">El crédito de la cuenta caduca en unos minutos</string> + <string name="account_credit_expires_soon">El crédito de la cuenta caduca pronto</string> + <string name="account_credit_has_expired">No queda tiempo de uso de VPN en la cuenta.</string> + <string name="account_number">Número de cuenta</string> + <string name="account_time_notification_channel_description">Muestra avisos cuando el tiempo de la cuenta está a punto de caducar</string> + <string name="account_time_notification_channel_name">Recordatorios de tiempo de la cuenta</string> + <string name="add_a_server">Añadir un servidor</string> + <string name="add_anyway">Añadir de todos modos</string> + <string name="add_time_to_account">Compre crédito en nuestro sitio web o canjee un cupón.</string> + <string name="all_applications">Todas las aplicaciones</string> + <string name="allow_lan_footer">Permite el acceso a otros dispositivos de la misma red para compartir archivos, imprimir, etc.</string> + <string name="app_version">Versión de la aplicación</string> + <string name="auth_failed">Error de autenticación de la cuenta.</string> + <string name="auto_connect">Conexión automática</string> + <string name="auto_connect_footer">Al iniciarse la aplicación, se conecta automáticamente a un servidor.</string> + <string name="back">Volver</string> + <string name="blocked_connection">CONEXIÓN BLOQUEADA</string> + <string name="blocking_all_connections">Bloqueando todas las conexiones</string> + <string name="blocking_internet">Internet bloqueado</string> + <string name="buy_credit">Comprar créditos</string> + <string name="buy_more_credit">Comprar más créditos</string> + <string name="cancel">Cancelar</string> + <string name="confirm_local_dns">El servidor DNS local no funcionará a no ser que habilite la opción «Uso compartido de red local» en Preferencias.</string> + <string name="confirm_no_email">Va a enviar el informe de problemas sin indicar una forma de contacto. Para obtener una respuesta sobre el informe, necesita especificar su dirección de correo electrónico.</string> + <string name="congrats">¡Enhorabuena!</string> + <string name="connect">Proteger mi conexión</string> + <string name="connecting">Conectando</string> + <string name="connecting_to_daemon">Conectando al servicio del sistema Mullvad…</string> + <string name="copied_mullvad_account_number">El número de cuenta de Mullvad se copió en el Portapapeles</string> + <string name="copied_to_clipboard">Copiado en el Portapapeles</string> + <string name="copied_wireguard_public_key">La clave pública de WireGuard se copió en el Portapapeles</string> + <string name="create_account">Crear cuenta</string> + <string name="creating_new_account">Creando cuenta…</string> + <string name="creating_secure_connection">CREANDO CONEXIÓN SEGURA</string> + <string name="critical_error">Error crítico (precisa su atención)</string> + <string name="custom_dns_footer">Active esta opción para agregar como mínimo un servidor DNS.</string> + <string name="custom_dns_hint">Escriba la IP</string> + <string name="custom_tunnel_host_resolution_error">No se puede resolver el nombre de host del servidor personalizado</string> + <string name="disconnect">Desconectar</string> + <string name="disconnecting">Desconectando</string> + <string name="dismiss">Descartar</string> + <string name="dont_have_an_account">¿No tiene un número de cuenta?</string> + <string name="edit_message">Editar mensaje</string> + <string name="enable">Habilitar</string> + <string name="enable_custom_dns">Usar servidor DNS personalizado</string> + <string name="enter_voucher_code">Escriba el código del cupón</string> + <string name="error_occurred">Se produjo un error.</string> + <string name="error_state">NO SE PUDO PROTEGER LA CONEXIÓN</string> + <string name="exclude_applications">Aplicaciones excluidas</string> + <string name="failed_to_block_internet">No se puede bloquear todo el tráfico de red. Intente solucionar el problema o póngase en contacto con nosotros.</string> + <string name="failed_to_create_account">No se puede crear la cuenta</string> + <string name="failed_to_generate_key">No se pudo generar una clave</string> + <string name="failed_to_send">No se pudo enviar</string> + <string name="failed_to_send_details">Antes de volver a intentarlo, puede que necesite volver a la pantalla principal de la aplicación y hacer clic en Desconectar. No se preocupe, la información que haya escrito seguirá estando disponible en el formulario.</string> + <string name="faqs_and_guides">Preguntas frecuentes y guías</string> + <string name="foreground_notification_channel_description">Muestra el estado actual del túnel VPN</string> + <string name="foreground_notification_channel_name">Estado del túnel VPN</string> + <string name="here_is_your_account_number">Este es un número de cuenta. ¡Guárdelo bien!</string> + <string name="hint_default">Predeterminado</string> + <string name="in_address">Entrada</string> + <string name="invalid_dns_servers">Las direcciones del servidor DNS personalizado %1$s no son válidas</string> + <string name="invalid_voucher">El código del cupón no es válido.</string> + <string name="ipv6_unavailable">No se puede configurar IPv6</string> + <string name="is_offline">El dispositivo está sin conexión y no puede establecerse ningún túnel</string> + <string name="less_than_a_day_left">queda menos de un día</string> + <string name="less_than_a_minute_ago">hace menos de un minuto</string> + <string name="local_network_sharing">Uso compartido de la red local</string> + <string name="log_out">Cerrar sesión</string> + <string name="logged_in_title">Sesión iniciada</string> + <string name="logging_in_description">Comprobando número de cuenta</string> + <string name="logging_in_title">Iniciando la sesión…</string> + <string name="login_description">Escriba su número de cuenta</string> + <string name="login_fail_description">Número de cuenta no válido</string> + <string name="login_fail_title">Error de inicio de sesión</string> + <string name="login_title">Iniciar sesión</string> + <string name="mullvad_account_number">Número de cuenta de Mullvad</string> + <string name="no_matching_bridge_relay">Ningún servidor de retransmisión de puente coincide con la configuración actual</string> + <string name="no_matching_relay">Ningún servidor de retransmisión coincide con la configuración actual</string> + <string name="no_wireguard_key">Falta una clave de WireGuard válida. Administre las claves en la Configuración avanzada.</string> + <string name="not_blocking_internet">PUEDE QUE SE ESTÉ FILTRANDO EL TRÁFICO DE RED</string> + <string name="out_address">Salida</string> + <string name="out_of_time">Tiempo agotado</string> + <string name="paid_until">Pagado hasta</string> + <string name="pay_to_start_using">Para empezar a usar la aplicación, primero necesita agregar tiempo a su cuenta.</string> + <string name="problem_report_description">Para ayudarle de una forma más eficiente, se adjuntará el archivo de registro de la aplicación a este mensaje. Sus datos permanecerán protegidos y privados, ya que se anonimizan antes de enviarse a través de un canal cifrado.</string> + <string name="public_key">Clave pública</string> + <string name="reconnecting">Volviendo a establecer la conexión</string> + <string name="redeem">Canjear</string> + <string name="redeem_voucher">Canjear cupón</string> + <string name="report_a_problem">Informar de un problema</string> + <string name="secure_connection">CONEXIÓN SEGURA</string> + <string name="secured">Protegido</string> + <string name="select_location">Seleccionar ubicación</string> + <string name="select_location_description">Mientras esté conectado, su ubicación real permanecerá oculta con una ubicación privada y segura en la región seleccionada.</string> + <string name="send">Enviar</string> + <string name="send_anyway">Enviar de todos modos</string> + <string name="sending">Enviando…</string> + <string name="sent">Enviado</string> + <string name="sent_contact">Si es necesario, le enviaremos un correo electrónico a %1$s</string> + <string name="sent_thanks">¡Gracias!</string> + <string name="set_dns_error">No se pudo establecer el servidor DNS del sistema</string> + <string name="set_firewall_policy_error">Error al aplicar las reglas del firewall. Puede que el dispositivo no esté protegido actualmente</string> + <string name="settings">Configuración</string> + <string name="settings_account">Cuenta</string> + <string name="settings_advanced">Avanzadas</string> + <string name="settings_preferences">Preferencias</string> + <string name="show_system_apps">Mostrar aplicaciones del sistema</string> + <string name="split_tunneling">Tunelización dividida</string> + <string name="split_tunneling_description">La tunelización dividida permite seleccionar qué aplicaciones no deben enrutarse a través del túnel VPN.</string> + <string name="start_tunnel_error">No se pudo iniciar la conexión del túnel</string> + <string name="switch_location">Cambiar ubicación</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">Hay demasiadas claves de WireGuard registradas en la cuenta</string> + <string name="try_again">Volver a intentarlo</string> + <string name="udp">UDP</string> + <string name="unsecured">No protegido</string> + <string name="unsecured_connection">CONEXIÓN NO SEGURA</string> + <string name="unsupported_version">VERSIÓN NO ADMITIDA</string> + <string name="unsupported_version_description">Ejecuta una versión no compatible de la aplicación. Actualice a %1$s ahora para garantizar su seguridad</string> + <string name="unsupported_version_without_upgrade">Ejecuta una versión de la aplicación que no es compatible.</string> + <string name="update_available">ACTUALIZACIÓN DISPONIBLE</string> + <string name="update_available_description">Instale Mullvad VPN (%1$s) para seguir actualizado</string> + <string name="update_available_footer">Hay una actualización disponible, descárguela para seguir protegido.</string> + <string name="user_email_hint">Su correo electrónico (opcional)</string> + <string name="user_message_hint">Describa el problema en inglés o sueco.</string> + <string name="view_logs">Ver registros de la aplicación</string> + <string name="virtual_adapter_problem">Error del adaptador virtual</string> + <string name="voucher_already_used">El código del cupón ya se ha usado.</string> + <string name="vpn_permission_denied_error">Se denegó el permiso para usar una conexión VPN al crear el túnel. Intente volver a establecer la conexión.</string> + <string name="we_will_look_into_this">Revisaremos esto.</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">Error de WireGuard</string> + <string name="wireguard_generate_key">Generar clave</string> + <string name="wireguard_key">Clave de WireGuard</string> + <string name="wireguard_key_generated">Clave generada</string> + <string name="wireguard_key_invalid">La clave no es válida</string> + <string name="wireguard_key_reconnecting">Volviendo a conectar con la nueva clave de WireGuard…</string> + <string name="wireguard_key_valid">La clave es válida</string> + <string name="wireguard_key_verification_failure">Error al verificar la clave</string> + <string name="wireguard_manage_keys">Administrar claves</string> + <string name="wireguard_mtu">MTU de WireGuard</string> + <string name="wireguard_mtu_footer">Establezca el valor de MTU de WireGuard. Intervalo válido: %1$d-%2$d.</string> + <string name="wireguard_public_key">Clave pública de WireGuard</string> + <string name="wireguard_replace_key">Volver a generar clave</string> + <string name="wireguard_verify_key">Verificar clave</string> +</resources> diff --git a/android/app/src/main/res/values-fi/plurals.xml b/android/app/src/main/res/values-fi/plurals.xml new file mode 100644 index 0000000000..43c76d8042 --- /dev/null +++ b/android/app/src/main/res/values-fi/plurals.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="one">1 päivä jäljellä</item> + <item quantity="other">%1$d päivää jäljellä</item> + </plurals> + <plurals name="months_left"> + <item quantity="one">1 kuukausi jäljellä</item> + <item quantity="other">%1$d kuukautta jäljellä</item> + </plurals> + <plurals name="years_left"> + <item quantity="one">1 vuosi jäljellä</item> + <item quantity="other">%1$d vuotta jäljellä</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">1 päivä sitten</item> + <item quantity="other">%1$d päivää sitten</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="one">minuutti sitten</item> + <item quantity="other">%1$d minuuttia sitten</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">kuukausi sitten</item> + <item quantity="other">%1$d kuukautta sitten</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">vuosi sitten</item> + <item quantity="other">%1$d vuotta sitten</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">tunti sitten</item> + <item quantity="other">%1$d tuntia sitten</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="one">Tilin käyttöaika päättyy vuorokauden kuluttua</item> + <item quantity="other">Tilin käyttöaika päättyy %1$d vuorokauden kuluttua</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="one">Tilin käyttöaika päättyy tunnin kuluttua</item> + <item quantity="other">Tilin käyttöaika päättyy %1$d tunnin kuluttua</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-fi/strings.xml b/android/app/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000000..83b508d6e8 --- /dev/null +++ b/android/app/src/main/res/values-fi/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">Tili luotu</string> + <string name="account_credit_expires_in_a_few_minutes">Tilin käyttöaika päättyy muutaman minuutin kuluttua</string> + <string name="account_credit_expires_soon">Tilin käyttöaika päättyy pian</string> + <string name="account_credit_has_expired">Sinulla ei ole enempää VPN-aikaa jäljellä tällä tilillä.</string> + <string name="account_number">Tilin numero</string> + <string name="account_time_notification_channel_description">Näyttää muistutuksia, kun tilin käyttöaika on umpeutumassa</string> + <string name="account_time_notification_channel_name">Muistutukset tilin käyttöajasta</string> + <string name="add_a_server">Lisää palvelin</string> + <string name="add_anyway">Lisää silti</string> + <string name="add_time_to_account">Osta käyttöaikaa verkkosivustoltamme tai lunasta kuponki.</string> + <string name="all_applications">Kaikki sovellukset</string> + <string name="allow_lan_footer">Sallii jakamisen, tulostuksen ym. saman verkon muille laitteille.</string> + <string name="app_version">Sovelluksen versio</string> + <string name="auth_failed">Tilin todentaminen epäonnistui.</string> + <string name="auto_connect">Automaattinen yhteys</string> + <string name="auto_connect_footer">Yhdistä palvelimeen automaattisesti, kun sovellus avataan.</string> + <string name="back">Takaisin</string> + <string name="blocked_connection">ESTETTY YHTEYS</string> + <string name="blocking_all_connections">Kaikki yhteydet estetään</string> + <string name="blocking_internet">Verkkoyhteys estetään</string> + <string name="buy_credit">Osta käyttöaikaa</string> + <string name="buy_more_credit">Uudista tilaus</string> + <string name="cancel">Peruuta</string> + <string name="confirm_local_dns">Paikallinen DNS-palvelin ei toimi, ellet ota paikallisen verkon jakamisasetusta käyttöön Asetuksissa.</string> + <string name="confirm_no_email">Olet aikeissa lähettää ongelmaraportin ilman yhteystietojasi. Mikäli haluat vastauksen raporttiisi, anna sähköpostosoite.</string> + <string name="congrats">Onnittelut!</string> + <string name="connect">Suojaa yhteyteni</string> + <string name="connecting">Yhdistetään</string> + <string name="connecting_to_daemon">Yhdistetään Mullvad-järjestelmäpalveluun...</string> + <string name="copied_mullvad_account_number">Mullvad-tilin numero kopioitu leikepöydälle</string> + <string name="copied_to_clipboard">Kopioitu leikepöydälle</string> + <string name="copied_wireguard_public_key">Julkinen WireGuard-avain kopioitu leikepöydälle</string> + <string name="create_account">Luo tili</string> + <string name="creating_new_account">Luodaan tiliä...</string> + <string name="creating_secure_connection">LUODAAN SUOJATTU YHTEYS</string> + <string name="critical_error">Vakava virhe (vaatii huomiotasi)</string> + <string name="custom_dns_footer">Ota käyttöön lisätäksesi vähintään yhden DNS-palvelimen.</string> + <string name="custom_dns_hint">Anna IP-osoite</string> + <string name="custom_tunnel_host_resolution_error">Mukautetun palvelimen isäntänimen selvittäminen epäonnistui</string> + <string name="disconnect">Katkaise yhteys</string> + <string name="disconnecting">Katkaistaan yhteyttä</string> + <string name="dismiss">Jätä huomiotta</string> + <string name="dont_have_an_account">Eikö sinulla ole tilinumeroa?</string> + <string name="edit_message">Muokkaa viestiä</string> + <string name="enable">Ota käyttöön</string> + <string name="enable_custom_dns">Käytä mukautettua DNS-palvelinta</string> + <string name="enter_voucher_code">Syötä kuponkikoodi</string> + <string name="error_occurred">Ilmeni virhe.</string> + <string name="error_state">YHTEYDEN SUOJAAMINEN EPÄONNISTUI</string> + <string name="exclude_applications">Poissuljetut sovellukset</string> + <string name="failed_to_block_internet">Kaiken verkkoliikenteen estäminen epäonnistui. Käytä vianetsintää tai raportoi meille ongelmasta.</string> + <string name="failed_to_create_account">Tilin luonti epäonnistui</string> + <string name="failed_to_generate_key">Avaimen luominen epäonnistui</string> + <string name="failed_to_send">Lähetys epäonnistui</string> + <string name="failed_to_send_details">Ennen kuin yrität uudelleen, sinun on ehkä palattava sovelluksen päänäytölle ja napsautettava yhteyden katkaisua. Älä huoli – syöttämäsi tiedot säilyvät lomakkeella.</string> + <string name="faqs_and_guides">UKK:t ja oppaat</string> + <string name="foreground_notification_channel_description">Näyttää VPN-tunnelin nykyisen tilan</string> + <string name="foreground_notification_channel_name">VPN-tunnelin tila</string> + <string name="here_is_your_account_number">Tässä tulee tilisi numero. Laita se talteen!</string> + <string name="hint_default">Oletus</string> + <string name="in_address">Saapuva</string> + <string name="invalid_dns_servers">Mukautetut DNS-palvelimen osoitteet %1$s ovat virheellisiä</string> + <string name="invalid_voucher">Kuponkikoodi ei kelpaa.</string> + <string name="ipv6_unavailable">IPv6:n määritys epäonnistui</string> + <string name="is_offline">Tämä laite ei ole yhdistetty verkkoon. Tunneleita ei voida luoda.</string> + <string name="less_than_a_day_left">alle vuorokausi jäljellä</string> + <string name="less_than_a_minute_ago">alle minuutti sitten</string> + <string name="local_network_sharing">Paikallisen verkon jakaminen</string> + <string name="log_out">Kirjaudu ulos</string> + <string name="logged_in_title">Kirjautuneena sisään</string> + <string name="logging_in_description">Tarkistetaan tilin numeroa</string> + <string name="logging_in_title">Kirjaudutaan sisään...</string> + <string name="login_description">Syötä tilisi numero</string> + <string name="login_fail_description">Virheellinen tilin numero</string> + <string name="login_fail_title">Sisäänkirjautuminen epäonnistui</string> + <string name="login_title">Kirjaudu sisään</string> + <string name="mullvad_account_number">Mullvad-tilin numero</string> + <string name="no_matching_bridge_relay">Mikään siltausvälityspalvelin ei vastaa nykyisiä asetuksia</string> + <string name="no_matching_relay">Mikään välityspalvelin ei vastaa nykyisiä asetuksia</string> + <string name="no_wireguard_key">Kelvollinen WireGuard-avain puuttuu. Hallinnoi avaimia lisäasetuksissa.</string> + <string name="not_blocking_internet">VERKKOLIIKENTEESI SAATTAA VUOTAA</string> + <string name="out_address">Lähtevä</string> + <string name="out_of_time">Ei käyttöaikaa</string> + <string name="paid_until">Maksu ennen</string> + <string name="pay_to_start_using">Voit aloittaa sovelluksen käyttämisen lisäämällä ensin aikaa tilillesi.</string> + <string name="problem_report_description">Jotta voimme olla avuksi parhaamme mukaan, sovelluksesi lokitiedosto liitetään tähän viestiin. Tietosi pysyvät suojattuina ja yksityisinä, ja ne anonymisoidaan salatun kanavan kautta ennen lähetystä.</string> + <string name="public_key">Julkinen avain</string> + <string name="reconnecting">Yhdistetään uudelleen</string> + <string name="redeem">Lunasta</string> + <string name="redeem_voucher">Lunasta kuponki</string> + <string name="report_a_problem">Raportoi ongelmasta</string> + <string name="secure_connection">SUOJATTU YHTEYS</string> + <string name="secured">Suojattu</string> + <string name="select_location">Valitse sijainti</string> + <string name="select_location_description">Kun yhteys on muodostettu, yksityinen ja suojattu sijainti valitulta alueelta naamioi todellisen sijaintisi.</string> + <string name="send">Lähetä</string> + <string name="send_anyway">Lähetä silti</string> + <string name="sending">Lähetetään...</string> + <string name="sent">Lähetetty</string> + <string name="sent_contact">Tarvittaessa otamme sinuun yhteyttä (yhteydenottotapa: %1$s)</string> + <string name="sent_thanks">Kiitos!</string> + <string name="set_dns_error">Järjestelmän DNS-palvelimen asetus epäonnistui</string> + <string name="set_firewall_policy_error">Palomuurisääntöjä ei voitu ottaa käyttöön. Laite ei välttämättä ole suojattu.</string> + <string name="settings">Asetukset</string> + <string name="settings_account">Tili</string> + <string name="settings_advanced">Lisäasetukset</string> + <string name="settings_preferences">Asetukset</string> + <string name="show_system_apps">Näytä järjestelmäsovellukset</string> + <string name="split_tunneling">Sovelluskohtainen yhdistäminen</string> + <string name="split_tunneling_description">Jaettu tunnelointi antaa mahdollisuuden valita, mitä sovelluksia ei reititetä VPN-tunnelin kautta.</string> + <string name="start_tunnel_error">Tunneliyhteyden muodostaminen epäonnistui</string> + <string name="switch_location">Vaihda sijaintia</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">Tilille on rekisteröity liian monta WireGuard-avainta</string> + <string name="try_again">Yritä uudelleen</string> + <string name="udp">UDP</string> + <string name="unsecured">Suojaamaton</string> + <string name="unsecured_connection">SUOJAAMATON YHTEYS</string> + <string name="unsupported_version">EI-TUETTU VERSIO</string> + <string name="unsupported_version_description">Sovellusversiosi ei ole tuettu. Taataksesi turvallisuutesi päivitä heti versioon %1$s.</string> + <string name="unsupported_version_without_upgrade">Sovellusversiotasi ei tueta.</string> + <string name="update_available">PÄIVITYS SAATAVILLA</string> + <string name="update_available_description">Päivitä asentamalla Mullvad VPN:n versio (%1$s)</string> + <string name="update_available_footer">Päivitys saatavilla. Lataa se pysyäksesi suojattuna.</string> + <string name="user_email_hint">Sähköpostisi (valinnainen)</string> + <string name="user_message_hint">Kuvaile ongelmaasi englanniksi tai ruotsiksi.</string> + <string name="view_logs">Tarkastele sovelluslokeja</string> + <string name="virtual_adapter_problem">Virtuaalisovittimen virhe</string> + <string name="voucher_already_used">Kuponkikoodi on jo käytetty.</string> + <string name="vpn_permission_denied_error">VPN-lupa evättiin tunnelia luotaessa. Yritä muodostaa yhteys uudelleen.</string> + <string name="we_will_look_into_this">Tutkimme asiaa.</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">WireGuard-virhe</string> + <string name="wireguard_generate_key">Luo avain</string> + <string name="wireguard_key">WireGuard-avain</string> + <string name="wireguard_key_generated">Avain luotu</string> + <string name="wireguard_key_invalid">Virheellinen avain</string> + <string name="wireguard_key_reconnecting">Yhdistetään uudelleen uudella WireGuard-avaimella...</string> + <string name="wireguard_key_valid">Kelvollinen avain</string> + <string name="wireguard_key_verification_failure">Avaimen todentaminen epäonnistui</string> + <string name="wireguard_manage_keys">Hallitse avaimia</string> + <string name="wireguard_mtu">WireGuard MTU</string> + <string name="wireguard_mtu_footer">Aseta WireGuardin MTU-arvo väliltä %1$d–%2$d.</string> + <string name="wireguard_public_key">Julkinen WireGuard-avain</string> + <string name="wireguard_replace_key">Luo uusi avain</string> + <string name="wireguard_verify_key">Todenna avain</string> +</resources> diff --git a/android/app/src/main/res/values-fr/plurals.xml b/android/app/src/main/res/values-fr/plurals.xml new file mode 100644 index 0000000000..b708512154 --- /dev/null +++ b/android/app/src/main/res/values-fr/plurals.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="one">1 jour restant</item> + <item quantity="other">%1$d jours restants</item> + </plurals> + <plurals name="months_left"> + <item quantity="one">1 mois restant</item> + <item quantity="other">%1$d mois restants</item> + </plurals> + <plurals name="years_left"> + <item quantity="one">1 an restant</item> + <item quantity="other">%1$d ans restants</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">il y a un jour</item> + <item quantity="other">il y a %1$d jours</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="one">il y a une minute</item> + <item quantity="other">il y a %1$d minutes</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">il y a un mois</item> + <item quantity="other">il y a %1$d mois</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">il y a un an</item> + <item quantity="other">il y a %1$d ans</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">il y a une heure</item> + <item quantity="other">Il y a %1$d heures</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="one">Les crédits du compte expirent dans un jour</item> + <item quantity="other">Les crédits du compte expirent dans %1$d jours</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="one">Les crédits du compte expirent dans une heure</item> + <item quantity="other">Les crédits du compte expirent dans %1$d heures</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000000..c3f5016e0d --- /dev/null +++ b/android/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">Compte créé</string> + <string name="account_credit_expires_in_a_few_minutes">Les crédits du compte expirent dans quelques minutes</string> + <string name="account_credit_expires_soon">Les crédits du compte expirent bientôt</string> + <string name="account_credit_has_expired">Vous n\'avez plus de temps de VPN sur ce compte.</string> + <string name="account_number">Numéro de compte</string> + <string name="account_time_notification_channel_description">Affiche des rappels lorsque le temps du compte va expirer</string> + <string name="account_time_notification_channel_name">Rappels de temps pour le compte</string> + <string name="add_a_server">Ajouter un serveur</string> + <string name="add_anyway">Ajouter quand même</string> + <string name="add_time_to_account">Achetez du crédit sur notre site web ou échangez un bon.</string> + <string name="all_applications">Toutes les applications</string> + <string name="allow_lan_footer">Autorise l\'accès aux autres appareils sur le même réseau pour partager, imprimer, etc.</string> + <string name="app_version">Version de l\'application</string> + <string name="auth_failed">Connexion au compte échouée.</string> + <string name="auto_connect">Connexion automatique</string> + <string name="auto_connect_footer">Connexion automatique à un serveur dès le démarrage de l\'application.</string> + <string name="back">Retour</string> + <string name="blocked_connection">CONNEXION BLOQUÉE</string> + <string name="blocking_all_connections">Blocage de toutes les connexions</string> + <string name="blocking_internet">Internet bloqué</string> + <string name="buy_credit">Acheter des crédits</string> + <string name="buy_more_credit">Acheter plus de crédits</string> + <string name="cancel">Annuler</string> + <string name="confirm_local_dns">Le serveur DNS local ne fonctionnera pas si vous n\'activez pas le « Partage du réseau local » dans les préférences.</string> + <string name="confirm_no_email">Vous êtes sur le point d\'envoyer un signalement de problème sans nous fournir un moyen de vous contacter. Si vous désirez une réponse à votre signalement, vous devez saisir une adresse e-mail.</string> + <string name="congrats">Félicitations !</string> + <string name="connect">Sécuriser ma connexion</string> + <string name="connecting">Connexion</string> + <string name="connecting_to_daemon">Connexion au service système Mullvad...</string> + <string name="copied_mullvad_account_number">Numéro de compte Mullvad copié dans le presse-papiers</string> + <string name="copied_to_clipboard">Copié dans le presse-papiers</string> + <string name="copied_wireguard_public_key">Clé WireGuard publique copiée dans le presse-papiers</string> + <string name="create_account">Créer un compte</string> + <string name="creating_new_account">Création du compte…</string> + <string name="creating_secure_connection">CRÉATION D\'UNE CONNEXION SÉCURISÉE</string> + <string name="critical_error">Erreur critique (votre attention est requise)</string> + <string name="custom_dns_footer">Activez pour ajouter au moins un serveur DNS.</string> + <string name="custom_dns_hint">Saisir l\'IP</string> + <string name="custom_tunnel_host_resolution_error">Échec de la résolution du nom d\'hôte du serveur personnalisé</string> + <string name="disconnect">Déconnexion</string> + <string name="disconnecting">Déconnexion en cours</string> + <string name="dismiss">Ignorer</string> + <string name="dont_have_an_account">Vous n\'avez pas de numéro de compte ?</string> + <string name="edit_message">Modifier le message</string> + <string name="enable">Activer</string> + <string name="enable_custom_dns">Utiliser un serveur DNS personnalisé</string> + <string name="enter_voucher_code">Saisir un code de bon</string> + <string name="error_occurred">Une erreur est survenue.</string> + <string name="error_state">ÉCHEC DE LA SÉCURISATION DE LA CONNEXION</string> + <string name="exclude_applications">Applications exclues</string> + <string name="failed_to_block_internet">Impossible de bloquer tout le trafic du réseau. Veuillez dépanner ou nous signaler le problème.</string> + <string name="failed_to_create_account">Échec de la création du compte</string> + <string name="failed_to_generate_key">Échec de la génération de clé</string> + <string name="failed_to_send">Échec de l\'envoi</string> + <string name="failed_to_send_details">Vous allez peut-être devoir retourner à l\'écran principal de l\'application pour cliquer sur Déconnexion avant de réessayer. Ne vous inquiétez pas, les informations saisies resteront dans le formulaire.</string> + <string name="faqs_and_guides">FAQ et guides</string> + <string name="foreground_notification_channel_description">Affiche l\'état actuel du tunnel VPN</string> + <string name="foreground_notification_channel_name">État du tunnel VPN</string> + <string name="here_is_your_account_number">Voici votre numéro de compte. Gardez-le !</string> + <string name="hint_default">Par défaut</string> + <string name="in_address">Entrante</string> + <string name="invalid_dns_servers">Les adresses de serveur DNS personnalisées %1$s ne sont pas valides</string> + <string name="invalid_voucher">Le code du bon n\'est pas valide.</string> + <string name="ipv6_unavailable">Impossible de configurer IPv6</string> + <string name="is_offline">Cet appareil est hors ligne. Aucun tunnel ne peut être établi</string> + <string name="less_than_a_day_left">moins d\'un jour restant</string> + <string name="less_than_a_minute_ago">il y a moins d\'une minute</string> + <string name="local_network_sharing">Partage réseau local</string> + <string name="log_out">Déconnexion</string> + <string name="logged_in_title">Connecté</string> + <string name="logging_in_description">Vérification du numéro de compte</string> + <string name="logging_in_title">Connexion...</string> + <string name="login_description">Saisissez votre numéro de compte</string> + <string name="login_fail_description">Numéro de compte non valide</string> + <string name="login_fail_title">Échec de la connexion</string> + <string name="login_title">Connexion</string> + <string name="mullvad_account_number">Numéro de compte Mullvad</string> + <string name="no_matching_bridge_relay">Aucun serveur de passerelle relais ne correspond à vos paramètres actuels</string> + <string name="no_matching_relay">Aucun serveur de relais ne correspond à vos paramètres actuels</string> + <string name="no_wireguard_key">Une clé wireguard valide manque. Gérez les clés dans les paramètre avancés.</string> + <string name="not_blocking_internet">VOUS POURRIEZ AVOIR DES FUITES DE TRAFIC RÉSEAU</string> + <string name="out_address">Sortante</string> + <string name="out_of_time">Plus de temps</string> + <string name="paid_until">Payé jusqu\'au</string> + <string name="pay_to_start_using">Pour commencer à utiliser l\'application, vous devez d\'abord ajouter du temps à votre compte.</string> + <string name="problem_report_description">Pour mieux vous aider, le fichier journal de l\'application est joint à ce message. Vos données restent privées et en sécurité dans la mesure où elles sont rendues anonymes avant d\'être envoyées via un canal chiffré.</string> + <string name="public_key">Clé publique</string> + <string name="reconnecting">Reconnexion</string> + <string name="redeem">Échanger</string> + <string name="redeem_voucher">Échanger un bon</string> + <string name="report_a_problem">Signaler un problème</string> + <string name="secure_connection">CONNEXION SÉCURISÉE</string> + <string name="secured">Sécurisé</string> + <string name="select_location">Sélectionner une localisation</string> + <string name="select_location_description">Quand vous êtes connecté, votre vraie localisation est masquée par une localisation privée et sécurisée de la région sélectionnée.</string> + <string name="send">Envoyer</string> + <string name="send_anyway">Envoyer quand même</string> + <string name="sending">Envoi...</string> + <string name="sent">Envoyé</string> + <string name="sent_contact">Si nécessaire, nous vous contacterons sur %1$s</string> + <string name="sent_thanks">Merci !</string> + <string name="set_dns_error">Échec de la configuration du serveur DNS système</string> + <string name="set_firewall_policy_error">Échec de l\'application des règles du pare-feu. L\'appareil est potentiellement non sécurisé à l\'heure actuelle</string> + <string name="settings">Paramètres</string> + <string name="settings_account">Compte</string> + <string name="settings_advanced">Avancé</string> + <string name="settings_preferences">Préférences</string> + <string name="show_system_apps">Afficher les applications système</string> + <string name="split_tunneling">Split tunneling</string> + <string name="split_tunneling_description">Le split tunneling permet de sélectionner quelles applications ne doivent pas passer par le tunnel VPN.</string> + <string name="start_tunnel_error">Échec du démarrage de la connexion tunnel</string> + <string name="switch_location">Changer de localisation</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">Trop de clés WireGuard enregistrées sur le compte</string> + <string name="try_again">Réessayer</string> + <string name="udp">UDP</string> + <string name="unsecured">Non sécurisé</string> + <string name="unsecured_connection">CONNEXION NON SÉCURISÉE</string> + <string name="unsupported_version">VERSION NON PRISE EN CHARGE</string> + <string name="unsupported_version_description">Vous utilisez une version d\'application non prise en charge. Veuillez installer maintenant la version %1$s pour assurer votre sécurité</string> + <string name="unsupported_version_without_upgrade">Vous utilisez une version de l\'application non prise en charge.</string> + <string name="update_available">MISE À JOUR DISPONIBLE</string> + <string name="update_available_description">Installez Mullvad VPN (%1$s) pour rester à jour</string> + <string name="update_available_footer">Mise à jour disponible. Téléchargez-la pour rester en sécurité.</string> + <string name="user_email_hint">Votre e-mail (facultatif)</string> + <string name="user_message_hint">Veuillez décrire votre problème en anglais ou en suédois.</string> + <string name="view_logs">Afficher les journaux de l\'application</string> + <string name="virtual_adapter_problem">Erreur d\'adaptateur virtuel</string> + <string name="voucher_already_used">Le code du bon a déjà été utilisé.</string> + <string name="vpn_permission_denied_error">La permission VPN a été refusée lors de la création du tunnel. Veuillez essayer de vous reconnecter.</string> + <string name="we_will_look_into_this">Nous allons nous pencher dessus.</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">Erreur WireGuard</string> + <string name="wireguard_generate_key">Générer la clé</string> + <string name="wireguard_key">Clé WireGuard</string> + <string name="wireguard_key_generated">Clé générée</string> + <string name="wireguard_key_invalid">La clé est invalide</string> + <string name="wireguard_key_reconnecting">Reconnexion avec la nouvelle clé WireGuard…</string> + <string name="wireguard_key_valid">La clé est valide</string> + <string name="wireguard_key_verification_failure">Échec de la vérification de la clé</string> + <string name="wireguard_manage_keys">Gérer les clés</string> + <string name="wireguard_mtu">MTU WireGuard</string> + <string name="wireguard_mtu_footer">Définir la valeur MTU WireGuard. Plage valide : %1$d - %2$d.</string> + <string name="wireguard_public_key">Clé WireGuard publique</string> + <string name="wireguard_replace_key">Regénérer la clé</string> + <string name="wireguard_verify_key">Vérifier la clé</string> +</resources> diff --git a/android/app/src/main/res/values-it/plurals.xml b/android/app/src/main/res/values-it/plurals.xml new file mode 100644 index 0000000000..4fec91b5ba --- /dev/null +++ b/android/app/src/main/res/values-it/plurals.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="one">1 giorno rimanente</item> + <item quantity="other">%1$d giorni rimanenti</item> + </plurals> + <plurals name="months_left"> + <item quantity="one">1 mese rimanente</item> + <item quantity="other">%1$d mesi rimanenti</item> + </plurals> + <plurals name="years_left"> + <item quantity="one">1 anno rimanente</item> + <item quantity="other">%1$d anni rimanenti</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">un giorno fa</item> + <item quantity="other">%1$d giorni fa</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="one">un minuto fa</item> + <item quantity="other">%1$d minuti fa</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">un mese fa</item> + <item quantity="other">%1$d mesi fa</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">un anno fa</item> + <item quantity="other">%1$d anni fa</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">un\'ora fa</item> + <item quantity="other">%1$d ore fa</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="one">Il credito dell\'account scade tra un giorno</item> + <item quantity="other">Il credito dell\'account scade tra %1$d giorni</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="one">Il credito dell\'account scade tra un\'ora</item> + <item quantity="other">Il credito dell\'account scade tra %1$d ore</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml new file mode 100644 index 0000000000..4d102a5dfa --- /dev/null +++ b/android/app/src/main/res/values-it/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">Account creato</string> + <string name="account_credit_expires_in_a_few_minutes">Il credito dell\'account scadrà tra pochi minuti</string> + <string name="account_credit_expires_soon">Il credito dell\'account scadrà presto</string> + <string name="account_credit_has_expired">Hai esaurito il tempo VPN su questo account.</string> + <string name="account_number">Numero di account</string> + <string name="account_time_notification_channel_description">Mostra promemoria quando il tempo dell\'account sta per scadere</string> + <string name="account_time_notification_channel_name">Promemoria temporali per l\'account</string> + <string name="add_a_server">Aggiungi un server</string> + <string name="add_anyway">Aggiungi comunque</string> + <string name="add_time_to_account">Acquista credito sul nostro sito web o riscatta un voucher.</string> + <string name="all_applications">Tutte le applicazioni</string> + <string name="allow_lan_footer">Consenti l\'accesso ad altri dispositivi sulla stessa rete per condividere, stampare e altro.</string> + <string name="app_version">Versione app</string> + <string name="auth_failed">Autenticazione account non riuscita.</string> + <string name="auto_connect">Connessione automatica</string> + <string name="auto_connect_footer">Connettiti automaticamente al server all\'avvio dell\'app.</string> + <string name="back">Indietro</string> + <string name="blocked_connection">CONNESSIONE BLOCCATA</string> + <string name="blocking_all_connections">Blocco di tutte le connessioni</string> + <string name="blocking_internet">Blocco di Internet</string> + <string name="buy_credit">Acquista credito</string> + <string name="buy_more_credit">Acquista altro credito</string> + <string name="cancel">Annulla</string> + <string name="confirm_local_dns">Il server DNS locale non funzionerà a meno che non si abiliti \"Condivisione rete locale\" in Preferenze.</string> + <string name="confirm_no_email">Stai inviando la segnalazione di un problema senza averci indicato un modo per ricontattarti. Se desideri ricevere risposta, inserisci un indirizzo e-mail.</string> + <string name="congrats">Complimenti!</string> + <string name="connect">Proteggi mia connessione</string> + <string name="connecting">Connessione</string> + <string name="connecting_to_daemon">Connessione al servizio del sistema Mullvad...</string> + <string name="copied_mullvad_account_number">Numero account di Mullvad copiato negli appunti</string> + <string name="copied_to_clipboard">Copiato negli appunti</string> + <string name="copied_wireguard_public_key">Chiave pubblica WireGuard copiata negli appunti</string> + <string name="create_account">Crea account</string> + <string name="creating_new_account">Creazione account...</string> + <string name="creating_secure_connection">CREAZIONE CONNESSIONE PROTETTA</string> + <string name="critical_error">Errore critico (è necessario intervenire)</string> + <string name="custom_dns_footer">Abilita per aggiungere almeno un server DNS.</string> + <string name="custom_dns_hint">Inserisci IP</string> + <string name="custom_tunnel_host_resolution_error">Impossibile risolvere il nome host del server personalizzato</string> + <string name="disconnect">Disconnetti</string> + <string name="disconnecting">Disconnessione</string> + <string name="dismiss">Ignora</string> + <string name="dont_have_an_account">Non hai un numero di account?</string> + <string name="edit_message">Modifica messaggio</string> + <string name="enable">Abilita</string> + <string name="enable_custom_dns">Usa un server DNS personalizzato</string> + <string name="enter_voucher_code">Inserisci codice voucher</string> + <string name="error_occurred">Si è verificato un errore.</string> + <string name="error_state">IMPOSSIBILE STABILIRE UNA CONNESSIONE PROTETTA</string> + <string name="exclude_applications">Applicazioni escluse</string> + <string name="failed_to_block_internet">Impossibile bloccare tutto il traffico di rete. Risolvi i problemi o segnalaceli.</string> + <string name="failed_to_create_account">Impossibile creare l\'account</string> + <string name="failed_to_generate_key">Impossibile generare una chiave</string> + <string name="failed_to_send">Impossibile inviare</string> + <string name="failed_to_send_details">Prima di riprovare, potresti dover tornare alla schermata principale dell\'app e fare clic su Disconnetti. Non preoccuparti: le informazioni inserite rimarranno nel modulo.</string> + <string name="faqs_and_guides">FAQ e guide</string> + <string name="foreground_notification_channel_description">Mostra lo stato attuale del tunnel VPN</string> + <string name="foreground_notification_channel_name">Stato del tunnel VPN</string> + <string name="here_is_your_account_number">Ecco il tuo numero di account. Salvalo!</string> + <string name="hint_default">Predefinito</string> + <string name="in_address">Ricezione</string> + <string name="invalid_dns_servers">Gli indirizzi del server DNS personalizzato %1$s non sono validi</string> + <string name="invalid_voucher">Il codice voucher non è valido.</string> + <string name="ipv6_unavailable">Impossibile configurare IPv6</string> + <string name="is_offline">Il dispositivo è offline. Impossibile stabilire tunnel</string> + <string name="less_than_a_day_left">meno di un giorno rimanente</string> + <string name="less_than_a_minute_ago">meno di un minuto fa</string> + <string name="local_network_sharing">Condivisione rete locale</string> + <string name="log_out">Esci</string> + <string name="logged_in_title">Accesso effettuato</string> + <string name="logging_in_description">Verifica numero di account</string> + <string name="logging_in_title">Accesso...</string> + <string name="login_description">Inserisci il tuo numero di account</string> + <string name="login_fail_description">Numero di account non valido</string> + <string name="login_fail_title">Accesso non riuscito</string> + <string name="login_title">Accedi</string> + <string name="mullvad_account_number">Numero di account Mullvad</string> + <string name="no_matching_bridge_relay">Nessun bridge relay server corrispondente alle impostazioni correnti</string> + <string name="no_matching_relay">Nessun relay server corrispondente alle impostazioni correnti</string> + <string name="no_wireguard_key">Manca una chiave WireGuard valida. Gestisci le chiavi da Impostazioni avanzate.</string> + <string name="not_blocking_internet">POSSIBILI PERDITE NEL TRAFFICO DI RETE</string> + <string name="out_address">Invio</string> + <string name="out_of_time">Scaduto</string> + <string name="paid_until">Pagato fino al</string> + <string name="pay_to_start_using">Per iniziare a utilizzare l\'app, devi prima aggiungere tempo al tuo account.</string> + <string name="problem_report_description">Per aiutarti in modo più efficace, il file di registro della tua app sarà allegato a questo messaggio. I tuoi dati rimarranno protetti e privati, e saranno anonimizzati prima di essere inviati tramite un canale crittografato.</string> + <string name="public_key">Chiave pubblica</string> + <string name="reconnecting">Riconnessione</string> + <string name="redeem">Riscatta</string> + <string name="redeem_voucher">Riscatta voucher</string> + <string name="report_a_problem">Segnala un problema</string> + <string name="secure_connection">CONNESSIONE PROTETTA</string> + <string name="secured">Protetto</string> + <string name="select_location">Seleziona posizione</string> + <string name="select_location_description">Durante la connessione, la tua posizione reale è nascosta da una posizione privata e protetta nell\'area selezionata.</string> + <string name="send">Invia</string> + <string name="send_anyway">Invia comunque</string> + <string name="sending">Invio...</string> + <string name="sent">Inviato</string> + <string name="sent_contact">Se necessario, ti contatteremo all\'indirizzo %1$s</string> + <string name="sent_thanks">Grazie!</string> + <string name="set_dns_error">Impossibile impostare il server DNS di sistema</string> + <string name="set_firewall_policy_error">Impossibile applicare le regole firewall. Il dispositivo potrebbe essere non protetto</string> + <string name="settings">Impostazioni</string> + <string name="settings_account">Account</string> + <string name="settings_advanced">Avanzate</string> + <string name="settings_preferences">Preferenze</string> + <string name="show_system_apps">Mostra app di sistema</string> + <string name="split_tunneling">Split tunneling</string> + <string name="split_tunneling_description">Lo split tunneling consente di selezionare quali applicazioni non devono essere instradate attraverso il tunnel VPN.</string> + <string name="start_tunnel_error">Impossibile avviare la connessione tunnel</string> + <string name="switch_location">Cambia posizione</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">Troppe chiavi WireGuard registrate per l\'account</string> + <string name="try_again">Riprova</string> + <string name="udp">UDP</string> + <string name="unsecured">Non protetto</string> + <string name="unsecured_connection">CONNESSIONE NON PROTETTA</string> + <string name="unsupported_version">VERSIONE NON SUPPORTATA</string> + <string name="unsupported_version_description">Stai utilizzando una versione dell\'app non supportata. Aggiorna alla versione %1$s per continuare a essere protetto</string> + <string name="unsupported_version_without_upgrade">Stai eseguendo una versione non supportata dell\'app.</string> + <string name="update_available">AGGIORNAMENTO DISPONIBILE</string> + <string name="update_available_description">Installa Mullvad VPN (%1$s) per rimanere aggiornato</string> + <string name="update_available_footer">Aggiornamento disponibile; scarica per rimanere protetto.</string> + <string name="user_email_hint">La tua e-mail (opzionale)</string> + <string name="user_message_hint">Descrivi il tuo problema in inglese o svedese.</string> + <string name="view_logs">Visualizza registri app</string> + <string name="virtual_adapter_problem">Errore scheda virtuale</string> + <string name="voucher_already_used">Il codice voucher è già stato utilizzato.</string> + <string name="vpn_permission_denied_error">L\'autorizzazione VPN è stata negata durante la creazione del tunnel. Prova a connetterti di nuovo.</string> + <string name="we_will_look_into_this">Verificheremo.</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">Errore WireGuard</string> + <string name="wireguard_generate_key">Genera chiave</string> + <string name="wireguard_key">Chiave WireGuard</string> + <string name="wireguard_key_generated">Chiave generata</string> + <string name="wireguard_key_invalid">La chiave non è valida</string> + <string name="wireguard_key_reconnecting">Riconnessione con nuova chiave WireGuard...</string> + <string name="wireguard_key_valid">La chiave è valida</string> + <string name="wireguard_key_verification_failure">Verifica chiave non riuscita</string> + <string name="wireguard_manage_keys">Gestisci chiavi</string> + <string name="wireguard_mtu">MTU WireGuard</string> + <string name="wireguard_mtu_footer">Imposta il valore MTU WireGuard. Intervallo valido: %1$d - %2$d.</string> + <string name="wireguard_public_key">Chiave pubblica WireGuard</string> + <string name="wireguard_replace_key">Rigenera chiave</string> + <string name="wireguard_verify_key">Verifica chiave</string> +</resources> diff --git a/android/app/src/main/res/values-ja/plurals.xml b/android/app/src/main/res/values-ja/plurals.xml new file mode 100644 index 0000000000..9ae8cb2365 --- /dev/null +++ b/android/app/src/main/res/values-ja/plurals.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="other">残り1日\\n\\n残り%1$d日</item> + </plurals> + <plurals name="months_left"> + <item quantity="other">残り1ヶ月\\n\\n残り%1$d ヶ月</item> + </plurals> + <plurals name="years_left"> + <item quantity="other">残り1年\\n\\n残り%1$d年</item> + </plurals> + <plurals name="days_ago"> + <item quantity="other">1日前\\n\\n%1$d 日前</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="other">残り1分\\n\\n残り%1$d 分</item> + </plurals> + <plurals name="months_ago"> + <item quantity="other">1ヶ月前\\n\\n%1$d ヶ月前</item> + </plurals> + <plurals name="years_ago"> + <item quantity="other">1年前\\n\\n%1$d 年前</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="other">1時間前\\n\\n%1$d時間前</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="other">アカウントのクレジットが1日後に無効になります\\n\\nアカウントのクレジットが%1$d日後に無効になります</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="other">アカウントのクレジットが1時間後に無効になります\\n\\nアカウントのクレジットが%1$d時間後に無効になります</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-ja/strings.xml b/android/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000000..ab61aa47dd --- /dev/null +++ b/android/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">アカウントを作成しました</string> + <string name="account_credit_expires_in_a_few_minutes">アカウントクレジットは数分後に無効になります</string> + <string name="account_credit_expires_soon">アカウントのクレジットがもうすぐ無効になります</string> + <string name="account_credit_has_expired">このアカウントには、もう残っているVPN時間がありません。</string> + <string name="account_number">アカウント番号</string> + <string name="account_time_notification_channel_description">アカウントの期限切れが迫っているときにリマインダーを表示します</string> + <string name="account_time_notification_channel_name">アカウント時間のリマインダー</string> + <string name="add_a_server">サーバーを追加</string> + <string name="add_anyway">追加を続ける</string> + <string name="add_time_to_account">当社ウェブサイトでクレジットを購入するか、バウチャーを使用してください。</string> + <string name="all_applications">すべてのアプリケーション</string> + <string name="allow_lan_footer">共有や印刷のため、同一ネットワーク上の他のデバイスへのアクセスを許可します。</string> + <string name="app_version">アプリのバージョン</string> + <string name="auth_failed">アカウントの認証に失敗しました。</string> + <string name="auto_connect">自動接続</string> + <string name="auto_connect_footer">アプリ起動時に自動的にサーバーに接続します。</string> + <string name="back">戻る</string> + <string name="blocked_connection">ブロックされた接続</string> + <string name="blocking_all_connections">すべての接続をブロックしています</string> + <string name="blocking_internet">インターネットをブロック中</string> + <string name="buy_credit">クレジットを購入</string> + <string name="buy_more_credit">追加クレジットを購入</string> + <string name="cancel">キャンセル</string> + <string name="confirm_local_dns">環境設定で「ローカルネットワーク共有」を有効にしない限り、ローカルDNSサーバーは機能しません。</string> + <string name="confirm_no_email">お客様への返信先を入力せずに問題の報告を送信しようとしています。ご報告に対する返信が必要な場合は、返信先のメールアドレスを入力する必要があります。</string> + <string name="congrats">おめでとうございます!</string> + <string name="connect">接続を保護する</string> + <string name="connecting">接続中</string> + <string name="connecting_to_daemon">Mullvad システムサービスに接続中...</string> + <string name="copied_mullvad_account_number">Mullvadアカウント番号をクリップボードにコピーしました</string> + <string name="copied_to_clipboard">クリップボードにコピーしました</string> + <string name="copied_wireguard_public_key">WireGuard公開鍵をクリップボードにコピーしました</string> + <string name="create_account">アカウントを作成する</string> + <string name="creating_new_account">アカウントを作成中...</string> + <string name="creating_secure_connection">セキュリティ保護接続を確立中</string> + <string name="critical_error">重大なエラー (ご注意ください)</string> + <string name="custom_dns_footer">1つ以上のDNSサーバーを追加できるようにします。</string> + <string name="custom_dns_hint">IP を入力</string> + <string name="custom_tunnel_host_resolution_error">カスタムサーバーのホスト名を解決できませんでした</string> + <string name="disconnect">接続解除</string> + <string name="disconnecting">接続解除中</string> + <string name="dismiss">閉じる</string> + <string name="dont_have_an_account">アカウント番号を持っていませんか?</string> + <string name="edit_message">メッセージを編集する</string> + <string name="enable">有効化する</string> + <string name="enable_custom_dns">カスタムDNSサーバーを使う</string> + <string name="enter_voucher_code">バウチャーコードを入力</string> + <string name="error_occurred">エラー発生。</string> + <string name="error_state">セキュリティ保護接続を確立できませんでした</string> + <string name="exclude_applications">除外対象アプリケーション</string> + <string name="failed_to_block_internet">すべてのネットワーク通信をブロックできませんでした。問題に対処するか、当社に問題を報告してください。</string> + <string name="failed_to_create_account">アカウントを作成できませんでした</string> + <string name="failed_to_generate_key">鍵の生成に失敗しました</string> + <string name="failed_to_send">送信に失敗しました</string> + <string name="failed_to_send_details">再試行する前に、アプリのメイン画面に戻って [接続解除] をクリックする必要があるかもしれません。すでにフォームに入力された情報が失われることはありませんので、ご安心ください。</string> + <string name="faqs_and_guides">よくある質問とガイド</string> + <string name="foreground_notification_channel_description">現在のVPNトンネルのステータスを表示します</string> + <string name="foreground_notification_channel_name">VPNトンネルのステータス</string> + <string name="here_is_your_account_number">これがあなたのアカウント番号です。保存してください!</string> + <string name="hint_default">デフォルト</string> + <string name="in_address">内側</string> + <string name="invalid_dns_servers">カスタムDNSサーバーアドレス %1$s は無効です</string> + <string name="invalid_voucher">バウチャーコードが無効です。</string> + <string name="ipv6_unavailable">IPv6を設定できませんでした</string> + <string name="is_offline">このデバイスはオフラインであるため、トンネルを作成できません。</string> + <string name="less_than_a_day_left">残り1日未満</string> + <string name="less_than_a_minute_ago">1分未満前</string> + <string name="local_network_sharing">ローカルネットワーク共有</string> + <string name="log_out">ログアウト</string> + <string name="logged_in_title">ログインしました</string> + <string name="logging_in_description">アカウント番号を確認中</string> + <string name="logging_in_title">ログイン中...</string> + <string name="login_description">アカウント番号を入力してください</string> + <string name="login_fail_description">アカウント番号が正しくありません</string> + <string name="login_fail_title">ログインに失敗しました</string> + <string name="login_title">ログイン</string> + <string name="mullvad_account_number">Mullvadアカウント番号</string> + <string name="no_matching_bridge_relay">現在の設定に一致するブリッジ中継サーバーはありません</string> + <string name="no_matching_relay">現在の設定に一致する中継サーバーはありません</string> + <string name="no_wireguard_key">有効なWireGuard鍵が見つかりません。詳細設定で鍵を管理してください。</string> + <string name="not_blocking_internet">ネットワーク通信が漏洩している可能性があります。</string> + <string name="out_address">外側</string> + <string name="out_of_time">時間切れ</string> + <string name="paid_until">次の日時まで支払い済み</string> + <string name="pay_to_start_using">アプリを使い始めるには、まずはアカウントに時間を追加する必要があります。</string> + <string name="problem_report_description">さらに効率よく問題解決を支援するため、お使いのアプリのログファイルをこのメッセージに添付します。個人データは匿名化された後に暗号化されたチャネルで送信されるため、その安全性は維持され、公開されることはありません。</string> + <string name="public_key">公開鍵</string> + <string name="reconnecting">再接続中</string> + <string name="redeem">使用する</string> + <string name="redeem_voucher">バウチャーを使用する</string> + <string name="report_a_problem">問題を報告する</string> + <string name="secure_connection">セキュリティ保護された接続</string> + <string name="secured">セキュリティ保護されています</string> + <string name="select_location">場所を選択</string> + <string name="select_location_description">接続中、あなたの実際の場所は、選択した地域内の非公開かつセキュリティ保護された場所で隠蔽されています。</string> + <string name="send">送信</string> + <string name="send_anyway">とにかく送信する</string> + <string name="sending">送信中...</string> + <string name="sent">送信済み</string> + <string name="sent_contact">必要に応じて %1$s 宛にご連絡します</string> + <string name="sent_thanks">ありがとうございます!</string> + <string name="set_dns_error">システムのDNSサーバーを設定できませんでした</string> + <string name="set_firewall_policy_error">ファイヤウォール規則の適用に失敗しました。現在、デバイスがセキュリティ保護されていない可能性があります。</string> + <string name="settings">設定</string> + <string name="settings_account">アカウント</string> + <string name="settings_advanced">詳細</string> + <string name="settings_preferences">環境設定</string> + <string name="show_system_apps">システムアプリの表示</string> + <string name="split_tunneling">スプリットトンネリング</string> + <string name="split_tunneling_description">スプリットトンネリングを使用すると、VPNトンネルを介してルーティングするべきでないアプリケーションを選択できます。</string> + <string name="start_tunnel_error">トンネル接続の開始に失敗しました</string> + <string name="switch_location">場所を切り替える</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">アカウントに登録されているWireGuard鍵が多すぎます</string> + <string name="try_again">再試行</string> + <string name="udp">UDP</string> + <string name="unsecured">セキュリティ保護されていません</string> + <string name="unsecured_connection">セキュリティ保護されていない接続</string> + <string name="unsupported_version">未対応のバージョン</string> + <string name="unsupported_version_description">未対応のアプリバージョンを実行中です。今すぐ %1$s にアップデートしてセキュリティを確保してください。</string> + <string name="unsupported_version_without_upgrade">サポートされていないバージョンのアプリを実行しています。</string> + <string name="update_available">アップデート可</string> + <string name="update_available_description">Mullvad VPN (%1$s) をインストールして常に最新の状態を保ちましょう</string> + <string name="update_available_footer">アップデートできます。セキュリティを維持するにはダウンロードしてしてください。</string> + <string name="user_email_hint">あなたのメールアドレス(任意)</string> + <string name="user_message_hint">問題を英語またはスウェーデン語で説明してください。</string> + <string name="view_logs">アプリのログを表示</string> + <string name="virtual_adapter_problem">仮想アダプタエラー</string> + <string name="voucher_already_used">バウチャーコードはすでに使用されています。</string> + <string name="vpn_permission_denied_error">トンネルを作成中にVPN許可が拒否されました。もう一度接続してみてください。</string> + <string name="we_will_look_into_this">この問題を調査いたします。</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">WireGuardエラー</string> + <string name="wireguard_generate_key">鍵を生成</string> + <string name="wireguard_key">WireGuard鍵</string> + <string name="wireguard_key_generated">鍵が生成されました</string> + <string name="wireguard_key_invalid">鍵は無効です</string> + <string name="wireguard_key_reconnecting">新しいWireGuard鍵に再度接続中...</string> + <string name="wireguard_key_valid">鍵は有効です</string> + <string name="wireguard_key_verification_failure">鍵の検証に失敗しました</string> + <string name="wireguard_manage_keys">鍵を管理</string> + <string name="wireguard_mtu">WireGuard MTU</string> + <string name="wireguard_mtu_footer">WireGuardのMTU値を設定します。有効範囲: %1$d ~ %2$d。</string> + <string name="wireguard_public_key">WireGuard公開鍵</string> + <string name="wireguard_replace_key">鍵を生成</string> + <string name="wireguard_verify_key">鍵を検証</string> +</resources> diff --git a/android/app/src/main/res/values-ko/plurals.xml b/android/app/src/main/res/values-ko/plurals.xml new file mode 100644 index 0000000000..13e893b3b4 --- /dev/null +++ b/android/app/src/main/res/values-ko/plurals.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="other">%1$d일 남음</item> + </plurals> + <plurals name="months_left"> + <item quantity="other">%1$d개월 남음</item> + </plurals> + <plurals name="years_left"> + <item quantity="other">%1$d년 남음</item> + </plurals> + <plurals name="days_ago"> + <item quantity="other">%1$d일 전</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="other">%1$d분 전</item> + </plurals> + <plurals name="months_ago"> + <item quantity="other">%1$d개월 전</item> + </plurals> + <plurals name="years_ago"> + <item quantity="other">%1$d년 전</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="other">%1$d시간 전</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="other">계정 크레딧이 %1$d일 후에 만료됩니다.</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="other">계정 크레딧이 %1$d시간 후에 만료됩니다.</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-ko/strings.xml b/android/app/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000000..a8bfe3f7c0 --- /dev/null +++ b/android/app/src/main/res/values-ko/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">계정 생성됨</string> + <string name="account_credit_expires_in_a_few_minutes">계정 크레딧이 몇 분 후에 만료됨</string> + <string name="account_credit_expires_soon">계정 크레딧이 곧 만료됨</string> + <string name="account_credit_has_expired">이 계정에 더 이상 VPN 시간이 없습니다.</string> + <string name="account_number">계정 번호</string> + <string name="account_time_notification_channel_description">계정 시간이 만료되려고 할 때 알림 표시</string> + <string name="account_time_notification_channel_name">계정 시간 알림</string> + <string name="add_a_server">서버 추가</string> + <string name="add_anyway">추가</string> + <string name="add_time_to_account">웹 사이트에서 크레딧을 구매하거나 바우처를 사용하세요.</string> + <string name="all_applications">모든 애플리케이션</string> + <string name="allow_lan_footer">공유, 인쇄 등을 위해 동일한 네트워크의 다른 장치에 액세스할 수 있습니다.</string> + <string name="app_version">앱 버전</string> + <string name="auth_failed">계정 인증에 실패했습니다.</string> + <string name="auto_connect">자동 연결</string> + <string name="auto_connect_footer">앱이 시작되면 자동으로 서버에 연결합니다.</string> + <string name="back">뒤로</string> + <string name="blocked_connection">연결 차단됨</string> + <string name="blocking_all_connections">모든 연결 차단 중</string> + <string name="blocking_internet">인터넷 차단</string> + <string name="buy_credit">크레딧 구매</string> + <string name="buy_more_credit">추가 크레딧 구매</string> + <string name="cancel">취소</string> + <string name="confirm_local_dns">환경 설정에서 ”로컬 네트워크 공유”를 활성화하지 않으면 로컬 DNS 서버가 작동하지 않습니다.</string> + <string name="confirm_no_email">연락처 없이 문제 보고서를 보내려고 합니다. 보고서에 대한 답변을 원하면 이메일 주소를 입력해야 합니다.</string> + <string name="congrats">축하합니다!</string> + <string name="connect">내 연결 보안 유지</string> + <string name="connecting">연결 중</string> + <string name="connecting_to_daemon">Mullvad 시스템 서비스에 연결하는 중...</string> + <string name="copied_mullvad_account_number">클립보드에 Mullvad 계정 번호 복사됨</string> + <string name="copied_to_clipboard">클립보드에 복사됨</string> + <string name="copied_wireguard_public_key">WireGuard 공개 키가 클립보드에 복사됨</string> + <string name="create_account">계정 생성</string> + <string name="creating_new_account">계정 생성 중...</string> + <string name="creating_secure_connection">보안 연결 생성 중</string> + <string name="critical_error">심각한 오류(주의가 필요함)</string> + <string name="custom_dns_footer">하나 이상의 DNS 서버를 추가할 수 있습니다.</string> + <string name="custom_dns_hint">IP 입력</string> + <string name="custom_tunnel_host_resolution_error">사용자 지정 서버의 호스트 이름을 확인하지 못했습니다.</string> + <string name="disconnect">연결 끊기</string> + <string name="disconnecting">연결 해제 중</string> + <string name="dismiss">해제</string> + <string name="dont_have_an_account">계정 번호가 없으신가요?</string> + <string name="edit_message">메시지 편집</string> + <string name="enable">사용</string> + <string name="enable_custom_dns">사용자 지정 DNS 서버 사용</string> + <string name="enter_voucher_code">바우처 코드 입력</string> + <string name="error_occurred">오류가 발생했습니다.</string> + <string name="error_state">보안 연결 실패</string> + <string name="exclude_applications">제외된 애플리케이션</string> + <string name="failed_to_block_internet">모든 네트워크 트래픽을 차단하지 못했습니다. 문제를 해결하거나 당사에 보고해 주세요.</string> + <string name="failed_to_create_account">계정을 만들지 못함</string> + <string name="failed_to_generate_key">키를 생성하지 못함</string> + <string name="failed_to_send">전송하지 못함</string> + <string name="failed_to_send_details">다시 시도하기 전에 앱의 기본 화면으로 돌아가 연결 해제를 클릭해야 할 수 있습니다. 걱정하지 마세요. 입력한 정보는 양식에 남아 있습니다.</string> + <string name="faqs_and_guides">FAQ 및 가이드</string> + <string name="foreground_notification_channel_description">현재 VPN 터널 상태 표시</string> + <string name="foreground_notification_channel_name">VPN 터널 상태</string> + <string name="here_is_your_account_number">계정 번호는 다음과 같습니다. 저장하세요!</string> + <string name="hint_default">기본값</string> + <string name="in_address">인</string> + <string name="invalid_dns_servers">사용자 지정 DNS 서버 주소 %1$s이(가) 잘못되었습니다.</string> + <string name="invalid_voucher">유효하지 않은 바우처 코드입니다.</string> + <string name="ipv6_unavailable">IPv6을 구성할 수 없습니다.</string> + <string name="is_offline">장치가 오프라인 상태이며, 터널을 설정할 수 없습니다</string> + <string name="less_than_a_day_left">1일 이내</string> + <string name="less_than_a_minute_ago">1분 이내</string> + <string name="local_network_sharing">로컬 네트워크 공유</string> + <string name="log_out">로그아웃</string> + <string name="logged_in_title">다음으로 로그인</string> + <string name="logging_in_description">계정 번호 확인 중</string> + <string name="logging_in_title">로그인 중...</string> + <string name="login_description">계정 번호 입력</string> + <string name="login_fail_description">유효하지 않은 계정 번호</string> + <string name="login_fail_title">로그인 실패</string> + <string name="login_title">로그인</string> + <string name="mullvad_account_number">Mullvad 계정 번호</string> + <string name="no_matching_bridge_relay">현재 설정과 일치하는 브리지 릴레이 서버가 없습니다.</string> + <string name="no_matching_relay">현재 설정과 일치하는 릴레이 서버가 없습니다.</string> + <string name="no_wireguard_key">유효한 WireGuard 키가 없습니다. 고급 설정에서 키를 관리하세요.</string> + <string name="not_blocking_internet">네트워크 트래픽이 유출될 수 있습니다.</string> + <string name="out_address">아웃</string> + <string name="out_of_time">시간 초과</string> + <string name="paid_until">유효 기간</string> + <string name="pay_to_start_using">앱 사용을 시작하려면, 먼저 계정에 시간을 추가해야 합니다.</string> + <string name="problem_report_description">효과적인 문제 해결을 위해 앱의 로그 파일이 이 메시지에 첨부됩니다. 사용자 데이터는 암호화된 채널을 통해 전송되기 전에 익명 처리되므로 안전하고 비공개로 유지됩니다.</string> + <string name="public_key">공개 키</string> + <string name="reconnecting">다시 연결 중</string> + <string name="redeem">사용</string> + <string name="redeem_voucher">바우처 사용</string> + <string name="report_a_problem">문제 신고</string> + <string name="secure_connection">보안 연결</string> + <string name="secured">안전함</string> + <string name="select_location">위치 선택</string> + <string name="select_location_description">연결되어 있는 동안 실제 위치는 선택한 지역의 안전한 비공개 위치로 마스킹됩니다.</string> + <string name="send">전송</string> + <string name="send_anyway">그래도 전송</string> + <string name="sending">전송 중...</string> + <string name="sent">전송 완료</string> + <string name="sent_contact">필요한 경우 %1$s(으)로 연락드리겠습니다.</string> + <string name="sent_thanks">감사합니다!</string> + <string name="set_dns_error">시스템 DNS 서버를 설정하지 못했습니다.</string> + <string name="set_firewall_policy_error">방화벽 규칙을 적용하지 못했습니다. 장치가 현재 안전하지 않을 수 있습니다.</string> + <string name="settings">설정</string> + <string name="settings_account">계정</string> + <string name="settings_advanced">고급</string> + <string name="settings_preferences">환경 설정</string> + <string name="show_system_apps">시스템 앱 표시</string> + <string name="split_tunneling">분할 터널링</string> + <string name="split_tunneling_description">분할 터널링을 사용하면 VPN 터널을 통해 라우팅되지 않아야 하는 애플리케이션을 선택할 수 있습니다.</string> + <string name="start_tunnel_error">터널 연결을 시작하지 못했습니다.</string> + <string name="switch_location">위치 전환</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">계정에 너무 많은 WireGuard 키가 등록됨</string> + <string name="try_again">다시 시도</string> + <string name="udp">UDP</string> + <string name="unsecured">안전하지 않음</string> + <string name="unsecured_connection">비보안 연결</string> + <string name="unsupported_version">지원되지 않는 버전</string> + <string name="unsupported_version_description">지원되지 않는 앱 버전을 실행 중입니다. 지금 %1$s(으)로 업그레이드하여 보안을 유지하세요.</string> + <string name="unsupported_version_without_upgrade">지원되지 않는 앱 버전을 실행 중입니다.</string> + <string name="update_available">업데이트 사용 가능</string> + <string name="update_available_description">Mullvad VPN(%1$s)을 설치하여 최신 상태로 유지하세요.</string> + <string name="update_available_footer">업데이트를 사용할 수 있습니다. 안전을 유지하기 위해 다운로드하세요.</string> + <string name="user_email_hint">이메일(선택 사항)</string> + <string name="user_message_hint">문제를 영어나 스웨덴어로 설명해 주세요.</string> + <string name="view_logs">앱 로그 보기</string> + <string name="virtual_adapter_problem">가상 어댑터 오류</string> + <string name="voucher_already_used">이미 사용된 바우처 코드입니다.</string> + <string name="vpn_permission_denied_error">터널을 만드는 동안 VPN 사용 권한이 거부되었습니다. 다시 연결해 보세요.</string> + <string name="we_will_look_into_this">조사해보겠습니다.</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">WireGuard 오류</string> + <string name="wireguard_generate_key">키 생성</string> + <string name="wireguard_key">WireGuard 키</string> + <string name="wireguard_key_generated">키 생성됨</string> + <string name="wireguard_key_invalid">유효하지 않은 키</string> + <string name="wireguard_key_reconnecting">새 WireGuard 키로 다시 연결 중...</string> + <string name="wireguard_key_valid">유효한 키</string> + <string name="wireguard_key_verification_failure">키 확인 실패</string> + <string name="wireguard_manage_keys">키 관리</string> + <string name="wireguard_mtu">WireGuard MTU</string> + <string name="wireguard_mtu_footer">WireGuard MTU 값을 설정하세요. 유효 범위: %1$d - %2$d.</string> + <string name="wireguard_public_key">WireGuard 공개 키</string> + <string name="wireguard_replace_key">키 다시 생성</string> + <string name="wireguard_verify_key">키 확인</string> +</resources> diff --git a/android/app/src/main/res/values-my/plurals.xml b/android/app/src/main/res/values-my/plurals.xml new file mode 100644 index 0000000000..b99a79b345 --- /dev/null +++ b/android/app/src/main/res/values-my/plurals.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="other">%1$d ရက်သာ ကျန်တော့သည်</item> + </plurals> + <plurals name="months_left"> + <item quantity="other">%1$d လသာ ကျန်တော့သည်</item> + </plurals> + <plurals name="years_left"> + <item quantity="other">%1$d နှစ်သာ ကျန်တော့သည်</item> + </plurals> + <plurals name="days_ago"> + <item quantity="other">လွန်ခဲ့သော %1$d ရက်</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="other">လွန်ခဲ့သော %1$d မိနစ်</item> + </plurals> + <plurals name="months_ago"> + <item quantity="other">လွန်ခဲ့သော %1$d လ</item> + </plurals> + <plurals name="years_ago"> + <item quantity="other">လွန်ခဲ့သော %1$d နှစ်</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="other">လွန်ခဲ့သော %1$d နာရီ</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="other">%1$d ရက်အကြာတွင် အကောင့်ခရက်ဒစ် သက်တမ်းကုန်ပါတော့မည်</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="other">%1$d နာရီအကြာတွင် အကောင့်ခရက်ဒစ် သက်တမ်းကုန်ပါတော့မည်</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-my/strings.xml b/android/app/src/main/res/values-my/strings.xml new file mode 100644 index 0000000000..7819620498 --- /dev/null +++ b/android/app/src/main/res/values-my/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">အကောင့် ဖန်တီးပြီး</string> + <string name="account_credit_expires_in_a_few_minutes">မိနစ်အနည်းငယ်အကြာတွင် အကောင့်ခရက်ဒစ် သက်တမ်းကုန်ပါတော့မည်</string> + <string name="account_credit_expires_soon">မကြာမီ အကောင့် ခရက်ဒစ် သက်တမ်းကုန်ပါတော့မည်</string> + <string name="account_credit_has_expired">ဤအကောင့်တွင် နောက်ထပ် VPN သုံးနိုင်ချိန် မကျန်တော့ပါ။</string> + <string name="account_number">အကောင့် နံပါတ်</string> + <string name="account_time_notification_channel_description">အကောင့်အချိန် သက်တမ်းကုန်ခါနီးချိန်၌ သတိပေးချက်များ ပြသပေးပါသည်</string> + <string name="account_time_notification_channel_name">အကောင့်အချိန် သတိပေးချက်များ</string> + <string name="add_a_server">ဆာဗာ ပေါင်းထည့်ရန်</string> + <string name="add_anyway">မည်သို့ပင်ဖြစ်စေ ပေါင်းထည့်ရန်</string> + <string name="add_time_to_account">ကျွန်ုပ်တို့၏ ဝက်ဘ်ဆိုက်တွင် ခရက်ဒစ် ဝယ်ယူပါ သို့မဟုတ် ဘောက်ချာဖြင့် လဲယူပါ။</string> + <string name="all_applications">အပလီကေးရှင်း အားလုံး</string> + <string name="allow_lan_footer">ဝေမျှရန်၊ ပရင့်ထုတ်ရန်စသည်တို့အတွက် တူညီသည့် ကွန်ရက်ရှိ အခြားစက်များ ရယူသုံးစွဲခွင့်ပြုပေးပါသည်။</string> + <string name="app_version">အက်ပ်ဗားရှင်း</string> + <string name="auth_failed">အကောင့် စစ်မှန်ကြောင်း သက်သေပြမှု မအောင်မြင်ပါ။</string> + <string name="auto_connect">အော်တို ချိတ်ဆက်မှု</string> + <string name="auto_connect_footer">အက်ပ် စတင်ဆောင်ရွက်ချိန်တွင် ဆာဗာနှင့် အော်တို ချိတ်ဆက်သွားပါမည်။</string> + <string name="back">နောက်သို့</string> + <string name="blocked_connection">ပိတ်ဆို့ထားသည့် ချိတ်ဆက်မှု</string> + <string name="blocking_all_connections">ချိတ်ဆက်မှု အားလုံးကို ပိတ်ဆို့ထားပါသည်</string> + <string name="blocking_internet">အင်တာနက် ပိတ်ဆို့နေပါသည်</string> + <string name="buy_credit">ခရက်ဒစ် ဝယ်ရန်</string> + <string name="buy_more_credit">ခရက်ဒစ်များ ဝယ်ရန်</string> + <string name="cancel">မလုပ်တော့ပါ</string> + <string name="confirm_local_dns">လိုကယ် DNS ဆာဗာသည် လိုလားမှုများအောက်ရှိ \"လိုကယ် ကွန်ရက် ဝေမျှမှု\"ကို မဖွင့်မချင်း အလုပ်လုပ်မည် မဟုတ်ပါ။</string> + <string name="confirm_no_email">သင်သည် သင့်ထံ ကျွန်ုပ်တို့ ပြန်ဆက်သွယ်နိုင်မည့် နည်းလမ်း မပါဘဲ ပြဿနာ ရီပို့တ်ကို ပေးပို့တော့မည် ဖြစ်ပါသည်။ သင့်ရီပို့တ်အတွက် အဖြေ ရရှိလိုပါက အီမေးလိပ်စာ ဖြည့်သွင်းပေးရပါမည်။</string> + <string name="congrats">ဝမ်းသာပါတယ်။</string> + <string name="connect">ကျွန်ုပ်၏ ချိတ်ဆက်မှုကို ကာကွယ်ရန်</string> + <string name="connecting">ချိတ်ဆက်နေပါသည်</string> + <string name="connecting_to_daemon">Mullvad စနစ် ဝန်ဆောင်မှုနှင့် ချိတ်ဆက်နေဆဲ...</string> + <string name="copied_mullvad_account_number">Mullvad အကောင့်နံပါတ်ကို ကလစ်ဘုတ်တွင် ကူးထားပါသည်</string> + <string name="copied_to_clipboard">ကလစ်ဘုတ်တွင် ကူးယူပြီး</string> + <string name="copied_wireguard_public_key">WireGuard အများသုံး ကီးကို ကလစ်ဘုတ်တွင် ကူးထားပါသည်</string> + <string name="create_account">အကောင့် ဖန်တီးရန်</string> + <string name="creating_new_account">အကောင့် ဖန်တီးနေဆဲ...</string> + <string name="creating_secure_connection">လုံခြုံသည့် ချိတ်ဆက်မှုကို ဖန်တီးနေပါသည်</string> + <string name="critical_error">အလွန်အရေးပါသည့် ချို့ယွင်းချက် (သင့်အာရုံစိုက်မှု လိုအပ်ပါသည်)</string> + <string name="custom_dns_footer">အနည်းဆုံး DNS ဆာဗာတစ်ခုကို ပေါင်းထည့်ပါ။</string> + <string name="custom_dns_hint">IP ဖြည့်ပါ</string> + <string name="custom_tunnel_host_resolution_error">စိတ်ကြိုက် ဆာဗာ၏ Hostname ကို ဖြေရှင်း၍ မရနိုင်ပါ</string> + <string name="disconnect">ချိတ်ဆက်မှုဖြုတ်ရန်</string> + <string name="disconnecting">ချိတ်ဆက်မှုဖြုတ်နေပါသည်</string> + <string name="dismiss">ဖယ်ပစ်ရန်</string> + <string name="dont_have_an_account">အကောင့်နံပါတ် မရှိ ဖြစ်နေပါသလား။</string> + <string name="edit_message">မက်ဆေ့ချ် တည်းဖြတ်ရန်</string> + <string name="enable">ဖွင့်ရန်</string> + <string name="enable_custom_dns">စိတ်ကြိုက် DNS ဆာဗာကို သုံးရန်</string> + <string name="enter_voucher_code">ဘောက်ချာကုဒ် ဖြည့်သွင်းရန်</string> + <string name="error_occurred">ချို့ယွင်းချက် ဖြစ်ပေါ်ခဲ့ပါသည်။</string> + <string name="error_state">ချိတ်ဆက်မှုကို ကာကွယ်ရန် မအောင်မြင်ပါ</string> + <string name="exclude_applications">အပလီကေးရှင်းများ ဖယ်ထားပြီး</string> + <string name="failed_to_block_internet">ကွန်ရက် ကူးလူးမှု အားလုံးကို ပိတ်ဆို့ရန် မအောင်မြင်ပါ။ ပြစ်ချက်ရှာဖွေ ဖယ်ရှားပေးပါ သို့မဟုတ် ပြဿနာကို ကျွန်ုပ်တို့ထံ ရီပို့တ်လုပ်ပေးပါ။</string> + <string name="failed_to_create_account">အကောင့် ဖန်တီးရန် မအောင်မြင်ခဲ့ပါ</string> + <string name="failed_to_generate_key">ကီး ထုတ်ရန် မအောင်မြင်ခဲ့ပါ</string> + <string name="failed_to_send">ပို့ရန် မအောင်မြင်ခဲ့ပါ</string> + <string name="failed_to_send_details">သင်သည် အက်ပ်၏ ပင်မ စာမျက်နှာသို့ ပြန်သွားရန် လိုပြီး ထပ်မကြိုးစားမီ ချိတ်ဆက်မှုဖြုတ်ရန်ကို နှိပ်ပါ။ စိတ်မပူပါနှင့်၊ သင်ဖြည့်သွင်းထားသည့် အချက်အလက်များသည် ဖောင်တွင် ကျန်ရှိနေပါမည်။</string> + <string name="faqs_and_guides">မေးလေ့ရှိသည့် မေးခွန်းများနှင့် လမ်းညွှန်များ</string> + <string name="foreground_notification_channel_description">လက်ရှိ VPN Tunnel အခြေအနေကို ပြသပေးပါသည်</string> + <string name="foreground_notification_channel_name">VPN Tunnel အခြေအနေ</string> + <string name="here_is_your_account_number">ဤသည်မှာ သင့်အကောင့်နံပါတ် ဖြစ်ပါသည်။ သိမ်းမှတ်ထားပါ။</string> + <string name="hint_default">ပုံသေ</string> + <string name="in_address">အဝင်</string> + <string name="invalid_dns_servers">စိတ်ကြိုက် DNS ဆာဗာလိပ်စာများ %1$s မှားနေပါသည်</string> + <string name="invalid_voucher">ဘောက်ချာကုဒ် မှားနေပါသည်။</string> + <string name="ipv6_unavailable">IPv6 ကို သတ်မှတ်ချိန်ညှိ၍ မရနိုင်ပါ</string> + <string name="is_offline">ဤစက်သည် အော့ဖ်လိုင်း ဖြစ်နေပါသည်၊ Tunnel များ ဖန်တီး၍ မရနိုင်ပါ</string> + <string name="less_than_a_day_left">တစ်ရက်အောက်သာ ကျန်တော့သည်</string> + <string name="less_than_a_minute_ago">လွန်ခဲ့သော စက္ကန့်ပိုင်း</string> + <string name="local_network_sharing">လိုကယ် ကွန်ရက် ဝေမျှမှု</string> + <string name="log_out">ထွက်ရန်</string> + <string name="logged_in_title">ဝင်ရောက်ထားပြီး</string> + <string name="logging_in_description">အကောင့်နံပါတ်ကို စစ်နေပါသည်</string> + <string name="logging_in_title">ဝင်ရောက်နေဆဲ...</string> + <string name="login_description">သင့်အကောင့်နံပါတ်ကို ဖြည့်သွင်းပါ</string> + <string name="login_fail_description">အကောင့်နံပါတ် မှားယွင်းနေပါသည်</string> + <string name="login_fail_title">ဝင်ရောက်မှု မအောင်မြင်ပါ</string> + <string name="login_title">ဝင်ရန်</string> + <string name="mullvad_account_number">Mullvad အကောင့်နံပါတ်</string> + <string name="no_matching_bridge_relay">လက်ရှိဆက်တင်နှင့် ကိုက်ညီသည့် ပေါင်းကူး ထပ်ဆင့်ဆာဗာ မရှိပါ</string> + <string name="no_matching_relay">လက်ရှိဆက်တင်နှင့် ကိုက်ညီသည့် ထပ်ဆင့်ဆာဗာ မရှိပါ</string> + <string name="no_wireguard_key">အကျုံးဝင်သည့် WireGuard ကီး မရှိပါ။ အဆင့်မြင့်ဆက်တင် အောက်တွင် ကီးများကို စီမံခန့်ခွဲပါ။</string> + <string name="not_blocking_internet">ကွန်ရက် ကူးလူးမှု ပေါက်ကြားနေနိုင်ပါသည်</string> + <string name="out_address">အထွက်</string> + <string name="out_of_time">အချိန်စေ့သွားပါပြီ</string> + <string name="paid_until">ဖော်ပြပါအထိ ပေးချေထားပြီး</string> + <string name="pay_to_start_using">အက်ပ်ကို စသုံးရန်အတွက် ဦးစွာ သင့်အကောင့်တွင် အချိန်ပေါင်းထည့်ပေးရန် လိုအပ်ပါသည်။</string> + <string name="problem_report_description">သင့်အား ပိုမိုထိရောက်စွာ ကူညီနိုင်ရန် သင့်အက်ပ်၏ မှတ်တမ်းဖိုင်ကို ဤမက်ဆေ့ချ်နှင့်အတူ တွဲပေးသွားပါမည်။ ကုဒ်ပြောင်းဝှက်ထားသည့် ချန်နယ်မှတစ်ဆင့် မပေးပို့မီ သင့်ဒေတာများကို အမည်မဖော်ဘဲ ထားမည်ဖြစ်သောကြောင့် လျှို့ဝှက်လုံခြုံလျက် ရှိနေပါမည်။</string> + <string name="public_key">အများသုံး ကီး</string> + <string name="reconnecting">ပြန်ချိတ်ဆက်နေပါသည်</string> + <string name="redeem">လဲယူရန်</string> + <string name="redeem_voucher">ဘောက်ချာဖြင့် လဲယူရန်</string> + <string name="report_a_problem">ပြဿနာ ရီပို့တ်လုပ်ရန်</string> + <string name="secure_connection">လုံခြုံသည့် ချိတ်ဆက်မှု</string> + <string name="secured">လုံခြုံပါသည်</string> + <string name="select_location">တည်နေရာ ရွေးရန်</string> + <string name="select_location_description">ချိတ်ဆက်ထားချိန်တွင် သင့်တည်နေရာအမှန်ကို ရွေးချယ်ထားသည့် ဒေသရှိ လျှို့ဝှက်လုံခြုံသည့် တည်နေရာဖြင့် ဖုံးကွယ်ထားပါသည်။</string> + <string name="send">ပို့ရန်</string> + <string name="send_anyway">မည်သို့ပင်ဖြစ်စေ ပို့ရန်</string> + <string name="sending">ပို့နေဆဲ...</string> + <string name="sent">ပို့ပြီး</string> + <string name="sent_contact">လိုအပ်ပါက %1$s မှတစ်ဆင့် ကျွန်ုပ်တို့ထံ ဆက်သွယ်ပါ</string> + <string name="sent_thanks">ကျေးဇူးတင်ပါသည်။</string> + <string name="set_dns_error">စနစ် DNS ဆာဗာ သတ်မှတ်ချိန်ညှိရန် မအောင်မြင်ပါ</string> + <string name="set_firewall_policy_error">Firewall စည်းမျဉ်းများ သုံး၍ မရနိုင်ပါ။ စက်သည် လက်ရှိ၌ မလုံခြုံနိုင်ပါ</string> + <string name="settings">ဆက်တင်</string> + <string name="settings_account">အကောင့်</string> + <string name="settings_advanced">အဆင့်မြင့်</string> + <string name="settings_preferences">လိုလားမှုများ</string> + <string name="show_system_apps">စနစ်အက်ပ်များ ပြရန်</string> + <string name="split_tunneling">Split Tunneling</string> + <string name="split_tunneling_description">Split Tunneling သည် VPN Tunnel မှတစ်ဆင့် လမ်းကြောင်းအတိုင်း မပို့သင့်သည့် အက်ပ်များကို ရွေးချယ်နိုင်ပါသည်။</string> + <string name="start_tunnel_error">Tunnel ချိတ်ဆက်မှုကို စတင်ရန် မအောင်မြင်ခဲ့ပါ</string> + <string name="switch_location">တည်နေရာ ပြောင်းရန်</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">အကောင့်တွင် စာရင်းသွင်းထားသည့် WireGuard ကီးများ များလွန်းနေပါသည်</string> + <string name="try_again">ထပ်ကြိုးစားရန်</string> + <string name="udp">UDP</string> + <string name="unsecured">မလုံခြုံပါ</string> + <string name="unsecured_connection">မလုံခြုံသည့် ချိတ်ဆက်မှု</string> + <string name="unsupported_version">တွဲဖက်မလုပ်ဆောင်နိုင်သည့် ဗားရှင်း</string> + <string name="unsupported_version_description">သင်သည် တွဲဖက်မလုပ်ဆောင်နိုင်သည့် အက်ပ်ဗားရှင်းကို သုံးနေပါသည်။ သင့်လုံခြုံရေးအတွက် ယခုပင် %1$s အထိ အဆင့်မြှင့်တင်ပေးပါ</string> + <string name="unsupported_version_without_upgrade">တွဲဖက်မလုပ်ဆောင်နိုင်သည့် အက်ပ်ဗားရှင်းဖြင့် လုပ်ဆောင်နေပါသည်။</string> + <string name="update_available">အပ်ဒိတ် ရရှိနိုင်ပါပြီ</string> + <string name="update_available_description">အပ်ဒိတ် ဖြစ်နေစေရန် Mullvad VPN (%1$s) ကို ထည့်သွင်းပါ</string> + <string name="update_available_footer">အပ်ဒိတ် ရရှိနိုင်ပါပြီ၊ ဆက်လက် လုံခြုံစေရန် ဒေါင်းလုဒ်လုပ်ပါ။</string> + <string name="user_email_hint">သင့်အီးမေးလ် (မဖြည့်လည်း ရပါသည်)</string> + <string name="user_message_hint">သင့်ပြဿနာကို အင်္ဂလိပ်ဘာသာ သို့မဟုတ် ဆွီဒင်ဘာသာဖြင့် ဖော်ပြပေးပါ။</string> + <string name="view_logs">အက်ပ်မှတ်တမ်းများ ကြည့်ရန်</string> + <string name="virtual_adapter_problem">စက်တွင်း အဒက်တာ ချို့ယွင်းချက်</string> + <string name="voucher_already_used">ဘောက်ချာကုဒ် သုံးထားပြီးသား ဖြစ်ပါသည်။</string> + <string name="vpn_permission_denied_error">Tunnel ဖန်တီးနေစဉ် VPN ခွင့်ပြုချက်ကို ပယ်ချခဲ့ပါသည်။ ထပ်မံချိတ်ဆက်ပေးပါ။</string> + <string name="we_will_look_into_this">ဤသည်ကို စစ်ဆေးလိုက်ပါမည်။</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">WireGuard ချို့ယွင်းချက်</string> + <string name="wireguard_generate_key">ကီး ထုတ်ရန်</string> + <string name="wireguard_key">WireGuard ကီး</string> + <string name="wireguard_key_generated">ကီး ထုတ်ပြီး</string> + <string name="wireguard_key_invalid">ကီး မှားနေပါသည်</string> + <string name="wireguard_key_reconnecting">WireGuard ကီးအသစ်ဖြင့် ပြန်ချိတ်ဆက်နေဆဲ...</string> + <string name="wireguard_key_valid">ကီး မှန်ပါသည်</string> + <string name="wireguard_key_verification_failure">ကီးစစ်ဆေးမှု မအောင်မြင်ပါ</string> + <string name="wireguard_manage_keys">ကီးများ စီမံခန့်ခွဲရန်</string> + <string name="wireguard_mtu">WireGuard MTU</string> + <string name="wireguard_mtu_footer">WireGuard MTU တန်ဖိုးကို သတ်မှတ်ပါ။ အကျုံးဝင်သည့် အပိုင်းအခြား- %1$d - %2$d</string> + <string name="wireguard_public_key">WireGuard အများသုံး ကီး</string> + <string name="wireguard_replace_key">ကီး ပြန်ထုတ်ရန်</string> + <string name="wireguard_verify_key">ကီး စစ်ဆေးရန်</string> +</resources> diff --git a/android/app/src/main/res/values-nb/plurals.xml b/android/app/src/main/res/values-nb/plurals.xml new file mode 100644 index 0000000000..dea8a0c6f9 --- /dev/null +++ b/android/app/src/main/res/values-nb/plurals.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="one">1 dag igjen</item> + <item quantity="other">%1$d dager igjen</item> + </plurals> + <plurals name="months_left"> + <item quantity="one">1 måned igjen</item> + <item quantity="other">%1$d måneder igjen</item> + </plurals> + <plurals name="years_left"> + <item quantity="one">1 år igjen</item> + <item quantity="other">%1$d år igjen</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">én dag siden</item> + <item quantity="other">%1$d dager siden</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="one">ett minutt siden</item> + <item quantity="other">%1$d minutter siden</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">én måned siden</item> + <item quantity="other">%1$d måneder siden</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">ett år siden</item> + <item quantity="other">%1$d år siden</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">én time siden</item> + <item quantity="other">%1$d timer siden</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="one">Kontokreditt utløper om én dag</item> + <item quantity="other">Kontokreditt utløper om %1$d dager</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="one">Kontokreditt utløper om én time</item> + <item quantity="other">Kontokreditt utløper om %1$d timer</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-nb/strings.xml b/android/app/src/main/res/values-nb/strings.xml new file mode 100644 index 0000000000..ae0748b3d5 --- /dev/null +++ b/android/app/src/main/res/values-nb/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">Konto opprettet</string> + <string name="account_credit_expires_in_a_few_minutes">Kontokreditt utløper om noen få minutter</string> + <string name="account_credit_expires_soon">Kontokreditt utløper snart</string> + <string name="account_credit_has_expired">Du har ikke mer VPN-tid igjen på kontoen.</string> + <string name="account_number">Kontonummer</string> + <string name="account_time_notification_channel_description">Viser påminnelser når tidsavbrudd for kontoen er i ferd med å inntreffe</string> + <string name="account_time_notification_channel_name">Påminnelser om tidsavbrudd for konto</string> + <string name="add_a_server">Legg til en server</string> + <string name="add_anyway">Legg til likevel</string> + <string name="add_time_to_account">Du kan enten kjøpe kreditt på nettsiden vår eller løse inn en kupong.</string> + <string name="all_applications">Alle applikasjoner</string> + <string name="allow_lan_footer">Gir tilgang til andre enheter på samme nettverk for deling, utskrift osv.</string> + <string name="app_version">Appversjon</string> + <string name="auth_failed">Autentisering av konto mislykket.</string> + <string name="auto_connect">Automatisk tilkobling</string> + <string name="auto_connect_footer">Kobler automatisk til en server når appen starter.</string> + <string name="back">Tilbake</string> + <string name="blocked_connection">TILKOBLING BLOKKERT</string> + <string name="blocking_all_connections">Blokkerer alle tilkoblinger</string> + <string name="blocking_internet">Blokkerer internettet</string> + <string name="buy_credit">Kjøp kreditt</string> + <string name="buy_more_credit">Kjøp mer kreditt</string> + <string name="cancel">Avbryt</string> + <string name="confirm_local_dns">Den lokale DNS-serveren fungerer ikke med mindre du aktiverer «Deling av lokalt nettverk» under Innstillinger.</string> + <string name="confirm_no_email">Problemrapporten blir nå sendt uten en måte for oss å kontakte deg på. Hvis du ønsker svar på rapporten, må du oppgi en e-postadresse.</string> + <string name="congrats">Gratulerer!</string> + <string name="connect">Gjør tilkoblingen sikker</string> + <string name="connecting">Kobler til</string> + <string name="connecting_to_daemon">Kobler til Mullvads systemtjeneste ...</string> + <string name="copied_mullvad_account_number">Kopierte Mullvad-kontonummer til utklippstavlen</string> + <string name="copied_to_clipboard">Kopiert til utklippstavlen</string> + <string name="copied_wireguard_public_key">Kopierte WireGuard offentlig nøkkel til utklippstavlen</string> + <string name="create_account">Opprett konto</string> + <string name="creating_new_account">Oppretter konto ...</string> + <string name="creating_secure_connection">OPPRETTER SIKKER TILKOBLING</string> + <string name="critical_error">Kritisk feil (krever din oppmerksomhet)</string> + <string name="custom_dns_footer">Aktiver for å legge til minst én DNS-server.</string> + <string name="custom_dns_hint">Angi IP</string> + <string name="custom_tunnel_host_resolution_error">Kunne ikke løse vertsnavnet til den egendefinerte serveren</string> + <string name="disconnect">Koble fra</string> + <string name="disconnecting">Kobler fra</string> + <string name="dismiss">Ignorer</string> + <string name="dont_have_an_account">Har du ikke et kontonummer?</string> + <string name="edit_message">Rediger melding</string> + <string name="enable">Aktiver</string> + <string name="enable_custom_dns">Bruk egendefinert DNS-server</string> + <string name="enter_voucher_code">Skriv inn kupongkode</string> + <string name="error_occurred">Det oppstod en feil.</string> + <string name="error_state">KUNNE IKKE OPPRETTE SIKKER TILKOBLING</string> + <string name="exclude_applications">Ekskluder applikasjoner</string> + <string name="failed_to_block_internet">Kunne ikke blokkere all nettverkstrafikk. Kjør feilsøking eller rapporter inn problemet til oss.</string> + <string name="failed_to_create_account">Kunne ikke opprette konto</string> + <string name="failed_to_generate_key">Generering av en nøkkel mislyktes</string> + <string name="failed_to_send">Kunne ikke sende</string> + <string name="failed_to_send_details">Du må gå tilbake til hovedskjermen til appen og trykke på \"Koble fra\" før du kan prøve på nytt. Men det er ingen grunn til bekymring, informasjonen du har skrevet i skjemaet blir ikke borte.</string> + <string name="faqs_and_guides">Ofte stilte spørsmål og veiledninger</string> + <string name="foreground_notification_channel_description">Viser gjeldende VPN-tunnelstatus</string> + <string name="foreground_notification_channel_name">VPN-tunnelstatus</string> + <string name="here_is_your_account_number">Dette er kontonummeret ditt. Ta vare på det!</string> + <string name="hint_default">Standard</string> + <string name="in_address">Inngående</string> + <string name="invalid_dns_servers">Egendefinerte DNS-serveradresser %1$s er ugyldige</string> + <string name="invalid_voucher">Ugyldig kupongkode.</string> + <string name="ipv6_unavailable">Kunne ikke konfigurere IPv6</string> + <string name="is_offline">Enheten er frakoblet. Ingen tunneler kan opprettes</string> + <string name="less_than_a_day_left">mindre enn én dag igjen</string> + <string name="less_than_a_minute_ago">mindre enn ett minutt siden</string> + <string name="local_network_sharing">Deling over lokalt nettverk</string> + <string name="log_out">Logg ut</string> + <string name="logged_in_title">Du er logget inn</string> + <string name="logging_in_description">Kontrollerer kontonummer</string> + <string name="logging_in_title">Logger inn ...</string> + <string name="login_description">Skriv inn kontonummeret ditt</string> + <string name="login_fail_description">Ugyldig kontonummer</string> + <string name="login_fail_title">Kunne ikke logge inn</string> + <string name="login_title">Logg inn</string> + <string name="mullvad_account_number">Mullvad-kontonummer</string> + <string name="no_matching_bridge_relay">Ingen bro-relayserver samsvarer med de gjeldende innstillingene</string> + <string name="no_matching_relay">Ingen relayservere passer til innstillingene</string> + <string name="no_wireguard_key">Det mangler en gyldig WireGuard-nøkkel. Du kan behandle nøklene under avanserte innstillinger.</string> + <string name="not_blocking_internet">DET KAN VÆRE EN NETTVERKSLEKKASJE HOS DEG</string> + <string name="out_address">Utgående</string> + <string name="out_of_time">Tiden har utløpt</string> + <string name="paid_until">Betalt fram til</string> + <string name="pay_to_start_using">For å starte bruken av appen, må du først legge til tid til kontoen.</string> + <string name="problem_report_description">For å kunne gi deg god nok hjelp vil loggfilen til appen ligge som vedlegg til meldingen. All data forblir beskyttet og privat gjennom anonymisering før det sendes gjennom en kryptert kanal.</string> + <string name="public_key">Offentlig nøkkel</string> + <string name="reconnecting">Kobler til på nytt</string> + <string name="redeem">Løs inn</string> + <string name="redeem_voucher">Løs inn kupong</string> + <string name="report_a_problem">Rapporter et problem</string> + <string name="secure_connection">SIKKER TILKOBLING</string> + <string name="secured">Sikret</string> + <string name="select_location">Velg plassering</string> + <string name="select_location_description">Mens du er tilkoblet vil din egentlige plassering være skjult med en privat og sikker plassering i den valgte regionen.</string> + <string name="send">Send</string> + <string name="send_anyway">Send allikevel</string> + <string name="sending">Sender ...</string> + <string name="sent">Sendt</string> + <string name="sent_contact">Vi vil kontakte deg på %1$s ved behov</string> + <string name="sent_thanks">Takk!</string> + <string name="set_dns_error">Kunne ikke angi systemets DNS-server</string> + <string name="set_firewall_policy_error">Kunne ikke anvende brannmurregler. Enheten kan være usikker</string> + <string name="settings">Innstillinger</string> + <string name="settings_account">Konto</string> + <string name="settings_advanced">Avansert</string> + <string name="settings_preferences">Preferanser</string> + <string name="show_system_apps">Vis systemapper</string> + <string name="split_tunneling">Delt tunnel</string> + <string name="split_tunneling_description">Tunneldeling gjør det mulig å velge hvilke applikasjoner som ikke skal rutes gjennom VPN-tunnelen.</string> + <string name="start_tunnel_error">Kunne ikke starte tunneltilkobling</string> + <string name="switch_location">Bytt plassering</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">For mange WireGuard-nøkler er registrert til kontoen din</string> + <string name="try_again">Prøv på nytt</string> + <string name="udp">UDP</string> + <string name="unsecured">Usikret</string> + <string name="unsecured_connection">USIKKER TILKOBLING</string> + <string name="unsupported_version">VERSJON UTEN STØTTE</string> + <string name="unsupported_version_description">Du kjører en appversjon som ikke er støttet. Oppdater til %1$s for å ha den beste sikkerheten</string> + <string name="unsupported_version_without_upgrade">Du kjører en appversjon som ikke støttes.</string> + <string name="update_available">OPPDATERING TILGJENGELIG</string> + <string name="update_available_description">Installer Mullvad VPN (%1$s) for å holde deg oppdatert</string> + <string name="update_available_footer">Oppdatering tilgjengelig. Last ned for å oppdatere sikkerheten.</string> + <string name="user_email_hint">E-post (valgfritt)</string> + <string name="user_message_hint">Beskriv problemet ditt på engelsk eller svensk.</string> + <string name="view_logs">Se applogger</string> + <string name="virtual_adapter_problem">Virtuell adapterfeil</string> + <string name="voucher_already_used">Kupongkoden er allerede brukt.</string> + <string name="vpn_permission_denied_error">VPN-tillatelse ble avvist under opprettelsen av tunnelen. Prøv å koble til igjen.</string> + <string name="we_will_look_into_this">Dette skal vi følge opp.</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">WireGuard-feil</string> + <string name="wireguard_generate_key">Generer nøkkel</string> + <string name="wireguard_key">WireGuard-nøkkel</string> + <string name="wireguard_key_generated">Nøkkel generert</string> + <string name="wireguard_key_invalid">Nøkkel er ugyldig</string> + <string name="wireguard_key_reconnecting">Kobler til på nytt med ny WireGuard-nøkkel ...</string> + <string name="wireguard_key_valid">Nøkkel er gyldig</string> + <string name="wireguard_key_verification_failure">Nøkkelverifisering mislyktes</string> + <string name="wireguard_manage_keys">Behandle nøkler</string> + <string name="wireguard_mtu">WireGuard MTU</string> + <string name="wireguard_mtu_footer">Angi WireGuard MTU-verdi. Verdiområde: %1$d-%2$d.</string> + <string name="wireguard_public_key">WireGuard offentlig nøkkel</string> + <string name="wireguard_replace_key">Generer nøkkel på nytt</string> + <string name="wireguard_verify_key">Bekreft nøkkel</string> +</resources> diff --git a/android/app/src/main/res/values-nl/plurals.xml b/android/app/src/main/res/values-nl/plurals.xml new file mode 100644 index 0000000000..7a99c6a6f7 --- /dev/null +++ b/android/app/src/main/res/values-nl/plurals.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="one">1 dag resterend</item> + <item quantity="other">%1$d dagen resterend</item> + </plurals> + <plurals name="months_left"> + <item quantity="one">1 maand resterend</item> + <item quantity="other">%1$d maanden resterend</item> + </plurals> + <plurals name="years_left"> + <item quantity="one">1 jaar resterend</item> + <item quantity="other">%1$d jaar resterend</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">een dag geleden</item> + <item quantity="other">%1$d dagen geleden</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="one">een minuut geleden</item> + <item quantity="other">%1$d minuten geleden</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">een maand geleden</item> + <item quantity="other">%1$d maanden geleden</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">een jaar geleden</item> + <item quantity="other">%1$d jaar geleden</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">een uur geleden</item> + <item quantity="other">%1$d uur geleden</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="one">Accountkrediet verloopt over een dag</item> + <item quantity="other">Accountkrediet verloopt over %1$d dagen</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="one">Accountkrediet verloopt over een uur</item> + <item quantity="other">Accountkrediet verloopt over %1$d uur</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-nl/strings.xml b/android/app/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000000..734dd30d85 --- /dev/null +++ b/android/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">Account aangemaakt</string> + <string name="account_credit_expires_in_a_few_minutes">Accountkrediet verloopt over enkele minuten</string> + <string name="account_credit_expires_soon">Accountkrediet verloopt binnenkort</string> + <string name="account_credit_has_expired">U hebt geen VPN-tijd meer op dit account.</string> + <string name="account_number">Accountnummer</string> + <string name="account_time_notification_channel_description">Toont herinneringen wanneer de accounttijd op het punt staat te verlopen</string> + <string name="account_time_notification_channel_name">Accounttijdsherinneringen</string> + <string name="add_a_server">Server toevoegen</string> + <string name="add_anyway">Toch toevoegen</string> + <string name="add_time_to_account">Koop krediet op onze website of wissel een voucher in.</string> + <string name="all_applications">Alle toepassingen</string> + <string name="allow_lan_footer">Biedt toegang tot andere toestellen op hetzelfde netwerk voor delen, afdrukken, en dergelijke</string> + <string name="app_version">Appversie</string> + <string name="auth_failed">Accountauthenticatie mislukt.</string> + <string name="auto_connect">Automatisch verbinden</string> + <string name="auto_connect_footer">Automatisch verbinden met een server wanneer de app wordt opgestart.</string> + <string name="back">Terug</string> + <string name="blocked_connection">VERBINDING GEBLOKKEERD</string> + <string name="blocking_all_connections">Alle verbindingen worden geblokkeerd</string> + <string name="blocking_internet">Internet blokkeren</string> + <string name="buy_credit">Krediet kopen</string> + <string name="buy_more_credit">Meer krediet kopen</string> + <string name="cancel">Annuleren</string> + <string name="confirm_local_dns">De lokale DNS-server werkt niet tenzij u \"Lokale netwerken delen\" inschakelt onder Voorkeuren.</string> + <string name="confirm_no_email">U staat op het punt om het probleemrapport te verzenden zonder een contactmethode op te geven. Voer een e-mailadres in als u een antwoord wenst op het rapport.</string> + <string name="congrats">Gefeliciteerd!</string> + <string name="connect">Mijn verbinding beveiligen</string> + <string name="connecting">Verbinden</string> + <string name="connecting_to_daemon">Verbinden met Mullvad-systeemdienst...</string> + <string name="copied_mullvad_account_number">Mullvad-accountnummer gekopieerd naar klembord</string> + <string name="copied_to_clipboard">Gekopieerd naar klembord</string> + <string name="copied_wireguard_public_key">Openbare WireGuard-sleutel gekopieerd naar klembord</string> + <string name="create_account">Account aanmaken</string> + <string name="creating_new_account">Account aanmaken...</string> + <string name="creating_secure_connection">BEVEILIGDE VERBINDING AANMAKEN</string> + <string name="critical_error">Kritieke fout (uw aandacht is vereist)</string> + <string name="custom_dns_footer">Schakel in om minimaal één DNS-server toe te voegen.</string> + <string name="custom_dns_hint">Voer IP-adres in</string> + <string name="custom_tunnel_host_resolution_error">Kon de hostnaam van de aangepaste server niet omzetten</string> + <string name="disconnect">Verbinding verbreken</string> + <string name="disconnecting">Verbinding wordt verbroken</string> + <string name="dismiss">Negeren</string> + <string name="dont_have_an_account">Hebt u geen accountnummer?</string> + <string name="edit_message">Bericht bewerken</string> + <string name="enable">Inschakelen</string> + <string name="enable_custom_dns">Aangepaste DNS-server gebruiken</string> + <string name="enter_voucher_code">Vouchercode invoeren</string> + <string name="error_occurred">Er is een fout opgetreden.</string> + <string name="error_state">VERBINDING BEVEILIGEN MISLUKT</string> + <string name="exclude_applications">Uitgesloten toepassingen</string> + <string name="failed_to_block_internet">Kon alle netwerkverkeer niet blokkeren. Los problemen op of meld het aan ons.</string> + <string name="failed_to_create_account">Account aanmaken mislukt</string> + <string name="failed_to_generate_key">Sleutel genereren mislukt</string> + <string name="failed_to_send">Verzenden mislukt</string> + <string name="failed_to_send_details">Voordat u het opnieuw probeert, moet u mogelijk terugkeren naar het hoofdscherm van de app en klikken op Verbinding verbreken. De in het formulier ingevoerde gegevens blijven bewaard.</string> + <string name="faqs_and_guides">Veelgestelde vragen en gidsen</string> + <string name="foreground_notification_channel_description">Toont huidige status van VPN-tunnel</string> + <string name="foreground_notification_channel_name">Status VPN-tunnel</string> + <string name="here_is_your_account_number">Hier is uw accountnummer. Sla het op!</string> + <string name="hint_default">Standaard</string> + <string name="in_address">In</string> + <string name="invalid_dns_servers">Aangepaste DNS-serveradressen %1$s zijn ongeldig</string> + <string name="invalid_voucher">Vouchercode is ongeldig.</string> + <string name="ipv6_unavailable">Kon IPv6 niet configureren</string> + <string name="is_offline">Dit apparaat is offline, er kunnen geen tunnels tot stand worden gebracht</string> + <string name="less_than_a_day_left">minder dan een dag over</string> + <string name="less_than_a_minute_ago">minder dan een minuut geleden</string> + <string name="local_network_sharing">Delen op lokaal netwerk</string> + <string name="log_out">Afmelden</string> + <string name="logged_in_title">Aangemeld</string> + <string name="logging_in_description">Accountnummer wordt gecontroleerd</string> + <string name="logging_in_title">Aanmelden...</string> + <string name="login_description">Voer uw accountnummer in</string> + <string name="login_fail_description">Ongeldig accountnummer</string> + <string name="login_fail_title">Aanmelden mislukt</string> + <string name="login_title">Aanmelden</string> + <string name="mullvad_account_number">Mullvad-accountnummer</string> + <string name="no_matching_bridge_relay">Er komt geen bridge-relaisserver overeen met de huidige instellingen</string> + <string name="no_matching_relay">Er komt geen relaisserver overeen met de huidige instellingen</string> + <string name="no_wireguard_key">Geldige WireGuard-sleutel ontbreekt. Beheer sleutels onder Geavanceerde instellingen.</string> + <string name="not_blocking_internet">U LEKT MOGELIJK NETWERKVERKEER</string> + <string name="out_address">Uit</string> + <string name="out_of_time">Geen tijd meer</string> + <string name="paid_until">Betaald tot</string> + <string name="pay_to_start_using">Om de app te gebruiken, moet u eerst tijd toevoegen aan uw account.</string> + <string name="problem_report_description">Het logboekbestand van uw app wordt aan dit bericht gekoppeld zodat we u beter kunnen helpen. Uw gegevens blijven veilig en privé, omdat ze worden geanonimiseerd voordat ze over een versleuteld kanaal worden verzonden.</string> + <string name="public_key">Openbare sleutel</string> + <string name="reconnecting">Opnieuw verbinden</string> + <string name="redeem">Inwisselen</string> + <string name="redeem_voucher">Voucher inwisselen</string> + <string name="report_a_problem">Een probleem rapporteren</string> + <string name="secure_connection">BEVEILIGDE VERBINDING</string> + <string name="secured">Beveiligd</string> + <string name="select_location">Locatie selecteren</string> + <string name="select_location_description">Wanneer u verbonden bent, wordt uw daadwerkelijke locatie gemaskeerd met een private en veilige locatie in de geselecteerde regio.</string> + <string name="send">Verzenden</string> + <string name="send_anyway">Toch verzenden</string> + <string name="sending">Verzenden...</string> + <string name="sent">Verzonden</string> + <string name="sent_contact">Indien nodig nemen we contact met u op via %1$s</string> + <string name="sent_thanks">Bedankt!</string> + <string name="set_dns_error">Instellen DNS-server systeem mislukt</string> + <string name="set_firewall_policy_error">Toepassen van firewallregels mislukt. Het apparaat is mogelijk niet beveiligd</string> + <string name="settings">Instellingen</string> + <string name="settings_account">Account</string> + <string name="settings_advanced">Geavanceerd</string> + <string name="settings_preferences">Voorkeuren</string> + <string name="show_system_apps">Systeemapps weergeven</string> + <string name="split_tunneling">Split tunneling</string> + <string name="split_tunneling_description">Split tunneling maakt het mogelijk te kiezen welke toepassingen niet via de VPN-tunnel moeten worden omgeleid.</string> + <string name="start_tunnel_error">Starten van tunnelverbinding mislukt</string> + <string name="switch_location">Locatie wijzigen</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">Te veel WireGuard-sleutels geregistreerd op account</string> + <string name="try_again">Probeer het opnieuw</string> + <string name="udp">UDP</string> + <string name="unsecured">Niet beveiligd</string> + <string name="unsecured_connection">NIET-BEVEILIGDE VERBINDING</string> + <string name="unsupported_version">NIET-ONDERSTEUNDE VERSIE</string> + <string name="unsupported_version_description">U gebruikt een niet-ondersteunde versie van de app. Upgrade nu naar %1$s om uw veiligheid te waarborgen</string> + <string name="unsupported_version_without_upgrade">U gebruikt een niet-ondersteunde appversie.</string> + <string name="update_available">UPDATE BESCHIKBAAR</string> + <string name="update_available_description">Installeer Mullvad VPN (%1$s) om up-to-date te blijven</string> + <string name="update_available_footer">Update beschikbaar, download deze om veilig te blijven.</string> + <string name="user_email_hint">Uw e-mailadres (optioneel)</string> + <string name="user_message_hint">Beschrijf uw probleem in het Engels of Zweeds.</string> + <string name="view_logs">Applogboeken weergeven</string> + <string name="virtual_adapter_problem">Fout virtuele adapter</string> + <string name="voucher_already_used">Vouchercode is al gebruikt.</string> + <string name="vpn_permission_denied_error">VPN-toestemming is geweigerd tijdens opzetten van de tunnel. Probeer opnieuw te verbinden.</string> + <string name="we_will_look_into_this">We gaan het bekijken.</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">WireGuard-fout</string> + <string name="wireguard_generate_key">Sleutel genereren</string> + <string name="wireguard_key">WireGuard-sleutel</string> + <string name="wireguard_key_generated">Sleutel gegenereerd</string> + <string name="wireguard_key_invalid">Sleutel is ongeldig</string> + <string name="wireguard_key_reconnecting">Opnieuw verbinden met nieuwe WireGuard-sleutel...</string> + <string name="wireguard_key_valid">Sleutel is geldig</string> + <string name="wireguard_key_verification_failure">Verificatie sleutel mislukt</string> + <string name="wireguard_manage_keys">Sleutels beheren</string> + <string name="wireguard_mtu">WireGuard-MTU</string> + <string name="wireguard_mtu_footer">Stel MTU-waarde voor WireGuard in. Geldig bereik: %1$d - %2$d.</string> + <string name="wireguard_public_key">Openbare WireGuard-sleutel</string> + <string name="wireguard_replace_key">Sleutel opnieuw genereren</string> + <string name="wireguard_verify_key">Sleutel verifiëren</string> +</resources> diff --git a/android/app/src/main/res/values-pl/plurals.xml b/android/app/src/main/res/values-pl/plurals.xml new file mode 100644 index 0000000000..67654bfc29 --- /dev/null +++ b/android/app/src/main/res/values-pl/plurals.xml @@ -0,0 +1,63 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="one">Pozostał 1 dzień</item> + <item quantity="few">Pozostały %1$d dni</item> + <item quantity="many">Pozostało %1$d dni</item> + <item quantity="other">Pozostało %1$d dnia</item> + </plurals> + <plurals name="months_left"> + <item quantity="one">Pozostał 1 miesiąc</item> + <item quantity="few">Pozostały %1$d miesiące</item> + <item quantity="many">Pozostało %1$d miesięcy</item> + <item quantity="other">Pozostało %1$d miesiąca</item> + </plurals> + <plurals name="years_left"> + <item quantity="one">Pozostał 1 rok</item> + <item quantity="few">Pozostały %1$d lata</item> + <item quantity="many">Pozostało %1$d lat</item> + <item quantity="other">Pozostało %1$d roku</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">dzień temu</item> + <item quantity="few">%1$d dni temu</item> + <item quantity="many">%1$d dni temu</item> + <item quantity="other">%1$d dnia temu</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="one">minutę temu</item> + <item quantity="few">%1$d minuty temu</item> + <item quantity="many">%1$d minut temu</item> + <item quantity="other">%1$d minuty temu</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">miesiąc temu</item> + <item quantity="few">%1$d miesiące temu</item> + <item quantity="many">%1$d miesięcy temu</item> + <item quantity="other">%1$d miesiąca temu</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">rok temu</item> + <item quantity="few">%1$d lata temu</item> + <item quantity="many">%1$d lat temu</item> + <item quantity="other">%1$d roku temu</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">godzinę temu</item> + <item quantity="few">%1$d godziny temu</item> + <item quantity="many">%1$d godzin temu</item> + <item quantity="other">%1$d godzuny temu</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="one">Doładowanie konta wygasa za 1 dzień</item> + <item quantity="few">Doładowanie konta wygasa za %1$d dni</item> + <item quantity="many">Doładowanie konta wygasa za %1$d dni</item> + <item quantity="other">Doładowanie konta wygasa za %1$d dnia</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="one">Doładowanie konta wygasa za 1 godzinę</item> + <item quantity="few">Doładowanie konta wygasa za %1$d godziny</item> + <item quantity="many">Doładowanie konta wygasa za %1$d godzin</item> + <item quantity="other">Doładowanie konta wygasa za %1$d godziny</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000000..f2b758330c --- /dev/null +++ b/android/app/src/main/res/values-pl/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">Utworzono konto</string> + <string name="account_credit_expires_in_a_few_minutes">Doładowanie konta wygasa za kilka minut</string> + <string name="account_credit_expires_soon">Doładowanie konta wkrótce wygasa</string> + <string name="account_credit_has_expired">Na tym koncie nie masz już czasu VPN.</string> + <string name="account_number">Numer konta</string> + <string name="account_time_notification_channel_description">Pokazuje przypomnienia, gdy kończy się czas na koncie</string> + <string name="account_time_notification_channel_name">Przypomnienia o czasie na koncie</string> + <string name="add_a_server">Dodaj serwer</string> + <string name="add_anyway">Mimo to dodaj</string> + <string name="add_time_to_account">Doładuj w naszej witrynie internetowej lub zrealizuj kupon.</string> + <string name="all_applications">Wszystkie aplikacje</string> + <string name="allow_lan_footer">Umożliwia dostęp do innych urządzeń w tej samej sieci w celu udostępniania, drukowania itd.</string> + <string name="app_version">Wersja aplikacji</string> + <string name="auth_failed">Niepowodzenie uwierzytelnienia konta.</string> + <string name="auto_connect">Automatyczne łączenie</string> + <string name="auto_connect_footer">Automatycznie łącz z serwerem po uruchomieniu aplikacji.</string> + <string name="back">Wstecz</string> + <string name="blocked_connection">ZABLOKOWANE POŁĄCZENIE</string> + <string name="blocking_all_connections">Blokowanie wszystkich połączeń</string> + <string name="blocking_internet">Blokowanie Internetu</string> + <string name="buy_credit">Kup doładowanie</string> + <string name="buy_more_credit">Doładuj konto</string> + <string name="cancel">Anuluj</string> + <string name="confirm_local_dns">Lokalny serwer DNS nie będzie działał, dopóki nie włączysz opcji „Udostępnianie sieci lokalnej” w Preferencjach.</string> + <string name="confirm_no_email">Za chwilę wyślesz zgłoszenie problemu, nie umożliwiając nam skontaktowania się z Tobą. Aby uzyskać odpowiedź na zgłoszenie, musisz podać adres e-mail.</string> + <string name="congrats">Gratulacje!</string> + <string name="connect">Zabezpiecz moje połączenie</string> + <string name="connecting">Łączenie</string> + <string name="connecting_to_daemon">Łączenie z usługą systemową Mullvad...</string> + <string name="copied_mullvad_account_number">Skopiowano numer konta Mullvad do schowka</string> + <string name="copied_to_clipboard">Skopiowano do schowka</string> + <string name="copied_wireguard_public_key">Skopiowano klucz publiczny WireGuard do schowka</string> + <string name="create_account">Utwórz konto</string> + <string name="creating_new_account">Tworzenie konta...</string> + <string name="creating_secure_connection">TWORZENIE BEZPIECZNEGO POŁĄCZENIA</string> + <string name="critical_error">Błąd krytyczny (wymagana uwaga)</string> + <string name="custom_dns_footer">Włącz, aby dodać co najmniej jeden serwer DNS.</string> + <string name="custom_dns_hint">Wprowadź adres IP</string> + <string name="custom_tunnel_host_resolution_error">Nie można rozpoznać nazwy hosta serwera niestandardowego</string> + <string name="disconnect">Rozłącz</string> + <string name="disconnecting">Rozłączanie</string> + <string name="dismiss">Odrzuć</string> + <string name="dont_have_an_account">Nie masz numeru konta?</string> + <string name="edit_message">Edytuj wiadomość</string> + <string name="enable">Włącz</string> + <string name="enable_custom_dns">Użyj niestandardowego serwera DNS</string> + <string name="enter_voucher_code">Wprowadź kod kuponu</string> + <string name="error_occurred">Wystąpił błąd.</string> + <string name="error_state">BŁĄD ZABEZPIECZANIA POŁĄCZENIA</string> + <string name="exclude_applications">Wykluczone aplikacje</string> + <string name="failed_to_block_internet">Nie można zablokować całego ruchu sieciowego. Rozwiąż problem lub zgłoś go nam.</string> + <string name="failed_to_create_account">Nie można utworzyć konta</string> + <string name="failed_to_generate_key">Nie można wygenerować klucza</string> + <string name="failed_to_send">Błąd wysyłania</string> + <string name="failed_to_send_details">Być może przed ponowną próbą trzeba będzie wrócić do ekranu głównego aplikacji i kliknąć przycisk Rozłącz. Nie martw się, wprowadzone informacje pozostaną w formularzu.</string> + <string name="faqs_and_guides">Często zadawane pytania i poradniki</string> + <string name="foreground_notification_channel_description">Pokazuje bieżący status tunelu VPN</string> + <string name="foreground_notification_channel_name">Status tunelu VPN</string> + <string name="here_is_your_account_number">Oto Twój numer konta. Zachowaj go!</string> + <string name="hint_default">Domyślnie</string> + <string name="in_address">Wejście</string> + <string name="invalid_dns_servers">Niestandardowe adresy serwerów DNS %1$s są nieprawidłowe</string> + <string name="invalid_voucher">Nieprawidłowy kod kuponu.</string> + <string name="ipv6_unavailable">Nie można skonfigurować IPv6</string> + <string name="is_offline">To urządzenie jest offline, nie można ustanowić tuneli</string> + <string name="less_than_a_day_left">pozostał mniej niż jeden dzień</string> + <string name="less_than_a_minute_ago">mniej niż minutę temu</string> + <string name="local_network_sharing">Udostępnianie sieci lokalnej</string> + <string name="log_out">Wyloguj się</string> + <string name="logged_in_title">Zalogowano jako</string> + <string name="logging_in_description">Sprawdzanie numeru konta</string> + <string name="logging_in_title">Logowanie...</string> + <string name="login_description">Wprowadź numer konta</string> + <string name="login_fail_description">Nieprawidłowy numer konta</string> + <string name="login_fail_title">Błąd logowania</string> + <string name="login_title">Logowanie</string> + <string name="mullvad_account_number">Numer konta Mullvad</string> + <string name="no_matching_bridge_relay">Żaden pomostowy serwer przekazujący nie odpowiada bieżącym ustawieniom</string> + <string name="no_matching_relay">Żaden serwer przekazujący nie odpowiada bieżącym ustawieniom</string> + <string name="no_wireguard_key">Brak prawidłowego klucza WireGuard. Zarządzaj kluczami w Ustawieniach zaawansowanych.</string> + <string name="not_blocking_internet">TWÓJ RUCH SIECIOWY MOŻE WYCIEKAĆ</string> + <string name="out_address">Wyjście</string> + <string name="out_of_time">Koniec czasu</string> + <string name="paid_until">Płatne do</string> + <string name="pay_to_start_using">Aby rozpocząć korzystanie z aplikacji, musisz najpierw dodać czas do swojego konta.</string> + <string name="problem_report_description">Aby pomóc Ci skuteczniej, do tej wiadomości dołączony zostanie plik dzienników aplikacji. Twoje dane pozostaną bezpieczne i prywatne, ponieważ przed wysłaniem zaszyfrowanym kanałem zostają one zanonimizowane.</string> + <string name="public_key">Klucz publiczny</string> + <string name="reconnecting">Ponowne łączenie</string> + <string name="redeem">Zrealizuj</string> + <string name="redeem_voucher">Zrealizuj kupon</string> + <string name="report_a_problem">Zgłoś problem</string> + <string name="secure_connection">BEZPIECZNE POŁĄCZENIE</string> + <string name="secured">Zabezpieczone</string> + <string name="select_location">Wybierz lokalizację</string> + <string name="select_location_description">Podczas połączenia Twoja prawdziwa lokalizacja jest maskowana prywatną i bezpieczną lokalizacją w wybranym regionie.</string> + <string name="send">Wyślij</string> + <string name="send_anyway">Mimo to wyślij</string> + <string name="sending">Wysyłanie...</string> + <string name="sent">Wysłano</string> + <string name="sent_contact">W razie potrzeby skontaktujemy się z Tobą pod adresem %1$s</string> + <string name="sent_thanks">Dziękujemy!</string> + <string name="set_dns_error">Niepowodzenie ustawienia systemowego serwera DNS</string> + <string name="set_firewall_policy_error">Błąd stosowania reguł zapory. Urządzenie może być obecnie niezabezpieczone</string> + <string name="settings">Ustawienia</string> + <string name="settings_account">Konto</string> + <string name="settings_advanced">Zaawansowane</string> + <string name="settings_preferences">Preferencje</string> + <string name="show_system_apps">Pokaż aplikacje systemowe</string> + <string name="split_tunneling">Dzielone tunelowanie</string> + <string name="split_tunneling_description">Dzielone tunelowanie umożliwia wybranie aplikacji, które nie powinny być kierowane przez tunel VPN.</string> + <string name="start_tunnel_error">Niepowodzenie uruchomienia połączenia tunelowego</string> + <string name="switch_location">Zmień lokalizację</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">Na koncie zarejestrowano zbyt wiele kluczy WireGuard</string> + <string name="try_again">Spróbuj ponownie</string> + <string name="udp">UDP</string> + <string name="unsecured">Niezabezpieczone</string> + <string name="unsecured_connection">NIEZABEZPIECZONE POŁĄCZENIE</string> + <string name="unsupported_version">WERSJA NIEOBSŁUGIWANA</string> + <string name="unsupported_version_description">Używasz nieobsługiwanej wersji aplikacji. Aby zapewnić sobie bezpieczeństwo, uaktualnij teraz do wersji %1$s</string> + <string name="unsupported_version_without_upgrade">Używasz nieobsługiwanej wersji aplikacji.</string> + <string name="update_available">DOSTĘPNA JEST AKTUALIZACJA</string> + <string name="update_available_description">Aby być na bieżąco, zainstaluj Mullvad VPN (%1$s)</string> + <string name="update_available_footer">Dostępna jest aktualizacja. Aby zachować bezpieczeństwo, pobierz ją.</string> + <string name="user_email_hint">Twój adres e-mail (opcjonalnie)</string> + <string name="user_message_hint">Opisz problem po angielsku lub szwedzku.</string> + <string name="view_logs">Wyświetl dzienniki aplikacji</string> + <string name="virtual_adapter_problem">Błąd wirtualnej karty sieciowej</string> + <string name="voucher_already_used">Kod z tego kuponu został już użyty.</string> + <string name="vpn_permission_denied_error">Uprawnienie VPN zostało odrzucone podczas tworzenia tunelu. Spróbuj połączyć się ponownie.</string> + <string name="we_will_look_into_this">Sprawdzimy to.</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">Błąd WireGuard</string> + <string name="wireguard_generate_key">Wygeneruj klucz</string> + <string name="wireguard_key">Klucz WireGuard</string> + <string name="wireguard_key_generated">Wygenerowano klucz</string> + <string name="wireguard_key_invalid">Nieprawidłowy klucz</string> + <string name="wireguard_key_reconnecting">Ponowne łączenie z nowym kluczem WireGuard...</string> + <string name="wireguard_key_valid">Prawidłowy klucz</string> + <string name="wireguard_key_verification_failure">Błąd weryfikacji klucza</string> + <string name="wireguard_manage_keys">Zarządzaj kluczami</string> + <string name="wireguard_mtu">MTU WireGuard</string> + <string name="wireguard_mtu_footer">Ustaw wartość MTU WireGuard. Prawidłowy zakres: %1$d–%2$d.</string> + <string name="wireguard_public_key">Klucz publiczny WireGuard</string> + <string name="wireguard_replace_key">Ponownie wygeneruj klucz</string> + <string name="wireguard_verify_key">Zweryfikuj klucz</string> +</resources> diff --git a/android/app/src/main/res/values-pt/plurals.xml b/android/app/src/main/res/values-pt/plurals.xml new file mode 100644 index 0000000000..e4eba372a7 --- /dev/null +++ b/android/app/src/main/res/values-pt/plurals.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="one">1 dia restante</item> + <item quantity="other">%1$d dias restantes</item> + </plurals> + <plurals name="months_left"> + <item quantity="one">1 mês restante</item> + <item quantity="other">%1$d meses restantes</item> + </plurals> + <plurals name="years_left"> + <item quantity="one">1 ano restante</item> + <item quantity="other">%1$d anos restantes</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">há um dia</item> + <item quantity="other">há %1$d dias</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="one">há um minuto</item> + <item quantity="other">há %1$d minutos</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">há um mês</item> + <item quantity="other">há %1$d meses</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">há um ano</item> + <item quantity="other">há %1$d anos</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">há uma hora</item> + <item quantity="other">há %1$d horas</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="one">O crédito da conta expira dentro de um dia</item> + <item quantity="other">O crédito da conta expira dentro de %1$d dias</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="one">O crédito da conta expira dentro de uma hora</item> + <item quantity="other">O crédito da conta expira dentro de %1$d horas</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-pt/strings.xml b/android/app/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000000..29e2b0a007 --- /dev/null +++ b/android/app/src/main/res/values-pt/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">Conta criada</string> + <string name="account_credit_expires_in_a_few_minutes">O crédito da conta expira dentro de alguns minutos</string> + <string name="account_credit_expires_soon">O crédito da conta expira brevemente</string> + <string name="account_credit_has_expired">Não tem mais Tempo de VPN restante nesta conta.</string> + <string name="account_number">Número de conta</string> + <string name="account_time_notification_channel_description">Mostra lembretes quando o tempo da conta está prestes a expirar</string> + <string name="account_time_notification_channel_name">Lembretes de tempo da conta</string> + <string name="add_a_server">Adicionar um servidor</string> + <string name="add_anyway">Adicionar mesmo assim</string> + <string name="add_time_to_account">Compre crédito no nosso sítio da web ou reclame um voucher.</string> + <string name="all_applications">Todas as aplicações</string> + <string name="allow_lan_footer">Permite o acesso a outros dispositivos na mesma rede para partilha, impressão, etc.</string> + <string name="app_version">Versão da app</string> + <string name="auth_failed">Erro na autenticação da conta.</string> + <string name="auto_connect">Ligação automática</string> + <string name="auto_connect_footer">Liga-se automaticamente a um servidor quando a app inicia.</string> + <string name="back">Retroceder</string> + <string name="blocked_connection">LIGAÇÃO BLOQUEADA</string> + <string name="blocking_all_connections">A bloquear todas as ligações</string> + <string name="blocking_internet">A bloquear a Internet</string> + <string name="buy_credit">Comprar crédito</string> + <string name="buy_more_credit">Comprar mais crédito</string> + <string name="cancel">Cancelar</string> + <string name="confirm_local_dns">O servidor DNS local não funcionará exceto se ativar \"Partilha de rede local\" em Preferências.</string> + <string name="confirm_no_email">Está prestes a enviar o relatório de problema sem que tenhamos uma forma de lhe responder. Se pretender uma resposta ao seu relatório, tem de introduzir um endereço de email.</string> + <string name="congrats">Parabéns!</string> + <string name="connect">Tornar a minha ligação segura</string> + <string name="connecting">A ligar</string> + <string name="connecting_to_daemon">A ligar-se ao serviço de sistema Mulvad...</string> + <string name="copied_mullvad_account_number">Número de conta Mullvad copiado para a área de transferência</string> + <string name="copied_to_clipboard">Copiado para a área de transferência</string> + <string name="copied_wireguard_public_key">A chave pública WireGuard foi copiada para a área de transferência</string> + <string name="create_account">Criar conta</string> + <string name="creating_new_account">A criar conta...</string> + <string name="creating_secure_connection">A CRIAR LIGAÇÃO SEGURA</string> + <string name="critical_error">Erro crítico (é necessária a sua atenção)</string> + <string name="custom_dns_footer">Ativar para adicionar pelo menos um servidor DNS.</string> + <string name="custom_dns_hint">Introduzir IP</string> + <string name="custom_tunnel_host_resolution_error">Não foi possível resolver o nome do anfitrião do servidor personalizado</string> + <string name="disconnect">Desligar</string> + <string name="disconnecting">A desligar</string> + <string name="dismiss">Dispensar</string> + <string name="dont_have_an_account">Não tem um número de conta?</string> + <string name="edit_message">Editar mensagem</string> + <string name="enable">Ativar</string> + <string name="enable_custom_dns">Usar servidor DNS personalizado</string> + <string name="enter_voucher_code">Introduza o código do voucher</string> + <string name="error_occurred">Ocorreu um erro.</string> + <string name="error_state">ERRO AO ESTABELECER LIGAÇÃO SEGURA</string> + <string name="exclude_applications">Aplicações excluídas</string> + <string name="failed_to_block_internet">Não foi possível bloquear todo o tráfego de rede. Experimente a resolução de problemas ou comunique-nos o problema.</string> + <string name="failed_to_create_account">Não foi possível criar a conta</string> + <string name="failed_to_generate_key">Não foi possível gerar uma chave</string> + <string name="failed_to_send">Erro no envio</string> + <string name="failed_to_send_details">Pode ter de voltar ao ecrã principal da app e clicar em Desligar antes de tentar novamente. Não se preocupe, a informação que introduziu permanecerá no formulário.</string> + <string name="faqs_and_guides">Perguntas frequentes e guias</string> + <string name="foreground_notification_channel_description">Indica o estado atual do túnel VPN</string> + <string name="foreground_notification_channel_name">Estado do túnel VPN</string> + <string name="here_is_your_account_number">Aqui tem o seu número de conta. Guarde-o!</string> + <string name="hint_default">Padrão</string> + <string name="in_address">Entrada</string> + <string name="invalid_dns_servers">Os endereços do servidor DNS personalizado %1$s são inválidos</string> + <string name="invalid_voucher">Código do voucher inválido.</string> + <string name="ipv6_unavailable">Não foi possível configurar o IPv6</string> + <string name="is_offline">Este dispositivo está offline, não foi possível configurar túneis</string> + <string name="less_than_a_day_left">menos de um dia restante</string> + <string name="less_than_a_minute_ago">há menos de um minuto</string> + <string name="local_network_sharing">Partilha de rede local</string> + <string name="log_out">Terminar sessão</string> + <string name="logged_in_title">Sessão iniciada</string> + <string name="logging_in_description">A verificar o número da conta</string> + <string name="logging_in_title">A iniciar sessão...</string> + <string name="login_description">Introduza o seu número de conta</string> + <string name="login_fail_description">Número de conta inválido</string> + <string name="login_fail_title">Erro ao iniciar sessão</string> + <string name="login_title">Iniciar sessão</string> + <string name="mullvad_account_number">Número de conta Mullvad</string> + <string name="no_matching_bridge_relay">Nenhum servidor bridge de retransmissão corresponde às configurações atuais</string> + <string name="no_matching_relay">Nenhum servidor de retransmissão corresponde às configurações atuais</string> + <string name="no_wireguard_key">Chave WireGuard válida em falta. Faça a gestão das chaves em Definições Avançadas.</string> + <string name="not_blocking_internet">PODERÁ ESTAR A PERDER TRÁFEGO DE REDE</string> + <string name="out_address">Saída</string> + <string name="out_of_time">Sem tempo</string> + <string name="paid_until">Pago até</string> + <string name="pay_to_start_using">Para começar a utilizar a aplicação, primeiro tem de adicionar tempo à sua conta.</string> + <string name="problem_report_description">Para o ajudarmos de forma mais eficaz, o ficheiro de registo da sua aplicação será anexado a esta mensagem. Os seus dados permanecerão seguros e privados, pois são tornados anónimos antes de serem enviados através de um canal encriptado.</string> + <string name="public_key">Chave pública</string> + <string name="reconnecting">A religar</string> + <string name="redeem">Reclamar</string> + <string name="redeem_voucher">Reclamar voucher</string> + <string name="report_a_problem">Reportar um problema</string> + <string name="secure_connection">LIGAÇÃO SEGURA</string> + <string name="secured">Seguro</string> + <string name="select_location">Selecionar local</string> + <string name="select_location_description">Enquanto estiver ligado, a sua localização real será mascarada com uma localização privada e segura na região selecionada.</string> + <string name="send">Enviar</string> + <string name="send_anyway">Enviar mesmo assim</string> + <string name="sending">A enviar...</string> + <string name="sent">Enviado</string> + <string name="sent_contact">Se necessário, iremos contactá-lo através de %1$s</string> + <string name="sent_thanks">Obrigado!</string> + <string name="set_dns_error">Erro ao definir o servidor de sistema DNS</string> + <string name="set_firewall_policy_error">Erro ao aplicar as regras da firewall. O dispositivo pode estar atualmente inseguro</string> + <string name="settings">Definições</string> + <string name="settings_account">Conta</string> + <string name="settings_advanced">Avançadas</string> + <string name="settings_preferences">Preferências</string> + <string name="show_system_apps">Mostrar aplicações do sistema</string> + <string name="split_tunneling">Divisão do túnel</string> + <string name="split_tunneling_description">A divisão do túnel permite selecionar quais as aplicações que devem ser direcionadas através do túnel VPN.</string> + <string name="start_tunnel_error">Erro ao iniciar a ligação de túnel</string> + <string name="switch_location">Alterar local</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">Demasiadas chaves WireGuard registadas na conta</string> + <string name="try_again">Tentar novamente</string> + <string name="udp">UDP</string> + <string name="unsecured">Inseguro</string> + <string name="unsecured_connection">LIGAÇÃO INSEGURA</string> + <string name="unsupported_version">VERSÃO NÃO SUPORTADA</string> + <string name="unsupported_version_description">Está a correr uma versão da app que não é suportada. Faça o upgrade para %1$s agora de forma a garantir a sua segurança</string> + <string name="unsupported_version_without_upgrade">Está a executar uma versão da aplicação não suportada.</string> + <string name="update_available">ESTÁ DISPONÍVEL UMA ATUALIZAÇÃO</string> + <string name="update_available_description">Instalar o Mullvad VPN (%1$s) para ficar atualizado</string> + <string name="update_available_footer">Atualização disponível, transfira-a para ficar seguro.</string> + <string name="user_email_hint">O seu email (opcional)</string> + <string name="user_message_hint">Descreva o seu problema em Inglês ou Sueco.</string> + <string name="view_logs">Ver os registos da app</string> + <string name="virtual_adapter_problem">Erro de adaptador virtual</string> + <string name="voucher_already_used">O código do voucher já foi utilizado.</string> + <string name="vpn_permission_denied_error">A transmissão foi negada durante a criação do túnel. Tente fazer novamente a ligação.</string> + <string name="we_will_look_into_this">Vamos analisar esta situação.</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">Erro WireGuard</string> + <string name="wireguard_generate_key">Gerar chave</string> + <string name="wireguard_key">Chave WireGuard</string> + <string name="wireguard_key_generated">Chave gerada</string> + <string name="wireguard_key_invalid">A chave é inválida</string> + <string name="wireguard_key_reconnecting">A restabelecer ligação com nova chave WireGuard...</string> + <string name="wireguard_key_valid">A chave é válida</string> + <string name="wireguard_key_verification_failure">Não foi possível verificar a chave</string> + <string name="wireguard_manage_keys">Gerir chaves</string> + <string name="wireguard_mtu">WireGuard MTU</string> + <string name="wireguard_mtu_footer">Definir o valor WireGuard MTU. Intervalo válido: %1$d - %2$d.</string> + <string name="wireguard_public_key">Chave pública WireGuard</string> + <string name="wireguard_replace_key">Voltar a gerar chave</string> + <string name="wireguard_verify_key">Verificar chave</string> +</resources> diff --git a/android/app/src/main/res/values-ru/plurals.xml b/android/app/src/main/res/values-ru/plurals.xml new file mode 100644 index 0000000000..3a4cfffb59 --- /dev/null +++ b/android/app/src/main/res/values-ru/plurals.xml @@ -0,0 +1,63 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="one">Остался %1$d день</item> + <item quantity="few">Осталось %1$d дня</item> + <item quantity="many">Осталось %1$d дней</item> + <item quantity="other">Осталось %1$d дня</item> + </plurals> + <plurals name="months_left"> + <item quantity="one">Остался %1$d месяц</item> + <item quantity="few">Осталось %1$d месяца</item> + <item quantity="many">Осталось %1$d месяцев</item> + <item quantity="other">Осталось %1$d месяца</item> + </plurals> + <plurals name="years_left"> + <item quantity="one">Остался %1$d год</item> + <item quantity="few">Осталось %1$d года</item> + <item quantity="many">Осталось %1$d лет</item> + <item quantity="other">Осталось %1$d года</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">%1$d день назад</item> + <item quantity="few">%1$d дня назад</item> + <item quantity="many">%1$d дней назад</item> + <item quantity="other">%1$d дня назад</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="one">%1$d минуту назад</item> + <item quantity="few">%1$d минуты назад</item> + <item quantity="many">%1$d минут назад</item> + <item quantity="other">%1$d минуты назад</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">%1$d месяц назад</item> + <item quantity="few">%1$d месяца назад</item> + <item quantity="many">%1$d месяцев назад</item> + <item quantity="other">%1$d месяца назад</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">%1$d год назад</item> + <item quantity="few">%1$d года назад</item> + <item quantity="many">%1$d лет назад</item> + <item quantity="other">%1$d года назад</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">%1$d час назад</item> + <item quantity="few">%1$d часа назад</item> + <item quantity="many">%1$d часов назад</item> + <item quantity="other">%1$d часа назад</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="one">Срок действия баланса учетной записи истекает через %1$d день</item> + <item quantity="few">Срок действия баланса учетной записи истекает через %1$d дня</item> + <item quantity="many">Срок действия баланса учетной записи истекает через %1$d дней</item> + <item quantity="other">Срок действия баланса учетной записи истекает через %1$d дня</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="one">Срок действия баланса учетной записи истекает через %1$d час</item> + <item quantity="few">Срок действия баланса учетной записи истекает через %1$d часа</item> + <item quantity="many">Срок действия баланса учетной записи истекает через %1$d часов</item> + <item quantity="other">Срок действия баланса учетной записи истекает через %1$d часа</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000000..d28f1535fa --- /dev/null +++ b/android/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">Учетная запись создана</string> + <string name="account_credit_expires_in_a_few_minutes">Баланс учетной записи закончится через несколько минут</string> + <string name="account_credit_expires_soon">Баланс учетной записи скоро закончится</string> + <string name="account_credit_has_expired">На этой учетной записи времени VPN не осталось.</string> + <string name="account_number">Номер учетной записи</string> + <string name="account_time_notification_channel_description">Показывает уведомления, когда время на учетной записи скоро закончится</string> + <string name="account_time_notification_channel_name">Напоминания о времени на учетной записи</string> + <string name="add_a_server">Добавить сервер</string> + <string name="add_anyway">Всё равно добавить</string> + <string name="add_time_to_account">Пополните баланс у нас на сайте или погасите ваучер.</string> + <string name="all_applications">Все приложения</string> + <string name="allow_lan_footer">Разрешить доступ к другим устройствам в той же сети для передачи данных, печати и т. д.</string> + <string name="app_version">Версия приложения</string> + <string name="auth_failed">Ошибка аутентификации учетной записи.</string> + <string name="auto_connect">Автоподключение</string> + <string name="auto_connect_footer">Автоматически подключаться к серверу при запуске приложения.</string> + <string name="back">Назад</string> + <string name="blocked_connection">ПОДКЛЮЧЕНИЕ ЗАБЛОКИРОВАНО</string> + <string name="blocking_all_connections">Блокируются все подключения</string> + <string name="blocking_internet">Блокируется доступ в Интернет</string> + <string name="buy_credit">Пополнить баланс</string> + <string name="buy_more_credit">Пополнить баланс</string> + <string name="cancel">Отмена</string> + <string name="confirm_local_dns">Локальный DNS-сервер не будет работать, пока вы не включите «Обмен данными в локальной сети» в разделе «Параметры».</string> + <string name="confirm_no_email">Вы собираетесь отправить отчет о проблеме, не оставив контакты. Если вы хотите получить ответ, введите свой адрес электронной почты.</string> + <string name="congrats">Поздравляем!</string> + <string name="connect">Защитить мое подключение</string> + <string name="connecting">Идет подключение</string> + <string name="connecting_to_daemon">Подключение к системному сервису Mullvad...</string> + <string name="copied_mullvad_account_number">Номер учетной записи Mullvad скопирован в буфер обмена</string> + <string name="copied_to_clipboard">Скопировано в буфер обмена</string> + <string name="copied_wireguard_public_key">Открытый ключ WireGuard скопирован в буфер обмена</string> + <string name="create_account">Создать учетную запись</string> + <string name="creating_new_account">Создание учетной записи...</string> + <string name="creating_secure_connection">СОЗДАНИЕ ЗАЩИЩЕННОГО ПОДКЛЮЧЕНИЯ</string> + <string name="critical_error">Критическая ошибка (требуется ваше участие)</string> + <string name="custom_dns_footer">Чтобы добавить как минимум один DNS-сервер, включите этот параметр.</string> + <string name="custom_dns_hint">Введите IP-адрес</string> + <string name="custom_tunnel_host_resolution_error">Не удалось преобразовать имя узла пользовательского сервера</string> + <string name="disconnect">Отключить</string> + <string name="disconnecting">Отключение</string> + <string name="dismiss">Закрыть</string> + <string name="dont_have_an_account">У вас нет номера учетной записи?</string> + <string name="edit_message">Изменить сообщение</string> + <string name="enable">Включить</string> + <string name="enable_custom_dns">Пользовательский DNS-сервер</string> + <string name="enter_voucher_code">Введите код ваучера</string> + <string name="error_occurred">Произошла ошибка.</string> + <string name="error_state">НЕ УДАЛОСЬ УСТАНОВИТЬ БЕЗОПАСНОЕ ПОДКЛЮЧЕНИЕ</string> + <string name="exclude_applications">Исключенные приложения</string> + <string name="failed_to_block_internet">Не удалось заблокировать весь сетевой трафик. Устраните неисправность или сообщите нам о проблеме.</string> + <string name="failed_to_create_account">Не удалось создать учетную запись</string> + <string name="failed_to_generate_key">Не удалось сгенерировать ключ</string> + <string name="failed_to_send">Ошибка отправки</string> + <string name="failed_to_send_details">Возможно, потребуется вернуться на основной экран приложения и нажать кнопку «Отключить», прежде чем повторять попытку. Не волнуйтесь: введенные в форму данные сохранятся.</string> + <string name="faqs_and_guides">Ответы на вопросы и руководства</string> + <string name="foreground_notification_channel_description">Показывает текущее состояние VPN-туннеля</string> + <string name="foreground_notification_channel_name">Состояние туннеля VPN</string> + <string name="here_is_your_account_number">Вот номер вашей учетной записи. Сохраните его!</string> + <string name="hint_default">По умолчанию</string> + <string name="in_address">Вход</string> + <string name="invalid_dns_servers">Пользовательские адреса DNS-серверов %1$s недопустимы</string> + <string name="invalid_voucher">Код ваучера недействителен.</string> + <string name="ipv6_unavailable">Не удалось сконфигурировать IPv6</string> + <string name="is_offline">Устройство вне сети, установить подключение к туннелям невозможно</string> + <string name="less_than_a_day_left">осталось менее суток</string> + <string name="less_than_a_minute_ago">менее минуты назад</string> + <string name="local_network_sharing">Обмен данными в локальной сети</string> + <string name="log_out">Выйти</string> + <string name="logged_in_title">Вход выполнен</string> + <string name="logging_in_description">Проверка номера учетной записи</string> + <string name="logging_in_title">Выполняется вход...</string> + <string name="login_description">Введите номер учетной записи</string> + <string name="login_fail_description">Недействительный номер учетной записи</string> + <string name="login_fail_title">Ошибка входа</string> + <string name="login_title">Вход</string> + <string name="mullvad_account_number">Номер учетной записи Mullvad</string> + <string name="no_matching_bridge_relay">Текущим настройкам не соответствует ни один мостовой сервер ретрансляции</string> + <string name="no_matching_relay">Текущим настройкам не соответствует ни один сервер ретрансляции</string> + <string name="no_wireguard_key">Не найден корректный ключ WireGuard. Управлять ключами можно в разделе «Дополнительные настройки».</string> + <string name="not_blocking_internet">ВОЗМОЖНА УТЕЧКА СЕТЕВОГО ТРАФИКА</string> + <string name="out_address">Выход</string> + <string name="out_of_time">Закончилось время</string> + <string name="paid_until">Оплачено до</string> + <string name="pay_to_start_using">Чтобы пользоваться приложением, нужно добавить время на учетную запись.</string> + <string name="problem_report_description">Чтобы помощь была эффективнее, к этому сообщению будет прикреплен файл журнала из приложения. Ваши данные останутся защищенными и конфиденциальными: они обезличиваются и отправляются по зашифрованному каналу.</string> + <string name="public_key">Открытый ключ</string> + <string name="reconnecting">Идет переподключение</string> + <string name="redeem">Погасить</string> + <string name="redeem_voucher">Погасить ваучер</string> + <string name="report_a_problem">Сообщить о проблеме</string> + <string name="secure_connection">ЗАЩИЩЕННОЕ ПОДКЛЮЧЕНИЕ</string> + <string name="secured">Подключение защищено</string> + <string name="select_location">Выбрать местоположение</string> + <string name="select_location_description">При подключении реальное местоположение маскируется защищенным конфиденциальным местоположением в выбранном регионе.</string> + <string name="send">Отправить</string> + <string name="send_anyway">Все равно отправить</string> + <string name="sending">Идет отправка...</string> + <string name="sent">Отправлено</string> + <string name="sent_contact">При необходимости мы свяжемся с вами по адресу %1$s</string> + <string name="sent_thanks">Спасибо!</string> + <string name="set_dns_error">Не удалось установить системный DNS-сервер</string> + <string name="set_firewall_policy_error">Не удалось применить правила брандмауэра. Сейчас устройство может быть не защищено</string> + <string name="settings">Настройки</string> + <string name="settings_account">Учетная запись</string> + <string name="settings_advanced">Дополнительные</string> + <string name="settings_preferences">Параметры</string> + <string name="show_system_apps">Показывать системные приложения</string> + <string name="split_tunneling">Раздельное туннелирование</string> + <string name="split_tunneling_description">Раздельное туннелирование позволяет выбрать, какие приложения не должны маршрутизироваться через VPN-туннель.</string> + <string name="start_tunnel_error">Не удалось запустить подключение к туннелю</string> + <string name="switch_location">Сменить местоположение</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">Для этой учетной записи зарегистрировано слишком много ключей WireGuard</string> + <string name="try_again">Повторить попытку</string> + <string name="udp">UDP</string> + <string name="unsecured">Подключение не защищено</string> + <string name="unsecured_connection">НЕЗАЩИЩЕННОЕ ПОДКЛЮЧЕНИЕ</string> + <string name="unsupported_version">НЕПОДДЕРЖИВАЕМАЯ ВЕРСИЯ</string> + <string name="unsupported_version_description">Вы используете неподдерживаемую версию приложения. Чтобы обеспечить безопасность, обновитесь до версии %1$s</string> + <string name="unsupported_version_without_upgrade">Версия приложения, с которой вы работаете, не поддерживается.</string> + <string name="update_available">ЕСТЬ ОБНОВЛЕНИЕ</string> + <string name="update_available_description">Пользуйтесь актуальной версией — установите Mullvad VPN (%1$s)</string> + <string name="update_available_footer">Доступно обновление. Установите его, чтобы защитить подключения.</string> + <string name="user_email_hint">Ваша электронная почта (необязательно)</string> + <string name="user_message_hint">Опишите свою проблему на английском или шведском.</string> + <string name="view_logs">Открыть журналы</string> + <string name="virtual_adapter_problem">Ошибка виртуального адаптера</string> + <string name="voucher_already_used">Этот код ваучера уже использовался.</string> + <string name="vpn_permission_denied_error">При создании туннеля в доступе к VPN было отказано. Попробуйте подключиться снова.</string> + <string name="we_will_look_into_this">Мы рассмотрим эту проблему.</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">Ошибка WireGuard</string> + <string name="wireguard_generate_key">Сгенерировать ключ</string> + <string name="wireguard_key">Ключ WireGuard</string> + <string name="wireguard_key_generated">Ключ сгенерирован</string> + <string name="wireguard_key_invalid">Ключ недействителен</string> + <string name="wireguard_key_reconnecting">Повторное подключение с новым ключом WireGuard...</string> + <string name="wireguard_key_valid">Ключ действителен</string> + <string name="wireguard_key_verification_failure">Ошибка проверки ключа</string> + <string name="wireguard_manage_keys">Управление ключами</string> + <string name="wireguard_mtu">MTU для WireGuard</string> + <string name="wireguard_mtu_footer">Значение MTU для WireGuard. Допустимый диапазон: %1$d–%2$d.</string> + <string name="wireguard_public_key">Открытый ключ WireGuard</string> + <string name="wireguard_replace_key">Повторно сгенерировать ключ</string> + <string name="wireguard_verify_key">Проверка ключа</string> +</resources> diff --git a/android/app/src/main/res/values-sv/plurals.xml b/android/app/src/main/res/values-sv/plurals.xml new file mode 100644 index 0000000000..d31ab0ad5b --- /dev/null +++ b/android/app/src/main/res/values-sv/plurals.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="one">1 dag kvar</item> + <item quantity="other">%1$d dagar kvar</item> + </plurals> + <plurals name="months_left"> + <item quantity="one">1 månad kvar</item> + <item quantity="other">%1$d månader kvar</item> + </plurals> + <plurals name="years_left"> + <item quantity="one">1 år kvar</item> + <item quantity="other">%1$d år kvar</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">en dag sedan</item> + <item quantity="other">%1$d dagar sedan</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="one">en minut sedan</item> + <item quantity="other">%1$d minuter sedan</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">en månad sedan</item> + <item quantity="other">%1$d månader sedan</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">ett år sedan</item> + <item quantity="other">%1$d år sedan</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">en timme sedan</item> + <item quantity="other">%1$d timmar sedan</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="one">Kontokrediten slutar gälla om en dag</item> + <item quantity="other">Kontokrediten slutar gälla om %1$d dagar</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="one">Kontokrediten slutar gälla om en timme</item> + <item quantity="other">Kontokrediten slutar gälla om %1$d timmar</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-sv/strings.xml b/android/app/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000000..3b4a8b9701 --- /dev/null +++ b/android/app/src/main/res/values-sv/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">Kontot har skapats</string> + <string name="account_credit_expires_in_a_few_minutes">Kontokrediten slutar gälla om några minuter</string> + <string name="account_credit_expires_soon">Kontokrediten slutar snart gälla</string> + <string name="account_credit_has_expired">Du har ingen VPN-tid kvar på det här kontot.</string> + <string name="account_number">Kontonummer</string> + <string name="account_time_notification_channel_description">Visar påminnelser när kontots tidsgräns uppnås</string> + <string name="account_time_notification_channel_name">Påminnelser om kontotid</string> + <string name="add_a_server">Lägg till en server</string> + <string name="add_anyway">Lägg till ändå</string> + <string name="add_time_to_account">Du kan antingen köpa kredit på vår webbplats eller lösa in en kupong.</string> + <string name="all_applications">Alla applikationer</string> + <string name="allow_lan_footer">Tillåter åtkomst till andra enheter i samma nätverk för delning, utskrift etc.</string> + <string name="app_version">Appversion</string> + <string name="auth_failed">Autentiseringen av kontot misslyckades.</string> + <string name="auto_connect">Anslut automatiskt</string> + <string name="auto_connect_footer">Anslut automatiskt till en server när appen startas.</string> + <string name="back">Tillbaka</string> + <string name="blocked_connection">ANSLUTNINGEN BLOCKERAD</string> + <string name="blocking_all_connections">Blockerar alla anslutningar</string> + <string name="blocking_internet">Blockerar internet</string> + <string name="buy_credit">Köp kredit</string> + <string name="buy_more_credit">Köp mer kredit</string> + <string name="cancel">Avbryt</string> + <string name="confirm_local_dns">Den lokala DNS-servern fungerar inte om du inte aktiverar \"Lokal nätverksdelning\" under Inställningar.</string> + <string name="confirm_no_email">Du är på väg att skicka problemrapporten utan att vi har möjlighet att besvara dig. Om du vill ha svar på din rapport måste du ange en e-postadress.</string> + <string name="congrats">Grattis!</string> + <string name="connect">Skydda min anslutning</string> + <string name="connecting">Ansluter</string> + <string name="connecting_to_daemon">Ansluter till Mullvads systemtjänst...</string> + <string name="copied_mullvad_account_number">Kopierade Mullvad-kontonummer till urklipp</string> + <string name="copied_to_clipboard">Kopierat till urklipp</string> + <string name="copied_wireguard_public_key">Kopierade offentlig nyckel för WireGuard till urklipp</string> + <string name="create_account">Skapa konto</string> + <string name="creating_new_account">Skapar konto...</string> + <string name="creating_secure_connection">SKAPAR SÄKER ANSLUTNING</string> + <string name="critical_error">Kritiskt fel (kräver din uppmärksamhet)</string> + <string name="custom_dns_footer">Aktivera för att lägga till minst en DNS-server.</string> + <string name="custom_dns_hint">Ange IP</string> + <string name="custom_tunnel_host_resolution_error">Det gick inte att lösa värdnamnet för den anpassade servern</string> + <string name="disconnect">Koppla från</string> + <string name="disconnecting">Kopplar från</string> + <string name="dismiss">Ignorera</string> + <string name="dont_have_an_account">Har du inget kontonummer?</string> + <string name="edit_message">Redigera meddelande</string> + <string name="enable">Aktivera</string> + <string name="enable_custom_dns">Använd anpassad DNS-server</string> + <string name="enter_voucher_code">Ange kupongkod</string> + <string name="error_occurred">Ett fel har inträffat.</string> + <string name="error_state">DET GICK INTE ATT SÄKRA ANSLUTNINGEN</string> + <string name="exclude_applications">Exkluderade applikationer</string> + <string name="failed_to_block_internet">Det gick inte att blockera all nätverkstrafik. Felsök eller anmäl problemet till oss.</string> + <string name="failed_to_create_account">Det gick inte att skapa konto</string> + <string name="failed_to_generate_key">Det gick inte att generera en nyckel</string> + <string name="failed_to_send">Det gick inte att skicka</string> + <string name="failed_to_send_details">Du kan behöva gå tillbaka till appens huvudskärm och klicka på Koppla från innan du försöker igen. Oroa dig inte, den information du angett i formuläret kommer att bli kvar där.</string> + <string name="faqs_and_guides">Vanliga frågor och guider</string> + <string name="foreground_notification_channel_description">Visar nuvarande status för VPN-tunnel</string> + <string name="foreground_notification_channel_name">VPN-tunnelstatus</string> + <string name="here_is_your_account_number">Här är ditt kontonummer. Spara det!</string> + <string name="hint_default">Standard</string> + <string name="in_address">In</string> + <string name="invalid_dns_servers">Anpassade DNS-serveradresser %1$s är ogiltiga</string> + <string name="invalid_voucher">Kupongkoden är ogiltig.</string> + <string name="ipv6_unavailable">Det gick inte att konfigurera IPv6</string> + <string name="is_offline">Denna enhet är frånkopplad, inga tunnlar kan skapas</string> + <string name="less_than_a_day_left">mindre än en dag kvar</string> + <string name="less_than_a_minute_ago">mindre än en minut sedan</string> + <string name="local_network_sharing">Lokal nätverksdelning</string> + <string name="log_out">Logga ut</string> + <string name="logged_in_title">Inloggad</string> + <string name="logging_in_description">Kontrollerar kontonummer</string> + <string name="logging_in_title">Loggar in...</string> + <string name="login_description">Ange ditt kontonummer</string> + <string name="login_fail_description">Ogiltigt kontonummer</string> + <string name="login_fail_title">Inloggningen misslyckades</string> + <string name="login_title">Logga in</string> + <string name="mullvad_account_number">Mullvad-kontonummer</string> + <string name="no_matching_bridge_relay">Ingen bryggreläserver matchar de aktuella inställningarna</string> + <string name="no_matching_relay">Ingen reläserver matchar de aktuella inställningarna</string> + <string name="no_wireguard_key">Giltig WireGuard-nyckel saknas. Hantera nycklar i avancerade inställningar.</string> + <string name="not_blocking_internet">DU KANSKE HAR LÄCKAGE I NÄTVERKSTRAFIKEN</string> + <string name="out_address">Ut</string> + <string name="out_of_time">Ingen tid kvar</string> + <string name="paid_until">Betalat till</string> + <string name="pay_to_start_using">Om du vill börja använda appen måste du först lägga till tid i ditt konto.</string> + <string name="problem_report_description">För att hjälpa dig mer effektivt kommer appens loggfil att bifogas i detta meddelande. Dina uppgifter förblir säkra och privata, eftersom de anonymiseras innan de skickas över en krypterad kanal.</string> + <string name="public_key">Offentlig nyckel</string> + <string name="reconnecting">Återansluter</string> + <string name="redeem">Lös in</string> + <string name="redeem_voucher">Lös in kupong</string> + <string name="report_a_problem">Rapportera ett problem</string> + <string name="secure_connection">SÄKER ANSLUTNING</string> + <string name="secured">Skyddad</string> + <string name="select_location">Välj plats</string> + <string name="select_location_description">Medan du är ansluten maskeras din riktiga plats med en privat och säker plats i den valda regionen.</string> + <string name="send">Skicka</string> + <string name="send_anyway">Skicka ändå</string> + <string name="sending">Skicka...</string> + <string name="sent">Skickat</string> + <string name="sent_contact">Om det behövs kontaktar vi dig på %1$s</string> + <string name="sent_thanks">Tack!</string> + <string name="set_dns_error">Det gick inte att ange systemets DNS-server</string> + <string name="set_firewall_policy_error">Det gick inte att tillämpa brandväggsregler. Enheten kan för närvarande vara osäker</string> + <string name="settings">Inställningar</string> + <string name="settings_account">Konto</string> + <string name="settings_advanced">Avancerat</string> + <string name="settings_preferences">Inställningar</string> + <string name="show_system_apps">Visa systemappar</string> + <string name="split_tunneling">Delade tunnlar</string> + <string name="split_tunneling_description">Delade tunnlar gör det möjligt att välja vilka applikationer som inte ska dirigeras genom VPN-tunneln.</string> + <string name="start_tunnel_error">Det gick inte att starta tunnelanslutning</string> + <string name="switch_location">Växla plats</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">För många WireGuard-nycklar registrerade på kontot</string> + <string name="try_again">Försök igen</string> + <string name="udp">UDP</string> + <string name="unsecured">Oskyddad</string> + <string name="unsecured_connection">OSÄKER ANSLUTNING</string> + <string name="unsupported_version">VERSION UTAN STÖD</string> + <string name="unsupported_version_description">Du kör en appversion som inte stöds. Uppgradera till %1$s nu för att garantera din säkerhet</string> + <string name="unsupported_version_without_upgrade">Du kör en appversion som inte stöds.</string> + <string name="update_available">UPPDATERING TILLGÄNGLIG</string> + <string name="update_available_description">Installera Mullvad VPN (%1$s) för att hålla dig uppdaterad</string> + <string name="update_available_footer">Uppdatering tillgänglig. Ladda ned för att vara säker.</string> + <string name="user_email_hint">Din e-postadress (valfritt)</string> + <string name="user_message_hint">Beskriv ditt problem på engelska eller svenska.</string> + <string name="view_logs">Visa appens loggar</string> + <string name="virtual_adapter_problem">Fel med virtuell adapter</string> + <string name="voucher_already_used">Kupongkoden har redan använts.</string> + <string name="vpn_permission_denied_error">VPN-behörighet nekades när tunneln skapades. Försök att ansluta igen.</string> + <string name="we_will_look_into_this">Vi kommer att undersöka detta.</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">WireGuard-fel</string> + <string name="wireguard_generate_key">Generera nyckel</string> + <string name="wireguard_key">WireGuard-nyckel</string> + <string name="wireguard_key_generated">Nyckel har genererats</string> + <string name="wireguard_key_invalid">Nyckeln är ogiltig</string> + <string name="wireguard_key_reconnecting">Återansluter med ny WireGuard-nyckel...</string> + <string name="wireguard_key_valid">Nyckeln är giltig</string> + <string name="wireguard_key_verification_failure">Det gick inte att verifiera nyckel</string> + <string name="wireguard_manage_keys">Hantera nycklar</string> + <string name="wireguard_mtu">WireGuard MTU</string> + <string name="wireguard_mtu_footer">Ange WireGuard MTU-värde. Giltigt intervall: %1$d–%2$d.</string> + <string name="wireguard_public_key">Offentlig nyckel för WireGuard</string> + <string name="wireguard_replace_key">Generera om nyckel</string> + <string name="wireguard_verify_key">Verifiera nyckel</string> +</resources> diff --git a/android/app/src/main/res/values-th/plurals.xml b/android/app/src/main/res/values-th/plurals.xml new file mode 100644 index 0000000000..96232c71d5 --- /dev/null +++ b/android/app/src/main/res/values-th/plurals.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="other">คงเหลือ %1$d วัน</item> + </plurals> + <plurals name="months_left"> + <item quantity="other">คงเหลือ %1$d เดือน</item> + </plurals> + <plurals name="years_left"> + <item quantity="other">คงเหลือ %1$d ปี</item> + </plurals> + <plurals name="days_ago"> + <item quantity="other">%1$d วันก่อน</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="other">%1$d นาทีก่อน</item> + </plurals> + <plurals name="months_ago"> + <item quantity="other">%1$d เดือนก่อน</item> + </plurals> + <plurals name="years_ago"> + <item quantity="other">%1$d ปีก่อน</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="other">%1$d ชั่วโมงก่อน</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="other">เครดิตของบัญชีจะหมดอายุใน %1$d วัน</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="other">เครดิตของบัญชีจะหมดอายุใน %1$d ชั่วโมง</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-th/strings.xml b/android/app/src/main/res/values-th/strings.xml new file mode 100644 index 0000000000..f3f35de0c6 --- /dev/null +++ b/android/app/src/main/res/values-th/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">สร้างบัญชีแล้ว</string> + <string name="account_credit_expires_in_a_few_minutes">เครดิตของบัญชีจะหมดอายุในอีกไม่กี่นาที</string> + <string name="account_credit_expires_soon">เครดิตของบัญชีจะหมดอายุในเร็วๆ นี้</string> + <string name="account_credit_has_expired">คุณไม่มีเวลาใช้งาน VPN เหลืออยู่ในบัญชีนี้แล้ว</string> + <string name="account_number">หมายเลขบัญชี</string> + <string name="account_time_notification_channel_description">แสดงการแจ้งเตือน ในขณะที่เวลาบัญชีใกล้หมดอายุ</string> + <string name="account_time_notification_channel_name">การแจ้งเตือนเวลาบัญชี</string> + <string name="add_a_server">เพิ่มเซิร์ฟเวอร์</string> + <string name="add_anyway">เพิ่มต่อไป</string> + <string name="add_time_to_account">ซื้อเครดิตบนเว็บไซต์ของเรา หรือแลกรับบัตรกำนัล</string> + <string name="all_applications">แอปพลิเคชันทั้งหมด</string> + <string name="allow_lan_footer">อนุญาตให้เข้าถึงอุปกรณ์อื่นบนเครือข่ายเดียวกันเพื่อแชร์ พิมพ์ ฯลฯ</string> + <string name="app_version">เวอร์ชันแอป</string> + <string name="auth_failed">ไม่สามารถรับรองความถูกต้องของบัญชีได้</string> + <string name="auto_connect">เชื่อมต่ออัตโนมัติ</string> + <string name="auto_connect_footer">เชื่อมต่อไปยังเซิร์ฟเวอร์โดยอัตโนมัติในทันทีที่เปิดแอป</string> + <string name="back">ย้อนกลับ</string> + <string name="blocked_connection">การเชื่อมต่อที่ถูกบล็อก</string> + <string name="blocking_all_connections">บล็อกการเชื่อมต่อทั้งหมด</string> + <string name="blocking_internet">กำลังบล็อกอินเทอร์เน็ต</string> + <string name="buy_credit">ซื้อเครดิต</string> + <string name="buy_more_credit">ซื้อเครดิตเพิ่ม</string> + <string name="cancel">ยกเลิก</string> + <string name="confirm_local_dns">เซิร์ฟเวอร์ DNS ในท้องถิ่นจะไม่ทำงาน เว้นแต่คุณจะเปิดใช้ \"การแชร์ในเครือข่ายท้องถิ่น\" ซึ่งอยู่ในส่วนการกำหนดค่า</string> + <string name="confirm_no_email">คุณกำลังจะส่งรายงานปัญหา โดยไม่มีการระบุวิธีการติดต่อกลับให้กับเรา และคุณจำเป็นต้องป้อนที่อยู่อีเมลของคุณ หากคุณอยากให้เราตอบกลับการรายงานของคุณ</string> + <string name="congrats">ยินดีด้วย!</string> + <string name="connect">รักษาความปลอดภัยในการเชื่อมต่อ</string> + <string name="connecting">กำลังเชื่อมต่อ</string> + <string name="connecting_to_daemon">กำลังเชื่อมต่อบริการของระบบ Mullvad...</string> + <string name="copied_mullvad_account_number">คัดลอกหมายเลขบัญชี Mullvad ไปยังคลิปบอร์ด</string> + <string name="copied_to_clipboard">คัดลอกไปยังคลิปบอร์ดแล้ว</string> + <string name="copied_wireguard_public_key">คัดลอกคีย์สาธารณะของ WireGuard ไปยังคลิปบอร์ด</string> + <string name="create_account">สร้างบัญชี</string> + <string name="creating_new_account">กำลังสร้างบัญชี...</string> + <string name="creating_secure_connection">กำลังสร้างการเชื่อมต่อที่ปลอดภัย</string> + <string name="critical_error">ข้อผิดพลาดร้ายแรง (คุณจำเป็นต้องตรวจสอบ)</string> + <string name="custom_dns_footer">เปิดเพื่อเพิ่มเซิร์ฟเวอร์ DNS อย่างน้อยหนึ่งรายการ</string> + <string name="custom_dns_hint">ป้อน IP</string> + <string name="custom_tunnel_host_resolution_error">ไม่พบชื่อโฮสต์ของเซิร์ฟเวอร์แบบกำหนดเอง</string> + <string name="disconnect">ตัดการเชื่อมต่อ</string> + <string name="disconnecting">กำลังตัดการเชื่อมต่อ</string> + <string name="dismiss">ละทิ้ง</string> + <string name="dont_have_an_account">ยังไม่มีหมายเลขบัญชีใช่ไหม</string> + <string name="edit_message">แก้ไขข้อความ</string> + <string name="enable">เปิดใช้งาน</string> + <string name="enable_custom_dns">ใช้เซิร์ฟเวอร์ DNS แบบกำหนดเอง</string> + <string name="enter_voucher_code">ป้อนรหัสบัตรกำนัล</string> + <string name="error_occurred">เกิดข้อผิดพลาดขึ้น</string> + <string name="error_state">ไม่สามารถเชื่อมต่ออย่างปลอดภัยได้</string> + <string name="exclude_applications">แอปพลิเคชันที่แยกออก</string> + <string name="failed_to_block_internet">ไม่สามารถบล็อกการรับส่งข้อมูลทางเครือข่ายทั้งหมดได้ โปรดแก้ไขปัญหาหรือรายงานปัญหามาที่เรา</string> + <string name="failed_to_create_account">ไม่สามารถสร้างบัญชีได้</string> + <string name="failed_to_generate_key">ไม่สามารถสร้างคีย์ได้</string> + <string name="failed_to_send">ไม่สามารถส่งได้</string> + <string name="failed_to_send_details">คุณอาจจำเป็นต้องย้อนกลับไปที่หน้าจอหลักของแอป และคลิกตัดการเชื่อมต่อ ก่อนที่จะลองใหม่อีกครั้ง และไม่ต้องกังวลไป เพราะข้อมูลที่คุณป้อนไว้จะยังคงอยู่ในแบบฟอร์มเหมือนเดิม</string> + <string name="faqs_and_guides">FAQ และคำแนะนำ</string> + <string name="foreground_notification_channel_description">แสดงสถานะช่องทาง VPN ในปัจจุบัน</string> + <string name="foreground_notification_channel_name">สถานะช่องทาง VPN</string> + <string name="here_is_your_account_number">นี่คือหมายเลขบัญชีของคุณ จดบันทึกไว้ด้วยนะ!</string> + <string name="hint_default">ค่าเริ่มต้น</string> + <string name="in_address">เข้า</string> + <string name="invalid_dns_servers">ที่อยู่เซิร์ฟเวอร์ DNS %1$s ที่กำหนดเองไม่ถูกต้อง</string> + <string name="invalid_voucher">รหัสบัตรกำนัลไม่ถูกต้อง</string> + <string name="ipv6_unavailable">ไม่สามารถกำหนดค่า IPv6 ได้</string> + <string name="is_offline">อุปกรณ์ออฟไลน์อยู่ในขณะนี้ ไม่สามารถสร้างช่องทางการเชื่อมต่อได้</string> + <string name="less_than_a_day_left">เหลือเวลาน้อยกว่าหนึ่งวัน</string> + <string name="less_than_a_minute_ago">น้อยกว่าหนึ่งนาทีก่อน</string> + <string name="local_network_sharing">การแชร์ในเครือข่ายท้องถิ่น</string> + <string name="log_out">ลงชื่อออก</string> + <string name="logged_in_title">เข้าสู่ระบบแล้ว</string> + <string name="logging_in_description">กำลังตรวจสอบหมายเลขบัญชี</string> + <string name="logging_in_title">กำลังเข้าสู่ระบบ...</string> + <string name="login_description">ป้อนหมายเลขบัญชีของคุณ</string> + <string name="login_fail_description">หมายเลขบัญชีไม่ถูกต้อง</string> + <string name="login_fail_title">การเข้าสู่ระบบล้มเหลว</string> + <string name="login_title">เข้าสู่ระบบ</string> + <string name="mullvad_account_number">หมายเลขบัญชี Mullvad</string> + <string name="no_matching_bridge_relay">ไม่มีเซิร์ฟเวอร์บริดจ์รีเลย์ที่ตรงกับการตั้งค่าในปัจจุบัน</string> + <string name="no_matching_relay">ไม่มีเซิร์ฟเวอร์รีเลย์ที่ตรงกับการตั้งค่าในปัจจุบัน</string> + <string name="no_wireguard_key">ไม่มีคีย์ WireGuard ที่ถูกต้อง โปรดจัดการคีย์ภายใต้ส่วนการตั้งค่าขั้นสูง</string> + <string name="not_blocking_internet">คุณอาจมีการรับส่งข้อมูลทางเครือข่ายที่รั่วไหลอยู่ในขณะนี้</string> + <string name="out_address">ออก</string> + <string name="out_of_time">หมดเวลา</string> + <string name="paid_until">ชำระเงินแล้วจนถึง</string> + <string name="pay_to_start_using">คุณจำเป็นต้องเพิ่มเวลาไปยังบัญชีของคุณก่อน เพื่อที่จะเริ่มใช้งานแอป</string> + <string name="problem_report_description">ไฟล์บันทึกในแอปของคุณจะแนบไปกับข้อความนี้ เพื่อที่เราจะสามารถช่วยเหลือคุณได้อย่างมีประสิทธิภาพมากขึ้น ข้อมูลของคุณจะยังคงมีความปลอดภัยและเป็นส่วนตัว เพราะจะไม่มีการระบุตัวตนก่อนส่งข้อมูลผ่านช่องทางที่มีการเข้ารหัส</string> + <string name="public_key">คีย์สาธารณะ</string> + <string name="reconnecting">กำลังเชื่อมต่อใหม่</string> + <string name="redeem">แลกรับ</string> + <string name="redeem_voucher">แลกบัตรกำนัล</string> + <string name="report_a_problem">รายงานปัญหา</string> + <string name="secure_connection">การเชื่อมต่อที่ปลอดภัย</string> + <string name="secured">ปลอดภัย</string> + <string name="select_location">เลือกตำแหน่งที่ตั้ง</string> + <string name="select_location_description">ในขณะที่เชื่อมต่อ ตำแหน่งที่ตั้งจริงของคุณจะถูกปิดบัง โดยตำแหน่งที่ตั้งที่เป็นส่วนตัวและปลอดภัย ในภูมิภาคที่เลือก</string> + <string name="send">ส่ง</string> + <string name="send_anyway">ส่งต่อไป</string> + <string name="sending">กำลังส่ง...</string> + <string name="sent">ส่ง</string> + <string name="sent_contact">เราจะติดต่อคุณทาง %1$s หากจำเป็น</string> + <string name="sent_thanks">ขอบคุณ!</string> + <string name="set_dns_error">ไม่สามารถตั้งค่าเซิร์ฟเวอร์ DNS ของระบบได้</string> + <string name="set_firewall_policy_error">ไม่สามารถปรับใช้งานกฎของไฟร์วอลล์ได้ อุปกรณ์อาจไม่มีความปลอดภัยในขณะนี้</string> + <string name="settings">การตั้งค่า</string> + <string name="settings_account">บัญชี</string> + <string name="settings_advanced">ขั้นสูง</string> + <string name="settings_preferences">การกำหนดค่า</string> + <string name="show_system_apps">แสดงแอประบบ</string> + <string name="split_tunneling">แยกช่องทาง</string> + <string name="split_tunneling_description">การแยกช่องทางทำให้คุณสามารถเลือกได้ว่า แอปพลิเคชันใดไม่ควรได้รับการกำหนดเส้นทางผ่านช่องทาง VPN</string> + <string name="start_tunnel_error">ไม่สามารถเริ่มการเชื่อมต่อช่องทางได้</string> + <string name="switch_location">สลับตำแหน่ง</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">มีคีย์ WireGuard ที่ลงทะเบียนไปยังบัญชีมากเกินไป</string> + <string name="try_again">ลองอีกครั้ง</string> + <string name="udp">UDP</string> + <string name="unsecured">ไม่ปลอดภัย</string> + <string name="unsecured_connection">การเชื่อมต่อที่ไม่ปลอดภัย</string> + <string name="unsupported_version">เวอร์ชันที่ไม่รองรับ</string> + <string name="unsupported_version_description">คุณกำลังใช้งานแอปในเวอร์ชันที่ไม่ได้รับการรองรับ โปรดอัปเกรดเป็นเวอร์ชัน %1$s ทันทีเพื่อความปลอดภัยของคุณ</string> + <string name="unsupported_version_without_upgrade">คุณกำลังใช้งานเวอร์ชันแอปที่ไม่ได้รับการสนับสนุน</string> + <string name="update_available">มีการอัปเดตให้ใช้งาน</string> + <string name="update_available_description">ติดตั้ง Mullvad VPN (%1$s) เพื่อรับอัปเดตล่าสุด</string> + <string name="update_available_footer">มีอัปเดตพร้อมใช้งาน ดาวน์โหลดเพื่อคงความปลอดภัยไว้</string> + <string name="user_email_hint">อีเมลของคุณ (ไม่บังคับ)</string> + <string name="user_message_hint">โปรดอธิบายปัญหาของคุณในภาษาอังกฤษหรือสวีเดน</string> + <string name="view_logs">ดูบันทึกของแอป</string> + <string name="virtual_adapter_problem">ข้อผิดพลาดของอะแดปเตอร์เสมือน</string> + <string name="voucher_already_used">รหัสบัตรกำนัลถูกใช้ไปแล้ว</string> + <string name="vpn_permission_denied_error">การให้สิทธิ์ VPN ถูกปฏิเสธ ในขณะที่สร้างช่องทาง โปรดลองเชื่อมต่อใหม่อีกครั้ง</string> + <string name="we_will_look_into_this">เราจะตรวจสอบปัญหานี้</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">ข้อผิดพลาด WireGuard</string> + <string name="wireguard_generate_key">สร้างคีย์</string> + <string name="wireguard_key">คีย์ WireGuard</string> + <string name="wireguard_key_generated">สร้างคีย์แล้ว</string> + <string name="wireguard_key_invalid">คีย์ไม่ถูกต้อง</string> + <string name="wireguard_key_reconnecting">กำลังเชื่อมต่ออีกครั้งด้วยคีย์ WireGuard ใหม่...</string> + <string name="wireguard_key_valid">คีย์ถูกต้อง</string> + <string name="wireguard_key_verification_failure">การตรวจสอบคีย์ล้มเหลว</string> + <string name="wireguard_manage_keys">จัดการคีย์</string> + <string name="wireguard_mtu">WireGuard MTU</string> + <string name="wireguard_mtu_footer">ตั้งค่า WireGuard MTU ช่วงที่ใช้ได้: %1$d - %2$d</string> + <string name="wireguard_public_key">คีย์สาธารณะของ WireGuard</string> + <string name="wireguard_replace_key">สร้างคีย์ใหม่</string> + <string name="wireguard_verify_key">ตรวจสอบคีย์</string> +</resources> diff --git a/android/app/src/main/res/values-tr/plurals.xml b/android/app/src/main/res/values-tr/plurals.xml new file mode 100644 index 0000000000..250f0e2261 --- /dev/null +++ b/android/app/src/main/res/values-tr/plurals.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="one">1 gün kaldı</item> + <item quantity="other">%1$d gün kaldı</item> + </plurals> + <plurals name="months_left"> + <item quantity="one">1 ay kaldı</item> + <item quantity="other">%1$d ay kaldı</item> + </plurals> + <plurals name="years_left"> + <item quantity="one">1 yıl kaldı</item> + <item quantity="other">%1$d yıl kaldı</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">bir gün önce</item> + <item quantity="other">%1$d gün önce</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="one">bir dakika önce</item> + <item quantity="other">%1$d dakika önce</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">bir ay önce</item> + <item quantity="other">%1$d ay önce</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">bir yıl önce</item> + <item quantity="other">%1$d yıl önce</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">bir saat önce</item> + <item quantity="other">%1$d saat önce</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="one">Hesap kredisi bir gün içinde sona eriyor</item> + <item quantity="other">Hesap kredisi %1$d gün içinde sona eriyor</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="one">Hesap kredisi bir saat içinde sona eriyor</item> + <item quantity="other">Hesap kredisi %1$d saat içinde sona eriyor</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-tr/strings.xml b/android/app/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000000..41d0b405cc --- /dev/null +++ b/android/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">Hesap oluşturuldu</string> + <string name="account_credit_expires_in_a_few_minutes">Hesap kredisinin süresi birkaç dakika içinde doluyor</string> + <string name="account_credit_expires_soon">Hesap kredisinin süresi yakında doluyor</string> + <string name="account_credit_has_expired">Bu hesap için artık VPN süreniz kalmadı.</string> + <string name="account_number">Hesap Kimliği</string> + <string name="account_time_notification_channel_description">Hesap süresinin dolmasına kısa süre kala hatırlatıcıları gösterir</string> + <string name="account_time_notification_channel_name">Hesap süresi hatırlatıcıları</string> + <string name="add_a_server">Sunucu ekle</string> + <string name="add_anyway">Yine de ekle</string> + <string name="add_time_to_account">Web sitemizden kredi satın alın veya kupon kullanın.</string> + <string name="all_applications">Tüm uygulamalar</string> + <string name="allow_lan_footer">Paylaşım, yazdırma vb. için aynı ağdaki diğer cihazlara erişime izin verir.</string> + <string name="app_version">Uygulama sürümü</string> + <string name="auth_failed">Hesap kimlik doğrulaması başarısız oldu.</string> + <string name="auto_connect">Otomatik Bağlan</string> + <string name="auto_connect_footer">Uygulama başladığında bir sunucuya otomatik olarak bağlan.</string> + <string name="back">Geri</string> + <string name="blocked_connection">ENGELLENEN BAĞLANTI</string> + <string name="blocking_all_connections">Tüm bağlantılar engelleniyor</string> + <string name="blocking_internet">İnternet bağlantısı engelleniyor</string> + <string name="buy_credit">Kredi satın alın</string> + <string name="buy_more_credit">Daha fazla kredi satın alın</string> + <string name="cancel">İptal et</string> + <string name="confirm_local_dns">Tercihler sekmesinin altındaki \"Yerel Ağ Paylaşımı\" seçeneğini etkinleştirmediğiniz sürece yerel DNS sunucusu çalışmaz.</string> + <string name="confirm_no_email">Sorun raporunu, size geri dönüş yapmamıza imkan vermeyen bir şekilde göndermek üzeresiniz. Sorununuz için yanıt almak istiyorsanız bir e-posta adresi girmelisiniz.</string> + <string name="congrats">Tebrikler!</string> + <string name="connect">Bağlantımı güvenceye al</string> + <string name="connecting">Bağlanılıyor</string> + <string name="connecting_to_daemon">Mullvad sistem hizmetlerine bağlanılıyor...</string> + <string name="copied_mullvad_account_number">Mullvad hesap numarası panoya kopyalandı</string> + <string name="copied_to_clipboard">Panoya kopyalandı</string> + <string name="copied_wireguard_public_key">WireGuard genel anahtarı panoya kopyalandı</string> + <string name="create_account">Hesap oluştur</string> + <string name="creating_new_account">Hesap oluşturuluyor...</string> + <string name="creating_secure_connection">GÜVENLİ BAĞLANTI OLUŞTURULUYOR</string> + <string name="critical_error">Kritik hata (lütfen dikkatli olun)</string> + <string name="custom_dns_footer">En az bir DNS sunucusu eklemek için etkinleştirin.</string> + <string name="custom_dns_hint">IP\'yi girin</string> + <string name="custom_tunnel_host_resolution_error">Özel sunucu ana bilgisayar adı çözülemiyor</string> + <string name="disconnect">Bağlantıyı Kes</string> + <string name="disconnecting">Bağlantı kesiliyor</string> + <string name="dismiss">Reddet</string> + <string name="dont_have_an_account">Hesap numaranız yok mu?</string> + <string name="edit_message">Mesajı düzenle</string> + <string name="enable">Etkinleştir</string> + <string name="enable_custom_dns">Özel DNS sunucusu kullanın</string> + <string name="enter_voucher_code">Kupon kodunu girin</string> + <string name="error_occurred">Bir hata oluştu.</string> + <string name="error_state">GÜVENLİ BAĞLANTI OLUŞTURULAMADI</string> + <string name="exclude_applications">Hariç tutulan uygulamalar</string> + <string name="failed_to_block_internet">Tüm ağ trafiği engellenemedi. Lütfen sorunu çözmeyi deneyin veya bize bildirin.</string> + <string name="failed_to_create_account">Hesap oluşturulamadı</string> + <string name="failed_to_generate_key">Anahtar oluşturulamadı</string> + <string name="failed_to_send">Gönderme başarısız</string> + <string name="failed_to_send_details">Yeniden denemeden önce uygulamanın ana ekranına dönüp Bağlantıyı Kes\'e tıklamanız gerekebilir. Endişelenmeyin, girdiğiniz bilgiler formda kalacaktır.</string> + <string name="faqs_and_guides">SSS ve Kılavuzlar</string> + <string name="foreground_notification_channel_description">Mevcut VPN tünelinin durumunu gösterir</string> + <string name="foreground_notification_channel_name">VPN tüneli durumu</string> + <string name="here_is_your_account_number">İşte hesap numaranız. Kaydedin!</string> + <string name="hint_default">Varsayılan</string> + <string name="in_address">Giriş</string> + <string name="invalid_dns_servers">Özel DNS sunucu adresleri (%1$s) geçersiz</string> + <string name="invalid_voucher">Kupon kodu geçersiz.</string> + <string name="ipv6_unavailable">IPv6 yapılandırılamadı</string> + <string name="is_offline">Bu cihaz çevrimdışı, tünel oluşturulamaz</string> + <string name="less_than_a_day_left">bir günden az kaldı</string> + <string name="less_than_a_minute_ago">bir dakikadan az</string> + <string name="local_network_sharing">Yerel ağ paylaşımı</string> + <string name="log_out">Oturumu kapat</string> + <string name="logged_in_title">Oturum açıldı</string> + <string name="logging_in_description">Hesap numarası kontrol ediliyor</string> + <string name="logging_in_title">Oturum açılıyor...</string> + <string name="login_description">Hesap numaranızı girin</string> + <string name="login_fail_description">Geçersiz hesap numarası</string> + <string name="login_fail_title">Oturum açma başarısız</string> + <string name="login_title">Oturum Aç</string> + <string name="mullvad_account_number">Mullvad hesap numarası</string> + <string name="no_matching_bridge_relay">Geçerli ayarlarla eşleşen hiçbir köprü aktarma sunucusu yok</string> + <string name="no_matching_relay">Geçerli ayarlara uyan aktarma sunucusu yok</string> + <string name="no_wireguard_key">Geçerli WireGuard anahtarı eksik. Gelişmiş ayarlardan anahtarları yönetin.</string> + <string name="not_blocking_internet">AĞ TRAFİĞİNİZDE SIZINTI OLABİLİR</string> + <string name="out_address">Çıkış</string> + <string name="out_of_time">Süre doldu</string> + <string name="paid_until">Şu tarihe kadar ödendi:</string> + <string name="pay_to_start_using">Uygulamayı kullanmaya başlamak için önce hesabınıza süre eklemeniz gerekir.</string> + <string name="problem_report_description">Size daha etkin bir şekilde yardımcı olmak için uygulamanızın günlük dosyası bu mesaja eklenecektir. Verileriniz şifrelenmiş bir kanal üzerinden gönderilmeden önce anonimleştirildiği için güvenli ve gizli kalacaktır.</string> + <string name="public_key">Genel anahtar</string> + <string name="reconnecting">Yeniden Bağlanılıyor</string> + <string name="redeem">Kullan</string> + <string name="redeem_voucher">Kuponu kullan</string> + <string name="report_a_problem">Bir sorun bildir</string> + <string name="secure_connection">GÜVENLİ BAĞLANTI</string> + <string name="secured">Güvenli</string> + <string name="select_location">Konum seçin</string> + <string name="select_location_description">Bağlıyken, gerçek konumunuz seçilen bölgedeki özel ve gizli bir konumla maskelenir.</string> + <string name="send">Gönder</string> + <string name="send_anyway">Yine de gönder</string> + <string name="sending">Gönderiliyor...</string> + <string name="sent">Gönderildi</string> + <string name="sent_contact">Gerektiğinde sizinle %1$s üzerinden iletişime geçeceğiz</string> + <string name="sent_thanks">Teşekkürler!</string> + <string name="set_dns_error">Sistem DNS sunucusu ayarlanamıyor</string> + <string name="set_firewall_policy_error">Güvenlik duvarı kuralları uygulanamıyor. Cihaz şu an korumasız olabilir</string> + <string name="settings">Ayarlar</string> + <string name="settings_account">Hesap</string> + <string name="settings_advanced">Gelişmiş</string> + <string name="settings_preferences">Tercihler</string> + <string name="show_system_apps">Sistem uygulamalarını göster</string> + <string name="split_tunneling">Bölünmüş tünelleme</string> + <string name="split_tunneling_description">Bölünmüş tünelleme, VPN tüneli üzerinden yönlendirilmemesi gereken uygulamaları seçmenize olanak tanır.</string> + <string name="start_tunnel_error">Tünel bağlantısı başlatılamıyor</string> + <string name="switch_location">Konum değiştir</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">Hesaba kayıtlı çok fazla WireGuard anahtarı</string> + <string name="try_again">Tekrar dene</string> + <string name="udp">UDP</string> + <string name="unsecured">Güvenli Değil</string> + <string name="unsecured_connection">GÜVENLİ OLMAYAN BAĞLANTI</string> + <string name="unsupported_version">DESTEKLENMEYEN SÜRÜM</string> + <string name="unsupported_version_description">Desteklenmeyen bir uygulama sürümü kullanıyorsunuz. Güvenliğinizi garanti altına almak için lütfen hemen %1$s sürümüne yükseltin</string> + <string name="unsupported_version_without_upgrade">Desteklenmeyen bir uygulama sürümünü kullanıyorsunuz.</string> + <string name="update_available">GÜNCELLEME MEVCUT</string> + <string name="update_available_description">Güncel kalmak için Mullvad VPN\'in (%1$s) sürümünü yükleyin</string> + <string name="update_available_footer">Güncelleme mevcut, güvende olmak için indirin.</string> + <string name="user_email_hint">E-posta adresiniz (isteğe bağlı)</string> + <string name="user_message_hint">Lütfen sorununuzu İngilizce veya Türkçe olarak tanımlayın.</string> + <string name="view_logs">Uygulama kayıtlarını görüntüle</string> + <string name="virtual_adapter_problem">Sanal adaptör hatası</string> + <string name="voucher_already_used">Kupon kodu zaten kullanılmış.</string> + <string name="vpn_permission_denied_error">Tünel oluşturulurken VPN izni reddedildi. Lütfen tekrar bağlanmayı deneyin.</string> + <string name="we_will_look_into_this">Bunu araştıracağız.</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">WireGuard hatası</string> + <string name="wireguard_generate_key">Anahtar oluştur</string> + <string name="wireguard_key">WireGuard anahtarı</string> + <string name="wireguard_key_generated">Anahtar oluşturuldu</string> + <string name="wireguard_key_invalid">Anahtar geçersiz</string> + <string name="wireguard_key_reconnecting">Yeni WireGuard anahtarıyla yeniden bağlanılıyor...</string> + <string name="wireguard_key_valid">Anahtar geçerli</string> + <string name="wireguard_key_verification_failure">Anahtar doğrulanamadı</string> + <string name="wireguard_manage_keys">Anahtarları yönet</string> + <string name="wireguard_mtu">WireGuard MTU</string> + <string name="wireguard_mtu_footer">WireGuard MTU değerini ayarlayın. Geçerli aralık: %1$d - %2$d.</string> + <string name="wireguard_public_key">WireGuard genel anahtarı</string> + <string name="wireguard_replace_key">Yeniden anahtar oluştur</string> + <string name="wireguard_verify_key">Anahtarı doğrula</string> +</resources> diff --git a/android/app/src/main/res/values-zh-rCN/plurals.xml b/android/app/src/main/res/values-zh-rCN/plurals.xml new file mode 100644 index 0000000000..5a9b4b2b33 --- /dev/null +++ b/android/app/src/main/res/values-zh-rCN/plurals.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="other">剩余 %1$d 天</item> + </plurals> + <plurals name="months_left"> + <item quantity="other">剩余 %1$d 个月</item> + </plurals> + <plurals name="years_left"> + <item quantity="other">剩余 %1$d 年</item> + </plurals> + <plurals name="days_ago"> + <item quantity="other">%1$d 天前</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="other">%1$d 分钟前</item> + </plurals> + <plurals name="months_ago"> + <item quantity="other">%1$d 个月前</item> + </plurals> + <plurals name="years_ago"> + <item quantity="other">%1$d 年前</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="other">%1$d 小时前</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="other">帐户额度将在 %1$d 天后到期</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="other">帐户额度将在 %1$d 小时后到期</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000000..67e8f4b28c --- /dev/null +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">已创建帐户</string> + <string name="account_credit_expires_in_a_few_minutes">帐户额度将在几分钟后到期</string> + <string name="account_credit_expires_soon">帐户额度即将到期</string> + <string name="account_credit_has_expired">此帐户没有更多的 VPN 时间了。</string> + <string name="account_number">帐号</string> + <string name="account_time_notification_channel_description">在帐户时间即将到期时显示提醒</string> + <string name="account_time_notification_channel_name">帐户时间提醒</string> + <string name="add_a_server">添加服务器</string> + <string name="add_anyway">仍然添加</string> + <string name="add_time_to_account">在我们的网站上购买额度或兑换优惠券。</string> + <string name="all_applications">所有应用程序</string> + <string name="allow_lan_footer">允许访问同一个网络上的其他设备以进行共享和打印等。</string> + <string name="app_version">应用版本</string> + <string name="auth_failed">帐户身份验证失败。</string> + <string name="auto_connect">自动连接</string> + <string name="auto_connect_footer">在应用启动时自动连接到服务器。</string> + <string name="back">返回</string> + <string name="blocked_connection">已阻止连接</string> + <string name="blocking_all_connections">正在阻止所有连接</string> + <string name="blocking_internet">正在阻止网络</string> + <string name="buy_credit">购买额度</string> + <string name="buy_more_credit">购买更多额度</string> + <string name="cancel">取消</string> + <string name="confirm_local_dns">除非您在“偏好设置”下启用“本地网络共享”,否则本地 DNS 服务器将不会运行。</string> + <string name="confirm_no_email">您即将发送问题报告,但没有提供让我们可以联系到您的方式。如果您希望获得回复,必须输入您的电子邮件地址。</string> + <string name="congrats">恭喜!</string> + <string name="connect">保护我的连接</string> + <string name="connecting">正在连接</string> + <string name="connecting_to_daemon">正在连接到 Mullvad 系统服务…</string> + <string name="copied_mullvad_account_number">已将 Mullvad 帐号复制到剪贴板</string> + <string name="copied_to_clipboard">已复制到剪贴板</string> + <string name="copied_wireguard_public_key">已将 WireGuard 公钥复制到剪贴板</string> + <string name="create_account">创建帐户</string> + <string name="creating_new_account">正在创建帐户…</string> + <string name="creating_secure_connection">正在创建安全连接</string> + <string name="critical_error">严重错误(需要注意)</string> + <string name="custom_dns_footer">启用以添加至少一个 DNS 服务器。</string> + <string name="custom_dns_hint">输入 IP</string> + <string name="custom_tunnel_host_resolution_error">无法解析自定义服务器的主机名</string> + <string name="disconnect">断开连接</string> + <string name="disconnecting">正在断开连接</string> + <string name="dismiss">关闭</string> + <string name="dont_have_an_account">没有帐号?</string> + <string name="edit_message">编辑消息</string> + <string name="enable">启用</string> + <string name="enable_custom_dns">使用自定义 DNS 服务器</string> + <string name="enter_voucher_code">输入优惠码</string> + <string name="error_occurred">出错了。</string> + <string name="error_state">无法保护连接</string> + <string name="exclude_applications">排除的应用程序</string> + <string name="failed_to_block_internet">无法阻止所有网络流量。请排除故障或向我们报告问题。</string> + <string name="failed_to_create_account">无法创建帐户</string> + <string name="failed_to_generate_key">无法生成密钥</string> + <string name="failed_to_send">无法发送</string> + <string name="failed_to_send_details">在重试之前,您需要返回应用的主屏幕并点击“断开连接”。别担心,您输入的信息将保留在窗体中。</string> + <string name="faqs_and_guides">常见问题解答与指南</string> + <string name="foreground_notification_channel_description">显示当前的 VPN 隧道状态</string> + <string name="foreground_notification_channel_name">VPN 隧道状态</string> + <string name="here_is_your_account_number">以下是您的帐号。请妥善保存!</string> + <string name="hint_default">默认</string> + <string name="in_address">内部</string> + <string name="invalid_dns_servers">自定义 DNS 服务器地址 %1$s 无效</string> + <string name="invalid_voucher">该优惠券码无效。</string> + <string name="ipv6_unavailable">无法配置 IPv6</string> + <string name="is_offline">此设备已离线,无法建立隧道</string> + <string name="less_than_a_day_left">剩余时间不足 1 天</string> + <string name="less_than_a_minute_ago">不到 1 分钟前</string> + <string name="local_network_sharing">本地网络共享</string> + <string name="log_out">退出</string> + <string name="logged_in_title">已登录</string> + <string name="logging_in_description">正在检查帐号</string> + <string name="logging_in_title">登录中…</string> + <string name="login_description">输入您的帐号</string> + <string name="login_fail_description">帐号无效</string> + <string name="login_fail_title">登录失败</string> + <string name="login_title">登录</string> + <string name="mullvad_account_number">Mullvad 帐号</string> + <string name="no_matching_bridge_relay">没有与当前设置匹配的桥接中继服务器</string> + <string name="no_matching_relay">没有与当前设置匹配的中继服务器</string> + <string name="no_wireguard_key">缺少有效的 WireGuard 密钥。在“高级”设置下管理密钥。</string> + <string name="not_blocking_internet">您的网络流量可能在泄露</string> + <string name="out_address">外部</string> + <string name="out_of_time">已没有时间</string> + <string name="paid_until">到期时间</string> + <string name="pay_to_start_using">要开始使用本应用,您首先需要向帐户中充入时间。</string> + <string name="problem_report_description">为了更有效地帮助您,您应用的日志文件将附加到此消息。您的数据将保持安全和私密,因为所有数据在发送之前都将通过加密通道进行匿名处理。</string> + <string name="public_key">公钥</string> + <string name="reconnecting">正在重新连接</string> + <string name="redeem">兑换</string> + <string name="redeem_voucher">兑换优惠券</string> + <string name="report_a_problem">报告问题</string> + <string name="secure_connection">安全连接</string> + <string name="secured">已受保护</string> + <string name="select_location">选择位置</string> + <string name="select_location_description">连接时,将使用选定区域中一个私密且安全的位置隐藏您的真实位置。</string> + <string name="send">发送</string> + <string name="send_anyway">仍然发送</string> + <string name="sending">正在发送…</string> + <string name="sent">已发送</string> + <string name="sent_contact">如果需要,我们将通过 %1$s 与您联系</string> + <string name="sent_thanks">谢谢!</string> + <string name="set_dns_error">无法设置系统 DNS 服务器</string> + <string name="set_firewall_policy_error">无法应用防火墙规则。设备当前可能不会受到保护</string> + <string name="settings">设置</string> + <string name="settings_account">帐户</string> + <string name="settings_advanced">高级</string> + <string name="settings_preferences">偏好设置</string> + <string name="show_system_apps">显示系统应用</string> + <string name="split_tunneling">拆分隧道</string> + <string name="split_tunneling_description">利用拆分隧道,您可以选择哪些应用程序不应通过 VPN 隧道进行路由。</string> + <string name="start_tunnel_error">无法启动隧道连接</string> + <string name="switch_location">切换位置</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">帐户中注册的 WireGuard 密钥过多</string> + <string name="try_again">重试</string> + <string name="udp">UDP</string> + <string name="unsecured">未受保护</string> + <string name="unsecured_connection">未受保护的连接</string> + <string name="unsupported_version">不受支持的版本</string> + <string name="unsupported_version_description">您正在运行不受支持的应用版本。请立即升级到 %1$s 以确保您的安全</string> + <string name="unsupported_version_without_upgrade">您正在运行不受支持的应用版本。</string> + <string name="update_available">有可用更新</string> + <string name="update_available_description">安装 Mullvad VPN (%1$s) 以保持最新状态</string> + <string name="update_available_footer">有可用更新,请下载以保持安全。</string> + <string name="user_email_hint">您的电子邮件(可选)</string> + <string name="user_message_hint">请用英语或瑞典语描述您的问题。</string> + <string name="view_logs">查看应用日志</string> + <string name="virtual_adapter_problem">虚拟适配器错误</string> + <string name="voucher_already_used">该优惠券码已被使用。</string> + <string name="vpn_permission_denied_error">创建隧道时,VPN 权限被拒绝。请尝试重新连接。</string> + <string name="we_will_look_into_this">我们将对此进行调查。</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">WireGuard 错误</string> + <string name="wireguard_generate_key">生成密钥</string> + <string name="wireguard_key">WireGuard 密钥</string> + <string name="wireguard_key_generated">已生成密钥</string> + <string name="wireguard_key_invalid">密钥无效</string> + <string name="wireguard_key_reconnecting">正在使用新的 wireGuard 密钥重新连接…</string> + <string name="wireguard_key_valid">密钥有效</string> + <string name="wireguard_key_verification_failure">密钥验证失败</string> + <string name="wireguard_manage_keys">管理密钥</string> + <string name="wireguard_mtu">WireGuard MTU</string> + <string name="wireguard_mtu_footer">设置 WireGuard MTU 值。有效范围:%1$d - %2$d。</string> + <string name="wireguard_public_key">WireGuard 公钥</string> + <string name="wireguard_replace_key">重新生成密钥</string> + <string name="wireguard_verify_key">验证密钥</string> +</resources> diff --git a/android/app/src/main/res/values-zh-rTW/plurals.xml b/android/app/src/main/res/values-zh-rTW/plurals.xml new file mode 100644 index 0000000000..5658c7b022 --- /dev/null +++ b/android/app/src/main/res/values-zh-rTW/plurals.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <plurals name="days_left"> + <item quantity="other">剩餘 %1$d 天</item> + </plurals> + <plurals name="months_left"> + <item quantity="other">剩餘 %1$d 個月</item> + </plurals> + <plurals name="years_left"> + <item quantity="other">剩餘 %1$d 年</item> + </plurals> + <plurals name="days_ago"> + <item quantity="other">%1$d 天前</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="other">%1$d 分鐘前</item> + </plurals> + <plurals name="months_ago"> + <item quantity="other">%1$d 個月前</item> + </plurals> + <plurals name="years_ago"> + <item quantity="other">%1$d 年前</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="other">%1$d 小時前</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="other">帳戶點數將在 %1$d 天後到期</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="other">帳戶點數將在 %1$d 小時後到期</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values-zh-rTW/strings.xml b/android/app/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000000..f8cbc821f1 --- /dev/null +++ b/android/app/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="account_created">已建立帳戶</string> + <string name="account_credit_expires_in_a_few_minutes">帳戶點數將在幾分鐘後到期</string> + <string name="account_credit_expires_soon">帳戶點數即將到期</string> + <string name="account_credit_has_expired">您這個帳戶已經沒有剩餘的 VPN 時間了。</string> + <string name="account_number">帳戶編號</string> + <string name="account_time_notification_channel_description">在帳戶時間即將到期時顯示提醒</string> + <string name="account_time_notification_channel_name">帳戶時間提醒</string> + <string name="add_a_server">新增伺服器</string> + <string name="add_anyway">仍要新增</string> + <string name="add_time_to_account">在我們網站上購買點數或兌換憑證。</string> + <string name="all_applications">所有應用程式</string> + <string name="allow_lan_footer">允許存取同一網路上的其他裝置,以進行共用、列印等。</string> + <string name="app_version">應用程式版本</string> + <string name="auth_failed">帳戶驗證失敗。</string> + <string name="auto_connect">自動連線</string> + <string name="auto_connect_footer">啟動應用程式時,自動連線伺服器。</string> + <string name="back">返回</string> + <string name="blocked_connection">被封鎖的連線</string> + <string name="blocking_all_connections">正在封鎖所有連線</string> + <string name="blocking_internet">正在封鎖網路</string> + <string name="buy_credit">購買點數</string> + <string name="buy_more_credit">購買更多點數</string> + <string name="cancel">取消</string> + <string name="confirm_local_dns">若要使本機 DNS 伺服器運作,需先在「偏好設定」下啟用「本機網路共用」。</string> + <string name="confirm_no_email">您即將傳送的問題報告未包含回覆方式資訊。如果想收到您這份報告的回覆,請輸入您的電子郵件位址。</string> + <string name="congrats">恭喜!</string> + <string name="connect">保護我的連線</string> + <string name="connecting">連線中</string> + <string name="connecting_to_daemon">連線 Mullvad 系統服務中...</string> + <string name="copied_mullvad_account_number">已將 Mullvad 帳號複製到剪貼簿</string> + <string name="copied_to_clipboard">已複製到剪貼簿</string> + <string name="copied_wireguard_public_key">已將 WireGuard 公開金鑰複製到剪貼簿</string> + <string name="create_account">建立帳戶</string> + <string name="creating_new_account">正在建立帳戶…</string> + <string name="creating_secure_connection">建立安全連線</string> + <string name="critical_error">嚴重錯誤 (需注意)</string> + <string name="custom_dns_footer">啟用以新增至少一個 DNS 伺服器。</string> + <string name="custom_dns_hint">輸入 IP</string> + <string name="custom_tunnel_host_resolution_error">無法解析自訂伺服器的主機名稱</string> + <string name="disconnect">中斷連線</string> + <string name="disconnecting">正在中斷連線</string> + <string name="dismiss">取消</string> + <string name="dont_have_an_account">沒有帳號?</string> + <string name="edit_message">編輯訊息</string> + <string name="enable">啟用</string> + <string name="enable_custom_dns">使用自訂 DNS 伺服器</string> + <string name="enter_voucher_code">輸入優惠券兌換碼</string> + <string name="error_occurred">發生錯誤了。</string> + <string name="error_state">保護連線失敗</string> + <string name="exclude_applications">已排除的應用程式</string> + <string name="failed_to_block_internet">無法封鎖所有網路流量。請排除故障或向我們回報問題。</string> + <string name="failed_to_create_account">無法建立帳戶</string> + <string name="failed_to_generate_key">無法產生金鑰</string> + <string name="failed_to_send">無法傳送</string> + <string name="failed_to_send_details">在重試之前,您可能需要返回應用程式的主畫面,然後按一下「中斷連線」。請不用擔心,您輸入的資訊將保留在表單中。</string> + <string name="faqs_and_guides">常見問題集與指南</string> + <string name="foreground_notification_channel_description">顯示目前的 VPN 通道狀態</string> + <string name="foreground_notification_channel_name">VPN 通道狀態</string> + <string name="here_is_your_account_number">以下是您的帳號。請妥善保管!</string> + <string name="hint_default">預設</string> + <string name="in_address">入境</string> + <string name="invalid_dns_servers">自訂 DNS 伺服器位址 %1$s 無效</string> + <string name="invalid_voucher">憑證兌換碼無效。</string> + <string name="ipv6_unavailable">無法配置 IPv6</string> + <string name="is_offline">此裝置目前離線中,無法建立通道</string> + <string name="less_than_a_day_left">剩餘時間不足 1 天</string> + <string name="less_than_a_minute_ago">不到 1 分鐘前</string> + <string name="local_network_sharing">本機網路分享</string> + <string name="log_out">登出</string> + <string name="logged_in_title">已登入</string> + <string name="logging_in_description">檢查帳號中</string> + <string name="logging_in_title">登入中...</string> + <string name="login_description">輸入您的帳號</string> + <string name="login_fail_description">帳號無效</string> + <string name="login_fail_title">登入失敗</string> + <string name="login_title">登入</string> + <string name="mullvad_account_number">Mullvad 帳號</string> + <string name="no_matching_bridge_relay">沒有與目前設定相符的橋接中繼伺服器</string> + <string name="no_matching_relay">沒有與目前設定相符的中繼伺服器</string> + <string name="no_wireguard_key">缺少有效的 WireGuard 金鑰。在「進階」設定下管理金鑰。</string> + <string name="not_blocking_internet">您的網路流量可能正在洩露</string> + <string name="out_address">出境</string> + <string name="out_of_time">逾時</string> + <string name="paid_until">支付至</string> + <string name="pay_to_start_using">需先在帳戶中加時,才能開始使用本應用程式。</string> + <string name="problem_report_description">為了更有效協助您,會將應用程式的日誌檔將附加到此郵件。您的資料會保持安全和私密性,因為這些資料會先經過匿名處理,再透過加密通道傳送。</string> + <string name="public_key">公開金鑰</string> + <string name="reconnecting">正在重新連線</string> + <string name="redeem">兌換</string> + <string name="redeem_voucher">兌換憑證</string> + <string name="report_a_problem">回報問題</string> + <string name="secure_connection">安全連線</string> + <string name="secured">安全</string> + <string name="select_location">選取位置</string> + <string name="select_location_description">連線時,會使用所選區域的一個私密安全位置,將您的真實位置遮住。</string> + <string name="send">傳送</string> + <string name="send_anyway">仍要傳送</string> + <string name="sending">傳送中...</string> + <string name="sent">已傳送</string> + <string name="sent_contact">如有需要,我們將以 %1$s 與您聯絡</string> + <string name="sent_thanks">謝謝!</string> + <string name="set_dns_error">無法設定系統 DNS 伺服器</string> + <string name="set_firewall_policy_error">無法套用防火牆規則。裝置目前可能不安全</string> + <string name="settings">設定</string> + <string name="settings_account">帳戶</string> + <string name="settings_advanced">進階</string> + <string name="settings_preferences">喜好設定</string> + <string name="show_system_apps">顯示系統應用程式</string> + <string name="split_tunneling">分割通道</string> + <string name="split_tunneling_description">利用拆分通道,您可以選擇哪些應用程式不應透過 VPN 通道進行路由。</string> + <string name="start_tunnel_error">無法啟動通道連線</string> + <string name="switch_location">切換位置</string> + <string name="tcp">TCP</string> + <string name="too_many_keys">帳戶中註冊的 WireGuard 金鑰過多</string> + <string name="try_again">再試一次</string> + <string name="udp">UDP</string> + <string name="unsecured">不安全</string> + <string name="unsecured_connection">不安全的連線</string> + <string name="unsupported_version">不支援的版本</string> + <string name="unsupported_version_description">您正在執行不受支援的應用程式版本。請立即升級到 %1$s,以確保您的安全</string> + <string name="unsupported_version_without_upgrade">您所執行的應用程式版本不受支援。</string> + <string name="update_available">可用的更新</string> + <string name="update_available_description">安裝 Mullvad VPN (%1$s) 以維持最新狀態</string> + <string name="update_available_footer">更新可用,請下載以維持安全。</string> + <string name="user_email_hint">您的電子郵件 (選填)</string> + <string name="user_message_hint">請用英語或瑞典語來說明您的問題。</string> + <string name="view_logs">檢視應用程式日誌</string> + <string name="virtual_adapter_problem">虛擬配接器錯誤</string> + <string name="voucher_already_used">此憑證兌換碼已有人用過。</string> + <string name="vpn_permission_denied_error">建立通道時,VPN 權限被拒絕。請嘗試重新連線。</string> + <string name="we_will_look_into_this">我們會對此進行調查。</string> + <string name="wireguard">WireGuard</string> + <string name="wireguard_error">WireGuard 錯誤</string> + <string name="wireguard_generate_key">產生金鑰</string> + <string name="wireguard_key">WireGuard 金鑰</string> + <string name="wireguard_key_generated">已產生金鑰</string> + <string name="wireguard_key_invalid">金鑰無效</string> + <string name="wireguard_key_reconnecting">正在使用新 WireGuard 金鑰重新連線...</string> + <string name="wireguard_key_valid">金鑰有效</string> + <string name="wireguard_key_verification_failure">金鑰驗證失敗</string> + <string name="wireguard_manage_keys">管理金鑰</string> + <string name="wireguard_mtu">WireGuard MTU</string> + <string name="wireguard_mtu_footer">設定 WireGuard MTU 值。有效範圍:%1$d - %2$d。</string> + <string name="wireguard_public_key">WireGuard 公開金鑰</string> + <string name="wireguard_replace_key">重新產生金鑰</string> + <string name="wireguard_verify_key">驗證金鑰</string> +</resources> diff --git a/android/app/src/main/res/values/attrs.xml b/android/app/src/main/res/values/attrs.xml new file mode 100644 index 0000000000..8eefb1c173 --- /dev/null +++ b/android/app/src/main/res/values/attrs.xml @@ -0,0 +1,62 @@ +<resources> + <declare-styleable name="Button"> + <attr name="buttonColor" + format="enum"> + <enum name="blue" + value="0" /> + <enum name="green" + value="1" /> + <enum name="red" + value="2" /> + </attr> + <attr name="detailImage" + format="reference" /> + <attr name="showSpinner" + format="boolean" /> + </declare-styleable> + <declare-styleable name="Cell"> + <attr name="footer" + format="reference|string" /> + </declare-styleable> + <declare-styleable name="CopyableInformationView"> + <attr name="clipboardLabel" + format="reference|string" /> + <attr name="copiedToast" + format="reference|string" /> + </declare-styleable> + <declare-styleable name="InformationView"> + <attr name="description" + format="reference|string" /> + <attr name="errorColor" + format="reference|color" /> + <attr name="informationColor" + format="reference|color" /> + <attr name="maxLength" + format="integer" /> + <attr name="whenMissing" + format="enum"> + <enum name="nothing" + value="0" /> + <enum name="hide" + value="1" /> + <enum name="showSpinner" + value="2" /> + </attr> + </declare-styleable> + <declare-styleable name="TextAttribute"> + <attr name="text" + format="reference|string" /> + </declare-styleable> + <declare-styleable name="Url"> + <attr name="url" + format="reference|string" /> + </declare-styleable> + <declare-styleable name="UrlButton"> + <attr name="withToken" + format="boolean" /> + </declare-styleable> + <attr name="actionListItemViewStyle" + type="reference" /> + <attr name="applicationListItemViewStyle" + type="reference" /> +</resources> diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..8f53508029 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,34 @@ +<resources> + <color name="colorPrimary">#294D73</color> + <color name="blue">#294D73</color> + <color name="blue80">#CC294D73</color> + <color name="blue60">#99294D73</color> + <color name="blue40">#66294D73</color> + <color name="blue20">#33294D73</color> + <color name="darkBlue">#192E45</color> + <color name="white">#FFFFFF</color> + <color name="white80">#CCFFFFFF</color> + <color name="white60">#99FFFFFF</color> + <color name="white40">#66FFFFFF</color> + <color name="white20">#33FFFFFF</color> + <color name="white10">#1AFFFFFF</color> + <color name="green">#44AD4D</color> + <color name="green90">#E644AD4D</color> + <color name="green80">#CC44AD4D</color> + <color name="red">#FFE34039</color> + <color name="red95">#F2E34039</color> + <color name="red80">#CCE34039</color> + <color name="red60">#99E34039</color> + <color name="red45">#72E34039</color> + <color name="red40">#66E34039</color> + <color name="yellow">#FFD323</color> + <color name="textInputBorder">#234161</color> + <color name="ic_launcher_background">@color/darkBlue</color> + <!-- Switch Colors --> + <color name="switch_thumb_fill_checked">@color/green</color> + <color name="switch_thumb_fill_unchecked">@color/red</color> + <color name="switch_thumb_fill">@color/switch_thumb_fill_selector</color> + <color name="switch_thumb_border">@android:color/transparent</color> + <color name="switch_track_fill">@android:color/transparent</color> + <color name="switch_track_border">@color/white80</color> +</resources> diff --git a/android/app/src/main/res/values/dimensions.xml b/android/app/src/main/res/values/dimensions.xml new file mode 100644 index 0000000000..c8e8b2ff33 --- /dev/null +++ b/android/app/src/main/res/values/dimensions.xml @@ -0,0 +1,56 @@ +<resources> + <dimen name="country_row_padding">18dp</dimen> + <dimen name="city_row_padding">34dp</dimen> + <dimen name="relay_row_padding">50dp</dimen> + <dimen name="list_item_divider">1dp</dimen> + <dimen name="dialog_margin">14dp</dimen> + <dimen name="account_login_input_height">48dp</dimen> + <dimen name="account_login_corner_radius">4dp</dimen> + <dimen name="account_login_border_width">2dp</dimen> + <dimen name="account_history_divider">1dp</dimen> + <dimen name="account_history_entry_height">48dp</dimen> + <dimen name="edit_text_corner_radius">4dp</dimen> + <dimen name="button_height">44dp</dimen> + <dimen name="cell_height">52dp</dimen> + <dimen name="cell_switch_border_radius">16dp</dimen> + <dimen name="cell_switch_width">48dp</dimen> + <dimen name="cell_switch_height">30dp</dimen> + <dimen name="cell_switch_knob_margin">4dp</dimen> + <dimen name="cell_switch_knob_max_translation">18dp</dimen> + <dimen name="cell_switch_knob_size">22dp</dimen> + <dimen name="settings_back_button_padding">12dp</dimen> + <dimen name="cell_left_padding">@dimen/side_margin</dimen> + <dimen name="cell_right_padding">16dp</dimen> + <dimen name="cell_inner_spacing">8dp</dimen> + <dimen name="cell_label_vertical_padding">14dp</dimen> + <dimen name="cell_input_width">80dp</dimen> + <dimen name="cell_input_height">34dp</dimen> + <dimen name="cell_footer_top_padding">6dp</dimen> + <dimen name="cell_footer_horizontal_padding">@dimen/side_margin</dimen> + <dimen name="app_version_warning_icon_size">28dp</dimen> + <dimen name="chevron_width">14dp</dimen> + <dimen name="chevron_height">24dp</dimen> + <dimen name="text_small">13sp</dimen> + <dimen name="text_hostname">15sp</dimen> + <dimen name="text_medium">16sp</dimen> + <dimen name="text_medium_plus">18sp</dimen> + <dimen name="text_big">24sp</dimen> + <dimen name="text_huge">30sp</dimen> + <dimen name="side_margin">22dp</dimen> + <dimen name="vertical_space">20dp</dimen> + <dimen name="half_vertical_space">10dp</dimen> + <dimen name="button_separation">18dp</dimen> + <dimen name="screen_vertical_margin">22dp</dimen> + <dimen name="app_list_item_icon_size">35dp</dimen> + <dimen name="progress_size">60dp</dimen> + <dimen name="icon_size">24dp</dimen> + <dimen name="widget_padding">16dp</dimen> + <dimen name="expanded_toolbar_height">104dp</dimen> + <!-- Switch Dimens--> + <dimen name="switch_width">46dp</dimen> + <dimen name="switch_height">30dp</dimen> + <dimen name="switch_thumb_size">30dp</dimen> + <dimen name="switch_thumb_padding">8dp</dimen> + <dimen name="switch_track_radius">16dp</dimen> + <dimen name="switch_track_stroke">2dp</dimen> +</resources> diff --git a/android/app/src/main/res/values/integers.xml b/android/app/src/main/res/values/integers.xml new file mode 100644 index 0000000000..3089382d18 --- /dev/null +++ b/android/app/src/main/res/values/integers.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <integer name="transition_animation_duration">@android:integer/config_mediumAnimTime</integer> +</resources> diff --git a/android/app/src/main/res/values/plurals.xml b/android/app/src/main/res/values/plurals.xml new file mode 100644 index 0000000000..b9aa90441e --- /dev/null +++ b/android/app/src/main/res/values/plurals.xml @@ -0,0 +1,43 @@ +<resources> + <plurals name="years_left"> + <item quantity="one">1 year left</item> + <item quantity="other">%d years left</item> + </plurals> + <plurals name="months_left"> + <item quantity="one">1 month left</item> + <item quantity="other">%d months left</item> + </plurals> + <plurals name="days_left"> + <item quantity="one">1 day left</item> + <item quantity="other">%d days left</item> + </plurals> + <plurals name="minutes_ago"> + <item quantity="zero">less than a minute ago</item> + <item quantity="one">a minute ago</item> + <item quantity="other">%d minutes ago</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">an hour ago</item> + <item quantity="other">%d hours ago</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">a day ago</item> + <item quantity="other">%d days ago</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">a month ago</item> + <item quantity="other">%d months ago</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">a year ago</item> + <item quantity="other">%d years ago</item> + </plurals> + <plurals name="account_credit_expires_in_days"> + <item quantity="one">Account credit expires in a day</item> + <item quantity="other">Account credit expires in %d days</item> + </plurals> + <plurals name="account_credit_expires_in_hours"> + <item quantity="one">Account credit expires in an hour</item> + <item quantity="other">Account credit expires in %d hours</item> + </plurals> +</resources> diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..b0828e73b0 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,175 @@ +<resources> + <string name="connecting">Connecting</string> + <string name="reconnecting">Reconnecting</string> + <string name="disconnecting">Disconnecting</string> + <string name="secured">Secured</string> + <string name="unsecured">Unsecured</string> + <string name="critical_error">Critical error (your attention is required)</string> + <string name="blocking_all_connections">Blocking all connections</string> + <string name="foreground_notification_channel_name">VPN tunnel status</string> + <string name="foreground_notification_channel_description">Shows current VPN tunnel + status</string> + <string name="account_time_notification_channel_name">Account time reminders</string> + <string name="account_time_notification_channel_description">Shows reminders when the account + time is about to expire</string> + <string name="connecting_to_daemon">Connecting to Mullvad system service...</string> + <string name="login_title">Login</string> + <string name="login_description">Enter your account number</string> + <string name="logging_in_title">Logging in...</string> + <string name="logging_in_description">Checking account number</string> + <string name="logged_in_title">Logged in</string> + <string name="login_fail_title">Login failed</string> + <string name="login_fail_description">Invalid account number</string> + <string name="dont_have_an_account">Don\'t have an account number?</string> + <string name="create_account">Create account</string> + <string name="creating_new_account">Creating account...</string> + <string name="failed_to_create_account">Failed to create account</string> + <string name="account_created">Account created</string> + <string name="congrats">Congrats!</string> + <string name="here_is_your_account_number">Here\'s your account number. Save it!</string> + <string name="pay_to_start_using">To start using the app, you first need to add time to your + account.</string> + <string name="buy_credit">Buy credit</string> + <string name="buy_more_credit">Buy more credit</string> + <string name="redeem_voucher">Redeem voucher</string> + <string name="enter_voucher_code">Enter voucher code</string> + <string name="redeem">Redeem</string> + <string name="invalid_voucher">Voucher code is invalid.</string> + <string name="voucher_already_used">Voucher code has already been used.</string> + <string name="error_occurred">An error occurred.</string> + <string name="settings">Settings</string> + <string name="settings_account">Account</string> + <string name="less_than_a_day_left">less than a day left</string> + <string name="less_than_a_minute_ago">less than a minute ago</string> + <string name="account_credit_expires_in_a_few_minutes">Account credit expires in a few + minutes</string> + <string name="account_credit_has_expired">You have no more VPN time left on this + account.</string> + <string name="out_of_time">Out of time</string> + <string name="add_time_to_account">Either buy credit on our website or redeem a + voucher.</string> + <string name="settings_preferences">Preferences</string> + <string name="settings_advanced">Advanced</string> + <string name="app_version">App version</string> + <string name="update_available_footer">Update available, download to remain safe.</string> + <string name="report_a_problem">Report a problem</string> + <string name="faqs_and_guides">FAQs & Guides</string> + <string name="account_number">Account number</string> + <string name="mullvad_account_number">Mullvad account number</string> + <string name="copied_mullvad_account_number">Copied Mullvad account number to + clipboard</string> + <string name="paid_until">Paid until</string> + <string name="log_out">Log out</string> + <string name="local_network_sharing">Local network sharing</string> + <string name="allow_lan_footer">Allows access to other devices on the same network for sharing, + printing etc.</string> + <string name="auto_connect">Auto-connect</string> + <string name="auto_connect_footer">Automatically connect to a server when the app + launches.</string> + <string name="wireguard_mtu">WireGuard MTU</string> + <string name="wireguard_mtu_footer">Set WireGuard MTU value. Valid range: %1$d - %2$d.</string> + <string name="hint_default">Default</string> + <string name="problem_report_description">To help you more effectively, your app\'s log file + will be attached to this message. Your data will remain secure and private, as it is anonymised + before being sent over an encrypted channel.</string> + <string name="user_email_hint">Your email (optional)</string> + <string name="user_message_hint">Please describe your problem in English or Swedish.</string> + <string name="view_logs">View app logs</string> + <string name="send">Send</string> + <string name="sending">Sending...</string> + <string name="sent">Sent</string> + <string name="failed_to_send">Failed to send</string> + <string name="confirm_no_email">You are about to send the problem report without a way for us + to get back to you. If you want an answer to your report you will have to enter an email + address.</string> + <string name="send_anyway">Send anyway</string> + <string name="back">Back</string> + <string name="sent_thanks">Thanks!</string> + <string name="we_will_look_into_this">We will look into this.</string> + <string name="sent_contact">If needed we will contact you on %1$s</string> + <string name="failed_to_send_details">You may need to go back to the app\'s main screen and + click Disconnect before trying again. Don\'t worry, the information you entered will remain in + the form.</string> + <string name="edit_message">Edit message</string> + <string name="try_again">Try again</string> + <string name="unsecured_connection">UNSECURED CONNECTION</string> + <string name="creating_secure_connection">CREATING SECURE CONNECTION</string> + <string name="secure_connection">SECURE CONNECTION</string> + <string name="blocked_connection">BLOCKED CONNECTION</string> + <string name="error_state">FAILED TO SECURE CONNECTION</string> + <string name="connect">Secure my connection</string> + <string name="cancel">Cancel</string> + <string name="disconnect">Disconnect</string> + <string name="dismiss">Dismiss</string> + <string name="switch_location">Switch location</string> + <string name="wireguard">WireGuard</string> + <string name="tcp">TCP</string> + <string name="udp">UDP</string> + <string name="in_address">In</string> + <string name="out_address">Out</string> + <string name="blocking_internet">Blocking internet</string> + <string name="not_blocking_internet">YOU MIGHT BE LEAKING NETWORK TRAFFIC</string> + <string name="failed_to_block_internet">Failed to block all network traffic. Please + troubleshoot or report the problem to us.</string> + <string name="auth_failed">Account authentication failed.</string> + <string name="ipv6_unavailable">Could not configure IPv6</string> + <string name="set_firewall_policy_error">Failed to apply firewall rules. The device might + currently be unsecured</string> + <string name="set_dns_error">Failed to set system DNS server</string> + <string name="invalid_dns_servers">Custom DNS server addresses %1$s are invalid</string> + <string name="start_tunnel_error">Failed to start tunnel connection</string> + <string name="vpn_permission_denied_error">VPN permission was denied when creating the tunnel. + Please try connecting again.</string> + <string name="no_matching_relay">No relay server matches the current settings</string> + <string name="no_matching_bridge_relay">No bridge relay server matches the current + settings</string> + <string name="no_wireguard_key">Valid WireGuard key is missing. Manage keys under Advanced + settings.</string> + <string name="custom_tunnel_host_resolution_error">Failed to resolve the hostname of custom + server</string> + <string name="is_offline">This device is offline, no tunnels can be established</string> + <string name="virtual_adapter_problem">Virtual adapter error</string> + <string name="wireguard_error">WireGuard error</string> + <string name="too_many_keys">Too many WireGuard keys registered to account</string> + <string name="failed_to_generate_key">Failed to generate a key</string> + <string name="update_available">UPDATE AVAILABLE</string> + <string name="update_available_description">Install Mullvad VPN (%1$s) to stay up to + date</string> + <string name="unsupported_version">UNSUPPORTED VERSION</string> + <string name="unsupported_version_description">You are running an unsupported app version. + Please upgrade to %1$s now to ensure your security</string> + <string name="unsupported_version_without_upgrade">You are running an unsupported app + version.</string> + <string name="account_credit_expires_soon">Account credit expires soon</string> + <string name="select_location">Select location</string> + <string name="select_location_description">While connected, your real location is masked with a + private and secure location in the selected region.</string> + <string name="wireguard_key">WireGuard key</string> + <string name="public_key">Public key</string> + <string name="wireguard_key_generated">Key generated</string> + <string name="wireguard_verify_key">Verify key</string> + <string name="wireguard_generate_key">Generate key</string> + <string name="wireguard_replace_key">Regenerate key</string> + <string name="wireguard_manage_keys">Manage keys</string> + <string name="wireguard_key_reconnecting">Reconnecting with new WireGuard key...</string> + <string name="wireguard_key_valid">Key is valid</string> + <string name="wireguard_key_invalid">Key is invalid</string> + <string name="wireguard_key_verification_failure">Key verification failed</string> + <string name="wireguard_public_key">WireGuard public key</string> + <string name="copied_wireguard_public_key">Copied WireGuard public key to clipboard</string> + <string name="split_tunneling">Split tunneling</string> + <string name="split_tunneling_description">Split tunneling makes it possible to select which + applications should not be routed through the VPN tunnel.</string> + <string name="enable">Enable</string> + <string name="enable_custom_dns">Use custom DNS server</string> + <string name="add_a_server">Add a server</string> + <string name="custom_dns_hint">Enter IP</string> + <string name="custom_dns_footer">Enable to add at least one DNS server.</string> + <string name="confirm_local_dns">The local DNS server will not work unless you enable \"Local + Network Sharing\" under Preferences.</string> + <string name="add_anyway">Add anyway</string> + <string name="exclude_applications">Excluded applications</string> + <string name="all_applications">All applications</string> + <string name="copied_to_clipboard">Copied to clipboard</string> + <string name="show_system_apps">Show system apps</string> +</resources> diff --git a/android/app/src/main/res/values/strings_non_translatable.xml b/android/app/src/main/res/values/strings_non_translatable.xml new file mode 100644 index 0000000000..98ef2737c4 --- /dev/null +++ b/android/app/src/main/res/values/strings_non_translatable.xml @@ -0,0 +1,18 @@ +<resources> + <string name="app_name" + translatable="false">Mullvad VPN</string> + <string name="login_hint" + translatable="false">0000 0000 0000 0000</string> + <string name="voucher_hint" + translatable="false">XXXX-XXXX-XXXX-XXXX</string> + <string name="account_url" + translatable="false">https://mullvad.net/account</string> + <string name="wg_key_url" + translatable="false">https://mullvad.net/account/ports</string> + <string name="create_account_url" + translatable="false">https://mullvad.net/account/create</string> + <string name="download_url" + translatable="false">https://mullvad.net/download</string> + <string name="faqs_and_guides_url" + translatable="false">https://mullvad.net/help/tag/mullvad-app/</string> +</resources> diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..742cc42c4f --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,123 @@ +<resources> + <style name="AppTheme" + parent="Theme.AppCompat.NoActionBar"> + <item name="colorPrimary">@color/colorPrimary</item> + <item name="android:navigationBarColor">@color/blue</item> + <item name="android:statusBarColor">@color/blue</item> + <item name="android:windowBackground">@color/blue</item> + <item name="switchStyle">@style/AppTheme.Switch</item> + <item name="actionListItemViewStyle">@style/ListItem.Action</item> + <item name="applicationListItemViewStyle">@style/ListItem.Action.Application</item> + <item name="android:spotShadowAlpha">0</item> + <item name="actionBarSize">48dp</item> + </style> + <style name="InputText" + parent="Widget.AppCompat.EditText"> + <item name="android:padding">14dp</item> + <item name="android:background">@drawable/input_text_background</item> + <item name="android:textCursorDrawable">@drawable/text_input_cursor</item> + <item name="android:textColorHint">@color/blue40</item> + <item name="android:textColor">@color/blue</item> + <item name="android:textSize">@dimen/text_small</item> + </style> + <style name="Button" + parent="Widget.AppCompat.Button.Borderless"> + <item name="android:layout_height">wrap_content</item> + <item name="android:layout_width">match_parent</item> + <item name="android:minHeight">@dimen/button_height</item> + <item name="android:paddingTop">0dp</item> + <item name="android:paddingBottom">0dp</item> + <item name="android:textAllCaps">false</item> + <item name="android:textColor">@color/white</item> + <item name="android:textSize">@dimen/text_medium_plus</item> + <item name="android:textStyle">bold</item> + </style> + <style name="GreenButton" + parent="Button"> + <item name="android:background">@drawable/green_button_background</item> + </style> + <style name="RedButton" + parent="Button"> + <item name="android:background">@drawable/red_button_background</item> + </style> + <style name="BlueButton" + parent="Button"> + <item name="android:background">@drawable/blue_button_background</item> + </style> + <style name="White20Button" + parent="Button"> + <item name="android:background">@drawable/white20_button_background</item> + </style> + <style name="SettingsHeader"> + <item name="android:textColor">@color/white</item> + <item name="android:textStyle">bold</item> + </style> + <style name="SettingsExpandedHeader" + parent="SettingsHeader"> + <item name="android:textSize">@dimen/text_huge</item> + </style> + <style name="SettingsCollapsedHeader" + parent="SettingsHeader"> + <item name="android:textSize">@dimen/text_medium</item> + </style> + <style name="TextAppearance.Mullvad" + parent="TextAppearance.AppCompat" /> + <style name="TextAppearance.Mullvad.Title1"> + <item name="android:textColor">@color/white</item> + <item name="android:textSize">@dimen/text_medium_plus</item> + </style> + <style name="TextAppearance.Mullvad.Title2"> + <item name="android:textColor">@color/white</item> + <item name="android:textSize">@dimen/text_medium</item> + </style> + <style name="TextAppearance.Mullvad.Small"> + <item name="android:textColor">@color/white60</item> + <item name="android:textSize">@dimen/text_small</item> + </style> + <style name="ListItem"> + <item name="android:layout_width">match_parent</item> + <item name="android:layout_height">wrap_content</item> + </style> + <style name="ListItem.DividerGroup"> + <item name="android:layout_height">@dimen/vertical_space</item> + </style> + <style name="ListItem.PlainText"> + <item name="android:focusable">false</item> + <item name="android:clickable">false</item> + <item name="android:paddingTop">5dp</item> + </style> + <style name="ListItem.Action"> + <item name="android:height">@dimen/cell_height</item> + <item name="android:layout_height">@dimen/cell_height</item> + <item name="android:background">@drawable/cell_button_background</item> + <item name="android:clickable">true</item> + <item name="android:focusable">true</item> + </style> + <style name="ListItem.Action.Application"> + <item name="android:background">@drawable/app_list_item_background</item> + </style> + <style name="ListItem.Action.Double"> + <item name="android:clickable">false</item> + <item name="android:focusable">false</item> + </style> + <style name="TextAppearance.Mullvad.CollapsingToolbar"> + <item name="android:textColor">@color/white</item> + </style> + <style name="TextAppearance.Mullvad.CollapsingToolbar.Expanded"> + <item name="android:textSize">30sp</item> + <item name="android:textStyle">bold</item> + </style> + <style name="TextAppearance.Mullvad.CollapsingToolbar.Collapsed"> + <item name="android:textSize">20sp</item> + <item name="android:textStyle">bold</item> + </style> + <!-- Switch Style --> + <style name="AppTheme.Switch"> + <item name="android:layout_width">@dimen/switch_width</item> + <item name="android:layout_height">@dimen/switch_height</item> + <item name="track">@drawable/switch_track</item> + <item name="android:thumb">@drawable/switch_thumb</item> + <item name="switchMinWidth">@dimen/switch_width</item> + <item name="showText">false</item> + </style> +</resources> diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/TestCoroutineRule.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/TestCoroutineRule.kt new file mode 100644 index 0000000000..1acdf9e577 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/TestCoroutineRule.kt @@ -0,0 +1,24 @@ +package net.mullvad.mullvadvpn + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class TestCoroutineRule( + val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() +) : TestWatcher() { + + override fun starting(description: Description?) { + super.starting(description) + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description?) { + super.finished(description) + Dispatchers.resetMain() + testDispatcher.cleanupTestCoroutines() + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/TestUtils.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/TestUtils.kt new file mode 100644 index 0000000000..4c4f043c06 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/TestUtils.kt @@ -0,0 +1,11 @@ +package net.mullvad.mullvadvpn + +import kotlin.test.assertTrue + +fun <T> assertLists(expected: List<T>, actual: List<T>, message: String? = null) = assertTrue( + expected.size == actual.size && expected.containsAll(actual) && actual.containsAll(expected), + message ?: """Expected list should have same size and contains same items. + | Expected(${expected.size}): $expected + | Actual(${actual.size}) : $actual + """.trimMargin() +) diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManagerTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManagerTest.kt new file mode 100644 index 0000000000..e6d43621a1 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManagerTest.kt @@ -0,0 +1,98 @@ +package net.mullvad.mullvadvpn.applist + +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.os.Looper +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlin.test.assertEquals +import kotlin.test.assertFails +import org.junit.After +import org.junit.Before +import org.junit.Test + +class ApplicationsIconManagerTest { + private val mockedPackageManager = mockk<PackageManager>() + private val mockedMainLooper = mockk<Looper>() + private val testSubject = ApplicationsIconManager(mockedPackageManager) + + @Before + fun setUp() { + mockkStatic(Looper::class) + every { Looper.getMainLooper() } returns mockedMainLooper + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun test_first_time_load_icon_from_PM() { + val testPackageName = "test" + val mockedDrawable = mockk<Drawable>() + every { mockedPackageManager.getApplicationIcon(testPackageName) } returns mockedDrawable + every { mockedMainLooper.isCurrentThread } returns false + + val result = testSubject.getAppIcon(testPackageName) + + assertEquals(mockedDrawable, result) + verify { + mockedMainLooper.isCurrentThread + mockedPackageManager.getApplicationIcon(testPackageName) + } + } + + @Test + fun test_second_time_load_icon_from_cache() { + val testPackageName = "test" + val mockedDrawable = mockk<Drawable>() + every { mockedPackageManager.getApplicationIcon(testPackageName) } returns mockedDrawable + every { mockedMainLooper.isCurrentThread } returns false + + val result = testSubject.getAppIcon(testPackageName) + val result2 = testSubject.getAppIcon(testPackageName) + + assertEquals(mockedDrawable, result) + assertEquals(mockedDrawable, result2) + verify(exactly = 2) { + mockedMainLooper.isCurrentThread + } + verify(exactly = 1) { + mockedPackageManager.getApplicationIcon(testPackageName) + } + } + + @Test + fun test_second_time_load_icon_from_PM_after_clear() { + val testPackageName = "test" + val mockedDrawable = mockk<Drawable>() + every { mockedPackageManager.getApplicationIcon(testPackageName) } returns mockedDrawable + every { mockedMainLooper.isCurrentThread } returns false + + val result = testSubject.getAppIcon(testPackageName) + testSubject.dispose() + val result2 = testSubject.getAppIcon(testPackageName) + + assertEquals(mockedDrawable, result) + assertEquals(mockedDrawable, result2) + verify(exactly = 2) { + mockedMainLooper.isCurrentThread + mockedPackageManager.getApplicationIcon(testPackageName) + } + } + + @Test + fun test_throw_exception_when_invoke_from_MainThread() { + val testPackageName = "test" + every { mockedMainLooper.isCurrentThread } returns true + + assertFails("Should not be called from MainThread") { + testSubject.getAppIcon(testPackageName) + } + verify { mockedMainLooper.isCurrentThread } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProviderTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProviderTest.kt new file mode 100644 index 0000000000..e1a9e37ac4 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProviderTest.kt @@ -0,0 +1,129 @@ +package net.mullvad.mullvadvpn.applist + +import android.Manifest +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verifyAll +import net.mullvad.mullvadvpn.assertLists +import org.junit.After +import org.junit.Test + +class ApplicationsProviderTest { + private val mockedPackageManager = mockk<PackageManager>() + private val selfPackageName = "self_package_name" + private val testSubject = ApplicationsProvider(mockedPackageManager, selfPackageName) + private val internet = Manifest.permission.INTERNET + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun test_get_apps() { + val launchWithInternetPackageName = "launch_with_internet_package_name" + val launchWithoutInternetPackageName = "launch_without_internet_package_name" + val nonLaunchWithInternetPackageName = "non_launch_with_internet_package_name" + val nonLaunchWithoutInternetPackageName = "non_launch_without_internet_package_name" + val leanbackLaunchWithInternetPackageName = "leanback_launch_with_internet_package_name" + val leanbackLaunchWithoutInternetPackageName = + "leanback_launch_without_internet_package_name" + + every { + mockedPackageManager.getInstalledApplications(PackageManager.GET_META_DATA) + } returns listOf( + createApplicationInfo(launchWithInternetPackageName, launch = true, internet = true), + createApplicationInfo(launchWithoutInternetPackageName, launch = true), + createApplicationInfo(nonLaunchWithInternetPackageName, internet = true), + createApplicationInfo(nonLaunchWithoutInternetPackageName), + createApplicationInfo( + leanbackLaunchWithInternetPackageName, + leanback = true, + internet = true + ), + createApplicationInfo(leanbackLaunchWithoutInternetPackageName, leanback = true), + createApplicationInfo(selfPackageName, internet = true, launch = true) + ) + + val result = testSubject.getAppsList() + val expected = listOf( + AppData(launchWithInternetPackageName, 0, launchWithInternetPackageName), + AppData(nonLaunchWithInternetPackageName, 0, nonLaunchWithInternetPackageName, true), + AppData(leanbackLaunchWithInternetPackageName, 0, leanbackLaunchWithInternetPackageName) + ) + + assertLists(expected, result) + + verifyAll { + mockedPackageManager.getInstalledApplications(PackageManager.GET_META_DATA) + + listOf( + launchWithInternetPackageName, + launchWithoutInternetPackageName, + nonLaunchWithInternetPackageName, + nonLaunchWithoutInternetPackageName, + leanbackLaunchWithInternetPackageName, + leanbackLaunchWithoutInternetPackageName, + selfPackageName + ).forEach { packageName -> + mockedPackageManager.checkPermission(internet, packageName) + } + + listOf( + launchWithInternetPackageName, + nonLaunchWithInternetPackageName, + leanbackLaunchWithInternetPackageName + ).forEach { packageName -> + mockedPackageManager.getLaunchIntentForPackage(packageName) + } + + listOf( + nonLaunchWithInternetPackageName, + leanbackLaunchWithInternetPackageName, + ).forEach { packageName -> + mockedPackageManager.getLeanbackLaunchIntentForPackage(packageName) + } + } + } + + private fun createApplicationInfo( + packageName: String, + launch: Boolean = false, + leanback: Boolean = false, + internet: Boolean = false, + systemApp: Boolean = false + ): ApplicationInfo { + val mockApplicationInfo = mockk<ApplicationInfo>() + + mockApplicationInfo.packageName = packageName + mockApplicationInfo.icon = 0 + + every { mockApplicationInfo.loadLabel(mockedPackageManager) } returns packageName + + every { + mockedPackageManager.getLaunchIntentForPackage(packageName) + } returns if (launch || systemApp) + mockk() + else + null + + every { + mockedPackageManager.getLeanbackLaunchIntentForPackage(packageName) + } returns if (leanback || systemApp) + mockk() + else + null + + every { + mockedPackageManager.checkPermission(Manifest.permission.INTERNET, packageName) + } returns if (internet) + PackageManager.PERMISSION_GRANTED + else + PackageManager.PERMISSION_DENIED + + return mockApplicationInfo + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/di/AppModuleTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/di/AppModuleTest.kt new file mode 100644 index 0000000000..c30a63fedf --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/di/AppModuleTest.kt @@ -0,0 +1,49 @@ +package net.mullvad.mullvadvpn.di + +import android.os.Messenger +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlin.test.assertEquals +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.ipc.MessageDispatcher +import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named +import org.koin.core.scope.Scope +import org.koin.test.KoinTest +import org.koin.test.KoinTestRule + +class AppModuleTest : KoinTest { + + @get:Rule + val koinTestRule = KoinTestRule.create { + modules(appModule) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun test_scope_linking() { + val appsScope: Scope = getKoin().createScope(APPS_SCOPE, named(APPS_SCOPE)) + val serviceConnectionScope = getKoin().createScope( + SERVICE_CONNECTION_SCOPE, + named(SERVICE_CONNECTION_SCOPE) + ) + + appsScope.linkTo(serviceConnectionScope) + + val mockedMessenger = mockk<Messenger>() + val mockedEventMessageHandler = mockk<MessageDispatcher<Event>>(relaxed = true) + val serviceConnectionSplitTunneling = serviceConnectionScope.get<SplitTunneling>( + parameters = { parametersOf(mockedMessenger, mockedEventMessageHandler) } + ) + + assertEquals(appsScope.get<SplitTunneling>(), serviceConnectionSplitTunneling) + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparatorTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparatorTest.kt new file mode 100644 index 0000000000..a3c96349d9 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparatorTest.kt @@ -0,0 +1,97 @@ +package net.mullvad.mullvadvpn.relaylist + +import io.mockk.mockk +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Test + +class RelayNameComparatorTest { + + private val mockedCity = mockk<RelayCity>(relaxed = true) + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun test_compare_respect_numbers_in_name() { + val relay9 = Relay(mockedCity, "se9-wireguard", false) + val relay10 = Relay(mockedCity, "se10-wireguard", false) + + relay9 assertOrderBothDirection relay10 + } + + @Test + fun test_compare_same_name() { + val relay9a = Relay(mockedCity, "se9-wireguard", false) + val relay9b = Relay(mockedCity, "se9-wireguard", false) + + assertTrue(RelayNameComparator.compare(relay9a, relay9b) == 0) + assertTrue(RelayNameComparator.compare(relay9b, relay9a) == 0) + } + + @Test + fun test_compare_only_numbers_in_name() { + val relay001 = Relay(mockedCity, "001", false) + val relay1 = Relay(mockedCity, "1", false) + val relay3 = Relay(mockedCity, "3", false) + val relay100 = Relay(mockedCity, "100", false) + + relay001 assertOrderBothDirection relay1 + relay001 assertOrderBothDirection relay3 + relay1 assertOrderBothDirection relay3 + relay3 assertOrderBothDirection relay100 + } + + @Test + fun test_compare_without_numbers_in_name() { + val relay9a = Relay(mockedCity, "se-wireguard", false) + val relay9b = Relay(mockedCity, "se-wireguard", false) + + assertTrue(RelayNameComparator.compare(relay9a, relay9b) == 0) + assertTrue(RelayNameComparator.compare(relay9b, relay9a) == 0) + } + + @Test + fun test_compare_with_trailing_zeros_in_name() { + val relay001 = Relay(mockedCity, "se001-wireguard", false) + val relay005 = Relay(mockedCity, "se005-wireguard", false) + + relay001 assertOrderBothDirection relay005 + } + + @Test + fun test_compare_prefix_and_numbers() { + val relayAr2 = Relay(mockedCity, "ar2-wireguard", false) + val relayAr8 = Relay(mockedCity, "ar8-wireguard", false) + val relaySe5 = Relay(mockedCity, "se5-wireguard", false) + val relaySe10 = Relay(mockedCity, "se10-wireguard", false) + + relayAr2 assertOrderBothDirection relayAr8 + relayAr8 assertOrderBothDirection relaySe5 + relaySe5 assertOrderBothDirection relaySe10 + } + + @Test + fun test_compare_suffix_and_numbers() { + val relay2c = Relay(mockedCity, "se2-cloud", false) + val relay2w = Relay(mockedCity, "se2-wireguard", false) + + relay2c assertOrderBothDirection relay2w + } + + @Test + fun test_compare_different_length() { + val relay22a = Relay(mockedCity, "se22", false) + val relay22b = Relay(mockedCity, "se22-wireguard", false) + + relay22a assertOrderBothDirection relay22b + } + + private infix fun Relay.assertOrderBothDirection(other: Relay) { + assertTrue(RelayNameComparator.compare(this, other) < 0) + assertTrue(RelayNameComparator.compare(other, this) > 0) + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt new file mode 100644 index 0000000000..ac229ba3fb --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt @@ -0,0 +1,238 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.annotation.StringRes +import androidx.lifecycle.viewModelScope +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import io.mockk.verifyAll +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runBlockingTest +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.TestCoroutineRule +import net.mullvad.mullvadvpn.applist.AppData +import net.mullvad.mullvadvpn.applist.ApplicationsProvider +import net.mullvad.mullvadvpn.applist.ViewIntent +import net.mullvad.mullvadvpn.assertLists +import net.mullvad.mullvadvpn.model.ListItemData +import net.mullvad.mullvadvpn.model.WidgetState +import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.Timeout + +class SplitTunnelingViewModelTest { + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + @get:Rule + val timeout = Timeout(3000L, TimeUnit.MILLISECONDS) + private val mockedApplicationsProvider = mockk<ApplicationsProvider>() + private val mockedSplitTunneling = mockk<SplitTunneling>() + private lateinit var testSubject: SplitTunnelingViewModel + + @Before + fun setup() { + every { mockedSplitTunneling.enabled } returns true + } + + @After + fun tearDown() { + testSubject.viewModelScope.coroutineContext.cancel() + unmockkAll() + } + + @Test + fun test_has_progress_on_start() = runBlockingTest(testCoroutineRule.testDispatcher) { + initTestSubject(emptyList()) + val actualList: List<ListItemData> = testSubject.listItems.first() + + val initialExpectedList = listOf( + createTextItem(R.string.split_tunneling_description), + createDivider(0), + createProgressItem() + ) + + assertLists(initialExpectedList, actualList) + + verify(exactly = 1) { + mockedApplicationsProvider.getAppsList() + } + } + + @Test + fun test_empty_app_list() = runBlockingTest(testCoroutineRule.testDispatcher) { + initTestSubject(emptyList()) + testSubject.processIntent(ViewIntent.ViewIsReady) + val actualList = testSubject.listItems.first() + val expectedList = listOf(createTextItem(R.string.split_tunneling_description)) + assertLists(expectedList, actualList) + } + + @Test + fun test_apps_list_delivered() = runBlockingTest(testCoroutineRule.testDispatcher) { + val appExcluded = AppData("test.excluded", 0, "testName1") + val appNotExcluded = AppData("test.not.excluded", 0, "testName2") + every { mockedSplitTunneling.isAppExcluded(appExcluded.packageName) } returns true + every { mockedSplitTunneling.isAppExcluded(appNotExcluded.packageName) } returns false + + initTestSubject(listOf(appExcluded, appNotExcluded)) + testSubject.processIntent(ViewIntent.ViewIsReady) + + val actualList = testSubject.listItems.first() + val expectedList = listOf( + createTextItem(R.string.split_tunneling_description), + createDivider(0), + createMainItem(R.string.exclude_applications), + createApplicationItem(appExcluded, true), + createDivider(1), + createSwitchItem(R.string.show_system_apps, false), + createMainItem(R.string.all_applications), + createApplicationItem(appNotExcluded, false), + ) + + assertLists(expectedList, actualList) + verifyAll { + mockedSplitTunneling.enabled + mockedSplitTunneling.isAppExcluded(appExcluded.packageName) + mockedSplitTunneling.isAppExcluded(appNotExcluded.packageName) + } + } + + @Test + fun test_remove_app_from_excluded() = runBlockingTest(testCoroutineRule.testDispatcher) { + val app = AppData("test", 0, "testName") + every { mockedSplitTunneling.isAppExcluded(app.packageName) } returns true + every { mockedSplitTunneling.includeApp(app.packageName) } just Runs + + initTestSubject(listOf(app)) + testSubject.processIntent(ViewIntent.ViewIsReady) + + val listBeforeAction = testSubject.listItems.first() + val expectedListBeforeAction = listOf( + createTextItem(R.string.split_tunneling_description), + createDivider(0), + createMainItem(R.string.exclude_applications), + createApplicationItem(app, true), + ) + + assertLists(expectedListBeforeAction, listBeforeAction) + + val item = listBeforeAction.first { it.identifier == app.packageName } + testSubject.processIntent(ViewIntent.ChangeApplicationGroup(item)) + + val itemsAfterAction = testSubject.listItems.first() + val expectedList = listOf( + createTextItem(R.string.split_tunneling_description), + createDivider(1), + createSwitchItem(R.string.show_system_apps, false), + createMainItem(R.string.all_applications), + createApplicationItem(app, false), + ) + + assertLists(expectedList, itemsAfterAction) + + verifyAll { + mockedSplitTunneling.enabled + mockedSplitTunneling.isAppExcluded(app.packageName) + mockedSplitTunneling.includeApp(app.packageName) + } + } + + @Test + fun test_add_app_to_excluded() = runBlockingTest(testCoroutineRule.testDispatcher) { + val app = AppData("test", 0, "testName") + every { mockedSplitTunneling.isAppExcluded(app.packageName) } returns false + every { mockedSplitTunneling.excludeApp(app.packageName) } just Runs + initTestSubject(listOf(app)) + testSubject.processIntent(ViewIntent.ViewIsReady) + + val listBeforeAction = testSubject.listItems.first() + val expectedListBeforeAction = listOf( + createTextItem(R.string.split_tunneling_description), + createDivider(1), + createSwitchItem(R.string.show_system_apps, false), + createMainItem(R.string.all_applications), + createApplicationItem(app, false), + ) + + assertLists(expectedListBeforeAction, listBeforeAction) + + val item = listBeforeAction.first { it.identifier == app.packageName } + testSubject.processIntent(ViewIntent.ChangeApplicationGroup(item)) + + val itemsAfterAction = testSubject.listItems.first() + val expectedList = listOf( + createTextItem(R.string.split_tunneling_description), + createDivider(0), + createMainItem(R.string.exclude_applications), + createApplicationItem(app, true), + ) + + assertLists(expectedList, itemsAfterAction) + + verifyAll { + mockedSplitTunneling.enabled + mockedSplitTunneling.isAppExcluded(app.packageName) + mockedSplitTunneling.excludeApp(app.packageName) + } + } + + private fun initTestSubject(appList: List<AppData>) { + every { mockedApplicationsProvider.getAppsList() } returns appList + testSubject = SplitTunnelingViewModel( + mockedApplicationsProvider, + mockedSplitTunneling, + testCoroutineRule.testDispatcher + ) + } + + private fun createApplicationItem( + appData: AppData, + checked: Boolean + ): ListItemData = ListItemData.build(appData.packageName) { + type = ListItemData.APPLICATION + text = appData.name + iconRes = appData.iconRes + action = ListItemData.ItemAction(appData.packageName) + widget = WidgetState.ImageState( + if (checked) R.drawable.ic_icons_remove else R.drawable.ic_icons_add + ) + } + + private fun createDivider(id: Int): ListItemData = ListItemData.build("space_$id") { + type = ListItemData.DIVIDER + } + + private fun createMainItem(@StringRes text: Int): ListItemData = + ListItemData.build("header_$text") { + type = ListItemData.ACTION + textRes = text + } + + private fun createTextItem(@StringRes text: Int): ListItemData = + ListItemData.build("text_$text") { + type = ListItemData.PLAIN + textRes = text + action = ListItemData.ItemAction(text.toString()) + } + + private fun createProgressItem(): ListItemData = ListItemData.build(identifier = "progress") { + type = ListItemData.PROGRESS + } + + private fun createSwitchItem(@StringRes text: Int, checked: Boolean): ListItemData = + ListItemData.build(identifier = "switch_$text") { + type = ListItemData.ACTION + textRes = text + action = ListItemData.ItemAction(text.toString()) + widget = WidgetState.SwitchState(checked) + } +} |
