From 62a2f42d6841568ab984606f65f4cc98b875835d Mon Sep 17 00:00:00 2001 From: austin Date: Tue, 28 Oct 2025 18:26:36 -0500 Subject: [PATCH 1/7] Fix QR code generation for large polycentric export bundles - Add GZIP compression for large export data (>2000 chars) - Implement fallback QR generation with different error correction levels - Add automatic decompression support in import functionality - Improve error handling with fallback to text display - Add localized error messages for QR code failures - Add compression ratio logging for debugging This fixes the 'Data too big' error when generating QR codes for polycentric profile exports by automatically compressing large data and providing multiple fallback mechanisms. --- .../activities/PolycentricBackupActivity.kt | 80 +++++++++++++++++-- .../PolycentricImportProfileActivity.kt | 32 +++++++- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-tr/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 14 files changed, 117 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt index 9cf58134..34331ec9 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt @@ -29,14 +29,19 @@ import com.futo.polycentric.core.StorageTypeCRDTSetItem import com.futo.polycentric.core.Store import com.futo.polycentric.core.toBase64Url import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType import com.google.zxing.MultiFormatWriter import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import userpackage.Protocol import userpackage.Protocol.ExportBundle import userpackage.Protocol.URLInfo +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPOutputStream +import android.util.Base64 class PolycentricBackupActivity : AppCompatActivity() { private lateinit var _buttonShare: BigButton; @@ -74,6 +79,8 @@ class PolycentricBackupActivity : AppCompatActivity() { try { val pair = withContext(Dispatchers.IO) { val bundle = createExportBundle() + Logger.i(TAG, "Export bundle created, length: ${bundle.length}") + val dimension = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics ).toInt() @@ -89,10 +96,22 @@ class PolycentricBackupActivity : AppCompatActivity() { _buttonCopy.visibility = View.VISIBLE } catch (e: Exception) { Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e) + + // Show the export bundle text even if QR code generation fails + _exportBundle = withContext(Dispatchers.IO) { createExportBundle() } + + // Provide more specific error message based on the exception + val errorMessage = when { + e.message?.contains("Data too big") == true -> getString(R.string.qr_code_too_large_use_text_below) + else -> getString(R.string.failed_to_generate_qr_code) + } + _textQR.text = errorMessage + _textQR.visibility = View.VISIBLE + _buttonShare.visibility = View.VISIBLE + _buttonCopy.visibility = View.VISIBLE + + // Hide QR image since generation failed _imageQR.visibility = View.INVISIBLE - _textQR.visibility = View.INVISIBLE - _buttonShare.visibility = View.INVISIBLE - _buttonCopy.visibility = View.INVISIBLE } finally { _loader.visibility = View.GONE } @@ -111,8 +130,33 @@ class PolycentricBackupActivity : AppCompatActivity() { } private fun generateQRCode(content: String, width: Int, height: Int): Bitmap { - val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height); - return bitMatrixToBitmap(bitMatrix); + // Try different error correction levels and settings to handle large data + val errorCorrectionLevels = listOf( + ErrorCorrectionLevel.L, // 7% recovery + ErrorCorrectionLevel.M, // 15% recovery + ErrorCorrectionLevel.Q, // 25% recovery + ErrorCorrectionLevel.H // 30% recovery + ) + + var lastException: Exception? = null + + for (errorLevel in errorCorrectionLevels) { + try { + val hints = java.util.EnumMap(EncodeHintType::class.java) + hints[EncodeHintType.ERROR_CORRECTION] = errorLevel + hints[EncodeHintType.MARGIN] = 1 + + val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints) + return bitMatrixToBitmap(bitMatrix) + } catch (e: Exception) { + lastException = e + Logger.w(TAG, "Failed to generate QR code with error correction level $errorLevel: ${e.message}") + continue + } + } + + // If all attempts fail, throw the last exception + throw lastException ?: Exception("Failed to generate QR code") } private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap { @@ -203,7 +247,31 @@ class PolycentricBackupActivity : AppCompatActivity() { .setBody(exportBundle.toByteString()) .build(); - return "polycentric://" + urlInfo.toByteArray().toBase64Url() + val originalData = urlInfo.toByteArray() + val originalUrl = "polycentric://" + originalData.toBase64Url() + + // If the original URL is too long, try compression + if (originalUrl.length > 2000) { // QR code practical limit + try { + val compressedData = compressData(originalData) + val compressedUrl = "polycentric://" + compressedData.toBase64Url() + val compressionRatio = (compressedUrl.length.toFloat() / originalUrl.length * 100).toInt() + Logger.i(TAG, "Using compressed export bundle. Original size: ${originalUrl.length}, Compressed size: ${compressedUrl.length}, Compression ratio: ${compressionRatio}%") + return compressedUrl + } catch (e: Exception) { + Logger.w(TAG, "Failed to compress export bundle, using original", e) + } + } + + return originalUrl + } + + private fun compressData(data: ByteArray): ByteArray { + val outputStream = ByteArrayOutputStream() + GZIPOutputStream(outputStream).use { gzip -> + gzip.write(data) + } + return outputStream.toByteArray() } companion object { diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt index ab6d70a3..0345d57b 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt @@ -30,6 +30,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import userpackage.Protocol import userpackage.Protocol.ExportBundle +import java.io.ByteArrayInputStream +import java.util.zip.GZIPInputStream class PolycentricImportProfileActivity : AppCompatActivity() { private lateinit var _buttonHelp: ImageButton; @@ -108,7 +110,20 @@ class PolycentricImportProfileActivity : AppCompatActivity() { lifecycleScope.launch(Dispatchers.IO) { try { val data = url.substring("polycentric://".length).base64UrlToByteArray(); - val urlInfo = Protocol.URLInfo.parseFrom(data); + + // Try to parse as regular data first, if it fails, try decompressing + val urlInfo = try { + Protocol.URLInfo.parseFrom(data) + } catch (e: Exception) { + // If parsing fails, try to decompress the data + try { + val decompressedData = decompressData(data) + Protocol.URLInfo.parseFrom(decompressedData) + } catch (decompressException: Exception) { + throw Exception("Failed to parse URL data: ${e.message}") + } + } + if (urlInfo.urlType != 3L) { throw Exception("Expected urlInfo struct of type ExportBundle") } @@ -163,6 +178,21 @@ class PolycentricImportProfileActivity : AppCompatActivity() { } } + private fun decompressData(data: ByteArray): ByteArray { + val inputStream = ByteArrayInputStream(data) + val outputStream = java.io.ByteArrayOutputStream() + + GZIPInputStream(inputStream).use { gzip -> + val buffer = ByteArray(8192) // 8KB buffer + var bytesRead: Int + while (gzip.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + + return outputStream.toByteArray() + } + companion object { private const val TAG = "PolycentricImportProfileActivity"; } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 4724f6ce..269736f8 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -388,6 +388,7 @@ استثناء غير معالج في VS إرسال الاستثناء للمطورين… تم تعيين مفتاح الترخيص الخاص بك!\nقد يكون هناك حاجة لإعادة تشغيل التطبيق. + رمز الاستجابة السريعة كبير جدًا. استخدم النص أدناه لمشاركة ملفك الشخصي. تنسيق الترخيص غير صالح تنسيق المحتوى غير معروف تنسيق الملف غير معروف diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 234bb900..7cd81816 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -395,6 +395,7 @@ Unbehandelte Ausnahme in VS Ausnahme an Entwickler senden… Ihr Lizenzschlüssel wurde festgelegt!\nEin Neustart der App könnte erforderlich sein. + QR-Code zu groß. Verwenden Sie den Text unten, um Ihr Profil zu teilen. Ungültiges Lizenzformat Unbekanntes Inhaltsformat Unbekanntes Dateiformat diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 96b9449a..e9c04d20 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -372,6 +372,7 @@ Excepción no manejada en VS Enviar excepción a los desarrolladores... ¡Se ha configurado tu clave de licencia!\nPuede ser necesario reiniciar la aplicación. + Código QR demasiado grande. Use el texto de abajo para compartir su perfil. Formato de licencia no válido Formato de contenido desconocido Formato de archivo desconocido diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 557c485f..48ffdafe 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -411,6 +411,7 @@ Exception non gérée dans VS Envoyer l\'exception aux développeurs… Votre clé de licence a été définie !\nUn redémarrage de l\'application peut être nécessaire. + Code QR trop volumineux. Utilisez le texte ci-dessous pour partager votre profil. Format de licence invalide Format de contenu inconnu Format de fichier inconnu diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a5783903..d91c3b0b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -617,6 +617,7 @@ Eccezione non gestita in VS Invio eccezione agli sviluppatori… La tua chiave di licenza è stata impostata!\nIl riavvio dell\'app potrebbe essere richiesto. + Codice QR troppo grande. Usa il testo qui sotto per condividere il tuo profilo. Formato licenza non valido Formato contenuto sconosciuto Formato file sconosciuto diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e3452959..9177ffac 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -374,6 +374,7 @@ VSで未処理の例外 開発者に例外を送信… ライセンスキーが設定されました!\nアプリを再起動する可能性があります。 + QRコードが大きすぎます。下のテキストを使用してプロフィールを共有してください。 無効なライセンス形式 不明なコンテンツ形式 不明なファイル形式 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 0312da2f..01a84b8d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -410,6 +410,7 @@ VS에서 처리되지 않은 예외 개발자에게 예외를 보냅니다… 라이선스 키가 설정되었습니다!\n앱을 다시 시작해야 할 수 있습니다. + QR 코드가 너무 큽니다. 아래 텍스트를 사용하여 프로필을 공유하세요. 잘못된 라이선스 형식 알 수 없는 콘텐츠 형식 알 수 없는 파일 형식 diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index de46d305..9a0fcec9 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -407,6 +407,7 @@ Exceção não tratada no VS Enviar exceção aos desenvolvedores… Sua chave de licença foi definida!\nUma reinicialização do aplicativo pode ser necessária. + Código QR muito grande. Use o texto abaixo para compartilhar seu perfil. Formato de licença inválido Formato de conteúdo desconhecido Formato de arquivo desconhecido diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 82a479ad..cf66c21d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -407,6 +407,7 @@ Необработанное исключение в VS Отправить исключение разработчикам… Ваш лицензионный ключ установлен!\nМожет потребоваться перезагрузка приложения. + QR-код слишком большой. Используйте текст ниже, чтобы поделиться своим профилем. Неверный формат лицензии Неизвестный формат содержимого Неизвестный формат файла diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 5a17d7b9..8cc01b87 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -581,6 +581,7 @@ VS\'de bilinmeyen hata (exception) Geliştiricilere exception\'ı gönder… Lisans anahtarınız ayarlandı!\nUygulamayı yeniden başlatmanız gerekebilir. + QR kodu çok büyük. Profilinizi paylaşmak için aşağıdaki metni kullanın. Geçersiz lisans formatı Bilinmeyen içerik formatı Bilinmeyen dosya formatı diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 1e0e843f..74ab2f65 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -411,6 +411,7 @@ VS 中的未处理异常 向开发者发送异常… 您的许可证密钥已设置!\n可能需要重新启动应用程序。 + 二维码太大。请使用下方文字分享您的个人资料。 无效的许可证格式 未知的内容格式 未知的文件格式 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 92c52cf4..7f5a1f7c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -643,6 +643,7 @@ Unhandled exception in VS Send exception to developers… Your license key has been set!\nAn app restart might be required. + QR code too large. Use the text below to share your profile. Invalid license format Unknown content format Unknown file format From 9e9d26c75242dffea0ad83e4497940171792edeb Mon Sep 17 00:00:00 2001 From: austin Date: Thu, 13 Nov 2025 18:22:30 -0600 Subject: [PATCH 2/7] Full screen QR code viewer --- app/src/main/AndroidManifest.xml | 4 + .../activities/QRCodeFullscreenActivity.kt | 109 ++++++++++++++++++ .../layout/activity_qr_code_fullscreen.xml | 55 +++++++++ 3 files changed, 168 insertions(+) create mode 100644 app/src/main/java/com/futo/platformplayer/activities/QRCodeFullscreenActivity.kt create mode 100644 app/src/main/res/layout/activity_qr_code_fullscreen.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d271724b..2823496c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -245,5 +245,9 @@ android:name=".activities.PolycentricModerationActivity" android:exported="false" android:screenOrientation="portrait" /> + diff --git a/app/src/main/java/com/futo/platformplayer/activities/QRCodeFullscreenActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/QRCodeFullscreenActivity.kt new file mode 100644 index 00000000..9acefed2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/QRCodeFullscreenActivity.kt @@ -0,0 +1,109 @@ +package com.futo.platformplayer.activities + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.os.Bundle +import android.util.TypedValue +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import androidx.appcompat.app.AppCompatActivity +import com.futo.platformplayer.R +import com.futo.platformplayer.setNavigationBarColorAndIcons +import com.futo.platformplayer.states.StateApp +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.MultiFormatWriter +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel + +class QRCodeFullscreenActivity : AppCompatActivity() { + companion object { + private const val EXTRA_QR_TEXT = "qr_text" + + fun createIntent(context: Context, qrText: String): android.content.Intent { + return android.content.Intent(context, QRCodeFullscreenActivity::class.java).apply { + putExtra(EXTRA_QR_TEXT, qrText) + } + } + } + + override fun attachBaseContext(newBase: Context?) { + super.attachBaseContext(StateApp.instance.getLocaleContext(newBase)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_qr_code_fullscreen) + setNavigationBarColorAndIcons() + + val qrText = intent.getStringExtra(EXTRA_QR_TEXT) + + val imageQR = findViewById(R.id.image_qr_fullscreen) + val buttonBack = findViewById(R.id.button_back_fullscreen) + val buttonClose = findViewById(R.id.button_close_fullscreen) + + // Generate QR code bitmap from text + qrText?.let { text -> + try { + if (!isContentSuitableForQRCode(text)) { + throw Exception("Data too big for QR code generation") + } + + val dimension = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 300f, resources.displayMetrics + ).toInt() + val qrBitmap = generateQRCode(text, dimension, dimension) + imageQR.setImageBitmap(qrBitmap) + } catch (e: Exception) { + // If QR generation fails, show error or fallback + imageQR.setImageResource(R.drawable.ic_qr) + } + } + + buttonBack.setOnClickListener { + finish() + } + + buttonClose.setOnClickListener { + finish() + } + + imageQR.setOnClickListener { + finish() + } + } + + private fun isContentSuitableForQRCode(content: String): Boolean { + val bytes = content.toByteArray(Charsets.UTF_8) + return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes + } + + private fun generateQRCode(content: String, width: Int, height: Int): Bitmap { + if (!isContentSuitableForQRCode(content)) { + throw Exception("Data too big for QR code generation") + } + + val hints = java.util.EnumMap(EncodeHintType::class.java) + hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M + hints[EncodeHintType.MARGIN] = 1 + + val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints) + return bitMatrixToBitmap(bitMatrix) + } + + private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap { + val width = matrix.width + val height = matrix.height + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) + + for (x in 0 until width) { + for (y in 0 until height) { + bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE) + } + } + return bmp + } +} + diff --git a/app/src/main/res/layout/activity_qr_code_fullscreen.xml b/app/src/main/res/layout/activity_qr_code_fullscreen.xml new file mode 100644 index 00000000..0dd128e2 --- /dev/null +++ b/app/src/main/res/layout/activity_qr_code_fullscreen.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + From e4f51bb13050cf26dda487318491ab7bbd433e11 Mon Sep 17 00:00:00 2001 From: austin Date: Thu, 13 Nov 2025 18:22:58 -0600 Subject: [PATCH 3/7] export profile to file --- .../activities/PolycentricBackupActivity.kt | 140 +++++++++--------- .../layout/activity_polycentric_backup.xml | 35 ++++- app/src/main/res/values/strings.xml | 5 + 3 files changed, 106 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt index 34331ec9..963999ce 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricBackupActivity.kt @@ -13,15 +13,18 @@ import android.view.View import android.widget.ImageButton import android.widget.ImageView import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp.Companion.withContext import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.views.buttons.BigButton +import com.futo.platformplayer.activities.QRCodeFullscreenActivity import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.SignedEvent import com.futo.polycentric.core.StorageTypeCRDTItem @@ -39,18 +42,31 @@ import kotlinx.coroutines.withContext import userpackage.Protocol import userpackage.Protocol.ExportBundle import userpackage.Protocol.URLInfo -import java.io.ByteArrayOutputStream -import java.util.zip.GZIPOutputStream -import android.util.Base64 class PolycentricBackupActivity : AppCompatActivity() { private lateinit var _buttonShare: BigButton; private lateinit var _buttonCopy: BigButton; + private lateinit var _buttonExportFile: BigButton; private lateinit var _imageQR: ImageView; private lateinit var _exportBundle: String; private lateinit var _textQR: TextView; + private lateinit var _textQRHint: TextView; private lateinit var _loader: View + private val _createDocumentLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + uri?.let { fileUri -> + try { + contentResolver.openOutputStream(fileUri)?.use { outputStream -> + outputStream.write(_exportBundle.toByteArray()) + } + UIDialogs.toast(this, getString(R.string.profile_saved_successfully)) + } catch (e: Exception) { + Logger.e(TAG, "Failed to write to document", e) + UIDialogs.toast(this, "Failed to save profile: ${e.message}") + } + } + } + override fun attachBaseContext(newBase: Context?) { super.attachBaseContext(StateApp.instance.getLocaleContext(newBase)) } @@ -62,8 +78,10 @@ class PolycentricBackupActivity : AppCompatActivity() { _buttonShare = findViewById(R.id.button_share) _buttonCopy = findViewById(R.id.button_copy) + _buttonExportFile = findViewById(R.id.button_export_file) _imageQR = findViewById(R.id.image_qr) _textQR = findViewById(R.id.text_qr) + _textQRHint = findViewById(R.id.text_qr_hint) _loader = findViewById(R.id.progress_loader) findViewById(R.id.button_back).setOnClickListener { finish(); @@ -71,16 +89,23 @@ class PolycentricBackupActivity : AppCompatActivity() { _imageQR.visibility = View.INVISIBLE _textQR.visibility = View.INVISIBLE + _textQRHint.visibility = View.INVISIBLE _loader.visibility = View.VISIBLE _buttonShare.visibility = View.INVISIBLE _buttonCopy.visibility = View.INVISIBLE + _buttonExportFile.visibility = View.INVISIBLE lifecycleScope.launch { + val bundle = withContext(Dispatchers.IO) { createExportBundle() } + _exportBundle = bundle + Logger.i(TAG, "Export bundle created, length: ${bundle.length}") + try { val pair = withContext(Dispatchers.IO) { - val bundle = createExportBundle() - Logger.i(TAG, "Export bundle created, length: ${bundle.length}") - + if (!isContentSuitableForQRCode(bundle)) { + throw Exception("Data too big for QR code generation") + } + val dimension = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics ).toInt() @@ -88,28 +113,33 @@ class PolycentricBackupActivity : AppCompatActivity() { Pair(bundle, qr) } - _exportBundle = pair.first _imageQR.setImageBitmap(pair.second) _imageQR.visibility = View.VISIBLE _textQR.visibility = View.VISIBLE + _textQRHint.visibility = View.VISIBLE _buttonShare.visibility = View.VISIBLE _buttonCopy.visibility = View.VISIBLE - } catch (e: Exception) { - Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e) - - // Show the export bundle text even if QR code generation fails - _exportBundle = withContext(Dispatchers.IO) { createExportBundle() } - - // Provide more specific error message based on the exception - val errorMessage = when { - e.message?.contains("Data too big") == true -> getString(R.string.qr_code_too_large_use_text_below) - else -> getString(R.string.failed_to_generate_qr_code) + + _imageQR.setOnClickListener { + val intent = QRCodeFullscreenActivity.createIntent(this@PolycentricBackupActivity, _exportBundle) + startActivity(intent) } - _textQR.text = errorMessage + } catch (e: Exception) { + val byteSize = bundle.toByteArray(Charsets.UTF_8).size + Logger.e(TAG, "QR code generation failed. Bundle length: ${bundle.length} chars, ${byteSize} bytes, Error: ${e.message}", e) + + if (e.message?.contains("Data too big") == true) { + _textQR.text = getString(R.string.qr_code_too_large_use_file_export) + _buttonExportFile.visibility = View.VISIBLE + } else { + _textQR.text = getString(R.string.failed_to_generate_qr_code) + } + _textQR.visibility = View.VISIBLE + _textQRHint.visibility = View.INVISIBLE _buttonShare.visibility = View.VISIBLE _buttonCopy.visibility = View.VISIBLE - + // Hide QR image since generation failed _imageQR.visibility = View.INVISIBLE } finally { @@ -127,36 +157,29 @@ class PolycentricBackupActivity : AppCompatActivity() { val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle); clipboard.setPrimaryClip(clip); }; + + _buttonExportFile.onClick.subscribe { + val fileName = "polycentric_profile_${System.currentTimeMillis()}.txt" + _createDocumentLauncher.launch(fileName) + }; + } + + private fun isContentSuitableForQRCode(content: String): Boolean { + val bytes = content.toByteArray(Charsets.UTF_8) + return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes } private fun generateQRCode(content: String, width: Int, height: Int): Bitmap { - // Try different error correction levels and settings to handle large data - val errorCorrectionLevels = listOf( - ErrorCorrectionLevel.L, // 7% recovery - ErrorCorrectionLevel.M, // 15% recovery - ErrorCorrectionLevel.Q, // 25% recovery - ErrorCorrectionLevel.H // 30% recovery - ) - - var lastException: Exception? = null - - for (errorLevel in errorCorrectionLevels) { - try { - val hints = java.util.EnumMap(EncodeHintType::class.java) - hints[EncodeHintType.ERROR_CORRECTION] = errorLevel - hints[EncodeHintType.MARGIN] = 1 - - val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints) - return bitMatrixToBitmap(bitMatrix) - } catch (e: Exception) { - lastException = e - Logger.w(TAG, "Failed to generate QR code with error correction level $errorLevel: ${e.message}") - continue - } + if (!isContentSuitableForQRCode(content)) { + throw Exception("Data too big for QR code generation") } - - // If all attempts fail, throw the last exception - throw lastException ?: Exception("Failed to generate QR code") + + val hints = java.util.EnumMap(EncodeHintType::class.java) + hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M + hints[EncodeHintType.MARGIN] = 1 + + val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints) + return bitMatrixToBitmap(bitMatrix) } private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap { @@ -247,31 +270,8 @@ class PolycentricBackupActivity : AppCompatActivity() { .setBody(exportBundle.toByteString()) .build(); - val originalData = urlInfo.toByteArray() - val originalUrl = "polycentric://" + originalData.toBase64Url() - - // If the original URL is too long, try compression - if (originalUrl.length > 2000) { // QR code practical limit - try { - val compressedData = compressData(originalData) - val compressedUrl = "polycentric://" + compressedData.toBase64Url() - val compressionRatio = (compressedUrl.length.toFloat() / originalUrl.length * 100).toInt() - Logger.i(TAG, "Using compressed export bundle. Original size: ${originalUrl.length}, Compressed size: ${compressedUrl.length}, Compression ratio: ${compressionRatio}%") - return compressedUrl - } catch (e: Exception) { - Logger.w(TAG, "Failed to compress export bundle, using original", e) - } - } - - return originalUrl - } - - private fun compressData(data: ByteArray): ByteArray { - val outputStream = ByteArrayOutputStream() - GZIPOutputStream(outputStream).use { gzip -> - gzip.write(data) - } - return outputStream.toByteArray() + val data = urlInfo.toByteArray() + return "polycentric://" + data.toBase64Url() } companion object { diff --git a/app/src/main/res/layout/activity_polycentric_backup.xml b/app/src/main/res/layout/activity_polycentric_backup.xml index d6579dd7..130c4934 100644 --- a/app/src/main/res/layout/activity_polycentric_backup.xml +++ b/app/src/main/res/layout/activity_polycentric_backup.xml @@ -29,6 +29,8 @@ android:id="@+id/image_qr" android:layout_width="200dp" android:layout_height="200dp" + android:background="?android:attr/selectableItemBackgroundBorderless" + android:padding="8dp" app:srcCompat="@drawable/ic_qr" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" @@ -37,15 +39,31 @@ + app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintRight_toRightOf="parent" /> + + @@ -75,6 +93,15 @@ app:buttonSubText="@string/copy_your_identity_to_clipboard" app:buttonIcon="@drawable/ic_copy" android:layout_marginTop="8dp" /> + + 1500 2000 + Export to file or copy backup code + Tap QR code for fullscreen view + Export to File + Save profile to file for sharing + Profile saved successfully From fc2aba012040dc7715cbde6e4cfc9d7de414c9bc Mon Sep 17 00:00:00 2001 From: austin Date: Thu, 13 Nov 2025 18:40:30 -0600 Subject: [PATCH 4/7] import identity from file --- .../PolycentricImportProfileActivity.kt | 222 +++++++++++------- .../activity_polycentric_import_profile.xml | 24 +- app/src/main/res/values/strings.xml | 1 + 3 files changed, 156 insertions(+), 91 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt index 0345d57b..91a0cb5c 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt @@ -30,117 +30,168 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import userpackage.Protocol import userpackage.Protocol.ExportBundle -import java.io.ByteArrayInputStream -import java.util.zip.GZIPInputStream class PolycentricImportProfileActivity : AppCompatActivity() { - private lateinit var _buttonHelp: ImageButton; - private lateinit var _buttonScanProfile: LinearLayout; - private lateinit var _buttonImportProfile: LinearLayout; - private lateinit var _editProfile: EditText; - private lateinit var _loaderOverlay: LoaderOverlay; + private lateinit var _buttonHelp: ImageButton + private lateinit var _buttonScanProfile: LinearLayout + private lateinit var _buttonImportFile: LinearLayout + private lateinit var _buttonImportProfile: LinearLayout + private lateinit var _editProfile: EditText + private lateinit var _loaderOverlay: LoaderOverlay - private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) - scanResult?.let { - if (it.contents != null) { - val scannedUrl = it.contents - import(scannedUrl) + private val _qrCodeResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val scanResult = + IntentIntegrator.parseActivityResult(result.resultCode, result.data) + scanResult?.let { + if (it.contents != null) { + val scannedUrl = it.contents + import(scannedUrl) + } + } + } + + private val _filePickerLauncher = + registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let { fileUri -> + try { + // Check file size before reading + val fileSize = + contentResolver.openFileDescriptor(fileUri, "r")?.statSize ?: 0 + val maxFileSize = 10 * 1024 * 1024 // 10MB limit + + if (fileSize > maxFileSize) { + UIDialogs.toast(this, "File too large. Maximum size is 10MB.") + return@let + } + + if (fileSize == 0L) { + UIDialogs.toast(this, "Selected file is empty.") + return@let + } + + val content = + contentResolver + .openInputStream(fileUri) + ?.bufferedReader() + ?.readText() + content?.let { fileContent -> + val trimmedContent = fileContent.trim() + + // Check if content is empty after trimming + if (trimmedContent.isEmpty()) { + UIDialogs.toast(this, "Selected file contains no data.") + return@let + } + + // Check if content looks like a valid polycentric URL + if (!trimmedContent.startsWith("polycentric://")) { + UIDialogs.toast( + this, + "Selected file does not contain a valid polycentric profile URL." + ) + return@let + } + + import(trimmedContent) + } + ?: run { UIDialogs.toast(this, "Could not read file content.") } + } catch (e: SecurityException) { + Logger.e(TAG, "Security exception reading file", e) + UIDialogs.toast(this, "Permission denied to read file.") + } catch (e: OutOfMemoryError) { + Logger.e(TAG, "Out of memory reading file", e) + UIDialogs.toast(this, "File too large to process.") + } catch (e: Exception) { + Logger.e(TAG, "Failed to read file", e) + UIDialogs.toast(this, "Failed to read file: ${e.message}") + } + } } - } - } override fun attachBaseContext(newBase: Context?) { super.attachBaseContext(StateApp.instance.getLocaleContext(newBase)) } override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_polycentric_import_profile); - setNavigationBarColorAndIcons(); + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_polycentric_import_profile) + setNavigationBarColorAndIcons() - _buttonHelp = findViewById(R.id.button_help); - _buttonScanProfile = findViewById(R.id.button_scan_profile); - _buttonImportProfile = findViewById(R.id.button_import_profile); - _loaderOverlay = findViewById(R.id.loader_overlay); - _editProfile = findViewById(R.id.edit_profile); - findViewById(R.id.button_back).setOnClickListener { - finish(); - }; + _buttonHelp = findViewById(R.id.button_help) + _buttonScanProfile = findViewById(R.id.button_scan_profile) + _buttonImportFile = findViewById(R.id.button_import_file) + _buttonImportProfile = findViewById(R.id.button_import_profile) + _loaderOverlay = findViewById(R.id.loader_overlay) + _editProfile = findViewById(R.id.edit_profile) + findViewById(R.id.button_back).setOnClickListener { finish() } _buttonHelp.setOnClickListener { - startActivity(Intent(this, PolycentricWhyActivity::class.java)); - }; + startActivity(Intent(this, PolycentricWhyActivity::class.java)) + } _buttonScanProfile.setOnClickListener { val integrator = IntentIntegrator(this) integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) integrator.setPrompt(getString(R.string.scan_a_qr_code)) - integrator.setOrientationLocked(true); + integrator.setOrientationLocked(true) integrator.setCameraId(0) integrator.setBeepEnabled(false) integrator.setBarcodeImageEnabled(true) - integrator.setCaptureActivity(QRCaptureActivity::class.java); + integrator.setCaptureActivity(QRCaptureActivity::class.java) _qrCodeResultLauncher.launch(integrator.createScanIntent()) - }; + } + + _buttonImportFile.setOnClickListener { _filePickerLauncher.launch("text/plain") } _buttonImportProfile.setOnClickListener { if (_editProfile.text.isEmpty()) { - UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data)); - return@setOnClickListener; + UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data)) + return@setOnClickListener } - import(_editProfile.text.toString()); - }; + import(_editProfile.text.toString()) + } - val url = intent.getStringExtra("url"); + val url = intent.getStringExtra("url") if (url != null) { - import(url); + import(url) } } private fun import(url: String) { if (!url.startsWith("polycentric://")) { - UIDialogs.toast(this, getString(R.string.not_a_valid_url)); - return; + UIDialogs.toast(this, getString(R.string.not_a_valid_url)) + return } _loaderOverlay.show() lifecycleScope.launch(Dispatchers.IO) { try { - val data = url.substring("polycentric://".length).base64UrlToByteArray(); - - // Try to parse as regular data first, if it fails, try decompressing - val urlInfo = try { - Protocol.URLInfo.parseFrom(data) - } catch (e: Exception) { - // If parsing fails, try to decompress the data - try { - val decompressedData = decompressData(data) - Protocol.URLInfo.parseFrom(decompressedData) - } catch (decompressException: Exception) { - throw Exception("Failed to parse URL data: ${e.message}") - } - } + val data = url.substring("polycentric://".length).base64UrlToByteArray() + val urlInfo = Protocol.URLInfo.parseFrom(data) if (urlInfo.urlType != 3L) { throw Exception("Expected urlInfo struct of type ExportBundle") } - val exportBundle = ExportBundle.parseFrom(urlInfo.body); - val keyPair = KeyPair.fromProto(exportBundle.keyPair); + val exportBundle = ExportBundle.parseFrom(urlInfo.body) + val keyPair = KeyPair.fromProto(exportBundle.keyPair) - val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey); + val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey) if (existingProcessSecret != null) { withContext(Dispatchers.Main) { - UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported)); + UIDialogs.toast( + this@PolycentricImportProfileActivity, + getString(R.string.this_profile_is_already_imported) + ) } - return@launch; + return@launch } - val processSecret = ProcessSecret(keyPair, Process.random()); - Store.instance.addProcessSecret(processSecret); + val processSecret = ProcessSecret(keyPair, Process.random()) + Store.instance.addProcessSecret(processSecret) try { PolycentricStorage.instance.addProcessSecret(processSecret) @@ -148,52 +199,43 @@ class PolycentricImportProfileActivity : AppCompatActivity() { Logger.e(TAG, "Failed to save process secret to secret storage.", e) } - val processHandle = processSecret.toProcessHandle(); + val processHandle = processSecret.toProcessHandle() for (e in exportBundle.events.eventsList) { try { - val se = SignedEvent.fromProto(e); - Store.instance.putSignedEvent(se); + val se = SignedEvent.fromProto(e) + Store.instance.putSignedEvent(se) } catch (e: Throwable) { - Logger.w(TAG, "Ignored invalid event", e); + Logger.w(TAG, "Ignored invalid event", e) } } - StatePolycentric.instance.setProcessHandle(processHandle); - processHandle.fullyBackfillClient(ApiMethods.SERVER); + StatePolycentric.instance.setProcessHandle(processHandle) + processHandle.fullyBackfillClient(ApiMethods.SERVER) withContext(Dispatchers.Main) { - startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java)); - finish(); + startActivity( + Intent( + this@PolycentricImportProfileActivity, + PolycentricProfileActivity::class.java + ) + ) + finish() } } catch (e: Throwable) { - Logger.w(TAG, "Failed to import profile", e); + Logger.w(TAG, "Failed to import profile", e) withContext(Dispatchers.Main) { - UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'"); + UIDialogs.toast( + this@PolycentricImportProfileActivity, + getString(R.string.failed_to_import_profile) + " '${e.message}'" + ) } } finally { - withContext(Dispatchers.Main) { - _loaderOverlay.hide(); - } + withContext(Dispatchers.Main) { _loaderOverlay.hide() } } } } - private fun decompressData(data: ByteArray): ByteArray { - val inputStream = ByteArrayInputStream(data) - val outputStream = java.io.ByteArrayOutputStream() - - GZIPInputStream(inputStream).use { gzip -> - val buffer = ByteArray(8192) // 8KB buffer - var bytesRead: Int - while (gzip.read(buffer).also { bytesRead = it } != -1) { - outputStream.write(buffer, 0, bytesRead) - } - } - - return outputStream.toByteArray() - } - companion object { - private const val TAG = "PolycentricImportProfileActivity"; + private const val TAG = "PolycentricImportProfileActivity" } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/activity_polycentric_import_profile.xml b/app/src/main/res/layout/activity_polycentric_import_profile.xml index b7013f40..a856506c 100644 --- a/app/src/main/res/layout/activity_polycentric_import_profile.xml +++ b/app/src/main/res/layout/activity_polycentric_import_profile.xml @@ -47,6 +47,28 @@ android:text="@string/scan_qr" /> + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ff8d567d..7e0c60da 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1179,6 +1179,7 @@ Export to file or copy backup code Tap QR code for fullscreen view Export to File + Import from File Save profile to file for sharing Profile saved successfully From f89b074d280d674d39121a1edfb3ff5eea6e2794 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 18 Nov 2025 23:35:34 +0100 Subject: [PATCH 5/7] Various improvements to library and other fixes --- .../platformplayer/activities/MainActivity.kt | 19 +++++++ .../fragment/mainactivity/main/FeedView.kt | 5 +- .../main/LibraryArtistFragment.kt | 5 +- .../mainactivity/main/LibraryFilesFragment.kt | 29 ++++++++++ .../mainactivity/main/LibraryFragment.kt | 23 ++++---- .../mainactivity/main/VideoDetailView.kt | 50 +++++++++++++----- .../futo/platformplayer/models/Telemetry.kt | 3 +- .../platformplayer/states/StateLibrary.kt | 35 ++++++++---- .../platformplayer/states/StateTelemetry.kt | 3 +- .../viewholders/ArtistTileViewHolder.kt | 4 +- .../adapters/viewholders/FileViewHolder.kt | 4 +- .../views/buttons/ButtonsContainer.kt | 47 ++++++++++++++++ .../views/buttons/StandardButton.kt | 34 ++++++++++++ .../views/others/CreatorThumbnail.kt | 16 ++++-- .../platformplayer/views/pills/PillButton.kt | 2 +- .../views/segments/CommentsList.kt | 7 +++ app/src/main/res/drawable/ic_artist.png | Bin 0 -> 3401 bytes app/src/main/res/drawable/ic_folder.png | Bin 0 -> 462 bytes app/src/main/res/drawable/ic_song.png | Bin 0 -> 826 bytes app/src/main/res/layout/fragment_feed.xml | 18 +++++-- app/src/main/res/layout/list_file.xml | 6 +-- .../main/res/layout/view_button_standard.xml | 24 +++++++++ app/src/main/res/layout/view_buttons.xml | 28 ++++++++++ 23 files changed, 307 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/views/buttons/ButtonsContainer.kt create mode 100644 app/src/main/java/com/futo/platformplayer/views/buttons/StandardButton.kt create mode 100644 app/src/main/res/drawable/ic_artist.png create mode 100644 app/src/main/res/drawable/ic_folder.png create mode 100644 app/src/main/res/drawable/ic_song.png create mode 100644 app/src/main/res/layout/view_button_standard.xml create mode 100644 app/src/main/res/layout/view_buttons.xml diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index bcda0ff8..de0519a7 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -252,6 +252,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { UIDialogs.toast(this, "Notification permission denied"); }; + + fun requestNotificationPermissions() { _notificationPermissionLauncher?.launch(_notifPermission); } @@ -1379,6 +1381,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { ); } + var _callbackPermissionAudio: ((Boolean)->Unit)? = null; + var _callbackPermissionVideo: ((Boolean)->Unit)? = null; + val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted -> + _callbackPermissionAudio?.invoke(isGranted); + }); + val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted -> + _callbackPermissionVideo?.invoke(isGranted); + }); + fun requestPermissionAudio(cb: ((Boolean)->Unit)? = null) { + _callbackPermissionAudio = cb; + permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO); + } + fun requestPermissionVideo(cb: ((Boolean)->Unit)? = null) { + _callbackPermissionVideo = cb; + permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO); + } + val notifPermission = "android.permission.POST_NOTIFICATIONS"; val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt index 2a19fc6e..abf4ec7d 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt @@ -9,6 +9,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.* +import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager @@ -39,7 +40,7 @@ import java.time.OffsetDateTime import kotlin.math.max abstract class FeedView : LinearLayout where TPager : IPager, TViewHolder : RecyclerView.ViewHolder, TFragment : MainFragment { - protected val _feedRoot: FrameLayout; + protected val _feedRoot: ConstraintLayout; protected val _recyclerResults: RecyclerView; protected val _overlayContainer: FrameLayout; protected val _swipeRefresh: SwipeRefreshLayout; @@ -52,6 +53,7 @@ abstract class FeedView : L private val _emptyPagerContainer: FrameLayout; protected val _toolbarContentView: LinearLayout; + protected val _bottomContentView: LinearLayout; private var _loading: Boolean = true; @@ -136,6 +138,7 @@ abstract class FeedView : L setActiveTags(null); _toolbarContentView = findViewById(R.id.container_toolbar_content); + _bottomContentView = findViewById(R.id.container_bottom); _nextPageHandler = TaskHandler>>({fragment.lifecycleScope}, { if (it is IAsyncPager<*>) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt index 878cc8a5..f51943fb 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryArtistFragment.kt @@ -506,7 +506,10 @@ class LibraryArtistFragment : MainFragment() { val playlist = _artist?.toPlaylist(); if (playlist != null) { - val index = playlist.videos.indexOf(c); + val sameVideo = playlist.videos.find { it.name == c.name }; + val index = sameVideo?.let { + playlist.videos.indexOf(sameVideo) + } ?: -1; if (index == -1) return@subscribe; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFilesFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFilesFragment.kt index a50c047d..67c5bccd 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFilesFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFilesFragment.kt @@ -8,25 +8,32 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageButton import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView +import androidx.core.net.toUri import androidx.core.view.isVisible import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.video.LocalVideoDetails +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.structures.AdhocPager import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.fragment.mainactivity.topbar.FilesTopBarFragment +import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.FileEntry import com.futo.platformplayer.states.StateLibrary +import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.NoResultsView import com.futo.platformplayer.views.adapters.AnyAdapter import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder import com.futo.platformplayer.views.buttons.BigButton +import com.futo.platformplayer.views.buttons.ButtonsContainer class LibraryFilesFragment : MainFragment() { override val isMainView : Boolean = true; @@ -70,6 +77,7 @@ class LibraryFilesFragment : MainFragment() { private var root: FileEntry? = null; constructor(fragment: LibraryFilesFragment, inflater: LayoutInflater) : super(fragment, inflater) { + disableRefreshLayout(); } fun onShown(parameter: Any? = null) { @@ -139,6 +147,27 @@ class LibraryFilesFragment : MainFragment() { setPager(AdhocPager({ listOf(); }, stack.files)); setLoading(false); + val allSongs = stack.files.filter { !it.isDirectory }; + if(allSongs.any()) { + _bottomContentView.addView(ButtonsContainer(context, + listOf( + ButtonsContainer.Button("Play All", R.drawable.background_button_primary) { + StatePlayer.instance.setPlaylist(Playlist(stack.path.toUri().lastPathSegment ?: "", allSongs.map { + SerializedPlatformVideo.fromVideo(LocalVideoDetails.fromContent(it.path)) + }), focus = true, shuffle = false) + }, + ButtonsContainer.Button("Shuffle", R.drawable.background_button_accent) { + StatePlayer.instance.setPlaylist(Playlist(stack.path.toUri().lastPathSegment ?: "", allSongs.map { + SerializedPlatformVideo.fromVideo(LocalVideoDetails.fromContent(it.path)) + }), focus = true, shuffle = true) + } + )).apply { + this.layoutParams = LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + }) + } + else + _bottomContentView.removeAllViews(); + fragment.topBar?.let { if(it is FilesTopBarFragment) { if(navStack.size > 1) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt index a28f32c9..1d58331a 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt @@ -93,14 +93,18 @@ class LibraryFragment : MainFragment() { UIDialogs.showDialog(requireContext(), R.drawable.ic_library, "Music permissions", "We require permissions to see your on-device music, denying this will hide the option to see local music.", null, 1, UIDialogs.Action("Ok", { - permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO); + StateApp?.instance?.activity?.requestPermissionAudio { + setPermissionResultAudio(it); + } }, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("Cancel", { }, UIDialogs.ActionStyle.NONE)); } else -> { - permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO); + StateApp?.instance?.activity?.requestPermissionAudio { + setPermissionResultAudio(it); + } } } } @@ -113,24 +117,22 @@ class LibraryFragment : MainFragment() { UIDialogs.showDialog(requireContext(), R.drawable.ic_library, false, "Videos permissions", "We require permissions to see your on-device videos, denying this will hide the option to see local videos.", null, 1, UIDialogs.Action("Ok", { - permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO); + StateApp?.instance?.activity?.requestPermissionVideo { + setPermissionResultVideo(it); + } }, UIDialogs.ActionStyle.PRIMARY), UIDialogs.Action("Cancel", { }, UIDialogs.ActionStyle.NONE)); } else -> { - permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO); + StateApp?.instance?.activity?.requestPermissionVideo { + setPermissionResultVideo(it); + } } } } - val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted -> - setPermissionResultAudio(isGranted); - }); - val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted -> - setPermissionResultVideo(isGranted); - }); companion object { fun newInstance() = LibraryFragment().apply {} @@ -292,6 +294,7 @@ class LibraryFragment : MainFragment() { } fun onShown() { + UIDialogs.appToast("Library is in alpha\nImprovements are coming to local media playback.") } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index c7d012bf..1d5f0fab 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -55,6 +55,7 @@ import com.futo.platformplayer.api.media.LiveChatManager import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException +import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink import com.futo.platformplayer.api.media.models.chapters.ChapterType import com.futo.platformplayer.api.media.models.chapters.IChapter @@ -77,6 +78,7 @@ import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.models.video.LocalVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig @@ -175,6 +177,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import userpackage.Protocol import java.time.OffsetDateTime import java.util.Locale @@ -563,6 +566,18 @@ class VideoDetailView : ConstraintLayout { if (video is TutorialFragment.TutorialVideo) { return@setOnClickListener } + if(video is LocalVideoDetails) { + video?.author?.let { + if(it.url.startsWith("content://media/external/audio/artists")) { + fragment.navigate(it.url); + fragment.lifecycleScope.launch { + delay(100); + fragment.minimizeVideoDetail(); + }; + } + } + return@setOnClickListener; + } (video?.author ?: _searchVideo?.author)?.let { fragment.navigate(it); @@ -1035,7 +1050,7 @@ class VideoDetailView : ConstraintLayout { _slideUpOverlay?.hide(); } else null, - if(!isLimitedVersion && !(video?.isLive ?: false)) + if(!isLimitedVersion && !(video?.isLive ?: false) && !(video is LocalVideoDetails)) RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) { video?.let { _slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver); @@ -1058,15 +1073,16 @@ class VideoDetailView : ConstraintLayout { _slideUpOverlay?.hide(); } else null, - RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) { + if(!(video is LocalVideoDetails)) + RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) { video?.let { val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url; fragment.navigate(url); fragment.minimizeVideoDetail(); }; _slideUpOverlay?.hide(); - }, - if (StateSync.instance.hasAuthorizedDevice()) { + } else null, + if (StateSync.instance.hasAuthorizedDevice() && !(video is LocalVideoDetails)) { RoundButton(context, R.drawable.ic_device, context.getString(R.string.send_to_device), TAG_SEND_TO_DEVICE) { val devices = StateSync.instance.getAuthorizedSessions(); val videoToSend = video ?: return@RoundButton; @@ -1089,10 +1105,11 @@ class VideoDetailView : ConstraintLayout { }) } }} else null, - RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") { + if(!(video is LocalVideoDetails)) + RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") { reloadVideo(); _slideUpOverlay?.hide(); - }).filterNotNull(); + } else null).filterNotNull(); if(!_buttonPinStore.getAllValues().any()) _buttonPins.setButtons(*(buttons + listOf(_buttonMore)).toTypedArray()); else { @@ -1624,7 +1641,9 @@ class VideoDetailView : ConstraintLayout { _buttonSubscribe.setSubscribeChannel(video.author.url); setDescription(video.description.fixHtmlLinks()); - _creatorThumbnail.setThumbnail(video.author.thumbnail, false); + _creatorThumbnail.setThumbnail(video.author.thumbnail, false, + video is LocalVideoDetails + ); setPolycentricProfile(null, animate = false); _taskLoadPolycentricProfile.run(video.author.id); @@ -1652,7 +1671,7 @@ class VideoDetailView : ConstraintLayout { _rating.visibility = View.GONE; - if (StatePolycentric.instance.enabled) { + if (StatePolycentric.instance.enabled && !(video is LocalVideoDetails)) { fragment.lifecycleScope.launch(Dispatchers.IO) { try { val queryReferencesResponse = ApiMethods.getQueryReferences( @@ -1811,17 +1830,19 @@ class VideoDetailView : ConstraintLayout { _player.updateNextPrevious(); updateMoreButtons(); - if (videoDetail is TutorialFragment.TutorialVideo) { + if (videoDetail is TutorialFragment.TutorialVideo || videoDetail is LocalVideoDetails) { _buttonSubscribe.visibility = View.GONE - _buttonMore.visibility = View.GONE - _buttonPins.visibility = View.GONE + _buttonMore.visibility = if(videoDetail is LocalVideoDetails) View.VISIBLE else View.GONE; + _buttonPins.visibility = if(videoDetail is LocalVideoDetails) View.VISIBLE else View.GONE; _layoutRating.visibility = View.GONE + _rating.visibility = View.GONE; _layoutChangeBottomSection.visibility = View.GONE } else { _buttonSubscribe.visibility = View.VISIBLE _buttonMore.visibility = View.VISIBLE _buttonPins.visibility = View.VISIBLE _layoutRating.visibility = View.VISIBLE + _rating.visibility = View.VISIBLE; _layoutChangeBottomSection.visibility = View.VISIBLE } @@ -2685,7 +2706,11 @@ class VideoDetailView : ConstraintLayout { private fun fetchComments() { Logger.i(TAG, "fetchComments") video?.let { - _commentsList.load(true) { StatePlatform.instance.getComments(it); }; + if(video is LocalVideoDetails) { + _commentsList.clearComments(); + } + else + _commentsList.load(true) { StatePlatform.instance.getComments(it); }; } } private fun fetchPolycentricComments() { @@ -2972,6 +2997,7 @@ class VideoDetailView : ConstraintLayout { } onChannelClicked.subscribe { + Logger.i(TAG, "Opening channel url: ${it.url}"); if(it.url.isNotBlank()) { fragment.minimizeVideoDetail() fragment.navigate(it) diff --git a/app/src/main/java/com/futo/platformplayer/models/Telemetry.kt b/app/src/main/java/com/futo/platformplayer/models/Telemetry.kt index bf126caa..90da303e 100644 --- a/app/src/main/java/com/futo/platformplayer/models/Telemetry.kt +++ b/app/src/main/java/com/futo/platformplayer/models/Telemetry.kt @@ -12,5 +12,6 @@ data class Telemetry( val brand: String, val manufacturer: String, val model: String, - val sdkVersion: Int + val sdkVersion: Int, + val plugins: List? = null ) { } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt index b2d07e23..c3cf397a 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt @@ -7,6 +7,7 @@ import android.net.Uri import android.provider.MediaStore import android.provider.MediaStore.Audio.Artists import android.webkit.MimeTypeMap +import androidx.collection.emptyLongSet import androidx.core.database.getStringOrNull import androidx.core.net.toFile import androidx.core.net.toUri @@ -243,11 +244,12 @@ class StateLibrary { MediaStore.Audio.Media._ID, //0 MediaStore.Audio.Media.DISPLAY_NAME, //1 MediaStore.Audio.Media.ARTIST, //2 - MediaStore.Audio.Media.ALBUM_ID, //3 - MediaStore.Audio.Media.DURATION, //4 - MediaStore.Audio.Media.DATE_ADDED, //5 - MediaStore.Audio.Media.MIME_TYPE, //6 - MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //7 + MediaStore.Audio.Media.ARTIST_ID, //3 + MediaStore.Audio.Media.ALBUM_ID, //4 + MediaStore.Audio.Media.DURATION, //5 + MediaStore.Audio.Media.DATE_ADDED, //6 + MediaStore.Audio.Media.MIME_TYPE, //7 + MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //8 ); fun getDocumentTrack(url: String): IPlatformContentDetails? { @@ -359,11 +361,12 @@ class StateLibrary { val id = cursor.getString(0); val displayName = cursor.getString(1); val author = cursor.getString(2); - val albumId = cursor.getLong(3); - val duration = cursor.getLong(4).let { if(it > 0) it / 1000 else 0 }; - val date = cursor.getLong(5); - val contentType = cursor.getString(6); - val category = cursor.getString(7); + val authorId = cursor.getStringOrNull(3); + val albumId = cursor.getLong(4); + val duration = cursor.getLong(5).let { if(it > 0) it / 1000 else 0 }; + val date = cursor.getLong(6); + val contentType = cursor.getString(7); + val category = cursor.getString(8); val idLong = id.toLongOrNull(); val contentUrl = if(idLong != null ) @@ -371,6 +374,13 @@ class StateLibrary { else ""; + val authorIdLong = authorId?.toLongOrNull(); + val authorUrl = if(authorIdLong != null) + ContentUris.withAppendedId(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, authorIdLong).toString(); + else + ""; + + val albumContentUrl = if(albumId > 0) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString() else null; @@ -380,7 +390,10 @@ class StateLibrary { else null; val authorObj = if(!author.isNullOrBlank()) - PlatformAuthorLink(PlatformID.NONE, author, "", null, null) + PlatformAuthorLink( + if(authorId != null) PlatformID("LOCAL", authorId) else PlatformID.NONE, + author, + authorUrl, null, null) else PlatformAuthorLink.UNKNOWN; return LocalVideoDetails( diff --git a/app/src/main/java/com/futo/platformplayer/states/StateTelemetry.kt b/app/src/main/java/com/futo/platformplayer/states/StateTelemetry.kt index ac353c55..ade30537 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateTelemetry.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateTelemetry.kt @@ -39,7 +39,8 @@ class StateTelemetry { Build.BRAND, Build.MANUFACTURER, Build.MODEL, - Build.VERSION.SDK_INT + Build.VERSION.SDK_INT, + StatePlatform.instance.getEnabledClients().map { it.id }.toList() ); val headers = hashMapOf( diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ArtistTileViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ArtistTileViewHolder.kt index ab4f4664..70fb0b4a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ArtistTileViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/ArtistTileViewHolder.kt @@ -40,10 +40,10 @@ class ArtistTileViewHolder(val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder if (artist.thumbnail != null) Glide.with(it) .load(artist.thumbnail) - .placeholder(R.drawable.unknown_music) + .placeholder(R.drawable.ic_artist) .into(it) else - Glide.with(it).load(R.drawable.unknown_music).into(it); + Glide.with(it).load(R.drawable.ic_artist).into(it); }; _textName.text = artist.name; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/FileViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/FileViewHolder.kt index 04b94d0c..bb425af1 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/FileViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/FileViewHolder.kt @@ -42,11 +42,11 @@ class FileViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHold _file = file; _imageThumbnail?.let { if(file.isDirectory) - it.setImageResource(R.drawable.ic_library); + it.setImageResource(R.drawable.ic_folder); else { Glide.with(it) .load(file.thumbnail) - .placeholder(R.drawable.ic_music) + .placeholder(R.drawable.ic_song) .into(it) } }; diff --git a/app/src/main/java/com/futo/platformplayer/views/buttons/ButtonsContainer.kt b/app/src/main/java/com/futo/platformplayer/views/buttons/ButtonsContainer.kt new file mode 100644 index 00000000..8077306d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/buttons/ButtonsContainer.kt @@ -0,0 +1,47 @@ +package com.futo.platformplayer.views.buttons + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.collection.emptyLongSet +import com.futo.platformplayer.R +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.views.pills.PillButton + +class ButtonsContainer : LinearLayout { + + val container_buttons: LinearLayout + + var currentButtons: List