android app for otb-cloud uploading https://otb-cloud.outsidethebox.top/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

718 lines
28 KiB

cd /home/def/outsidethebox/otb-cloud-android || exit 1
set -e
BACKUPDIR="/home/def/backuphere/otb-cloud-android"
mkdir -p "$BACKUPDIR"
mkdir -p /home/def/outsidethebox/apk-drop
echo "===== BACKUP ====="
cp /home/def/outsidethebox/otb-cloud-android/app/build.gradle \
"$BACKUPDIR/app.build.gradle.$(date +%Y%m%d-%H%M%S).bak"
cp /home/def/outsidethebox/otb-cloud-android/app/src/main/AndroidManifest.xml \
"$BACKUPDIR/AndroidManifest.xml.$(date +%Y%m%d-%H%M%S).bak"
cp /home/def/outsidethebox/otb-cloud-android/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt \
"$BACKUPDIR/MainActivity.kt.$(date +%Y%m%d-%H%M%S).bak"
cp /home/def/outsidethebox/otb-cloud-android/app/src/main/res/layout/activity_main.xml \
"$BACKUPDIR/activity_main.xml.$(date +%Y%m%d-%H%M%S).bak"
echo "===== WRITE /home/def/outsidethebox/otb-cloud-android/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt ====="
cat > /home/def/outsidethebox/otb-cloud-android/app/src/main/java/top/outsidethebox/otbcloud/MainActivity.kt <<'EOF'
package top.outsidethebox.otbcloud
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.view.View
import android.widget.ArrayAdapter
import android.widget.ListView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import org.json.JSONObject
import top.outsidethebox.otbcloud.databinding.ActivityMainBinding
import java.io.BufferedReader
import java.io.DataOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URLEncoder
import java.net.URL
import java.util.UUID
import java.util.concurrent.Executors
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val executor = Executors.newSingleThreadExecutor()
private val mediaItems = mutableListOf<MediaItem>()
private lateinit var mediaAdapter: ArrayAdapter<String>
companion object {
private const val PREFS_NAME = "otb_cloud_prefs"
private const val KEY_DEVICE_UUID = "device_uuid"
private const val KEY_ACTIVATED = "activated"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_DEVICE_NAME = "device_name"
private const val KEY_DEVICE_LABEL = "device_label"
private const val KEY_RELATIVE_PATH = "relative_path"
private const val KEY_TOKEN = "token"
private const val ACTIVATE_URL = "https://otb-cloud.outsidethebox.top/api/android/activate"
private const val UPLOAD_URL = "https://otb-cloud.outsidethebox.top/api/android/upload"
private const val EXISTS_URL = "https://otb-cloud.outsidethebox.top/api/android/file-exists"
private const val REQ_READ_STORAGE = 2001
private const val VIDEO_BATCH_SIZE = 5
private const val IMAGE_BATCH_SIZE = 50
private const val PAUSE_BETWEEN_BATCHES_MS = 800L
}
enum class ScanMode {
IMAGES,
VIDEOS,
BOTH
}
data class MediaItem(
val path: String,
val displayName: String,
val sizeBytes: Long,
val mimeType: String
)
private var currentScanMode = ScanMode.BOTH
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
mediaAdapter = ArrayAdapter(
this,
android.R.layout.simple_list_item_multiple_choice,
mutableListOf<String>()
)
binding.mediaListView.choiceMode = ListView.CHOICE_MODE_MULTIPLE
binding.mediaListView.adapter = mediaAdapter
val deviceUuid = getOrCreateDeviceUuid()
binding.mediaListView.setOnItemClickListener { _, _, _, _ ->
updateSelectionInfo()
}
updateScanButtonLabel()
binding.scanButton.setOnClickListener {
cycleScanMode()
}
binding.runScanButton.setOnClickListener {
ensureStoragePermissionAndScanMedia()
}
binding.selectAllButton.setOnClickListener {
for (i in mediaItems.indices) {
binding.mediaListView.setItemChecked(i, true)
}
updateSelectionInfo()
}
binding.clearAllButton.setOnClickListener {
for (i in mediaItems.indices) {
binding.mediaListView.setItemChecked(i, false)
}
updateSelectionInfo()
}
binding.uploadSelectedButton.setOnClickListener {
val selected = getSelectedItems()
if (selected.isEmpty()) {
Toast.makeText(this, "No media selected", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
uploadSelectedMedia(selected)
}
binding.resetActivationButton.setOnClickListener {
resetActivation()
showActivationSection(deviceUuid)
Toast.makeText(this, "Activation cleared. Enter a new token.", Toast.LENGTH_LONG).show()
}
if (getPrefs().getBoolean(KEY_ACTIVATED, false)) {
showMediaSection()
} else {
showActivationSection(deviceUuid)
}
}
private fun getPrefs() = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private fun resetActivation() {
val prefs = getPrefs()
prefs.edit()
.remove(KEY_ACTIVATED)
.remove(KEY_DEVICE_ID)
.remove(KEY_DEVICE_NAME)
.remove(KEY_DEVICE_LABEL)
.remove(KEY_RELATIVE_PATH)
.remove(KEY_TOKEN)
.apply()
mediaItems.clear()
mediaAdapter.clear()
mediaAdapter.notifyDataSetChanged()
binding.statusText.text = "Activation cleared"
updateSelectionInfo()
}
private fun showActivationSection(deviceUuid: String) {
binding.activationSection.visibility = View.VISIBLE
binding.mediaSection.visibility = View.GONE
binding.tokenInput.isEnabled = true
binding.activateButton.isEnabled = true
binding.activateButton.text = "Activate"
binding.tokenInput.setText("")
binding.statusText.text = "Device UUID: $deviceUuid"
binding.activateButton.setOnClickListener {
val token = binding.tokenInput.text?.toString()?.trim().orEmpty()
if (token.isEmpty()) {
binding.statusText.text = "Token required"
return@setOnClickListener
}
binding.activateButton.isEnabled = false
binding.statusText.text = "Activating device..."
val phoneLabel = buildPhoneLabel()
executor.execute {
try {
val payload = JSONObject().apply {
put("token", token)
put("device_uuid", deviceUuid)
put("phone_label", phoneLabel)
}
val url = URL(ACTIVATE_URL)
val conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 15000
readTimeout = 20000
doOutput = true
setRequestProperty("Content-Type", "application/json")
setRequestProperty("Accept", "application/json")
}
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { writer ->
writer.write(payload.toString())
writer.flush()
}
val responseCode = conn.responseCode
val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream
val responseText = stream?.bufferedReader()?.use(BufferedReader::readText).orEmpty()
val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject()
runOnUiThread {
if (responseCode in 200..299 && json.optBoolean("ok", false)) {
val prefs = getPrefs()
prefs.edit()
.putBoolean(KEY_ACTIVATED, true)
.putString(KEY_TOKEN, token)
.putInt(KEY_DEVICE_ID, json.optInt("device_id", 0))
.putString(KEY_DEVICE_NAME, json.optString("device_name", ""))
.putString(KEY_DEVICE_LABEL, json.optString("device_label", ""))
.putString(KEY_RELATIVE_PATH, json.optString("relative_path", ""))
.apply()
Toast.makeText(this, "Activation successful", Toast.LENGTH_LONG).show()
showMediaSection()
} else {
val errorText = json.optString("error", "activation_failed")
binding.activateButton.isEnabled = true
binding.statusText.text = "Activation failed: $errorText"
Toast.makeText(this, "Activation failed: $errorText", Toast.LENGTH_LONG).show()
}
}
conn.disconnect()
} catch (e: Exception) {
runOnUiThread {
binding.activateButton.isEnabled = true
binding.statusText.text = "Activation error: ${e.message ?: "unknown error"}"
Toast.makeText(this, "Activation error", Toast.LENGTH_LONG).show()
}
}
}
}
}
private fun showMediaSection() {
val prefs = getPrefs()
val deviceName = prefs.getString(KEY_DEVICE_NAME, "unknown-device") ?: "unknown-device"
val relativePath = prefs.getString(KEY_RELATIVE_PATH, "") ?: ""
binding.activationSection.visibility = View.GONE
binding.mediaSection.visibility = View.VISIBLE
binding.deviceInfoText.text = "Activated to $deviceName\n$relativePath"
binding.statusText.text = "Ready to scan media"
updateScanButtonLabel()
updateSelectionInfo()
}
private fun cycleScanMode() {
currentScanMode = when (currentScanMode) {
ScanMode.IMAGES -> ScanMode.VIDEOS
ScanMode.VIDEOS -> ScanMode.BOTH
ScanMode.BOTH -> ScanMode.IMAGES
}
updateScanButtonLabel()
}
private fun updateScanButtonLabel() {
binding.scanButton.text = when (currentScanMode) {
ScanMode.IMAGES -> "Mode: Images"
ScanMode.VIDEOS -> "Mode: Videos"
ScanMode.BOTH -> "Mode: Images + Videos"
}
}
private fun ensureStoragePermissionAndScanMedia() {
val permissions = if (Build.VERSION.SDK_INT >= 33) {
when (currentScanMode) {
ScanMode.IMAGES -> arrayOf(Manifest.permission.READ_MEDIA_IMAGES)
ScanMode.VIDEOS -> arrayOf(Manifest.permission.READ_MEDIA_VIDEO)
ScanMode.BOTH -> arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO
)
}
} else {
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
val granted = permissions.all { permission ->
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
}
if (granted) {
scanCameraMedia()
} else {
ActivityCompat.requestPermissions(this, permissions, REQ_READ_STORAGE)
}
}
private fun scanCameraMedia() {
executor.execute {
val results = mutableListOf<MediaItem>()
fun scanImages() {
val projection = arrayOf(
MediaStore.Images.Media.DATA,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.SIZE,
MediaStore.Images.Media.BUCKET_DISPLAY_NAME
)
val cursor = contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
"${MediaStore.Images.Media.BUCKET_DISPLAY_NAME} = ?",
arrayOf("Camera"),
MediaStore.Images.Media.DATE_ADDED + " DESC"
)
cursor?.use {
val dataIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
val nameIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
val sizeIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)
while (it.moveToNext()) {
val path = it.getString(dataIndex) ?: continue
val name = it.getString(nameIndex) ?: "unknown"
val size = it.getLong(sizeIndex)
results.add(
MediaItem(
path = path,
displayName = name,
sizeBytes = size,
mimeType = "image/*"
)
)
}
}
}
fun scanVideos() {
val projection = arrayOf(
MediaStore.Video.Media.DATA,
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.SIZE,
MediaStore.Video.Media.BUCKET_DISPLAY_NAME
)
val cursor = contentResolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
projection,
"${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} = ?",
arrayOf("Camera"),
MediaStore.Video.Media.DATE_ADDED + " DESC"
)
cursor?.use {
val dataIndex = it.getColumnIndexOrThrow(MediaStore.Video.Media.DATA)
val nameIndex = it.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME)
val sizeIndex = it.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE)
while (it.moveToNext()) {
val path = it.getString(dataIndex) ?: continue
val name = it.getString(nameIndex) ?: "unknown"
val size = it.getLong(sizeIndex)
results.add(
MediaItem(
path = path,
displayName = name,
sizeBytes = size,
mimeType = "video/mp4"
)
)
}
}
}
when (currentScanMode) {
ScanMode.IMAGES -> scanImages()
ScanMode.VIDEOS -> scanVideos()
ScanMode.BOTH -> {
scanImages()
scanVideos()
}
}
runOnUiThread {
mediaItems.clear()
mediaItems.addAll(results)
mediaAdapter.clear()
mediaAdapter.addAll(
results.map {
val icon = if (it.mimeType.startsWith("video/")) "🎥" else "📷"
"$icon ${it.displayName} (${formatBytes(it.sizeBytes)})"
}
)
mediaAdapter.notifyDataSetChanged()
for (i in mediaItems.indices) {
binding.mediaListView.setItemChecked(i, false)
}
binding.statusText.text = "Scan complete: ${results.size} media file(s) found"
updateSelectionInfo()
Toast.makeText(this, "Found ${results.size} media file(s)", Toast.LENGTH_LONG).show()
}
}
}
private fun batchSizeForSelection(selected: List<MediaItem>): Int {
val containsVideo = selected.any { it.mimeType.startsWith("video/") }
return if (containsVideo) VIDEO_BATCH_SIZE else IMAGE_BATCH_SIZE
}
private fun uploadSelectedMedia(selected: List<MediaItem>) {
val prefs = getPrefs()
val deviceUuid = prefs.getString(KEY_DEVICE_UUID, null)
if (deviceUuid.isNullOrBlank()) {
binding.statusText.text = "Missing local device UUID"
Toast.makeText(this, "Missing device UUID", Toast.LENGTH_LONG).show()
return
}
binding.uploadSelectedButton.isEnabled = false
binding.scanButton.isEnabled = false
binding.runScanButton.isEnabled = false
binding.selectAllButton.isEnabled = false
binding.clearAllButton.isEnabled = false
executor.execute {
var uploaded = 0
var skipped = 0
var failed = 0
val batchSize = batchSizeForSelection(selected)
val batches = selected.chunked(batchSize)
for ((batchIndex, batch) in batches.withIndex()) {
runOnUiThread {
binding.statusText.text =
"Starting batch ${batchIndex + 1} of ${batches.size} (${batch.size} file(s))"
}
for ((itemIndex, item) in batch.withIndex()) {
runOnUiThread {
binding.statusText.text =
"Batch ${batchIndex + 1}/${batches.size} - File ${itemIndex + 1}/${batch.size}: ${item.displayName}"
}
if (serverFileExists(deviceUuid, item)) {
skipped += 1
runOnUiThread {
binding.statusText.text =
"Batch ${batchIndex + 1}/${batches.size} - Skipping existing: ${item.displayName}"
}
continue
}
val ok = uploadSingleFile(deviceUuid, item)
if (ok) {
uploaded += 1
} else {
failed += 1
}
}
if (batchIndex < batches.lastIndex) {
runOnUiThread {
binding.statusText.text =
"Completed batch ${batchIndex + 1}/${batches.size}. Pausing before next batch..."
}
try {
Thread.sleep(PAUSE_BETWEEN_BATCHES_MS)
} catch (_: Exception) {
}
}
}
runOnUiThread {
binding.uploadSelectedButton.isEnabled = true
binding.scanButton.isEnabled = true
binding.runScanButton.isEnabled = true
binding.selectAllButton.isEnabled = true
binding.clearAllButton.isEnabled = true
binding.statusText.text = "Upload complete: $uploaded uploaded, $skipped skipped, $failed failed"
Toast.makeText(
this,
"Upload complete: $uploaded uploaded, $skipped skipped, $failed failed",
Toast.LENGTH_LONG
).show()
}
}
}
private fun serverFileExists(deviceUuid: String, item: MediaItem): Boolean {
return try {
val url = URL(
EXISTS_URL +
"?device_uuid=" + URLEncoder.encode(deviceUuid, "UTF-8") +
"&original_filename=" + URLEncoder.encode(item.displayName, "UTF-8") +
"&size_bytes=" + item.sizeBytes
)
val conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 15000
readTimeout = 20000
setRequestProperty("Accept", "application/json")
}
val responseCode = conn.responseCode
val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream
val responseText = stream?.bufferedReader()?.use(BufferedReader::readText).orEmpty()
conn.disconnect()
if (responseCode !in 200..299) return false
val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject()
json.optBoolean("exists", false)
} catch (_: Exception) {
false
}
}
private fun uploadSingleFile(deviceUuid: String, item: MediaItem): Boolean {
return try {
val boundary = "----OTBCloudBoundary${System.currentTimeMillis()}"
val file = File(item.path)
if (!file.exists()) return false
val url = URL(UPLOAD_URL)
val conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 30000
readTimeout = 60000
doOutput = true
setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
setRequestProperty("Accept", "application/json")
}
val output = DataOutputStream(conn.outputStream)
fun writeFormField(name: String, value: String) {
output.writeBytes("--$boundary\r\n")
output.writeBytes("Content-Disposition: form-data; name=\"$name\"\r\n\r\n")
output.writeBytes(value)
output.writeBytes("\r\n")
}
writeFormField("device_uuid", deviceUuid)
output.writeBytes("--$boundary\r\n")
output.writeBytes("Content-Disposition: form-data; name=\"files\"; filename=\"${file.name}\"\r\n")
output.writeBytes("Content-Type: ${item.mimeType}\r\n\r\n")
FileInputStream(file).use { input ->
val buffer = ByteArray(8192)
while (true) {
val read = input.read(buffer)
if (read == -1) break
output.write(buffer, 0, read)
}
}
output.writeBytes("\r\n")
output.writeBytes("--$boundary--\r\n")
output.flush()
output.close()
val responseCode = conn.responseCode
val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream
val responseText = stream?.bufferedReader()?.use(BufferedReader::readText).orEmpty()
conn.disconnect()
if (responseCode !in 200..299) return false
val json = if (responseText.isNotBlank()) JSONObject(responseText) else JSONObject()
json.optBoolean("ok", false)
} catch (_: Exception) {
false
}
}
private fun getSelectedItems(): List<MediaItem> {
val selected = mutableListOf<MediaItem>()
for (i in mediaItems.indices) {
if (binding.mediaListView.isItemChecked(i)) {
selected.add(mediaItems[i])
}
}
return selected
}
private fun updateSelectionInfo() {
val selectedCount = getSelectedItems().size
val totalCount = mediaItems.size
binding.selectionInfoText.text = "Selected $selectedCount of $totalCount media file(s)"
}
private fun getOrCreateDeviceUuid(): String {
val prefs = getPrefs()
val existing = prefs.getString(KEY_DEVICE_UUID, null)
if (!existing.isNullOrBlank()) return existing
val newUuid = UUID.randomUUID().toString()
prefs.edit().putString(KEY_DEVICE_UUID, newUuid).apply()
return newUuid
}
private fun buildPhoneLabel(): String {
val manufacturer = Build.MANUFACTURER ?: "Android"
val model = Build.MODEL ?: "Device"
return "$manufacturer $model".trim()
}
private fun formatBytes(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val kb = bytes / 1024.0
if (kb < 1024) return String.format("%.1f KB", kb)
val mb = kb / 1024.0
if (mb < 1024) return String.format("%.1f MB", mb)
val gb = mb / 1024.0
return String.format("%.2f GB", gb)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQ_READ_STORAGE) {
val granted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }
if (granted) {
scanCameraMedia()
} else {
binding.statusText.text = "Storage permission denied"
Toast.makeText(this, "Storage permission is required to scan media", Toast.LENGTH_LONG).show()
}
}
}
}
EOF
echo "===== UPDATE /home/def/outsidethebox/otb-cloud-android/app/build.gradle ====="
python3 - << 'PY'
from pathlib import Path
import re
p = Path("/home/def/outsidethebox/otb-cloud-android/app/build.gradle")
text = p.read_text()
text = re.sub(r'versionCode\s+\d+', 'versionCode 21', text)
text = re.sub(r'versionName\s+"[^"]+"', 'versionName "0.2.1"', text)
p.write_text(text)
print("build.gradle updated")
PY
echo "===== UPDATE /home/def/outsidethebox/otb-cloud-android/app/src/main/AndroidManifest.xml ====="
python3 - << 'PY'
from pathlib import Path
p = Path("/home/def/outsidethebox/otb-cloud-android/app/src/main/AndroidManifest.xml")
text = p.read_text()
text = text.replace('android:label="OTB Cloud v0.2.0"', 'android:label="OTB Cloud v0.2.1"')
text = text.replace('android:label="OTB Cloud v0.1.9"', 'android:label="OTB Cloud v0.2.1"')
text = text.replace('android:label="OTB Cloud v0.1.8"', 'android:label="OTB Cloud v0.2.1"')
p.write_text(text)
print("AndroidManifest.xml updated")
PY
echo "===== UPDATE /home/def/outsidethebox/otb-cloud-android/app/src/main/res/layout/activity_main.xml ====="
python3 - << 'PY'
from pathlib import Path
p = Path("/home/def/outsidethebox/otb-cloud-android/app/src/main/res/layout/activity_main.xml")
text = p.read_text()
text = text.replace('OTB Cloud v0.2.0', 'OTB Cloud v0.2.1')
text = text.replace('OTB Cloud v0.1.9', 'OTB Cloud v0.2.1')
text = text.replace('OTB Cloud v0.1.8', 'OTB Cloud v0.2.1')
text = text.replace('Android Backup Client v0.2.0', 'Android Backup Client v0.2.1')
text = text.replace('Android Backup Client v0.1.9', 'Android Backup Client v0.2.1')
text = text.replace('Android Backup Client v0.1.8', 'Android Backup Client v0.2.1')
p.write_text(text)
print("activity_main.xml updated")
PY
echo "===== VERIFY SOURCE ====="
grep -Rn --exclude-dir=.gradle --exclude-dir=build --exclude="*.bak*" "0.2.1\|0.2.0\|0.1.9\|0.1.8" /home/def/outsidethebox/otb-cloud-android/app /home/def/outsidethebox/otb-cloud-android/app/build.gradle || true
echo "===== BUILD ====="
cd /home/def/outsidethebox/otb-cloud-android || exit 1
./gradlew clean assembleDebug --no-daemon
echo "===== COPY APK ====="
cp /home/def/outsidethebox/otb-cloud-android/app/build/outputs/apk/debug/app-debug.apk \
/home/def/outsidethebox/apk-drop/otb-cloud-v0.2.1.apk
echo "===== VERIFY APK VERSION ====="
aapt dump badging /home/def/outsidethebox/otb-cloud-android/app/build/outputs/apk/debug/app-debug.apk 2>/dev/null | grep version || true
echo "===== FINAL APK ====="
ls -lh /home/def/outsidethebox/apk-drop/otb-cloud-v0.2.1.apk