mirror of
https://github.com/izzy2lost/aab2apk.git
synced 2026-06-19 01:20:08 -07:00
- Added ADB Setup
- Added Command Executor for executing commands. - Added Generic Loading Dialog - Saved ADB path to storage - TODO : Need to add Device options if ADB setup is done - Updated kotlin_lint.yml
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
name: kotlin_lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "*"
|
||||
pull_request:
|
||||
paths:
|
||||
- "**/*.kt"
|
||||
|
||||
+71
-16
@@ -17,13 +17,13 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.*
|
||||
import command.CommandBuilder
|
||||
import command.CommandExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import local.FileStorageHelper
|
||||
import ui.Styles
|
||||
import ui.components.CheckboxWithText
|
||||
import ui.components.ChooseFileTextField
|
||||
import ui.components.CustomTextField
|
||||
import ui.components.*
|
||||
import utils.*
|
||||
import java.awt.Desktop
|
||||
import java.awt.FileDialog
|
||||
@@ -35,7 +35,7 @@ import utils.Strings
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App(fileStorageHelper: FileStorageHelper, savedPath: String?) {
|
||||
fun App(fileStorageHelper: FileStorageHelper, savedPath: String?, adbSavedPath: String?) {
|
||||
val density = LocalDensity.current // to calculate the intrinsic size of vector images (SVG, XML)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var logs by remember { mutableStateOf("") }
|
||||
@@ -57,21 +57,58 @@ fun App(fileStorageHelper: FileStorageHelper, savedPath: String?) {
|
||||
var keyPassword by remember { mutableStateOf("") }
|
||||
var isAutoUnzip by remember { mutableStateOf(true) }
|
||||
var savedJarPath by remember { mutableStateOf(savedPath) }
|
||||
var isAdbSetupDone by remember { mutableStateOf(false) }
|
||||
var adbPath by remember { mutableStateOf("") }
|
||||
var showLoadingDialog by remember { mutableStateOf(Pair("", false)) }
|
||||
|
||||
|
||||
//TODO: KNOWN ISSUE - Can't update file path once saved, For now Delete path.kb file inside storage directory.
|
||||
//TODO: (Fixed this issue need to test more!) - Can't update file path once saved, For now Delete path.kb file inside storage directory.
|
||||
savedJarPath?.let {
|
||||
bundletoolPath = it
|
||||
saveJarPath = true
|
||||
}
|
||||
|
||||
//Check if ADB Setup is Done or Not
|
||||
adbSavedPath?.let {
|
||||
adbPath = it
|
||||
isAdbSetupDone = true
|
||||
}
|
||||
|
||||
if (isOpen && !isLoading) {
|
||||
FileDialog { fileName, directory ->
|
||||
isOpen = false
|
||||
if (fileName.isNullOrEmpty() || directory.isNullOrEmpty()) {
|
||||
return@FileDialog
|
||||
}
|
||||
when (fileDialogType) {
|
||||
FileDialogType.BUNDLETOOL -> bundletoolPath = "${directory}$fileName"
|
||||
FileDialogType.AAPT2 -> aapt2Path = "${directory}$fileName"
|
||||
FileDialogType.KEY_STORE_PATH -> keyStorePath = "${directory}$fileName"
|
||||
FileDialogType.ADB_PATH -> {
|
||||
adbPath = "${directory}${fileName}"
|
||||
//Show Loading Here
|
||||
showLoadingDialog = Pair(Strings.VERIFYING_ADB_PATH, true)
|
||||
CommandExecutor().executeCommand(
|
||||
CommandBuilder()
|
||||
.verifyAdbPath(true, adbPath)
|
||||
.getAdbVerifyCommand(), coroutineScope,
|
||||
onSuccess = {
|
||||
logs += it
|
||||
isAdbSetupDone = true
|
||||
Log.i("Saving Path in DB $adbPath")
|
||||
fileStorageHelper.save(DBConstants.ADB_PATH, adbPath)
|
||||
//Hide Loading
|
||||
Thread.sleep(1000L)
|
||||
showLoadingDialog = Pair(Strings.VERIFYING_ADB_PATH, false)
|
||||
},
|
||||
onFailure = {
|
||||
logs += it
|
||||
isAdbSetupDone = false
|
||||
//Hide Loading
|
||||
showLoadingDialog = Pair(Strings.VERIFYING_ADB_PATH, false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
aabFilePath = Pair(directory, fileName)
|
||||
}
|
||||
@@ -79,6 +116,10 @@ fun App(fileStorageHelper: FileStorageHelper, savedPath: String?) {
|
||||
}
|
||||
}
|
||||
|
||||
if (showLoadingDialog.second) {
|
||||
LoadingDialog(showLoadingDialog.first)
|
||||
}
|
||||
|
||||
if (isExecute) {
|
||||
isExecute = false
|
||||
//Get Command to Execute
|
||||
@@ -158,13 +199,26 @@ fun App(fileStorageHelper: FileStorageHelper, savedPath: String?) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
|
||||
Spacer(modifier = Modifier.padding(12.dp))
|
||||
Text(
|
||||
text = Strings.APP_NAME,
|
||||
style = Styles.TextStyleBold(28.sp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = Strings.APP_NAME,
|
||||
style = Styles.TextStyleBold(28.sp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
ButtonWithToolTip(
|
||||
if (isAdbSetupDone) Strings.ABD_SETUP_DONE else Strings.SETUP_ADB,
|
||||
onClick = {
|
||||
fileDialogType = FileDialogType.ADB_PATH
|
||||
isOpen = true
|
||||
},
|
||||
Strings.SETUP_ADB_INFO,
|
||||
icon = if (isAdbSetupDone) "done" else "info",
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.padding(8.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -412,7 +466,7 @@ fun App(fileStorageHelper: FileStorageHelper, savedPath: String?) {
|
||||
@Composable
|
||||
private fun FileDialog(
|
||||
parent: Frame? = null,
|
||||
onCloseRequest: (fileName: String, directory: String) -> Unit
|
||||
onCloseRequest: (fileName: String?, directory: String?) -> Unit
|
||||
) = AwtWindow(
|
||||
create = {
|
||||
object : FileDialog(parent, Strings.CHOOSE_FILE, LOAD) {
|
||||
@@ -431,16 +485,17 @@ private fun FileDialog(
|
||||
fun main() = application {
|
||||
val fileStorageHelper = FileStorageHelper()
|
||||
//Check if path for bundletool exists in local storage
|
||||
val path = fileStorageHelper.read("path") as String?
|
||||
val path = fileStorageHelper.read(DBConstants.BUNDLETOOL_PATH) as String?
|
||||
val adbPath = fileStorageHelper.read(DBConstants.ADB_PATH) as String?
|
||||
Log.showLogs = true
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
state = rememberWindowState(
|
||||
width = 1000.dp, height = 1000.dp,
|
||||
width = 1200.dp, height = 1000.dp,
|
||||
position = WindowPosition(Alignment.Center)
|
||||
),
|
||||
title = Strings.APP_NAME
|
||||
) {
|
||||
App(fileStorageHelper, path)
|
||||
App(fileStorageHelper, path, adbPath)
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -1,3 +1,5 @@
|
||||
package command
|
||||
|
||||
import utils.SigningMode
|
||||
import utils.Utils
|
||||
|
||||
@@ -13,6 +15,7 @@ class CommandBuilder {
|
||||
private var keyStorePassword: String = ""
|
||||
private var keyAlias: String = ""
|
||||
private var keyPassword: String = ""
|
||||
private var adbVerifyCommandExecute = Pair(false, "")
|
||||
|
||||
fun bundletoolPath(path: String) = apply { this.bundletoolPath = path }
|
||||
fun aabFilePath(path: Pair<String, String>) = apply { this.aabFilePath = path }
|
||||
@@ -26,6 +29,16 @@ class CommandBuilder {
|
||||
fun keyAlias(alias: String) = apply { this.keyAlias = alias }
|
||||
fun keyPassword(password: String) = apply { this.keyPassword = password }
|
||||
|
||||
fun verifyAdbPath(value: Boolean, path: String) = apply { this.adbVerifyCommandExecute = Pair(value,path) }
|
||||
|
||||
fun getAdbVerifyCommand(): String {
|
||||
val (forVerify,path) = adbVerifyCommandExecute
|
||||
if (forVerify){
|
||||
return "\"${path}\" version"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
fun validateAndGetCommand(): Pair<String, Boolean> {
|
||||
if (bundletoolPath.isEmpty()) {
|
||||
return Pair("bundletoolPath", false)
|
||||
@@ -55,9 +68,15 @@ class CommandBuilder {
|
||||
if (isAapt2PathEnabled) {
|
||||
commandBuilder.append("--aapt2=\"$aapt2Path\" ")
|
||||
}
|
||||
commandBuilder.append("--bundle=\"${aabFilePath.first}${aabFilePath.second}\" --output=\"${aabFilePath.first}${aabFilePath.second.split(".")[0]}.apks\" ")
|
||||
commandBuilder.append(
|
||||
"--bundle=\"${aabFilePath.first}${aabFilePath.second}\" --output=\"${aabFilePath.first}${
|
||||
aabFilePath.second.split(
|
||||
"."
|
||||
)[0]
|
||||
}.apks\" "
|
||||
)
|
||||
|
||||
if (signingMode == SigningMode.RELEASE){
|
||||
if (signingMode == SigningMode.RELEASE) {
|
||||
commandBuilder.append("--ks=$keyStorePath --ks-pass=pass:$keyStorePassword --ks-key-alias=$keyAlias --key-pass=pass:$keyPassword ")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package command
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
|
||||
class CommandExecutor {
|
||||
|
||||
fun executeCommand(
|
||||
cmd: String,
|
||||
coroutineScope: CoroutineScope,
|
||||
onSuccess: (String) -> Unit,
|
||||
onFailure: (Throwable) -> Unit
|
||||
) {
|
||||
coroutineScope.launch(Dispatchers.IO){
|
||||
try {
|
||||
val runtime = Runtime.getRuntime()
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
val process = runtime.exec(cmd)
|
||||
val outputReader = BufferedReader(InputStreamReader(process.inputStream))
|
||||
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
|
||||
|
||||
var output = ""
|
||||
var errorOutput = ""
|
||||
|
||||
// Read command output
|
||||
var line: String?
|
||||
while (outputReader.readLine().also { line = it } != null) {
|
||||
output += line + "\n"
|
||||
}
|
||||
|
||||
// Read error output
|
||||
while (errorReader.readLine().also { line = it } != null) {
|
||||
errorOutput += line + "\n"
|
||||
}
|
||||
|
||||
process.waitFor()
|
||||
val endTime = System.currentTimeMillis()
|
||||
if (process.exitValue() == 0) {
|
||||
val executionTime = ((endTime - startTime) / 1000).toString() + "s"
|
||||
onSuccess("$cmd\n$output\nCommand Executed in $executionTime")
|
||||
} else {
|
||||
val exception = Exception("Command execution failed: $cmd$errorOutput")
|
||||
onFailure(exception)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onFailure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.TooltipArea
|
||||
import androidx.compose.foundation.TooltipPlacement
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.loadSvgPainter
|
||||
import androidx.compose.ui.res.useResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ui.Styles
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ButtonWithToolTip(label: String, onClick: () -> Unit, toolTipText: String = "", icon: String = "info", buttonColors: ButtonColors = ButtonDefaults.buttonColors()) {
|
||||
val density = LocalDensity.current // to calculate the intrinsic size of vector images (SVG, XML)
|
||||
Button(
|
||||
onClick = {
|
||||
onClick.invoke()
|
||||
},
|
||||
colors = buttonColors,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp)
|
||||
.wrapContentWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = Styles.TextStyleMedium(16.sp),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
if (toolTipText.isNotEmpty()) {
|
||||
TooltipArea(
|
||||
tooltip = {
|
||||
// composable tooltip content
|
||||
Surface(
|
||||
modifier = Modifier.shadow(4.dp),
|
||||
color = Color(255, 255, 210),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = toolTipText,
|
||||
color = Color.Black,
|
||||
style = Styles.TextStyleMedium(12.sp),
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
delayMillis = 200, // in milliseconds
|
||||
tooltipPlacement = TooltipPlacement.CursorPoint(
|
||||
alignment = Alignment.BottomEnd,
|
||||
offset = DpOffset.Zero // tooltip offset
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = useResource("$icon.svg") { loadSvgPainter(it, density) },
|
||||
contentDescription = icon,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ fun CheckboxWithText(label: String, isChecked: Boolean, onCheckedChange: (Boolea
|
||||
) {
|
||||
Text(
|
||||
text = toolTipText,
|
||||
|
||||
style = Styles.TextStyleMedium(12.sp),
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import ui.Styles
|
||||
|
||||
@Composable
|
||||
fun LoadingDialog(text: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
elevation = 8.dp
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.border(
|
||||
BorderStroke(1.dp, Color.LightGray),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
) {
|
||||
Dialog(
|
||||
title = text,
|
||||
onCloseRequest = { },
|
||||
undecorated = false,
|
||||
resizable = false,
|
||||
enabled = false
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(16.dp).fillMaxWidth().fillMaxHeight()
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = Styles.TextStyleBold(20.sp),
|
||||
modifier = Modifier.padding(start = 8.dp,end = 8.dp, bottom = 8.dp)
|
||||
)
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.loadSvgPainter
|
||||
import androidx.compose.ui.res.useResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ui.Styles
|
||||
|
||||
@Composable
|
||||
fun TextWithIcon(label: String, onIconClick: () -> Unit) {
|
||||
val density = LocalDensity.current // to calculate the intrinsic size of vector images (SVG, XML)
|
||||
Row {
|
||||
Text(
|
||||
text = label,
|
||||
style = Styles.TextStyleMedium(16.sp),
|
||||
color = Color.Black,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Icon(
|
||||
painter = useResource("info.svg") { loadSvgPainter(it, density) },
|
||||
contentDescription = "Info",
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
.clickable { onIconClick.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,20 @@ object Constant {
|
||||
const val BUNDLE_DOWNLOAD_LINK = "https://github.com/google/bundletool/releases"
|
||||
}
|
||||
|
||||
object FileDialogType{
|
||||
object DBConstants {
|
||||
const val BUNDLETOOL_PATH = "bundletool_path"
|
||||
const val ADB_PATH = "adb_path"
|
||||
}
|
||||
|
||||
object FileDialogType {
|
||||
const val AAB = -1
|
||||
const val BUNDLETOOL = 1
|
||||
const val AAPT2 = 2
|
||||
const val KEY_STORE_PATH = 3
|
||||
const val ADB_PATH = 4
|
||||
}
|
||||
|
||||
object SigningMode{
|
||||
object SigningMode {
|
||||
const val DEBUG = 1
|
||||
const val RELEASE = 2
|
||||
}
|
||||
|
||||
@@ -28,8 +28,14 @@ object Strings {
|
||||
const val KEY_PASSWORD = "Key Password"
|
||||
const val EXECUTE = "Execute"
|
||||
const val FILE_OPTIONS = "File Options"
|
||||
const val DEVICE_OPTIONS = "Device Options"
|
||||
const val AUTO_UNZIP = "Automatically Unzip and Delete Apks File"
|
||||
const val AUTO_UNZIP_INFO = "Automatically Unzip and Delete Apks File"
|
||||
const val CLEAR_LOGS = "Clear Logs"
|
||||
const val LOGS_VIEW = "Logs View"
|
||||
const val SETUP_ADB = "Set up Adb"
|
||||
const val ABD_SETUP_DONE = "Adb Connected"
|
||||
const val SETUP_ADB_INFO = "Setup ADB path to create builds based on connected device"
|
||||
const val DEVICE_ID = "Device Id"
|
||||
const val DEVICE_ID_INFO = "Device Id based on the Serial Number"
|
||||
const val VERIFYING_ADB_PATH = "Verifying ADB Path.."
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M382-240 154-468l57-57 171 171 367-367 57 57-424 424Z"/></svg>
|
||||
|
After Width: | Height: | Size: 159 B |
@@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M478-240q21 0 35.5-14.5T528-290q0-21-14.5-35.5T478-340q-21 0-35.5 14.5T428-290q0 21 14.5 35.5T478-240Zm-36-154h74q0-33 7.5-52t42.5-52q26-26 41-49.5t15-56.5q0-56-41-86t-97-30q-57 0-92.5 30T342-618l66 26q5-18 22.5-39t53.5-21q32 0 48 17.5t16 38.5q0 20-12 37.5T506-526q-44 39-54 59t-10 73Zm38 314q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path d="M478-240q21 0 35.5-14.5T528-290q0-21-14.5-35.5T478-340q-21 0-35.5 14.5T428-290q0 21 14.5 35.5T478-240Zm-36-154h74q0-33 7.5-52t42.5-52q26-26 41-49.5t15-56.5q0-56-41-86t-97-30q-57 0-92.5 30T342-618l66 26q5-18 22.5-39t53.5-21q32 0 48 17.5t16 38.5q0 20-12 37.5T506-526q-44 39-54 59t-10 73Zm38 314q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 676 B After Width: | Height: | Size: 682 B |
Reference in New Issue
Block a user