mirror of
https://github.com/izzy2lost/aab2apk.git
synced 2026-06-19 01:20:08 -07:00
Merge pull request #1 from iammohdzaki/feature/command-options
Added New Options for Command build-apks
This commit is contained in:
+8
@@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="AabToApk-jvm-1.0-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="AabToApk-jvm-1.0-SNAPSHOT.jar">
|
||||
<element id="module-output" name="AabToApk.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
+124
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Palette2">
|
||||
<group name="Swing">
|
||||
<item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" />
|
||||
</item>
|
||||
<item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" />
|
||||
</item>
|
||||
<item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" />
|
||||
</item>
|
||||
<item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.svg" removable="false" auto-create-binding="false" can-attach-label="true">
|
||||
<default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" />
|
||||
</item>
|
||||
<item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" />
|
||||
<initial-values>
|
||||
<property name="text" value="Button" />
|
||||
</initial-values>
|
||||
</item>
|
||||
<item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
|
||||
<initial-values>
|
||||
<property name="text" value="RadioButton" />
|
||||
</initial-values>
|
||||
</item>
|
||||
<item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
|
||||
<initial-values>
|
||||
<property name="text" value="CheckBox" />
|
||||
</initial-values>
|
||||
</item>
|
||||
<item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" />
|
||||
<initial-values>
|
||||
<property name="text" value="Label" />
|
||||
</initial-values>
|
||||
</item>
|
||||
<item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
|
||||
<preferred-size width="150" height="-1" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
|
||||
<preferred-size width="150" height="-1" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
|
||||
<preferred-size width="150" height="-1" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
||||
<preferred-size width="150" height="50" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
||||
<preferred-size width="150" height="50" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
||||
<preferred-size width="150" height="50" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" />
|
||||
</item>
|
||||
<item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
||||
<preferred-size width="150" height="50" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3">
|
||||
<preferred-size width="150" height="50" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
||||
<preferred-size width="150" height="50" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
|
||||
<preferred-size width="200" height="200" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
|
||||
<preferred-size width="200" height="200" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
|
||||
</item>
|
||||
<item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
|
||||
</item>
|
||||
<item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" />
|
||||
</item>
|
||||
<item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" />
|
||||
</item>
|
||||
<item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1">
|
||||
<preferred-size width="-1" height="20" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" />
|
||||
</item>
|
||||
<item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" />
|
||||
</item>
|
||||
</group>
|
||||
</component>
|
||||
</project>
|
||||
@@ -23,6 +23,7 @@ kotlin {
|
||||
val jvmMain by getting {
|
||||
dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
api("com.esotericsoftware:kryo:4.0.1")
|
||||
}
|
||||
}
|
||||
val jvmTest by getting
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,68 @@
|
||||
import utils.SigningMode
|
||||
import utils.Utils
|
||||
|
||||
class CommandBuilder {
|
||||
private var bundletoolPath: String = ""
|
||||
private var aabFilePath: Pair<String, String> = Pair("", "")
|
||||
private var isOverwrite: Boolean = false
|
||||
private var isAapt2PathEnabled: Boolean = false
|
||||
private var aapt2Path: String = ""
|
||||
private var isUniversalMode: Boolean = true
|
||||
private var signingMode: Int = 1
|
||||
private var keyStorePath: String = ""
|
||||
private var keyStorePassword: String = ""
|
||||
private var keyAlias: String = ""
|
||||
private var keyPassword: String = ""
|
||||
|
||||
fun bundletoolPath(path: String) = apply { this.bundletoolPath = path }
|
||||
fun aabFilePath(path: Pair<String, String>) = apply { this.aabFilePath = path }
|
||||
fun isOverwrite(overwrite: Boolean) = apply { this.isOverwrite = overwrite }
|
||||
fun isAapt2PathEnabled(enabled: Boolean) = apply { this.isAapt2PathEnabled = enabled }
|
||||
fun aapt2Path(path: String) = apply { this.aapt2Path = path }
|
||||
fun isUniversalMode(universalMode: Boolean) = apply { this.isUniversalMode = universalMode }
|
||||
fun signingMode(mode: Int) = apply { this.signingMode = mode }
|
||||
fun keyStorePath(path: String) = apply { this.keyStorePath = path }
|
||||
fun keyStorePassword(password: String) = apply { this.keyStorePassword = password }
|
||||
fun keyAlias(alias: String) = apply { this.keyAlias = alias }
|
||||
fun keyPassword(password: String) = apply { this.keyPassword = password }
|
||||
|
||||
fun validateAndGetCommand(): Pair<String, Boolean> {
|
||||
if (bundletoolPath.isEmpty()) {
|
||||
return Pair("bundletoolPath", false)
|
||||
}
|
||||
if (aabFilePath.first.isEmpty() || aabFilePath.second.isEmpty()) {
|
||||
return Pair("aabFilePath", false)
|
||||
}
|
||||
if (isAapt2PathEnabled && aapt2Path.isEmpty()) {
|
||||
return Pair("aapt2Path", false)
|
||||
}
|
||||
if (signingMode == SigningMode.RELEASE && (keyStorePath.isEmpty() || keyStorePassword.isEmpty() || keyAlias.isEmpty() || keyPassword.isEmpty())) {
|
||||
return Pair("Check Keystore Info!", false)
|
||||
}
|
||||
return Pair(getCommand(), true)
|
||||
}
|
||||
|
||||
private fun getCommand(): String {
|
||||
val commandBuilder = StringBuilder()
|
||||
if (Utils.isWindowsOS()) {
|
||||
commandBuilder.append("java -jar \"${bundletoolPath}\" build-apks ")
|
||||
if (isUniversalMode) {
|
||||
commandBuilder.append("--mode=universal ")
|
||||
}
|
||||
if (isOverwrite) {
|
||||
commandBuilder.append("--overwrite ")
|
||||
}
|
||||
if (isAapt2PathEnabled) {
|
||||
commandBuilder.append("--aapt2=\"$aapt2Path\" ")
|
||||
}
|
||||
commandBuilder.append("--bundle=\"${aabFilePath.first}${aabFilePath.second}\" --output=\"${aabFilePath.first}${aabFilePath.second.split(".")[0]}.apks\" ")
|
||||
|
||||
if (signingMode == SigningMode.RELEASE){
|
||||
commandBuilder.append("--ks=$keyStorePath --ks-pass=pass:$keyStorePassword --ks-key-alias=$keyAlias --key-pass=pass:$keyPassword ")
|
||||
}
|
||||
|
||||
return commandBuilder.toString()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
object Constant {
|
||||
const val SUCCESS = 0
|
||||
const val FAILURE = 1
|
||||
|
||||
fun getCommand(bundletoolPath: String): String {
|
||||
if (Utils.isWindowsOS()) {
|
||||
return "java -jar \"${bundletoolPath}\" build-apks --mode=universal --bundle=\"INPUT_FILE_PATH\" --output=\"OUTPUT_FILE_NAME.apks\""
|
||||
}
|
||||
return "bundletool build-apks --mode=universal --bundle=INPUT_FILE_PATH --output=OUTPUT_FILE_NAME.apks"
|
||||
}
|
||||
}
|
||||
+234
-69
@@ -1,13 +1,7 @@
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.rounded.Done
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -20,21 +14,26 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import local.FileStorageHelper
|
||||
import theme.Styles
|
||||
import theme.components.CheckboxWithText
|
||||
import theme.components.ChooseFileTextField
|
||||
import theme.components.CustomTextField
|
||||
import utils.*
|
||||
import java.awt.Desktop
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.net.URI
|
||||
import utils.Strings
|
||||
import java.nio.file.Paths
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
fun App(fileStorageHelper: FileStorageHelper, savedPath: String?) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var logs by remember { mutableStateOf("========== Logs View ==========\n\n") }
|
||||
var bundletoolPath by remember { mutableStateOf("") }
|
||||
@@ -42,16 +41,33 @@ fun App() {
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var isOpen by remember { mutableStateOf(false) }
|
||||
var isExecute by remember { mutableStateOf(false) }
|
||||
var isBundletool by remember { mutableStateOf(false) }
|
||||
var fileDialogType by remember { mutableStateOf(0) }
|
||||
var saveJarPath by remember { mutableStateOf(false) }
|
||||
var isOverwrite by remember { mutableStateOf(false) }
|
||||
var isAapt2PathEnabled by remember { mutableStateOf(false) }
|
||||
var aapt2Path by remember { mutableStateOf("") }
|
||||
var isUniversalMode by remember { mutableStateOf(true) }
|
||||
var signingMode by remember { mutableStateOf(SigningMode.DEBUG) }
|
||||
var keyStorePath by remember { mutableStateOf("") }
|
||||
var keyStorePassword by remember { mutableStateOf("") }
|
||||
var keyAlias by remember { mutableStateOf("") }
|
||||
var keyPassword by remember { mutableStateOf("") }
|
||||
|
||||
savedPath?.let {
|
||||
bundletoolPath = it
|
||||
saveJarPath = true
|
||||
}
|
||||
|
||||
if (isOpen && !isLoading) {
|
||||
FileDialog { fileName, directory ->
|
||||
isOpen = false
|
||||
if (isBundletool) {
|
||||
//Handle Error for Unknown files here
|
||||
bundletoolPath = "${directory}$fileName"
|
||||
} else {
|
||||
aabFilePath = Pair(directory, fileName)
|
||||
when (fileDialogType) {
|
||||
FileDialogType.BUNDLETOOL -> bundletoolPath = "${directory}$fileName"
|
||||
FileDialogType.AAPT2 -> aapt2Path = "${directory}$fileName"
|
||||
FileDialogType.KEY_STORE_PATH -> keyStorePath = "${directory}$fileName"
|
||||
else -> {
|
||||
aabFilePath = Pair(directory, fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,41 +75,63 @@ fun App() {
|
||||
if (isExecute) {
|
||||
isExecute = false
|
||||
//Get Command to Execute
|
||||
val cmd =
|
||||
Constant.getCommand(bundletoolPath).replace("INPUT_FILE_PATH", "${aabFilePath.first}${aabFilePath.second}")
|
||||
.replace("OUTPUT_FILE_NAME", "${aabFilePath.first}${aabFilePath.second}".split(".")[0])
|
||||
Log.i("Command $cmd")
|
||||
logs += "Executing Command : \n$cmd\n"
|
||||
val (cmd, isValid) = CommandBuilder()
|
||||
.bundletoolPath(bundletoolPath)
|
||||
.aabFilePath(aabFilePath)
|
||||
.isOverwrite(isOverwrite)
|
||||
.isUniversalMode(isUniversalMode)
|
||||
.isAapt2PathEnabled(isAapt2PathEnabled)
|
||||
.aapt2Path(aapt2Path)
|
||||
.signingMode(signingMode)
|
||||
.keyStorePath(keyStorePath)
|
||||
.keyStorePassword(keyStorePassword)
|
||||
.keyAlias(keyAlias)
|
||||
.keyPassword(keyPassword)
|
||||
.validateAndGetCommand()
|
||||
if (isValid) {
|
||||
Log.i("Command $cmd")
|
||||
logs += "Executing Command : \n$cmd\n"
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
//Save Path in Storage
|
||||
if (savedPath == null) {
|
||||
fileStorageHelper.save("path", bundletoolPath)
|
||||
} else {
|
||||
if(fileStorageHelper.delete("path"))
|
||||
saveJarPath = false
|
||||
}
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
isLoading = true
|
||||
val runtime = Runtime.getRuntime()
|
||||
val startTime = System.currentTimeMillis()
|
||||
try {
|
||||
//Launch Runtime to execute command
|
||||
val process = runtime.exec(cmd)
|
||||
// Read and log error output
|
||||
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
|
||||
Log.i("Process Error Output:")
|
||||
while (errorReader.readLine().also { logs += "\n ERROR -> $it" } != null) {
|
||||
isLoading = false
|
||||
}
|
||||
process.waitFor()
|
||||
val endTime = System.currentTimeMillis()
|
||||
if (process.exitValue() == 0) {
|
||||
Log.i("Command Executed in ${((endTime - startTime) / 1000)}s")
|
||||
logs += "\nCommand Executed in ${((endTime - startTime) / 1000)}s\n"
|
||||
// Do further file operation after new apks is generated
|
||||
FileHelper.performFileOperations(aabFilePath.first, aabFilePath.second) { status, message ->
|
||||
val runtime = Runtime.getRuntime()
|
||||
val startTime = System.currentTimeMillis()
|
||||
try {
|
||||
//Launch Runtime to execute command
|
||||
val process = runtime.exec(cmd)
|
||||
// Read and log error output
|
||||
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
|
||||
Log.i("Process Error Output:")
|
||||
while (errorReader.readLine().also { logs += "\n ERROR -> $it" } != null) {
|
||||
isLoading = false
|
||||
Log.i("STATUS - $status\nMESSAGE - $message")
|
||||
logs += "\nFiles Operations Starting...\n$message\n"
|
||||
}
|
||||
process.waitFor()
|
||||
val endTime = System.currentTimeMillis()
|
||||
if (process.exitValue() == 0) {
|
||||
Log.i("Command Executed in ${((endTime - startTime) / 1000)}s")
|
||||
logs += "\nCommand Executed in ${((endTime - startTime) / 1000)}s\n"
|
||||
// Do further file operation after new apks is generated
|
||||
FileHelper.performFileOperations(aabFilePath.first, aabFilePath.second) { status, message ->
|
||||
isLoading = false
|
||||
Log.i("STATUS - $status\nMESSAGE - $message")
|
||||
logs += "\nFiles Operations Starting...\n$message\n"
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
isLoading = false
|
||||
logs += "Failed -> ${e.printStackTrace()}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
isLoading = false
|
||||
logs += "Failed -> ${e.printStackTrace()}"
|
||||
}
|
||||
} else {
|
||||
Log.i("Error $cmd")
|
||||
logs += "\nError -> $cmd"
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,37 +143,51 @@ fun App() {
|
||||
|
||||
Spacer(modifier = Modifier.padding(12.dp))
|
||||
Text(
|
||||
text = "Android Bundletool UI",
|
||||
text = Strings.APP_NAME,
|
||||
style = Styles.TextStyleBold(28.sp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.padding(8.dp))
|
||||
//Bundle tool select flow
|
||||
ChooseFileTextField(
|
||||
bundletoolPath,
|
||||
"Select Bundletool Jar",
|
||||
onSelect = {
|
||||
isOpen = true
|
||||
isBundletool = true
|
||||
}
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
//Bundle tool select flow
|
||||
ChooseFileTextField(
|
||||
bundletoolPath,
|
||||
Strings.SELECT_BUNDLETOOL_JAR,
|
||||
onSelect = {
|
||||
fileDialogType = FileDialogType.BUNDLETOOL
|
||||
isOpen = true
|
||||
}
|
||||
)
|
||||
CheckboxWithText(
|
||||
Strings.SAVE_JAR_PATH,
|
||||
saveJarPath,
|
||||
onCheckedChange = {
|
||||
saveJarPath = it
|
||||
},
|
||||
Strings.SAVE_JAR_PATH_INFO
|
||||
)
|
||||
}
|
||||
val downloadInfo = buildAnnotatedString {
|
||||
withStyle(style = SpanStyle(color = Color.Blue,textDecoration = TextDecoration.Underline)) {
|
||||
append("Download Bundletool from here")
|
||||
withStyle(style = SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline)) {
|
||||
append(Strings.DOWNLOAD_BUNDLETOOL)
|
||||
}
|
||||
addStringAnnotation(
|
||||
tag = "URL",
|
||||
annotation = "https://github.com/google/bundletool/releases",
|
||||
tag = Strings.URL,
|
||||
annotation = Constant.BUNDLE_DOWNLOAD_LINK,
|
||||
start = 0,
|
||||
end = length
|
||||
)
|
||||
}
|
||||
ClickableText(
|
||||
text = downloadInfo,
|
||||
style = Styles.TextStyleNormal(14.sp),
|
||||
style = Styles.TextStyleMedium(14.sp),
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = {offset ->
|
||||
val annotations = downloadInfo.getStringAnnotations("URL", offset, offset)
|
||||
onClick = { offset ->
|
||||
val annotations = downloadInfo.getStringAnnotations(Strings.URL, offset, offset)
|
||||
if (annotations.isNotEmpty()) {
|
||||
val uri = URI(annotations.first().item)
|
||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||
@@ -149,16 +201,126 @@ fun App() {
|
||||
Spacer(modifier = Modifier.padding(8.dp))
|
||||
ChooseFileTextField(
|
||||
"${aabFilePath.first}${aabFilePath.second}",
|
||||
"Select Aab File",
|
||||
Strings.SELECT_AAB_FILE,
|
||||
onSelect = {
|
||||
fileDialogType = FileDialogType.AAB
|
||||
isOpen = true
|
||||
isBundletool = false
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.padding(8.dp))
|
||||
Text(
|
||||
text = Strings.OPTIONS_FOR_BUILD_APKS,
|
||||
style = Styles.TextStyleBold(20.sp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
CheckboxWithText(
|
||||
Strings.OVERWRITE,
|
||||
isOverwrite,
|
||||
onCheckedChange = {
|
||||
isOverwrite = it
|
||||
},
|
||||
Strings.OVERWRITE_INFO
|
||||
)
|
||||
CheckboxWithText(
|
||||
Strings.MODE_UNIVERSAL,
|
||||
isUniversalMode,
|
||||
onCheckedChange = {
|
||||
isUniversalMode = it
|
||||
},
|
||||
Strings.MODE_UNIVERSAL_INFO
|
||||
)
|
||||
CheckboxWithText(
|
||||
Strings.AAPT2_PATH,
|
||||
isAapt2PathEnabled,
|
||||
onCheckedChange = {
|
||||
isAapt2PathEnabled = it
|
||||
},
|
||||
Strings.AAPT2_PATH_INFO
|
||||
)
|
||||
}
|
||||
if (isAapt2PathEnabled) {
|
||||
ChooseFileTextField(
|
||||
aapt2Path,
|
||||
Strings.SELECT_AAPT2_FILE,
|
||||
onSelect = {
|
||||
fileDialogType = FileDialogType.AAPT2
|
||||
isOpen = true
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = Strings.SIGNING_MODE,
|
||||
style = Styles.TextStyleBold(16.sp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
CheckboxWithText(
|
||||
Strings.DEBUG,
|
||||
signingMode == SigningMode.DEBUG,
|
||||
onCheckedChange = {
|
||||
signingMode = if (it) SigningMode.DEBUG
|
||||
else SigningMode.RELEASE
|
||||
}
|
||||
)
|
||||
CheckboxWithText(
|
||||
Strings.RELEASE,
|
||||
signingMode == SigningMode.RELEASE,
|
||||
onCheckedChange = {
|
||||
signingMode = if (it) SigningMode.RELEASE
|
||||
else SigningMode.DEBUG
|
||||
}
|
||||
)
|
||||
}
|
||||
if (signingMode == SigningMode.RELEASE) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
ChooseFileTextField(
|
||||
keyStorePath,
|
||||
Strings.KEYSTORE_PATH,
|
||||
onSelect = {
|
||||
fileDialogType = FileDialogType.KEY_STORE_PATH
|
||||
isOpen = true
|
||||
}
|
||||
)
|
||||
CustomTextField(
|
||||
keyStorePassword,
|
||||
Strings.KEYSTORE_PASSWORD,
|
||||
onValueChange = {
|
||||
keyStorePassword = it
|
||||
}
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
CustomTextField(
|
||||
keyAlias,
|
||||
Strings.KEY_ALIAS,
|
||||
forPassword = false,
|
||||
onValueChange = {
|
||||
keyAlias = it
|
||||
}
|
||||
)
|
||||
CustomTextField(
|
||||
keyPassword,
|
||||
Strings.KEY_PASSWORD,
|
||||
onValueChange = {
|
||||
keyPassword = it
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.padding(8.dp))
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(size = 40.dp).padding(start = 16.dp, top = 0.dp, end = 0.dp, bottom = 0.dp),
|
||||
modifier = Modifier.size(size = 40.dp)
|
||||
.padding(start = 16.dp, top = 0.dp, end = 0.dp, bottom = 0.dp),
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
} else {
|
||||
@@ -171,7 +333,7 @@ fun App() {
|
||||
.wrapContentWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "Execute",
|
||||
text = Strings.EXECUTE,
|
||||
style = Styles.TextStyleMedium(16.sp),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium,
|
||||
@@ -195,7 +357,7 @@ private fun FileDialog(
|
||||
onCloseRequest: (fileName: String, directory: String) -> Unit
|
||||
) = AwtWindow(
|
||||
create = {
|
||||
object : FileDialog(parent, "Choose a file", LOAD) {
|
||||
object : FileDialog(parent, Strings.CHOOSE_FILE, LOAD) {
|
||||
override fun setVisible(value: Boolean) {
|
||||
super.setVisible(value)
|
||||
if (value) {
|
||||
@@ -209,15 +371,18 @@ 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?
|
||||
Log.showLogs = true
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
state = rememberWindowState(
|
||||
width = 800.dp, height = 800.dp,
|
||||
width = 1000.dp, height = 1000.dp,
|
||||
position = WindowPosition(Alignment.Center)
|
||||
),
|
||||
title = "Aab To Apk"
|
||||
title = Strings.APP_NAME
|
||||
) {
|
||||
App()
|
||||
App(fileStorageHelper, path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package local
|
||||
|
||||
import com.esotericsoftware.kryo.Kryo
|
||||
import com.esotericsoftware.kryo.io.Input
|
||||
import com.esotericsoftware.kryo.io.Output
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.file.Paths
|
||||
|
||||
const val DB_PATH = "/storage"
|
||||
|
||||
class FileStorageHelper {
|
||||
|
||||
private lateinit var kryo: Kryo
|
||||
|
||||
init {
|
||||
initializeDir()
|
||||
}
|
||||
private fun getKryo(): Kryo {
|
||||
return if (this::kryo.isInitialized) {
|
||||
kryo
|
||||
} else {
|
||||
val kryo = Kryo()
|
||||
kryo.register(KiteTable::class.java)
|
||||
kryo
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeDir() {
|
||||
val file = File(getCurrentDir())
|
||||
file.mkdir()
|
||||
}
|
||||
|
||||
private fun getCurrentDir(): String {
|
||||
return Paths.get("").toAbsolutePath().toString() + DB_PATH
|
||||
}
|
||||
|
||||
private fun getPath(key: String): String {
|
||||
return "${getCurrentDir()}/$key.kb"
|
||||
}
|
||||
|
||||
fun <E> save(key: String, value: E) {
|
||||
var kryoOutput: Output?
|
||||
try {
|
||||
val kiteTable = KiteTable(value)
|
||||
val fileStream = FileOutputStream(File(getPath(key)))
|
||||
kryoOutput = Output(fileStream)
|
||||
getKryo().writeObject(kryoOutput, kiteTable)
|
||||
kryoOutput.flush()
|
||||
fileStream.flush()
|
||||
kryoOutput.close()
|
||||
} catch (e: Exception) {
|
||||
throw KiteDbException(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun read(key: String): Any? {
|
||||
val keyFile = File(getPath(key))
|
||||
if (!keyFile.exists()) return null
|
||||
val kryoInput = Input(FileInputStream(keyFile))
|
||||
val kiteTable = getKryo().readObject(kryoInput, KiteTable::class.java)
|
||||
return kiteTable.mContent
|
||||
}
|
||||
|
||||
fun delete(key: String): Boolean {
|
||||
val keyFile = File(getPath(key))
|
||||
return if (keyFile.exists()) {
|
||||
keyFile.delete()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package local
|
||||
|
||||
class KiteDbException : RuntimeException {
|
||||
|
||||
constructor(detailMessage: String?) : super(detailMessage)
|
||||
|
||||
constructor(detailMessage: String?, throwable: Throwable?) : super(detailMessage, throwable)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package local
|
||||
|
||||
class KiteTable<T> {
|
||||
internal constructor()
|
||||
internal constructor(content: T) {
|
||||
mContent = content
|
||||
}
|
||||
|
||||
// Serialized content
|
||||
var mContent: T? = null
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package theme.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.TooltipArea
|
||||
import androidx.compose.foundation.TooltipPlacement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Checkbox
|
||||
import androidx.compose.material.Icon
|
||||
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.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.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import theme.Styles
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun CheckboxWithText(label: String, isChecked: Boolean, onCheckedChange: (Boolean) -> Unit, toolTipText: String = "") {
|
||||
val density = LocalDensity.current // to calculate the intrinsic size of vector images (SVG, XML)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(end = 16.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = isChecked,
|
||||
onCheckedChange = { onCheckedChange.invoke(it) },
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = Styles.TextStyleMedium(14.sp),
|
||||
)
|
||||
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,
|
||||
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("info.svg") { loadSvgPainter(it, density) },
|
||||
contentDescription = "Info",
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ fun ChooseFileTextField(value: String, label: String, onSelect: () -> Unit) {
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextField(
|
||||
modifier = Modifier.fillMaxWidth(0.5f).height(60.dp)
|
||||
modifier = Modifier.fillMaxWidth(0.4f).height(50.dp)
|
||||
.border(
|
||||
BorderStroke(width = 2.dp, color = MaterialTheme.colors.primary),
|
||||
shape = RoundedCornerShape(topEnd = 0.dp, bottomEnd = 0.dp, topStart = 10.dp, bottomStart = 10.dp)
|
||||
@@ -53,12 +53,12 @@ fun ChooseFileTextField(value: String, label: String, onSelect: () -> Unit) {
|
||||
onSelect.invoke()
|
||||
},
|
||||
modifier = Modifier
|
||||
.height(60.dp)
|
||||
.height(50.dp)
|
||||
.wrapContentWidth(),
|
||||
) {
|
||||
Icon(
|
||||
painter = useResource("open_folder.svg") { loadSvgPainter(it, density) },
|
||||
contentDescription = ""
|
||||
contentDescription = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package theme.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextField
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import theme.Styles
|
||||
|
||||
@Composable
|
||||
fun CustomTextField(value: String, label: String, forPassword: Boolean = true, onValueChange: (String) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextField(
|
||||
modifier = Modifier.fillMaxWidth(0.4f).height(50.dp)
|
||||
.border(
|
||||
BorderStroke(width = 2.dp, color = MaterialTheme.colors.primary),
|
||||
shape = RoundedCornerShape(topEnd = 10.dp, bottomEnd = 10.dp, topStart = 10.dp, bottomStart = 10.dp)
|
||||
),
|
||||
value = value,
|
||||
singleLine = true,
|
||||
visualTransformation = if (forPassword) PasswordVisualTransformation() else VisualTransformation.None,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
colors = TextFieldDefaults.textFieldColors(
|
||||
backgroundColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
),
|
||||
label = {
|
||||
Text(
|
||||
text = label,
|
||||
style = Styles.TextStyleMedium(16.sp),
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
},
|
||||
textStyle = Styles.TextStyleMedium(16.sp),
|
||||
onValueChange = {
|
||||
onValueChange.invoke(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package utils
|
||||
|
||||
object Constant {
|
||||
const val SUCCESS = 0
|
||||
const val FAILURE = 1
|
||||
const val BUNDLE_DOWNLOAD_LINK = "https://github.com/google/bundletool/releases"
|
||||
}
|
||||
|
||||
object FileDialogType{
|
||||
const val AAB = -1
|
||||
const val BUNDLETOOL = 1
|
||||
const val AAPT2 = 2
|
||||
const val KEY_STORE_PATH = 3
|
||||
}
|
||||
|
||||
object SigningMode{
|
||||
const val DEBUG = 1
|
||||
const val RELEASE = 2
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
package utils
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
package utils
|
||||
|
||||
import java.io.*
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
package utils
|
||||
|
||||
object Log {
|
||||
|
||||
var showLogs: Boolean = true
|
||||
@@ -0,0 +1,30 @@
|
||||
package utils
|
||||
|
||||
object Strings {
|
||||
const val APP_NAME = "Android Bundletool UI"
|
||||
const val URL = "URL"
|
||||
const val CHOOSE_FILE = "Choose a file"
|
||||
const val SELECT_BUNDLETOOL_JAR = "Select Bundletool Jar"
|
||||
const val SAVE_JAR_PATH = "Save Jar Path"
|
||||
const val SAVE_JAR_PATH_INFO = "Saves Path of Bundle Tool Jar for Future."
|
||||
const val DOWNLOAD_BUNDLETOOL = "Download Bundletool from here"
|
||||
const val SELECT_AAB_FILE = "Select Aab File"
|
||||
const val OPTIONS_FOR_BUILD_APKS = "Options for the bundletool build-apks command"
|
||||
const val OVERWRITE = "Overwrite"
|
||||
const val OVERWRITE_INFO = "Overwrites any existing output file with the path you specify using the --output option. If you don't include this flag and the output file already exists, you get a build error."
|
||||
const val AAPT2_PATH = "Aapt2 Path"
|
||||
const val AAPT2_PATH_INFO = "Specifies a custom path to AAPT2. By default, bundletool includes its own version of AAPT2."
|
||||
const val SELECT_AAPT2_FILE = "Select Aapt2 File"
|
||||
const val MODE_UNIVERSAL = "Mode Universal"
|
||||
const val MODE_UNIVERSAL_INFO = "Sets the mode to universal. Use this option if you want bundletool to build a single APK that includes all of your app's code and resources, so that the APK is compatible with all device configurations your app supports." +
|
||||
"\nNote: bundletool includes only feature modules that specify <dist:fusing dist:include=\"true\"/> in their manifest in a universal APK. To learn more, read about the feature module manifest.\n" +
|
||||
"Keep in mind, these APKs are larger than those optimized for a particular device configuration. However, they're easier to share with internal testers who, for example, want to test your app on multiple device configurations."
|
||||
const val SIGNING_MODE = "Signing Mode"
|
||||
const val DEBUG = "Debug"
|
||||
const val RELEASE = "Release"
|
||||
const val KEYSTORE_PATH = "Select Keystore Path"
|
||||
const val KEYSTORE_PASSWORD = "Keystore Password"
|
||||
const val KEY_ALIAS = "Key Alias"
|
||||
const val KEY_PASSWORD = "Key Password"
|
||||
const val EXECUTE = "Execute"
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import androidx.compose.ui.text.toLowerCase
|
||||
package utils
|
||||
|
||||
import java.util.*
|
||||
|
||||
object Utils {
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 676 B |
Reference in New Issue
Block a user