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