Compare commits
94 Commits
1.63.93-t1
...
main
Author | SHA1 | Date |
---|---|---|
kari-ts | 4fa86dbf03 | 9 hours ago |
Jonathan Nobels | 77c2d924ee | 12 hours ago |
Jonathan Nobels | b37492a547 | 13 hours ago |
kari-ts | 999c6f2357 | 13 hours ago |
Andrea Gottardo | 006b1e6852 | 13 hours ago |
kari-ts | 32e29c4efd | 14 hours ago |
kari-ts | 9aa3a840de | 1 day ago |
kari-ts | 0ff47f7ab5 | 1 day ago |
kari-ts | 12ad295706 | 1 day ago |
kari-ts | d842ccde22 | 1 day ago |
Andrea Gottardo | cbcc773b98 | 3 days ago |
Andrea Gottardo | cbc0035dfe | 4 days ago |
kari-ts | c47ead9412 | 4 days ago |
Percy Wegmann | 46cdbb7b9b | 4 days ago |
kari-ts | 5476288100 | 5 days ago |
kari-ts | a3b356a81c | 5 days ago |
Percy Wegmann | 411d7b2597 | 5 days ago |
Percy Wegmann | 59a88ffbab | 5 days ago |
kari-ts | f684bf696d | 1 week ago |
Percy Wegmann | 698fb868a7 | 1 week ago |
Andrea Gottardo | 82c17a4d1d | 1 week ago |
Jonathan Nobels | b615eb38b4 | 1 week ago |
Andrea Gottardo | 24d6cc7a08 | 1 week ago |
kari-ts | ec1dc8b0be | 1 week ago |
Percy Wegmann | edb3f5b0c5 | 1 week ago |
kari-ts | 7f66c373ea | 1 week ago |
kari-ts | 2d7d6e1357 | 1 week ago |
Jonathan Nobels | 45fd2e0661 | 2 weeks ago |
Percy Wegmann | 31b0ec8865 | 2 weeks ago |
Will Norris | 9703d48f1a | 2 weeks ago |
Jonathan Nobels | 17ad0c8cc0 | 2 weeks ago |
Jonathan Nobels | a2471d38cb | 2 weeks ago |
kari-ts | e6f6d35a99 | 2 weeks ago |
kari-ts | 5e3236260f | 2 weeks ago |
kari-ts | d330726ba1 | 2 weeks ago |
Andrea Gottardo | 0c0853a962 | 2 weeks ago |
James Tucker | 3f864b28c7 | 2 weeks ago |
kari-ts | 22c129ee1c | 3 weeks ago |
Andrea Gottardo | 427e2d29b4 | 3 weeks ago |
kari-ts | 1c0aef5418 | 3 weeks ago |
kari-ts | 39628be8a6 | 3 weeks ago |
Brad Fitzpatrick | 9dda2cc470 | 3 weeks ago |
kari-ts | a6bc2244b6 | 3 weeks ago |
kari-ts | 24dd83090c | 3 weeks ago |
kari-ts | ad3b6a5a64 | 3 weeks ago |
Percy Wegmann | 16fa0e9b9e | 3 weeks ago |
Andrea Gottardo | 88b0af2c9b | 3 weeks ago |
Andrea Gottardo | 7119424e32 | 4 weeks ago |
Jonathan Nobels | b06342629f | 4 weeks ago |
Percy Wegmann | 07d04ca750 | 4 weeks ago |
Percy Wegmann | 057e25c23d | 4 weeks ago |
Will Norris | a54ebf75ef | 4 weeks ago |
Jonathan Nobels | f4d2a277a5 | 4 weeks ago |
kari-ts | 75e2d8983b | 4 weeks ago |
kari-ts | bbb3c86fa8 | 4 weeks ago |
Percy Wegmann | bc8985126d | 4 weeks ago |
Brad Fitzpatrick | eb8d731a04 | 1 month ago |
kari-ts | 81acaef5b7 | 1 month ago |
kari-ts | 19177df1e2 | 1 month ago |
Praneet Loke | 6197cb9576 | 1 month ago |
kari-ts | 253c116f9b | 1 month ago |
Jonathan Nobels | 1c3af6713c | 1 month ago |
kari-ts | 39d1d0b3c3 | 1 month ago |
Andrea Gottardo | 56da7b66d0 | 1 month ago |
kari-ts | f95428f7fa | 1 month ago |
Percy Wegmann | 0c58841350 | 1 month ago |
Andrea Gottardo | 8a7148c085 | 1 month ago |
Jonathan Nobels | 372af99c53 | 1 month ago |
Andrea Gottardo | a73025b36f | 1 month ago |
Andrea Gottardo | 4d86c1a6f6 | 1 month ago |
Andrea Gottardo | a1d97baeb0 | 1 month ago |
Matt Drollette | 9533db44b7 | 1 month ago |
Andrea Gottardo | 44ac22c29d | 1 month ago |
kari-ts | 5ad25262ad | 1 month ago |
Jonathan Nobels | be6364ca95 | 1 month ago |
kari-ts | 3e32e97261 | 1 month ago |
Andrea Gottardo | 164a243b77 | 1 month ago |
Percy Wegmann | a77edc6724 | 1 month ago |
Percy Wegmann | d396fdab27 | 1 month ago |
Percy Wegmann | 0ae9da385e | 1 month ago |
Percy Wegmann | 9054264363 | 1 month ago |
Jonathan Nobels | 11f52ad96b | 1 month ago |
Percy Wegmann | 482b350ce0 | 1 month ago |
kari-ts | c8d1b30918 | 1 month ago |
kari-ts | 6a00880f61 | 1 month ago |
Jonathan Nobels | a3638f9fc7 | 1 month ago |
Percy Wegmann | c59c8537cf | 1 month ago |
Jonathan Nobels | cc244812a6 | 1 month ago |
kari-ts | a325a90558 | 1 month ago |
kari-ts | f14836a750 | 1 month ago |
kari-ts | 38f57b4737 | 1 month ago |
Percy Wegmann | d676dca4f4 | 1 month ago |
Jonathan Nobels | 32e407d06b | 1 month ago |
Percy Wegmann | 9bfa839380 | 1 month ago |
@ -1,31 +0,0 @@
|
||||
name: Build Legacy Debug APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: Switch to Java 17 # Note: 17 is pre-installed on ubuntu-latest
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Build Legacy APK
|
||||
run: make tailscale-debug.apk
|
@ -0,0 +1,36 @@
|
||||
name: go mod tidy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-branch/*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-go-mod-tidy:
|
||||
runs-on: [ubuntu-latest]
|
||||
timeout-minutes: 8
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
cache: false
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check 'go mod tidy' is clean
|
||||
run: |
|
||||
./tool/go mod tidy
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go mod tidy'."; exit 1)
|
@ -0,0 +1,160 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import androidx.test.ext.junit.rules.activityScenarioRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.LargeTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.By
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.UiSelector
|
||||
import dev.turingcomplete.kotlinonetimepassword.HmacAlgorithm
|
||||
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordConfig
|
||||
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordGenerator
|
||||
import org.apache.commons.codec.binary.Base32
|
||||
import org.junit.After
|
||||
import org.junit.Assert
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@LargeTest
|
||||
class MainActivityTest {
|
||||
companion object {
|
||||
const val TAG = "MainActivityTest"
|
||||
}
|
||||
|
||||
@get:Rule val activityRule = activityScenarioRule<MainActivity>()
|
||||
|
||||
@Before fun setUp() {}
|
||||
|
||||
@After fun tearDown() {}
|
||||
|
||||
/**
|
||||
* This test starts with a clean install, logs the user in to a tailnet using credentials provided
|
||||
* through a build config, and then makes sure we can hit https://hello.ts.net.
|
||||
*/
|
||||
@Test
|
||||
fun loginAndVisitHello() {
|
||||
val githubUsername = BuildConfig.GITHUB_USERNAME
|
||||
val githubPassword = BuildConfig.GITHUB_PASSWORD
|
||||
val github2FASecret = Base32().decode(BuildConfig.GITHUB_2FA_SECRET)
|
||||
val config =
|
||||
TimeBasedOneTimePasswordConfig(
|
||||
codeDigits = 6,
|
||||
hmacAlgorithm = HmacAlgorithm.SHA1,
|
||||
timeStep = 30,
|
||||
timeStepUnit = TimeUnit.SECONDS)
|
||||
val githubTOTP = TimeBasedOneTimePasswordGenerator(github2FASecret, config)
|
||||
|
||||
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||
Log.d(TAG, "Wait for VPN permission prompt and accept")
|
||||
device.find(By.text("Connection request"))
|
||||
device.find(By.text("OK")).click()
|
||||
|
||||
Log.d(TAG, "Click through Get Started screen")
|
||||
device.find(By.text("Get Started"))
|
||||
device.find(By.text("Get Started")).click()
|
||||
|
||||
asNecessary(
|
||||
timeout = 2.minutes,
|
||||
{
|
||||
Log.d(TAG, "Log in")
|
||||
device.find(By.text("Log in")).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Accept Chrome terms and conditions (if necessary)")
|
||||
device.find(By.text("Welcome to Chrome"))
|
||||
val dismissIndex =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) 1 else 0
|
||||
device.find(UiSelector().instance(dismissIndex).className(Button::class.java)).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Don't turn on sync")
|
||||
device.find(By.text("Turn on sync?"))
|
||||
device.find(By.text("No thanks")).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Log in with GitHub")
|
||||
device.find(By.text("Sign in with GitHub")).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Make sure GitHub page has loaded")
|
||||
device.find(By.text("New to GitHub"))
|
||||
device.find(By.text("Username or email address"))
|
||||
device.find(By.text("Sign in"))
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Enter credentials")
|
||||
device
|
||||
.find(UiSelector().instance(0).className(EditText::class.java))
|
||||
.setText(githubUsername)
|
||||
device
|
||||
.find(UiSelector().instance(1).className(EditText::class.java))
|
||||
.setText(githubPassword)
|
||||
device.find(By.text("Sign in")).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Enter 2FA")
|
||||
device.find(By.text("Two-factor authentication"))
|
||||
device
|
||||
.find(UiSelector().instance(0).className(EditText::class.java))
|
||||
.setText(githubTOTP.generate())
|
||||
device.find(UiSelector().instance(0).className(Button::class.java)).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Accept Tailscale app")
|
||||
device.find(By.text("Learn more about OAuth"))
|
||||
// Sleep a little to give button time to activate
|
||||
Thread.sleep(5.seconds.inWholeMilliseconds)
|
||||
device.find(UiSelector().instance(1).className(Button::class.java)).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Connect device")
|
||||
device.find(By.text("Connect device"))
|
||||
device.find(UiSelector().instance(0).className(Button::class.java)).click()
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Accept Permission (Either Storage or Notifications)")
|
||||
device.find(By.text("Continue")).click()
|
||||
device.find(By.text("Allow")).click()
|
||||
} catch (t: Throwable) {
|
||||
// we're not always prompted for permissions, that's okay
|
||||
}
|
||||
|
||||
Log.d(TAG, "Wait for VPN to connect")
|
||||
device.find(By.text("Connected"))
|
||||
|
||||
val helloResponse = helloTSNet
|
||||
Assert.assertTrue(
|
||||
"Response from hello.ts.net should show success",
|
||||
helloResponse.contains("You're connected over Tailscale!"))
|
||||
}
|
||||
}
|
||||
|
||||
private val helloTSNet: String
|
||||
get() {
|
||||
return URL("https://hello.ts.net").run {
|
||||
openConnection().run {
|
||||
this as HttpURLConnection
|
||||
connectTimeout = 30000
|
||||
readTimeout = 5000
|
||||
inputStream.bufferedReader().readText()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.util.Log
|
||||
import androidx.test.uiautomator.BySelector
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.UiObject
|
||||
import androidx.test.uiautomator.UiObject2
|
||||
import androidx.test.uiautomator.UiSelector
|
||||
import androidx.test.uiautomator.Until
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private val defaultTimeout = 10.seconds
|
||||
|
||||
private val threadLocalTimeout = ThreadLocal<Duration>()
|
||||
|
||||
/**
|
||||
* Wait until the specified timeout for the given selector and return the matching UiObject2.
|
||||
* Timeout defaults to 10 seconds.
|
||||
*
|
||||
* @throws Exception if selector is not found within timeout.
|
||||
*/
|
||||
fun UiDevice.find(
|
||||
selector: BySelector,
|
||||
timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout
|
||||
): UiObject2 {
|
||||
wait(Until.findObject(selector), timeout.inWholeMilliseconds)?.let {
|
||||
return it
|
||||
} ?: run { throw Exception("not found") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the specified timeout for the given selector and return the matching UiObject. Timeout
|
||||
* defaults to 10 seconds.
|
||||
*
|
||||
* @throws Exception if selector is not found within timeout.
|
||||
*/
|
||||
fun UiDevice.find(
|
||||
selector: UiSelector,
|
||||
timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout
|
||||
): UiObject {
|
||||
val obj = findObject(selector)
|
||||
if (!obj.waitForExists(timeout.inWholeMilliseconds)) {
|
||||
throw Exception("not found")
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an ordered collection of steps as necessary. If an earlier step fails but a subsequent
|
||||
* step succeeds, this skips the earlier step. This is useful for interruptible sequences like
|
||||
* logging in that may resume in an intermediate state.
|
||||
*/
|
||||
fun asNecessary(timeout: Duration, vararg steps: () -> Unit) {
|
||||
val interval = 250.milliseconds
|
||||
// Use a short timeout to avoid waiting on actions that can be skipped
|
||||
threadLocalTimeout.set(interval)
|
||||
try {
|
||||
val start = System.currentTimeMillis()
|
||||
var furthestSuccessful = -1
|
||||
while (System.currentTimeMillis() - start < timeout.inWholeMilliseconds) {
|
||||
for (i in furthestSuccessful + 1 ..< steps.size) {
|
||||
val step = steps[i]
|
||||
try {
|
||||
step()
|
||||
furthestSuccessful = i
|
||||
Log.d("TestUtil.asNecessary", "SUCCESS!")
|
||||
// Going forward, use the normal timeout on the assumption that subsequent steps will
|
||||
// succeed.
|
||||
threadLocalTimeout.remove()
|
||||
} catch (t: Throwable) {
|
||||
Log.d("TestUtil.asNecessary", t.toString())
|
||||
// Going forward, use a short timeout to avoid waiting on actions that can be skipped
|
||||
threadLocalTimeout.set(interval)
|
||||
}
|
||||
}
|
||||
if (furthestSuccessful == steps.size - 1) {
|
||||
// All steps have completed successfully
|
||||
return
|
||||
}
|
||||
// Still some steps left to run
|
||||
Thread.sleep(interval.inWholeMilliseconds)
|
||||
}
|
||||
throw Exception("failed to complete within timeout")
|
||||
} finally {
|
||||
threadLocalTimeout.remove()
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
@ -1,28 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.app.Fragment;
|
||||
import android.content.Intent;
|
||||
|
||||
public class Peer extends Fragment {
|
||||
|
||||
private static int resultOK = -1;
|
||||
|
||||
public class RequestCodes {
|
||||
public static final int requestPrepareVPN = 1001;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
if (requestCode == RequestCodes.requestPrepareVPN) {
|
||||
if (resultCode == resultOK) {
|
||||
App.getApplication().startVPN();
|
||||
} else {
|
||||
App.getApplication().setWantRunning(false);
|
||||
// notify VPN revoked
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.UninitializedApp
|
||||
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
||||
|
||||
object AndroidTVUtil {
|
||||
fun isAndroidTV(): Boolean {
|
||||
val pm = UninitializedApp.get().packageManager
|
||||
return (pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
|
||||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
|
||||
}
|
||||
}
|
||||
|
||||
// Applies a letterbox effect iff we're running on Android TV to reduce the overall width
|
||||
// of the UI.
|
||||
fun Modifier.universalFit(): Modifier {
|
||||
return when (isAndroidTV()) {
|
||||
true -> this.padding(horizontal = 150.dp, vertical = 10.dp).clip(RoundedCornerShape(10.dp))
|
||||
false -> this
|
||||
}
|
||||
}
|
@ -0,0 +1,153 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.theme.listItem
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.LoginWithAuthKeyViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.LoginWithCustomControlURLViewModel
|
||||
|
||||
data class LoginViewStrings(
|
||||
var title: String,
|
||||
var explanation: String,
|
||||
var inputTitle: String,
|
||||
var placeholder: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LoginWithCustomControlURLView(
|
||||
onNavigateHome: BackNavigation,
|
||||
backToSettings: BackNavigation,
|
||||
viewModel: LoginWithCustomControlURLViewModel = LoginWithCustomControlURLViewModel()
|
||||
) {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(
|
||||
R.string.add_account,
|
||||
onBack = backToSettings,
|
||||
)
|
||||
}) { innerPadding ->
|
||||
val error by viewModel.errorDialog.collectAsState()
|
||||
val strings =
|
||||
LoginViewStrings(
|
||||
title = stringResource(id = R.string.custom_control_menu),
|
||||
explanation = stringResource(id = R.string.custom_control_menu_desc),
|
||||
inputTitle = stringResource(id = R.string.custom_control_url_title),
|
||||
placeholder = stringResource(id = R.string.custom_control_placeholder),
|
||||
)
|
||||
|
||||
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
|
||||
|
||||
LoginView(
|
||||
innerPadding = innerPadding,
|
||||
strings = strings,
|
||||
onSubmitAction = { viewModel.setControlURL(it, onNavigateHome) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginWithAuthKeyView(
|
||||
onNavigateHome: BackNavigation,
|
||||
backToSettings: BackNavigation,
|
||||
viewModel: LoginWithAuthKeyViewModel = LoginWithAuthKeyViewModel()
|
||||
) {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(
|
||||
R.string.add_account,
|
||||
onBack = backToSettings,
|
||||
)
|
||||
}) { innerPadding ->
|
||||
val error by viewModel.errorDialog.collectAsState()
|
||||
val strings =
|
||||
LoginViewStrings(
|
||||
title = stringResource(id = R.string.auth_key_title),
|
||||
explanation = stringResource(id = R.string.auth_key_explanation),
|
||||
inputTitle = stringResource(id = R.string.auth_key_input_title),
|
||||
placeholder = stringResource(id = R.string.auth_key_placeholder),
|
||||
)
|
||||
// Show the error overlay if need be
|
||||
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
|
||||
|
||||
LoginView(
|
||||
innerPadding = innerPadding,
|
||||
strings = strings,
|
||||
onSubmitAction = { viewModel.setAuthKey(it, onNavigateHome) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginView(
|
||||
innerPadding: PaddingValues = PaddingValues(16.dp),
|
||||
strings: LoginViewStrings,
|
||||
onSubmitAction: (String) -> Unit,
|
||||
) {
|
||||
|
||||
var textVal by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(innerPadding)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)) {
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = { Text(text = strings.title) },
|
||||
supportingContent = { Text(text = strings.explanation) })
|
||||
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = { Text(text = strings.inputTitle) },
|
||||
supportingContent = {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent),
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
value = textVal,
|
||||
onValueChange = { textVal = it },
|
||||
placeholder = {
|
||||
Text(strings.placeholder, style = MaterialTheme.typography.bodySmall)
|
||||
})
|
||||
})
|
||||
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = { onSubmitAction(textVal) },
|
||||
content = { Text(stringResource(id = R.string.add_account_short)) })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.theme.AppTheme
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.LoginQRViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel()) {
|
||||
Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
val image by model.qrCode.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_to_connect_to_your_tailnet),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(200.dp)
|
||||
.background(MaterialTheme.colorScheme.onSurface)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center) {
|
||||
image?.let {
|
||||
Image(
|
||||
bitmap = it,
|
||||
contentDescription = "Scan to login",
|
||||
modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
Button(onClick = onDismiss) { Text(text = stringResource(R.string.dismiss)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun LoginQRViewPreview() {
|
||||
val vm = LoginQRViewModel()
|
||||
vm.qrCode.set(vm.generateQRCode("https://tailscale.com", 200, 0))
|
||||
AppTheme { LoginQRView({}, vm) }
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.view.ErrorDialogType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
const val AUTH_KEY_LENGTH = 16
|
||||
|
||||
open class CustomLoginViewModel : IpnViewModel() {
|
||||
val errorDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
|
||||
}
|
||||
|
||||
class LoginWithAuthKeyViewModel : CustomLoginViewModel() {
|
||||
// Sets the auth key and invokes the login flow
|
||||
fun setAuthKey(authKey: String, onSuccess: () -> Unit) {
|
||||
// The most basic of checks for auth key syntax
|
||||
if (authKey.isEmpty()) {
|
||||
errorDialog.set(ErrorDialogType.INVALID_AUTH_KEY)
|
||||
return
|
||||
}
|
||||
loginWithAuthKey(authKey) {
|
||||
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
|
||||
it.onSuccess { onSuccess() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LoginWithCustomControlURLViewModel : CustomLoginViewModel() {
|
||||
// Sets the custom control URL and invokes the login flow
|
||||
fun setControlURL(urlStr: String, onSuccess: () -> Unit) {
|
||||
// Some basic checks that the entered URL is "reasonable". The underlying
|
||||
// localAPIClient will use the default server if we give it a broken URL,
|
||||
// but we can make sure we can construct a URL from the input string and
|
||||
// ensure it has an http/https scheme
|
||||
when (urlStr.startsWith("http") && urlStr.contains("://") && urlStr.length > 7) {
|
||||
false -> {
|
||||
errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL)
|
||||
return
|
||||
}
|
||||
true -> {
|
||||
loginWithCustomControlURL(urlStr) {
|
||||
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
|
||||
it.onSuccess { onSuccess() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.WriterException
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class LoginQRViewModel : IpnViewModel() {
|
||||
|
||||
val qrCode: StateFlow<ImageBitmap?> = MutableStateFlow(null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
Notifier.browseToURL.collect { url ->
|
||||
url?.let { qrCode.set(generateQRCode(url, 200, 0)) } ?: run { qrCode.set(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun generateQRCode(content: String, size: Int, padding: Int): ImageBitmap? {
|
||||
val qrCodeWriter = QRCodeWriter()
|
||||
|
||||
val encodeHints = mapOf<EncodeHintType, Any?>(EncodeHintType.MARGIN to padding)
|
||||
|
||||
val bitmapMatrix =
|
||||
try {
|
||||
qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, size, size, encodeHints)
|
||||
} catch (ex: WriterException) {
|
||||
return null
|
||||
}
|
||||
|
||||
val qrCode =
|
||||
Bitmap.createBitmap(
|
||||
size,
|
||||
size,
|
||||
Bitmap.Config.ARGB_8888,
|
||||
)
|
||||
|
||||
for (x in 0 until size) {
|
||||
for (y in 0 until size) {
|
||||
val shouldColorPixel = bitmapMatrix?.get(x, y) ?: false
|
||||
val pixelColor = if (shouldColorPixel) Color.BLACK else Color.WHITE
|
||||
qrCode.setPixel(x, y, pixelColor)
|
||||
}
|
||||
}
|
||||
|
||||
return qrCode.asImageBitmap()
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 641 B |
Before Width: | Height: | Size: 406 B |
Before Width: | Height: | Size: 879 B |
Before Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 1.8 KiB |
@ -1,41 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="200dp"
|
||||
android:height="200dp"
|
||||
android:viewportWidth="200"
|
||||
android:viewportHeight="200">
|
||||
<path
|
||||
android:pathData="M0,0h200v200h-200z"
|
||||
android:fillColor="#1F1E1E"/>
|
||||
<path
|
||||
android:pathData="M50,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M87.5,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M125,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M50,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#ffffff"/>
|
||||
<path
|
||||
android:pathData="M87.5,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#ffffff"/>
|
||||
<path
|
||||
android:pathData="M125,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#ffffff"/>
|
||||
<path
|
||||
android:pathData="M50,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M87.5,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#ffffff"/>
|
||||
<path
|
||||
android:pathData="M125,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillAlpha="0.4"/>
|
||||
</vector>
|
@ -1,34 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="200dp"
|
||||
android:height="200dp"
|
||||
android:viewportWidth="200"
|
||||
android:viewportHeight="200">
|
||||
|
||||
<path
|
||||
android:pathData="M50,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#cccccc"/>
|
||||
<path
|
||||
android:pathData="M87.5,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#cccccc"/>
|
||||
<path
|
||||
android:pathData="M125,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#cccccc"/>
|
||||
<path
|
||||
android:pathData="M50,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#222222"/>
|
||||
<path
|
||||
android:pathData="M87.5,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#222222"/>
|
||||
<path
|
||||
android:pathData="M125,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#222222"/>
|
||||
<path
|
||||
android:pathData="M50,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#cccccc"/>
|
||||
<path
|
||||
android:pathData="M87.5,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#222222"/>
|
||||
<path
|
||||
android:pathData="M125,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#cccccc"/>
|
||||
</vector>
|
@ -1,36 +1,38 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="100"
|
||||
android:viewportHeight="100">
|
||||
<group android:translateX="20"
|
||||
android:translateY="20">
|
||||
<path
|
||||
android:pathData="M15,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||
android:fillColor="#FFFDFA"/>
|
||||
<path
|
||||
android:pathData="M30,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||
android:fillColor="#FFFDFA"/>
|
||||
<path
|
||||
android:pathData="M15,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||
android:fillColor="#54514D"/>
|
||||
<path
|
||||
android:pathData="M45,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||
android:fillColor="#54514D"/>
|
||||
<path
|
||||
android:pathData="M30,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||
android:fillColor="#FFFDFA"/>
|
||||
<path
|
||||
android:pathData="M45,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||
android:fillColor="#FFFDFA"/>
|
||||
<path
|
||||
android:pathData="M15,15.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||
android:fillColor="#54514D"/>
|
||||
<path
|
||||
android:pathData="M30,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||
android:fillColor="#54514D"/>
|
||||
<path
|
||||
android:pathData="M45,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||
android:fillColor="#54514D"/>
|
||||
</group>
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M34.5,39.37a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M49.13,39.37a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M63.75,39.37a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M34.5,54a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||
android:fillColor="#ffffff"/>
|
||||
<path
|
||||
android:pathData="M49.13,54a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||
android:fillColor="#ffffff"/>
|
||||
<path
|
||||
android:pathData="M63.75,54a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||
android:fillColor="#ffffff"/>
|
||||
<path
|
||||
android:pathData="M34.5,68.62a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M49.13,68.62a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||
android:fillColor="#ffffff"/>
|
||||
<path
|
||||
android:pathData="M63.75,68.62a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillAlpha="0.4"/>
|
||||
</vector>
|
||||
|
@ -0,0 +1,38 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="100dp"
|
||||
android:height="100dp"
|
||||
android:viewportWidth="100"
|
||||
android:viewportHeight="100">
|
||||
<path
|
||||
android:pathData="M0,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M37.5,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M75,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M-0,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="M37.5,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="M75,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="M-0,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M37.5,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="M75,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.4"/>
|
||||
</vector>
|
@ -0,0 +1,46 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="100dp"
|
||||
android:height="100dp"
|
||||
android:viewportWidth="100"
|
||||
android:viewportHeight="100">
|
||||
<path
|
||||
android:pathData="M0,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M37.5,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M75,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M-0,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:strokeAlpha="0.4"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M37.5,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:strokeAlpha="0.4"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M75,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:strokeAlpha="0.4"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M-0,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M37.5,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:strokeAlpha="0.4"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.4"/>
|
||||
<path
|
||||
android:pathData="M75,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.4"/>
|
||||
</vector>
|
Before Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 3.0 KiB |
After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 1021 B |
After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 4.2 KiB |
After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 6.9 KiB |
After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 4.8 KiB |