diff --git a/build.gradle b/build.gradle index 1aedde506..2900bd525 100644 --- a/build.gradle +++ b/build.gradle @@ -38,11 +38,13 @@ buildscript { versions.navigationFragment = '2.5.1' versions.firebaseBom = '26.1.0' versions.okhttp3LoggingInterceptor = '4.12.0' + // P2P embedded server (Netty + TLS). Keep all io.ktor:* on this version (3.x: Netty enableHttp2/enableH2c, HTTP/1.1 for TLS P2P). + versions.ktor = '3.3.0' versions.squareupTape = '1.2.3' versions.cacheword = '0.1.1' versions.glide = '4.15.0' versions.burgstallerOkhttpDigest = '3.1.1' - versions.javarosa = '2.6.0' + versions.javarosa = '4.4.0' versions.commonsIo = '2.6' versions.rxandroid = '2.1.1' versions.rxrelay = '2.1.1' @@ -92,6 +94,7 @@ buildscript { google() mavenCentral() maven { url 'https://jitpack.io' } // required for Nextcloud + } def isFdroid = project.hasProperty('fdroid') diff --git a/fastlane/metadata/android/en-US/changelogs/237.txt b/fastlane/metadata/android/en-US/changelogs/237.txt new file mode 100644 index 000000000..149c12ca9 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/237.txt @@ -0,0 +1,6 @@ +- Implemented Nearby Sharing: Nearby Sharing lets users securely share files in physical proximity. Nearby Sharing is fully offline, available across platforms and devices, and assures end-to-end encryption. https://tella-app.org/nearby-sharing/ +- Implemented security fixes detected by our most recent security audit (to be published soon!). +- New languages support: Ukrainian. +- Bug fixes and minor improvements. + +Full release notes: https://tella-app.org/releases diff --git a/fastlane/metadata/android/es/changelogs/237.txt b/fastlane/metadata/android/es/changelogs/237.txt new file mode 100644 index 000000000..05c0b1372 --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/237.txt @@ -0,0 +1,6 @@ +- Nueva funcionalidad Nearby Sharing: Permite compartir archivos de forma segura con personas que se encuentran físicamente cerca. Funciona sin conexión (offline), multi-plataforma (Android, iOS y Desktop) y garantiza cifrado de extremo a extremo: https://tella-app.org/nearby-sharing/ +- Correcciones de seguridad detectadas en nuestra última auditoría de seguridad. +- Nuevo idioma: Ucraniano. +- Corrección de bugs y pequeñas mejoras. + +Notas de la versión completas: https://tella-app.org/releases diff --git a/mobile/build.gradle b/mobile/build.gradle index cb3cc45a0..d5376f91a 100644 --- a/mobile/build.gradle +++ b/mobile/build.gradle @@ -1,6 +1,8 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + // Must match root build.gradle ext.versions.kotlin — Gradle plugin portal requires an explicit version here + id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.0' id 'kotlin-kapt' id 'com.google.dagger.hilt.android' } @@ -24,6 +26,10 @@ configurations.all { force "com.android.tools:desugar_jdk_libs:2.1.3" force "com.google.android.gms:play-services-base:$versions.playServicesBase" force "com.google.android.gms:play-services-basement:$versions.playServicesBase" + // Ktor (root versions.ktor) targets coroutines 1.7.x; 1.9+ drops internal APIs → NoSuchMethodError (LockFreeLinkedListHead.addLast) + force "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3" + force "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" + force "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:1.7.3" } } @@ -50,7 +56,10 @@ android { // Make sure SimpleXML configuration files are included pickFirsts += [ "META-INF/services/org.simpleframework.xml.*", - "META-INF/versions/9/OSGI-INF/MANIFEST.MF" + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + // Netty (transitive): identical metadata in every module JAR + "META-INF/io.netty.versions.properties", + "META-INF/INDEX.LIST", ] } } @@ -60,9 +69,8 @@ android { minSdk versions.minSdk targetSdk versions.targetSdk // F-Droid fastlane changelog: fastlane/metadata/android/en-US/changelogs/.txt - versionCode 233 - - versionName "2.20.2" + versionCode 237 + versionName "3.0.0" multiDexEnabled true ndk { abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64" } @@ -322,8 +330,8 @@ dependencies { playstoreImplementation "com.dropbox.core:dropbox-core-sdk:$versions.dropboxVersion" playstoreImplementation "com.dropbox.core:dropbox-android-sdk:$versions.dropboxVersion" implementation 'commons-httpclient:commons-httpclient:3.1' - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-rx2:1.10.2") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-rx2:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation 'commons-codec:commons-codec:1.15' @@ -337,9 +345,23 @@ dependencies { implementation "com.github.nextcloud-deps.hwsecurity:hwsecurity-fido:$versions.fidoVersion" implementation "com.github.nextcloud-deps.hwsecurity:hwsecurity-fido2:$versions.fidoVersion" + //QR CODE SCAN/GENERATE LIBRARY + implementation 'com.journeyapps:zxing-android-embedded:4.3.0' + + // ktor: Netty required for HTTPS (CIO engine has no TLS in 2.3.x). Keep io.netty in proguard-rules.pro. + implementation("io.ktor:ktor-serialization-kotlinx-json:${versions.ktor}") + implementation("io.ktor:ktor-server-core:${versions.ktor}") + implementation("io.ktor:ktor-server-netty:${versions.ktor}") + implementation("io.ktor:ktor-server-content-negotiation:${versions.ktor}") + implementation "io.ktor:ktor-server-call-logging:${versions.ktor}" + + + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3' // Use the latest version + + // Add exclusions to prevent other libraries from pulling in xmlpull // JavaRosa - allow it to use xpp3 - implementation ("org.opendatakit:opendatakit-javarosa:$versions.javarosa") { + implementation ("org.getodk:javarosa:$versions.javarosa") { exclude group: "xmlpull", module: "xmlpull" // NO xpp3 exclusion - JavaRosa needs it } diff --git a/mobile/proguard-rules.pro b/mobile/proguard-rules.pro index 77daf246e..6539053c0 100644 --- a/mobile/proguard-rules.pro +++ b/mobile/proguard-rules.pro @@ -5,6 +5,38 @@ -dontwarn org.kxml2.io.KXmlParser,org.kxml2.io.KXmlSerializer # ========== END MISSING CLASSES ========== +# ========== Netty/Ktor on Android - JVM-only classes not present on Android ========== +# R8 shrinks Netty channel classes; ReflectiveChannelFactory needs public no-arg ctors (NioServerSocketChannel) +-keep class io.netty.** { *; } +# Netty tcnative (OpenSSL native bindings - Android uses Conscrypt/JDK SSL) +-dontwarn io.netty.internal.tcnative.** +# java.lang.management (not on Android - used by Ktor debug detector) +-dontwarn java.lang.management.** +# Log4j2 (optional Netty logging backend) +-dontwarn org.apache.logging.log4j.** +# Jetty NPN (deprecated ALPN predecessor, not on Android) +-dontwarn org.eclipse.jetty.npn.** +# Reactor BlockHound (reactive blocking detection, not on Android) +-dontwarn reactor.blockhound.** +# Optional Netty codec / platform deps (referenced from netty-codec but not bundled; Ktor HTTPS does not need them) +-dontwarn com.aayushatharva.brotli4j.** +-dontwarn com.github.luben.zstd.** +-dontwarn com.jcraft.jzlib.** +-dontwarn com.ning.compress.** +-dontwarn com.oracle.svm.core.** +-dontwarn lzma.sdk.** +-dontwarn net.jpountz.** +-dontwarn org.jboss.marshalling.** +# OpenJDK-only cert APIs (Netty OpenJdkSelfSignedCertGenerator); Android uses Conscrypt / app-provided certs +-dontwarn sun.security.x509.** +# Netty 4.2+ optional pkitesting / CertificateBuilder (SelfSignedCertificate path, not on Android) +-dontwarn io.netty.pkitesting.** +# JFR (jdk.jfr.*) — not part of the Android Java API; optional Netty tracing hooks +-dontwarn jdk.jfr.** +# OSGi bundle metadata annotations (JCTools / shaded deps) — not used on Android +-dontwarn org.osgi.annotation.** +# ========== END Netty/Ktor ========== + # Keep the interfaces + concrete impls -keep class org.xmlpull.v1.XmlPullParser { *; } -keep class org.xmlpull.v1.XmlSerializer { *; } @@ -170,6 +202,11 @@ # Hilt -keep class * extends java.lang.annotation.Annotation { *; } -keep @javax.inject.Inject class * { *; } +# AndroidEntryPoint: keep generated Hilt_* bases (R8 minify — avoids Missing class Hilt_*) +-keep class org.horizontal.tella.mobile.**.Hilt_* { *; } +-keep interface org.horizontal.tella.mobile.**.*_GeneratedInjector { *; } +# Explicit: flavor-only fragments must not be stripped before Hilt aggregating task sees them +-keep class org.horizontal.tella.mobile.views.dialog.Hilt_CollectServerDialogFragment { *; } # Keep Gson adapters and creators -keep class * extends com.google.gson.TypeAdapter { *; } @@ -423,3 +460,7 @@ -keep class com.hzontal.tella_vault.Vault { *; } -keep class org.horizontal.tella.mobile.data.KeyRxVault { *; } # ========== END SIMPLE XML RULES ========== + + +-keep class org.bouncycastle.** { *; } +-dontwarn org.bouncycastle.** \ No newline at end of file diff --git a/mobile/src/fdroid/java/org/horizontal/tella/mobile/views/activity/camera/CameraActivity.kt b/mobile/src/fdroid/java/org/horizontal/tella/mobile/views/activity/camera/CameraActivity.kt index 94a7b1161..680bed6f5 100644 --- a/mobile/src/fdroid/java/org/horizontal/tella/mobile/views/activity/camera/CameraActivity.kt +++ b/mobile/src/fdroid/java/org/horizontal/tella/mobile/views/activity/camera/CameraActivity.kt @@ -8,11 +8,13 @@ import android.content.Intent import android.content.pm.PackageManager import android.hardware.SensorManager import android.media.AudioManager +import android.os.Build import android.os.Bundle import android.util.DisplayMetrics import android.view.OrientationEventListener import android.view.Surface import android.view.View +import android.view.WindowManager import android.widget.ImageView import android.widget.SeekBar import android.widget.TextView @@ -129,23 +131,32 @@ class CameraActivity : MetadataActivity(), IMetadataAttachPresenterContract.IVie override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + window.attributes = window.attributes.apply { + layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + } binding = ActivityCameraBinding.inflate(layoutInflater) setContentView(binding.root) // SurfaceView (default performance mode) can paint above sibling views on API 31+ / some devices. binding.viewFinder.implementationMode = PreviewView.ImplementationMode.COMPATIBLE - ViewCompat.setOnApplyWindowInsetsListener(binding.topBar) { view, insets -> - val statusBars = insets.getInsets(WindowInsetsCompat.Type.statusBars()) - val cutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) - // Never fall back to ViewUtil.getStatusBarHeight() here: with enableEdgeToEdge(), the system - // often reports statusBars.top == 0 while the fallback still adds a full status-bar inset, - // which shows as an empty black strip above the toolbar (Pixel / Android 12+). - val top = maxOf(statusBars.top, cutout.top) - view.updatePadding(top = top) - insets - } ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets -> + val statusBars = insets.getInsets(WindowInsetsCompat.Type.statusBars()) val navBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) - view.updatePadding(bottom = navBars.bottom) + val cutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) + val mandatory = insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()) + var top = maxOf(statusBars.top, cutout.top, mandatory.top) + if (top == 0) { + val ignoring = insets.getInsetsIgnoringVisibility( + WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.displayCutout() + ) + top = maxOf(top, ignoring.top) + } + val bottom = maxOf(navBars.bottom, mandatory.bottom) + val left = maxOf(statusBars.left, navBars.left, cutout.left, mandatory.left) + val right = maxOf(statusBars.right, navBars.right, cutout.right, mandatory.right) + view.setPadding(left, top, right, bottom) insets } ViewCompat.requestApplyInsets(binding.root) diff --git a/mobile/src/fdroid/java/org/horizontal/tella/mobile/views/dialog/CollectServerDialogFragment.kt b/mobile/src/fdroid/java/org/horizontal/tella/mobile/views/dialog/CollectServerDialogFragment.kt index 418663162..72fb03793 100644 --- a/mobile/src/fdroid/java/org/horizontal/tella/mobile/views/dialog/CollectServerDialogFragment.kt +++ b/mobile/src/fdroid/java/org/horizontal/tella/mobile/views/dialog/CollectServerDialogFragment.kt @@ -2,7 +2,6 @@ package org.horizontal.tella.mobile.views.dialog import android.annotation.SuppressLint import android.app.Dialog -import android.content.Context import android.content.DialogInterface import android.os.Bundle import android.text.TextUtils @@ -57,8 +56,6 @@ class CollectServerDialogFragment : AppCompatDialogFragment() { lateinit var advancedToggle: PanelToggleButton lateinit var advancedPanel: ViewGroup - private var context: Context? = null - private var validated = true interface CollectServerDialogHandler { @@ -98,7 +95,6 @@ class CollectServerDialogFragment : AppCompatDialogFragment() { val server = arguments?.getSerializable(OBJECT_KEY) as? CollectServer binding = DialogCollectServerBinding.inflate(inflater, container, false) - context = requireContext() initView() observeLiveData() diff --git a/mobile/src/fdroid/java/org/horizontal/tella/mobile/views/dialog/TellaUploadServerDialogFragment.kt b/mobile/src/fdroid/java/org/horizontal/tella/mobile/views/dialog/TellaUploadServerDialogFragment.kt index d7617a7d4..04de77ed8 100644 --- a/mobile/src/fdroid/java/org/horizontal/tella/mobile/views/dialog/TellaUploadServerDialogFragment.kt +++ b/mobile/src/fdroid/java/org/horizontal/tella/mobile/views/dialog/TellaUploadServerDialogFragment.kt @@ -33,13 +33,6 @@ class TellaUploadServerDialogFragment : AppCompatDialogFragment(), ICheckTUSServ private var presenter: CheckTUSServerPresenter? = null private var serverId: Long = 0 - - interface TellaUploadServerDialogHandler { - fun onTellaUploadServerDialogCreate(server: TellaReportServer?) - fun onTellaUploadServerDialogUpdate(server: TellaReportServer?) - fun onDialogDismiss() - } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index 8d4662785..4c7640d4c 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ + android:targetSdkVersion="36" /> + + + + + + + + + + + tools:replace="android:allowBackup,android:usesCleartextTraffic"> + + diff --git a/mobile/src/main/assets/testDemo.txt b/mobile/src/main/assets/testDemo.txt new file mode 100644 index 000000000..f6983e619 --- /dev/null +++ b/mobile/src/main/assets/testDemo.txt @@ -0,0 +1 @@ + This is a test yo \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/MyApplication.java b/mobile/src/main/java/org/horizontal/tella/mobile/MyApplication.java index 7a0bd3f4e..b100219f7 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/MyApplication.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/MyApplication.java @@ -19,6 +19,7 @@ import androidx.appcompat.app.AppCompatDelegate; import androidx.hilt.work.HiltWorkerFactory; import androidx.lifecycle.ProcessLifecycleOwner; +import androidx.multidex.MultiDex; import androidx.multidex.MultiDexApplication; import androidx.work.Configuration; @@ -33,9 +34,9 @@ import com.hzontal.tella_locking_ui.ui.pin.PinUnlockActivity; import com.hzontal.tella_vault.Vault; import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.lib.resources.status.OwnCloudVersion; import org.horizontal.tella.mobile.data.KeyRxVault; +import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.hzontal.shared_ui.data.CommonPrefs; import org.conscrypt.Conscrypt; @@ -51,7 +52,6 @@ import org.hzontal.tella.keys.wrapper.UnencryptedKeyWrapper; import java.io.File; -import java.lang.ref.WeakReference; import java.security.NoSuchAlgorithmException; import java.security.Security; import java.util.Arrays; @@ -103,7 +103,6 @@ public class MyApplication extends MultiDexApplication implements IUnlockRegistr Vault.Config vaultConfig; private static final String TAG = MyApplication.class.getSimpleName(); public static final String DOT = "."; - private static WeakReference appContext; private long startTime; private long totalTimeSpent = 0; // Store total time spent in the app private int activityReferences = 0; @@ -169,6 +168,7 @@ public static void resetKeys() { @Override protected void attachBaseContext(Context newBase) { + MultiDex.install(this); CommonPrefs.getInstance().init(newBase); SharedPrefs.getInstance().init(newBase); super.attachBaseContext(LocaleManager.getInstance().getLocalizedContext(newBase)); @@ -187,6 +187,11 @@ public void onCreate() { /// .detectAll().penaltyLog()/*.penaltyDeath()*/.build()); // todo: catch those.. Timber.plant(new Timber.DebugTree()); apiBuilder.setLogLevelFull(); + // Verbose TLS tracing: debug builds only (CVE-2025-TELLA3-003). + System.setProperty("javax.net.debug", "ssl,handshake"); + } else { + // Ensure JVM TLS debug is not left on from tooling or inherited state. + System.clearProperty("javax.net.debug"); } // todo: implement dagger2 CommonPrefs.getInstance().init(this); @@ -196,9 +201,6 @@ public void onCreate() { registerActivityLifecycleCallbacks(this); - System.setProperty("javax.net.debug", "ssl,handshake"); - - RxJavaPlugins.setErrorHandler(new Consumer() { @Override public void accept(Throwable throwable) { @@ -232,6 +234,7 @@ public void accept(Throwable throwable) { TellaKeysUI.initialize(mainKeyStore, mainKeyHolder, unlockRegistry, this, Preferences.getFailedUnlockOption(), Preferences.getUnlockRemainingAttempts(), Preferences.isShowUnlockRemainingAttempts()); insertConscrypt(); enableStrictMode(); + setupBouncyCastleProvider(); } @RequiresApi(api = Build.VERSION_CODES.M) @@ -350,7 +353,12 @@ public UnlockRegistry getUnlockRegistry() { @NonNull @Override public Configuration getWorkManagerConfiguration() { - return new Configuration.Builder().setMinimumLoggingLevel(android.util.Log.DEBUG).setWorkerFactory(workerFactory).build(); + // CVE-2025-TELLA3-003: avoid verbose WorkManager logs in production (was Log.DEBUG for all builds). + int minLogLevel = BuildConfig.DEBUG ? android.util.Log.DEBUG : android.util.Log.ERROR; + return new Configuration.Builder() + .setMinimumLoggingLevel(minLogLevel) + .setWorkerFactory(workerFactory) + .build(); } @Override @@ -404,15 +412,17 @@ private void insertConscrypt() { try { Conscrypt.Version version = Conscrypt.version(); - Log_OC.i(TAG, "Using Conscrypt/" - + version.major() - + DOT - + version.minor() - + DOT + version.patch() - + " for TLS"); - SSLEngine engine = SSLContext.getDefault().createSSLEngine(); - Log_OC.i(TAG, "Enabled protocols: " + Arrays.toString(engine.getEnabledProtocols()) + " }"); - Log_OC.i(TAG, "Enabled ciphers: " + Arrays.toString(engine.getEnabledCipherSuites()) + " }"); + if (BuildConfig.DEBUG) { + Log_OC.i(TAG, "Using Conscrypt/" + + version.major() + + DOT + + version.minor() + + DOT + version.patch() + + " for TLS"); + SSLEngine engine = SSLContext.getDefault().createSSLEngine(); + Log_OC.i(TAG, "Enabled protocols: " + Arrays.toString(engine.getEnabledProtocols()) + " }"); + Log_OC.i(TAG, "Enabled ciphers: " + Arrays.toString(engine.getEnabledCipherSuites()) + " }"); + } } catch (NoSuchAlgorithmException e) { Log_OC.e(TAG, e.getMessage()); } @@ -437,4 +447,15 @@ private void enableStrictMode() { } + private void setupBouncyCastleProvider() { + try { + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + Timber.i("BouncyCastle provider registered"); + } + } catch (Exception e) { + Timber.e(e, "Failed to register BouncyCastle provider"); + } + } + } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/certificate/CertificateGenerator.kt b/mobile/src/main/java/org/horizontal/tella/mobile/certificate/CertificateGenerator.kt new file mode 100644 index 000000000..0f8dc74ca --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/certificate/CertificateGenerator.kt @@ -0,0 +1,69 @@ +package org.horizontal.tella.mobile.certificate + +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Security +import java.security.cert.X509Certificate +import java.util.Date + +object CertificateGenerator { + + init { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(BouncyCastleProvider()) + } + } + + fun generateCertificate( + commonName: String = "Tella Android", + organization: String = "Tella", + validityDays: Int = 7, + ipAddress: String + ): Pair { + require(ipAddress.isNotBlank()) { "IP address must not be empty when generating a certificate." } + + val keyGen = KeyPairGenerator.getInstance("RSA") + keyGen.initialize(2048) + val keyPair = keyGen.generateKeyPair() + + val now = Date() + val validTo = Date(now.time + validityDays * 86400000L) + + val issuer = X500Name("CN=$commonName,O=$organization") + val subject = issuer + + val serialNumber = CertificateUtils.generateSerialNumber() + + val certBuilder = JcaX509v3CertificateBuilder( + issuer, serialNumber, now, validTo, subject, keyPair.public + ) + + val san = org.bouncycastle.asn1.x509.GeneralNames( + org.bouncycastle.asn1.x509.GeneralName( + org.bouncycastle.asn1.x509.GeneralName.iPAddress, ipAddress + ) + ) + certBuilder.addExtension( + org.bouncycastle.asn1.x509.Extension.subjectAlternativeName, + false, + san + ) + + val signer = JcaContentSignerBuilder("SHA256withRSA") + .build(keyPair.private) + + val holder = certBuilder.build(signer) + + val certificate = JcaX509CertificateConverter() + .getCertificate(holder) + + return Pair(keyPair, certificate) + } + + +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/certificate/CertificateUtils.kt b/mobile/src/main/java/org/horizontal/tella/mobile/certificate/CertificateUtils.kt new file mode 100644 index 000000000..cc07a7c92 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/certificate/CertificateUtils.kt @@ -0,0 +1,150 @@ +package org.horizontal.tella.mobile.certificate + +import android.util.Base64 +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.GeneralName +import org.bouncycastle.asn1.x509.GeneralNames +import org.bouncycastle.asn1.x509.KeyUsage +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import java.math.BigInteger +import java.security.KeyPair +import java.security.SecureRandom +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import java.security.KeyStore +import java.util.Date +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +object CertificateUtils { + + + fun generateKeyPair(): KeyPair { + val keyPairGenerator = java.security.KeyPairGenerator.getInstance("RSA") + keyPairGenerator.initialize(2048) + return keyPairGenerator.generateKeyPair() + } + + + fun generateSelfSignedCertificate(keyPair: KeyPair, ipAddresses: List): X509Certificate { + val uniqueIps = ipAddresses.map { it.trim() }.filter { it.isNotEmpty() }.distinct() + require(uniqueIps.isNotEmpty()) { "At least one IP is required for the P2P certificate SAN." } + + val now = Date() + val until = Date(now.time + 365L * 24 * 60 * 60 * 1000) // Valid for 1 year + + val serial = BigInteger(128, SecureRandom()) + val dn = X500Name("CN=Tella P2P, O=Tella, C=US") + + val certBuilder = JcaX509v3CertificateBuilder( + dn, serial, now, until, dn, keyPair.public + ) + + val generalNames = uniqueIps.map { GeneralName(GeneralName.iPAddress, it) }.toTypedArray() + val san = GeneralNames(generalNames) + + certBuilder.addExtension( + Extension.subjectAlternativeName, false, san + ) + certBuilder.addExtension( + Extension.basicConstraints, true, org.bouncycastle.asn1.x509.BasicConstraints(false) + ) + certBuilder.addExtension( + Extension.keyUsage, true, KeyUsage(KeyUsage.digitalSignature or KeyUsage.keyEncipherment) + ) + + val signer = JcaContentSignerBuilder("SHA256WithRSAEncryption").build(keyPair.private) + + val certHolder = certBuilder.build(signer) + return JcaX509CertificateConverter().setProvider(org.bouncycastle.jce.provider.BouncyCastleProvider()) + .getCertificate(certHolder) + } + + + fun generateSerialNumber(): BigInteger { + return BigInteger(128, SecureRandom()) + } + + fun certificateToPem(certificate: X509Certificate): String { + val encoded = Base64.encodeToString(certificate.encoded, Base64.NO_WRAP) + return "-----BEGIN CERTIFICATE-----\n$encoded\n-----END CERTIFICATE-----" + } + + /** + * SHA-256 of the leaf certificate DER ([X509Certificate.encoded]), lowercase hex. + * Same value as [org.horizontal.tella.mobile.data.peertopeer.FingerprintResult.certHex]. + */ + fun getLeafCertificateDerSha256Hex(certificate: X509Certificate): String { + val digest = java.security.MessageDigest.getInstance("SHA-256") + val hash = digest.digest(certificate.encoded) + return hash.joinToString("") { "%02x".format(it) } + } + + /** + * Trust manager that accepts only servers whose leaf certificate DER hash matches [expectedLeafCertSha256Hex]. + * This allows secure pinning for self-signed peers without trusting arbitrary certificates. + */ + fun getLeafCertPinnedTrustManager(expectedLeafCertSha256Hex: String): X509TrustManager { + val expected = normalizeHex(expectedLeafCertSha256Hex) + return object : X509TrustManager { + override fun getAcceptedIssuers(): Array = emptyArray() + override fun checkClientTrusted(chain: Array?, authType: String?) = Unit + override fun checkServerTrusted(chain: Array?, authType: String?) { + val leaf = chain?.firstOrNull() + ?: throw CertificateException("Empty certificate chain") + leaf.checkValidity() + val actual = normalizeHex(getLeafCertificateDerSha256Hex(leaf)) + if (actual != expected) { + throw CertificateException("Leaf certificate hash mismatch") + } + } + } + } + + /** + * Bootstrap trust manager for first-contact fingerprint collection: + * it requires a present, currently-valid X.509 leaf cert, but does not require CA-chain trust. + */ + fun getFingerprintCollectionTrustManager(): X509TrustManager = + object : X509TrustManager { + override fun getAcceptedIssuers(): Array = emptyArray() + override fun checkClientTrusted(chain: Array?, authType: String?) = Unit + override fun checkServerTrusted(chain: Array?, authType: String?) { + val leaf = chain?.firstOrNull() + ?: throw CertificateException("Empty certificate chain") + leaf.checkValidity() + } + } + + /** + * Returns the system default X509TrustManager (validates against device CA store). + * Use this for all TLS connections instead of trust-all to satisfy CVE-2025-TELLA3-001. + */ + fun getDefaultTrustManager(): X509TrustManager { + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(null as KeyStore?) + val trustManagers = tmf.trustManagers + require(trustManagers.size == 1 && trustManagers[0] is X509TrustManager) { + "Unexpected default trust managers: ${trustManagers.contentToString()}" + } + return trustManagers[0] as X509TrustManager + } + + /** + * SSLContext for first-contact fingerprint collection against self-signed peers. + */ + fun getFingerprintCollectionSSLContext(): SSLContext { + val tm = getFingerprintCollectionTrustManager() + return SSLContext.getInstance("TLS").apply { + init(null, arrayOf(tm), SecureRandom()) + } + } + + private fun normalizeHex(hexLike: String): String = + hexLike.trim().replace(":", "").replace("\\s".toRegex(), "").lowercase() +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/database/DataSource.java b/mobile/src/main/java/org/horizontal/tella/mobile/data/database/DataSource.java index efec3973a..5ada61258 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/data/database/DataSource.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/database/DataSource.java @@ -7,7 +7,6 @@ import android.content.Context; import android.database.Cursor; import android.text.TextUtils; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -1045,7 +1044,12 @@ private FormDef getCollectFormDef(CollectForm form) { if (cursor.moveToFirst()) { // todo: check if byte[] is empty and return null - return deserializeFormDef(cursor.getBlob(cursor.getColumnIndexOrThrow(D.C_FORM_DEF))); + byte[] data = cursor.getBlob(cursor.getColumnIndexOrThrow(D.C_FORM_DEF)); + if(data.length == 0) { + Timber.e("Empty"); + + } + return deserializeFormDef(data); } } catch (Exception e) { Timber.e(e, getClass().getName()); diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/entity/mapper/OpenRosaDataMapper.java b/mobile/src/main/java/org/horizontal/tella/mobile/data/entity/mapper/OpenRosaDataMapper.java index aaa77f205..b851776be 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/data/entity/mapper/OpenRosaDataMapper.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/entity/mapper/OpenRosaDataMapper.java @@ -3,6 +3,7 @@ import android.net.Uri; import org.javarosa.core.model.FormDef; +import org.javarosa.xform.parse.XFormParser; import org.javarosa.xform.util.XFormUtils; import java.util.ArrayList; @@ -10,6 +11,8 @@ import okhttp3.ResponseBody; import retrofit2.Response; +import timber.log.Timber; + import org.horizontal.tella.mobile.data.entity.OpenRosaResponseEntity; import org.horizontal.tella.mobile.data.entity.XFormEntity; import org.horizontal.tella.mobile.data.http.HttpStatus; @@ -74,7 +77,12 @@ public FormDef transform(ResponseBody responseBody) { try { return XFormUtils.getFormFromInputStream(responseBody.byteStream()); - } finally { + } + catch(XFormParser.ParseException e) { + Timber.e(e, getClass().getSimpleName()); + return null; + } + finally { responseBody.close(); } } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/FingerprintFetcher.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/FingerprintFetcher.kt new file mode 100644 index 000000000..4e53eb365 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/FingerprintFetcher.kt @@ -0,0 +1,299 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.horizontal.tella.mobile.certificate.CertificateUtils +import okhttp3.CertificatePinner +import okhttp3.Dns +import okhttp3.OkHttpClient +import timber.log.Timber +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Socket +import java.net.SocketTimeoutException +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.X509TrustManager +import kotlin.coroutines.resume + +/** + * Fingerprint payloads: + * - spkiHex : SHA-256(SPKI) in lowercase hex (optional, useful for logs) + * - okHttpPin: OkHttp SPKI pin string "sha256/" (optional, if you want SPKI pinning) + * - certHex : SHA-256(leaf certificate DER) in lowercase hex + */ +data class FingerprintResult( + val spkiHex: String, + val okHttpPin: String, + val certHex: String +) + +object FingerprintFetcher { + + // --------------------------------------------------------------------- + // Public: PRE-PIN handshake to read cert (no HTTP), then compute hashes + // --------------------------------------------------------------------- + suspend fun fetch(context: Context, ip: String, port: Int): Result = + withContext(Dispatchers.IO) { + try { + val wifi = getWifiNetworkPreferringValidated(context) + + // 1) Quick TCP probe (fail fast if nothing listening) + probeTcp(ip, port, wifi) + + // 2) TLS handshake (system CA validation) to read leaf cert + createBoundTlsSocket(ip, port, wifi).use { tls -> + val cert = tls.session.peerCertificates.first() as X509Certificate + return@withContext Result.success(fingerprintFromCert(cert)) + } + } catch (e: SSLHandshakeException) { + val msg = e.message.orEmpty() + return@withContext if ( + msg.contains("unexpected_message", true) || + msg.contains("unrecognized_name", true) || + msg.contains("EOF", true) || + msg.contains("received fatal alert", true) + ) { + Result.failure( + IllegalStateException("Server on $port appears to be plain HTTP or misconfigured TLS. ${e.message}") + ) + } else { + Result.failure(e) + } + } catch (e: SocketTimeoutException) { + Result.failure(RuntimeException("Connection timed out to $ip:$port", e)) + } catch (e: IOException) { + Result.failure(IOException("I/O error to $ip:$port: ${e.message}", e)) + } catch (e: Throwable) { + Result.failure(e) + } + } + + // --------------------------------------------------------------------- + // Public: Build clients that ENFORCE identity + // A) By certificate DER hash -> TrustManager-based pinning + // B) By SPKI pin (OkHttp native) -> Optional + // --------------------------------------------------------------------- + + fun buildClientPinnedByCertHash( + expectedCertSha256Hex: String, + hostForRequests: String, + network: Network? = null + ): OkHttpClient { + val trustManager = CertificateUtils.getLeafCertPinnedTrustManager(expectedCertSha256Hex) + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustManager), SecureRandom()) + } + + val builder = OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .connectTimeout(7, TimeUnit.SECONDS) + .readTimeout(7, TimeUnit.SECONDS) + .writeTimeout(7, TimeUnit.SECONDS) + + if (network != null) { + builder.socketFactory(network.socketFactory) + builder.dns(Dns { hostname -> network.getAllByName(hostname).toList() }) + } + return builder.build() + } + + suspend fun buildClientPinnedByCertHash( + context: Context, + expectedCertSha256Hex: String, + hostForRequests: String + ): OkHttpClient { + val wifi = getWifiNetworkPreferringValidated(context) + return buildClientPinnedByCertHash(expectedCertSha256Hex, hostForRequests, wifi) + } + + // Optional: SPKI pinning (OkHttp-native). This path still uses system CA trust. + // Use this for CA-trusted peers, not for the self-signed bootstrap/pinning flow. + fun buildPinnedClientWithOkPin( + okHttpPin: String, // must be "sha256/" + hostForPin: String, // same host used in the URL (IP or DNS) + network: Network? = null + ): OkHttpClient { + require(okHttpPin.startsWith("sha256/")) { "Pin must start with 'sha256/'" } + + val pinner = CertificatePinner.Builder() + .add(hostForPin, okHttpPin) + .build() + + val trustManager = CertificateUtils.getDefaultTrustManager() + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustManager), SecureRandom()) + } + + val builder = OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .certificatePinner(pinner) + .connectTimeout(7, TimeUnit.SECONDS) + .readTimeout(7, TimeUnit.SECONDS) + .writeTimeout(7, TimeUnit.SECONDS) + + if (network != null) { + builder.socketFactory(network.socketFactory) + builder.dns(Dns { hostname -> network.getAllByName(hostname).toList() }) + } + return builder.build() + } + + suspend fun buildPinnedClientWithOkPin( + context: Context, + okHttpPin: String, + hostForPin: String + ): OkHttpClient { + val wifi = getWifiNetworkPreferringValidated(context) + return buildPinnedClientWithOkPin(okHttpPin, hostForPin, wifi) + } + + // --------------------------------------------------------------------- + // Public helpers + // --------------------------------------------------------------------- + + suspend fun pickWifiNetwork(context: Context): Network? = + getWifiNetworkPreferringValidated(context) + + fun fingerprintFromCert(cert: X509Certificate): FingerprintResult { + val okHttpPin = CertificatePinner.pin(cert) // "sha256/" of SPKI + val spkiHex = sha256Hex(cert.publicKey.encoded) // SPKI hex (optional) + val certHex = sha256Hex(cert.encoded) // CERT DER hex + return FingerprintResult(spkiHex = spkiHex, okHttpPin = okHttpPin, certHex = certHex) + } + + // --------------------------------------------------------------------- + // Internals used by fetch() + // --------------------------------------------------------------------- + + private fun createBoundTlsSocket(ip: String, port: Int, wifi: Network?): SSLSocket { + val sslContext = CertificateUtils.getFingerprintCollectionSSLContext() + val factory = sslContext.socketFactory as SSLSocketFactory + + val s = factory.createSocket() as SSLSocket + s.soTimeout = 7000 + s.enabledProtocols = arrayOf("TLSv1.3", "TLSv1.2") + + try { + wifi?.bindSocket(s) + } catch (bindErr: Exception) { + Timber.w(bindErr, "bindSocket failed, continuing on default route") + } + + s.connect(InetSocketAddress(ip, port), 7000) + s.startHandshake() + return s + } + + private fun probeTcp(ip: String, port: Int, wifi: Network?) { + val sock = Socket() + try { wifi?.bindSocket(sock) } catch (_: Exception) {} + try { sock.connect(InetSocketAddress(ip, port), 2500) } finally { + try { sock.close() } catch (_: Exception) {} + } + } + + @Suppress("DEPRECATION") + private suspend fun getWifiNetworkPreferringValidated(ctx: Context): Network? = + withContext(Dispatchers.IO) { + val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + // Fast path: active VALIDATED Wi-Fi + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + cm.activeNetwork?.let { n -> + cm.getNetworkCapabilities(n)?.let { c -> + if (c.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + c.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + c.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + ) return@withContext n + } + } + } else { + cm.allNetworks.firstOrNull { n -> + val info = cm.getNetworkInfo(n) + val caps = cm.getNetworkCapabilities(n) + info?.isConnected == true && + caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + }?.let { return@withContext it } + } + + // Request a validated Wi-Fi (time-bound) + val request = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + val validated: Network? = try { + withTimeout(4_000) { + suspendCancellableCoroutine { cont -> + val cb = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + if (cont.isActive) { + cm.unregisterNetworkCallback(this) + cont.resume(network) + } + } + } + override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + ) { + if (cont.isActive) { + cm.unregisterNetworkCallback(this) + cont.resume(network) + } + } + } + override fun onUnavailable() { + if (cont.isActive) { + cm.unregisterNetworkCallback(this) + cont.resume(null) + } + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + cm.requestNetwork(request, cb, 4_000) + } else { + cm.requestNetwork(request, cb) + } + cont.invokeOnCancellation { runCatching { cm.unregisterNetworkCallback(cb) } } + } + } + } catch (_: Exception) { null } + + if (validated != null) return@withContext validated + + // Last resort: any connected Wi-Fi with INTERNET + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + cm.allNetworks.firstOrNull { n -> + cm.getNetworkCapabilities(n)?.let { c -> + c.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + c.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } == true + } + } else null + } + + private fun sha256Hex(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes) + .joinToString("") { "%02x".format(it) } + +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/P2PNetworkAddressPolicy.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/P2PNetworkAddressPolicy.kt new file mode 100644 index 000000000..f513bca66 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/P2PNetworkAddressPolicy.kt @@ -0,0 +1,82 @@ +package org.horizontal.tella.mobile.data.peertopeer + +/** + * Chooses a reachable same-LAN (RFC1918) address for Nearby Sharing discovery/QR, not the TLS bind address. + * + * Excludes loopback, link-local, CGNAT 100.64/10, multicast, and benchmark (198.18/15) ranges. + * Does not treat "public" global IPv4 as a LAN address for P2P advertisement. + */ +object P2PNetworkAddressPolicy { + + const val INADDR_ANY_IPV4: String = "0.0.0.0" + + /** + * IPv4 [a,b,c,d] for classification; [null] if not a valid IPv4 literal. + */ + private fun parseIpv4Octets(s: String): IntArray? { + val t = s.trim() + if (t.isEmpty()) return null + val parts = t.split('.') + if (parts.size != 4) return null + val out = IntArray(4) + for (i in 0..3) { + val p = parts[i] + if (p.isEmpty() || p.length > 3) return null + val n = p.toIntOrNull() ?: return null + if (n !in 0..255) return null + out[i] = n + } + return out + } + + private fun isExcludedUnusableLanIp(o: IntArray): Boolean { + val a = o[0] + val b = o[1] + val c = o[2] + val d = o[3] + if (a == 0 && b == 0 && c == 0 && d == 0) return true + if (a == 127) return true + if (a == 169 && b == 254) return true + if (a in 224..239) return true + if (a == 100 && b in 64..127) return true + if (a == 198 && b in 18..19) return true + return false + } + + /** Lower [tier] = higher preference. Null = not used for P2P advertisement. */ + private fun rfc1918PreferenceTier(o: IntArray): Int? { + if (isExcludedUnusableLanIp(o)) return null + val a = o[0] + val b = o[1] + when { + a == 192 && b == 168 -> return 0 + a == 10 -> return 1 + a == 172 && b in 16..31 -> return 2 + else -> return null + } + } + + fun isEligibleForAdvertise(ip: String): Boolean { + val octets = parseIpv4Octets(ip) ?: return false + return rfc1918PreferenceTier(octets) != null + } + + /** + * Distinct, filtered, ordered: 192.168/16, then 10/8, then 172.16-31, stable tie-breaker by string. + */ + fun filterAndOrderForAdvertise(ips: List): List { + if (ips.isEmpty()) return emptyList() + val withTier = ArrayList>() + val seen = HashSet() + for (raw in ips) { + val t = raw.trim() + if (t.isEmpty()) continue + if (!seen.add(t)) continue + val o = parseIpv4Octets(t) ?: continue + val tier = rfc1918PreferenceTier(o) ?: continue + withTier.add(t to tier) + } + withTier.sortWith(compareBy> { it.second }.thenBy { it.first }) + return withTier.map { it.first } + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerFileHash.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerFileHash.kt new file mode 100644 index 000000000..36f58b5ae --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerFileHash.kt @@ -0,0 +1,38 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.security.MessageDigest + +object PeerFileHash { + + fun sha256Hex(inputStream: InputStream): String { + val md = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(8192) + while (true) { + val read = inputStream.read(buffer) + if (read == -1) break + md.update(buffer, 0, read) + } + return md.digest().joinToString("") { b -> "%02x".format(b) } + } + + /** Lowercase hex SHA-256 and total bytes read (must match what we send over the wire). */ + fun sha256HexAndLength(inputStream: InputStream): Pair { + val md = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(8192) + var total = 0L + while (true) { + val read = inputStream.read(buffer) + if (read == -1) break + md.update(buffer, 0, read) + total += read + } + val hex = md.digest().joinToString("") { b -> "%02x".format(b) } + return hex to total + } + + fun sha256Hex(file: File): String = + FileInputStream(file).use { sha256Hex(it) } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerKeyProvider.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerKeyProvider.kt new file mode 100644 index 000000000..450fd883c --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerKeyProvider.kt @@ -0,0 +1,38 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import org.horizontal.tella.mobile.certificate.CertificateUtils +import java.security.KeyPair +import java.security.cert.X509Certificate + +object PeerKeyProvider { + private var keyPair: KeyPair? = null + private var certificate: X509Certificate? = null + private var certificateKey: String? = null + + fun getKeyPair(): KeyPair { + if (keyPair == null) { + keyPair = CertificateUtils.generateKeyPair() + } + return keyPair!! + } + + fun getCertificate(ipAddress: String): X509Certificate = + getCertificate(listOf(ipAddress)) + + fun getCertificate(ipAddresses: List): X509Certificate { + val key = ipAddresses.map { it.trim() }.filter { it.isNotEmpty() }.distinct().sorted().joinToString(",") + require(key.isNotEmpty()) + if (certificate == null || certificateKey != key) { + certificate = CertificateUtils.generateSelfSignedCertificate(getKeyPair(), ipAddresses) + certificateKey = key + } + return certificate!! + } + + fun reset() { + keyPair = null + certificate = null + certificateKey = null + } +} + diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerRateLimitCallSupport.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerRateLimitCallSupport.kt new file mode 100644 index 000000000..71e3483a5 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerRateLimitCallSupport.kt @@ -0,0 +1,16 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.path + + +fun ApplicationCall.peerClientIpForRateLimit(): String { + val host = request.local.remoteHost + if (host == "127.0.0.1") { + request.headers["X-Real-Ip"]?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } + } + return host +} + +/** Request path (no query) for per-route rate limiting, e.g. `/api/v1/upload`. */ +fun ApplicationCall.peerRoutePathForRateLimit(): String = request.path() diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerServerRateLimitConfig.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerServerRateLimitConfig.kt new file mode 100644 index 000000000..636b2969e --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerServerRateLimitConfig.kt @@ -0,0 +1,39 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import java.util.concurrent.TimeUnit + +/** Matches Tella Desktop `TimedRateLimiter.SetBurstAllowance(1000)`. */ +const val PEER_RATE_LIMIT_DESKTOP_BURST = 1000 + +/** + * Defaults align with Desktop cerca `TimedRateLimiter`-style limits (burst 1000, 30s refill, 24h idle). + * With [useFullUriAsKey] false, behavior is IP-only (cerca `SetLimitAllRoutes(true)`). With true, buckets + * are per client IP and request path (no query string). + */ +data class PeerServerRateLimitConfig( + /** Nanoseconds between adding one token (Desktop: 30 seconds). */ + val refreshIntervalNanos: Long = TimeUnit.SECONDS.toNanos(30), + /** Drop limiter state for this key after this idle period (Desktop: 24 hours). */ + val idleForgetNanos: Long = TimeUnit.HOURS.toNanos(24), + /** Maximum token bucket size / initial burst (same value as Desktop burst allowance). */ + val burstAllowance: Int = PEER_RATE_LIMIT_DESKTOP_BURST, + /** + * When false, bucket key is client IP only (cerca-style). When true (default), key is `ip|requestPath` + * using the URL path only (no query). + */ + val useFullUriAsKey: Boolean = true, + /** + * Optional [Retry-After] response header when limited (seconds). Desktop returns 429 without this header; default null. + */ + val retryAfterSecondsWhenLimited: Int? = null, +) { + init { + require(refreshIntervalNanos > 0) + require(idleForgetNanos > 0) + require(burstAllowance > 0) + } + + companion object { + val DEFAULT = PeerServerRateLimitConfig() + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerTimedRateLimiter.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerTimedRateLimiter.kt new file mode 100644 index 000000000..611a1e9f0 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerTimedRateLimiter.kt @@ -0,0 +1,58 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import java.util.concurrent.ConcurrentHashMap + +/** + * Token-bucket style limiter aligned with cerca `TimedRateLimiter` (v0.2.2): `golang.org/x/time/rate` + * with `rate.Every(refreshPeriod)` and burst allowance. + * When [PeerServerRateLimitConfig.useFullUriAsKey] is false, key is client IP only (cerca-style). + * When true (default), key is `ip|path`. + */ +class PeerTimedRateLimiter( + private val config: PeerServerRateLimitConfig = PeerServerRateLimitConfig.DEFAULT, + private val clockNanos: () -> Long = System::nanoTime, +) { + private val buckets = ConcurrentHashMap() + + private data class Bucket( + var tokens: Double, + var lastUpdateNanos: Long, + ) + + /** + * @param urlKey request path when [PeerServerRateLimitConfig.useFullUriAsKey] is true (ignored when false). + * @return true if the request should be rejected (429), false if allowed. + */ + fun isLimited(clientIp: String, urlKey: String): Boolean { + val key = if (config.useFullUriAsKey) "$clientIp|$urlKey" else clientIp + val now = clockNanos() + val capacity = config.burstAllowance.toDouble() + + val bucket = buckets.compute(key) { _, existing -> + when { + existing == null -> Bucket(capacity, now) + now - existing.lastUpdateNanos >= config.idleForgetNanos -> Bucket(capacity, now) + else -> existing + } + }!! + + synchronized(bucket) { + if (now - bucket.lastUpdateNanos >= config.idleForgetNanos) { + bucket.tokens = capacity + bucket.lastUpdateNanos = now + } + + val elapsed = now - bucket.lastUpdateNanos + bucket.tokens = + minOf(capacity, bucket.tokens + elapsed / config.refreshIntervalNanos.toDouble()) + bucket.lastUpdateNanos = now + + return if (bucket.tokens >= 1.0) { + bucket.tokens -= 1.0 + false + } else { + true + } + } + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerToPeerConstants.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerToPeerConstants.kt new file mode 100644 index 000000000..c4511d46b --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/PeerToPeerConstants.kt @@ -0,0 +1,23 @@ +package org.horizontal.tella.mobile.data.peertopeer + +/** + * Created by wafa on 3/7/2025. + */ +object PeerToPeerConstants { + + const val NEARBY_SHARING_TLS_PORT = 53320 + + /** + * App-private receive subdirectory under [android.content.Context.getNoBackupFilesDir]. + * Excluded from backup; not user-visible shared storage. + */ + const val P2P_RECEIVE_SUBDIR = "p2p-receive" + + /** Delete only receive temp files older than this at server start (crash orphans). */ + const val P2P_RECEIVE_STALE_MAX_AGE_MS = 48L * 60 * 60 * 1000 + + const val TRANSMISSION_ID_KEY = "transmissionId" + const val CONTENT_TYPE_JSON = "application/json" + const val CONTENT_TYPE = "Content-Type" + const val CONTENT_TYPE_OCTET = "application/octet-stream" +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/ServerPinger.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/ServerPinger.kt new file mode 100644 index 000000000..27294737b --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/ServerPinger.kt @@ -0,0 +1,122 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.horizontal.tella.mobile.certificate.CertificateUtils +import okhttp3.ConnectionSpec +import okhttp3.Dns +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.TlsVersion +import java.security.SecureRandom +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager + +object ServerPinger { + + suspend fun pingAndExtractFingerprint( + context: Context, + ip: String, + port: Int + ): Result = withContext(Dispatchers.IO) { + try { + val network = FingerprintFetcher.pickWifiNetwork(context) + + val trustManager = CertificateUtils.getFingerprintCollectionTrustManager() + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustManager), SecureRandom()) + } + val tlsSpec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2) + .allEnabledCipherSuites() + .build() + + val builder = OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .connectionSpecs(listOf(tlsSpec)) + .protocols(listOf(Protocol.HTTP_1_1)) + .connectTimeout(7, TimeUnit.SECONDS) + .readTimeout(7, TimeUnit.SECONDS) + .writeTimeout(7, TimeUnit.SECONDS) + + if (network != null) { + builder.socketFactory(network.socketFactory) + builder.dns(Dns { hostname -> network.getAllByName(hostname).toList() }) + } + + val client = builder.build() + + val req = Request.Builder() + .url("https://$ip:$port/api/v1/ping") + .post(RequestBody.create(null, ByteArray(0))) + .build() + + client.newCall(req).execute().use { resp -> + val cert = (resp.handshake?.peerCertificates?.firstOrNull() + ?: return@withContext Result.failure(IllegalStateException("No peer certificate in handshake")) + ) as X509Certificate + + val fp = FingerprintFetcher.fingerprintFromCert(cert) + // Even if HTTP code isn’t 2xx, we already got the cert. + return@withContext Result.success(fp) + } + } catch (t: Throwable) { + Result.failure(t) + } + } + + /** + * Pinned ping using CERT DER hash. + */ + suspend fun notifyServerPinnedByCert( + context: Context, + ip: String, + port: Int, + expectedCertSha256Hex: String + ) = withContext(Dispatchers.IO) { + val client = FingerprintFetcher.buildClientPinnedByCertHash( + context = context, + expectedCertSha256Hex = expectedCertSha256Hex, + hostForRequests = ip + ) + + val req = Request.Builder() + .url("https://$ip:$port/api/v1/ping") + .post(RequestBody.create(null, ByteArray(0))) + .build() + + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) error("Ping failed: HTTP ${resp.code}") + } + } + + /** + * Optional: pinned ping using SPKI pin (OkHttp-native). + */ + suspend fun notifyServerPinnedWithOkPin( + context: Context, + ip: String, + port: Int, + okHttpPin: String + ) = withContext(Dispatchers.IO) { + val client = FingerprintFetcher.buildPinnedClientWithOkPin( + context = context, + okHttpPin = okHttpPin, + hostForPin = ip + ) + + val req = Request.Builder() + .url("https://$ip:$port/api/v1/ping") + .post(RequestBody.create(null, ByteArray(0))) + .build() + + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) error("Ping failed: HTTP ${resp.code}") + } + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TellaPeerToPeerClient.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TellaPeerToPeerClient.kt new file mode 100644 index 000000000..cf464a138 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TellaPeerToPeerClient.kt @@ -0,0 +1,432 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.ConnectionSpec +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.TlsVersion +import org.horizontal.tella.mobile.certificate.CertificateUtils +import org.horizontal.tella.mobile.data.peertopeer.PeerToPeerConstants.CONTENT_TYPE +import org.horizontal.tella.mobile.data.peertopeer.PeerToPeerConstants.CONTENT_TYPE_JSON +import org.horizontal.tella.mobile.data.peertopeer.PeerToPeerConstants.CONTENT_TYPE_OCTET +import org.horizontal.tella.mobile.data.peertopeer.network.ProgressRequestBody +import org.horizontal.tella.mobile.data.peertopeer.remote.PeerApiRoutes +import org.horizontal.tella.mobile.data.peertopeer.remote.PeerUploadOutcome +import org.horizontal.tella.mobile.data.peertopeer.remote.PrepareUploadRequest +import org.horizontal.tella.mobile.data.peertopeer.remote.PrepareUploadResult +import org.horizontal.tella.mobile.data.peertopeer.remote.RegisterPeerResult +import org.horizontal.tella.mobile.domain.peertopeer.P2PFile +import org.horizontal.tella.mobile.domain.peertopeer.PeerPrepareUploadResponse +import org.horizontal.tella.mobile.domain.peertopeer.PeerRegisterPayload +import org.json.JSONObject +import timber.log.Timber +import java.io.InputStream +import java.security.SecureRandom +import java.util.UUID +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.net.ssl.SSLContext + +class TellaPeerToPeerClient @Inject constructor( + @ApplicationContext private val appContext: Context +) { + suspend fun registerPeerDevice( + ip: String, + port: String, + expectedFingerprint: String, + pin: String, + nonce: String, + ): RegisterPeerResult = withContext(Dispatchers.IO) { + val url = PeerApiRoutes.buildUrl(ip, port, PeerApiRoutes.REGISTER) + Timber.d("Connecting to: $url") + + val payload = PeerRegisterPayload( + pin = pin.trim(), + nonce = nonce, + ) + + val jsonPayload = Json.encodeToString(payload) + val requestBody = jsonPayload.toRequestBody() + val client = getClientWithFingerprintValidation(ip, expectedFingerprint) + + val request = Request.Builder() + .url(url) + .post(requestBody) + .addHeader(CONTENT_TYPE, CONTENT_TYPE_JSON) + // Encourage short-lived TLS sessions to avoid half-closed sockets across platforms. + .addHeader("Connection", "close") + .build() + + return@withContext try { + client.newCall(request).execute().use { response -> + val body = response.body.string() + + if (response.isSuccessful) { + when (val parsed = parseSessionIdFromResponse(body)) { + is RegisterPeerResult.Success -> parsed + is RegisterPeerResult.Failure -> parsed + else -> RegisterPeerResult.Failure(Exception("Unexpected success response shape")) + } + } else { + when (response.code) { + 400 -> RegisterPeerResult.InvalidFormat + 401 -> RegisterPeerResult.InvalidPin + 403 -> RegisterPeerResult.RejectedByReceiver + 409 -> RegisterPeerResult.Conflict + 429 -> RegisterPeerResult.TooManyRequests + 500 -> RegisterPeerResult.ServerError + else -> RegisterPeerResult.Failure(Exception("Unhandled error ${response.code}: $body")) + } + } + } + } catch (e: Exception) { + RegisterPeerResult.Failure(e) + } + } + + suspend fun prepareUpload( + ip: String, + port: String, + expectedFingerprint: String, // leaf cert DER SHA-256 hex (see CertificateUtils.getLeafCertificateDerSha256Hex) + title: String, + /** Plaintext file metadata: [P2PFile.sha256] and [P2PFile.size] must match the PUT body bytes. */ + files: List, + sessionId: String + ): PrepareUploadResult = withContext(Dispatchers.IO) { + val url = PeerApiRoutes.buildUrl(ip, port, PeerApiRoutes.PREPARE_UPLOAD) + + val requestPayload = PrepareUploadRequest( + title = title, + sessionId = sessionId, + nonce = UUID.randomUUID().toString(), + files = files, + ) + val jsonPayload = Json.encodeToString(requestPayload) + val requestBody = jsonPayload.toRequestBody() + val client = getClientWithFingerprintValidation(ip, expectedFingerprint) + + try { + val request = Request.Builder() + .url(url) + .post(requestBody) + .addHeader(CONTENT_TYPE, CONTENT_TYPE_JSON) + .addHeader("Connection", "close") + .build() + + client.newCall(request).execute().use { response -> + val responseBody = response.body.string() + Timber.d("prepareUpload: code=%d body=%s", response.code, responseBody.take(600)) + if (response.isSuccessful) { + parseTransmissionId(responseBody) + } else { + Timber.e("Server error ${response.code}: ${response.message}") + handleServerError(response.code, responseBody) + } + } + } catch (e: Exception) { + Timber.e(e, "Exception during prepareUpload") + PrepareUploadResult.Failure(e) + } + } + + suspend fun uploadFileWithProgress( + ip: String, + port: String, + expectedFingerprint: String, // leaf cert DER SHA-256 hex (see CertificateUtils.getLeafCertificateDerSha256Hex) + sessionId: String, + fileId: String, + transmissionId: String, + inputStream: InputStream, + fileSize: Long, + fileName: String, + onProgress: (bytesWritten: Long, totalBytes: Long) -> Unit + ): PeerUploadOutcome = withContext(Dispatchers.IO) { + Timber.d("session id from the client = %s", sessionId) + val uploadNonce = UUID.randomUUID().toString() + val url = PeerApiRoutes.buildUploadUrl( + ip, port, sessionId, fileId, transmissionId, uploadNonce + ) + + val client = getClientWithFingerprintValidation(ip, expectedFingerprint) + val requestBody = ProgressRequestBody(inputStream, fileSize, onProgress) + + val request = Request.Builder() + .url(url) + .put(requestBody) + .addHeader(CONTENT_TYPE, CONTENT_TYPE_OCTET) + .addHeader("Connection", "close") + .build() + + return@withContext try { + client.newCall(request).execute().use { response -> + val code = response.code + val outcome = when (code) { + 429 -> { + PeerUploadOutcome.TooManyRequests + } + + 409 -> { + PeerUploadOutcome.Failed + } + + 413 -> { + PeerUploadOutcome.PayloadTooLarge + } + + 406 -> { + PeerUploadOutcome.Failed + } + + else -> if (response.isSuccessful) { + PeerUploadOutcome.Success + } else { + PeerUploadOutcome.Failed + } + } + outcome + } + } catch (e: Exception) { + Timber.e(e, "Exception while uploading %s", fileId) + PeerUploadOutcome.Failed + } + } + + suspend fun closeConnection( + ip: String, + port: String, + expectedFingerprint: String, + sessionId: String + ): Boolean = withContext(Dispatchers.IO) { + val url = PeerApiRoutes.buildUrl(ip, port, PeerApiRoutes.CLOSE) + + val payload = Json.encodeToString(mapOf("sessionId" to sessionId)) + val requestBody = payload.toRequestBody() + val client = getClientWithFingerprintValidation(ip, expectedFingerprint) + + val request = Request.Builder() + .url(url) + .post(requestBody) + .addHeader(CONTENT_TYPE, CONTENT_TYPE_JSON) + .addHeader("Connection", "close") + .build() + + return@withContext try { + client.newCall(request) + + .execute().use { response -> + if (response.code == 429) { + Timber.w("closeConnection: rate limited (429)") + } + Timber.d("closeConnection: code=%d", response.code) + response.isSuccessful + } + } catch (e: Exception) { + Timber.e(e, "Failed to close connection") + false + } + } + + // ---------------- Internals ---------------- + + private fun parseSessionIdFromResponse(body: String): RegisterPeerResult { + return try { + val json = JSONObject(body) + + // Optional: some servers send { success: true/false } + val successFlag = json.optBoolean("success", true) + + // Required: non-empty sessionId + val sessionId = json.optString("sessionId", "").trim() + + when { + !successFlag -> { + val msg = + json.optString("message", json.optString("error", "Registration rejected")) + RegisterPeerResult.Failure(Exception(msg)) + } + + sessionId.isEmpty() -> { + RegisterPeerResult.Failure(Exception("Missing or empty sessionId")) + } + + else -> RegisterPeerResult.Success(sessionId) + } + } catch (e: Exception) { + Timber.e(e, "Malformed JSON response: %s", body) + RegisterPeerResult.Failure(Exception("Malformed JSON: ${e.message}")) + } + } + + private fun parseTransmissionId(body: String): PrepareUploadResult = + try { + val response = Json.decodeFromString(body) + PrepareUploadResult.Success(response.files) + } catch (e: Exception) { + Timber.e(e, "Invalid JSON response: %s", body) + PrepareUploadResult.Failure(Exception("Malformed server response")) + } + + private fun handleServerError(code: Int, body: String): PrepareUploadResult = + when (code) { + 400 -> PrepareUploadResult.BadRequest + 403 -> PrepareUploadResult.Forbidden + 409 -> PrepareUploadResult.Conflict + 413 -> PrepareUploadResult.PayloadTooLarge + 429 -> PrepareUploadResult.TooManyRequests + 500 -> PrepareUploadResult.ServerError + else -> PrepareUploadResult.Failure(Exception("Unhandled server error $code: $body")) + } + + /** + * OkHttp client that: + * - Pins the server by leaf certificate DER SHA-256 hex (CertificateUtils.getLeafCertificateDerSha256Hex). + * - Optionally binds sockets to a Wi-Fi Network if one is active/validated. + * - Keeps default hostname verification (IP SAN must match when using IP literals). + * - TLS: OkHttp is configured with [TlsVersion.TLS_1_3] first, then [TlsVersion.TLS_1_2] (prefers 1.3 on both peers when + * the stack supports it; e.g. TLS 1.3 is available from Android 10 / API 29 onward). A strict 1.3-only client was + * considered for parity with iOS defaults, but would block Nearby Sharing on minSdk 21 devices where 1.3 is + * unavailable—so we keep 1.2+1.3, matching the product call on the cross-platform thread (Feb 18 discussion). + */ + private fun getClientWithFingerprintValidation( + ip: String, + expectedFingerprintHex: String + ): OkHttpClient { + val expected = normalizeHex(expectedFingerprintHex) + + val trustManager = CertificateUtils.getLeafCertPinnedTrustManager(expected) + + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustManager), SecureRandom()) + } + + val tlsSpec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2) + .allEnabledCipherSuites() + .build() + + val builder = OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .connectionSpecs(listOf(tlsSpec)) + .protocols(listOf(Protocol.HTTP_1_1)) + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .writeTimeout(20, TimeUnit.SECONDS) + + // For LAN / hotspot peers, binding to an arbitrary "Wi‑Fi" Network can pick the wrong interface + // (e.g. secondary saved Wi‑Fi) and yield EHOSTUNREACH while the default network routes correctly. + if (!isPrivateOrLinkLocalIpv4(ip)) { + pickWifiNetwork(appContext)?.let { network -> + builder.socketFactory(network.socketFactory) + } + } + + return builder.build() + } + + /** True for RFC1918 / link-local so we let the OS choose the socket's outgoing interface. */ + private fun isPrivateOrLinkLocalIpv4(ip: String): Boolean { + val parts = ip.trim().split('.').mapNotNull { it.toIntOrNull() } + if (parts.size != 4) return false + val a = parts[0] + val b = parts[1] + return when { + a == 10 -> true + a == 172 && b in 16..31 -> true + a == 192 && b == 168 -> true + a == 169 && b == 254 -> true + a == 127 -> true + else -> false + } + } + + @Suppress("DEPRECATION") + private fun pickWifiNetwork(context: Context): Network? { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + // Prefer active validated Wi-Fi on API 23+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + cm.activeNetwork?.let { n -> + cm.getNetworkCapabilities(n)?.let { caps -> + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + ) return n + } + } + } + + // Otherwise, any Wi-Fi with INTERNET capability + return cm.allNetworks.firstOrNull { n -> + cm.getNetworkCapabilities(n)?.let { caps -> + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } == true + } + } + + private fun normalizeHex(hexLike: String): String = + hexLike.trim().replace(":", "").replace("\\s".toRegex(), "").lowercase() + + + /** Discovery client for /ping before we have a pin; uses system CA validation (no trust-all). */ + private fun newDiscoveryClient(network: Network?): OkHttpClient { + val trustManager = CertificateUtils.getFingerprintCollectionTrustManager() + val ssl = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustManager), SecureRandom()) + } + + val tlsSpec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2) + .allEnabledCipherSuites() + .build() + + return OkHttpClient.Builder() + .sslSocketFactory(ssl.socketFactory, trustManager) + .connectionSpecs(listOf(tlsSpec)) + .protocols(listOf(Protocol.HTTP_1_1)) + .apply { network?.let { socketFactory(it.socketFactory) } } + .connectTimeout(3, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + } + + suspend fun pingBeforeRegister(ip: String, port: String): Boolean = + withContext(Dispatchers.IO) { + val network = pickWifiNetwork(appContext) + val client = newDiscoveryClient(network) + + // Use the real path your server exposes; many backends use /api/v1/ping + val url = PeerApiRoutes.buildUrl(ip, port, "/api/v1/ping", secure = true) + + val req = Request.Builder() + .url("https://$ip:$port/api/v1/ping") + .post(okhttp3.RequestBody.create(null, ByteArray(0))) // or "".toRequestBody(null) + .build() + + runCatching { + client.newCall(req).execute().use { resp -> + if (resp.code == 429) { + Timber.w("pingBeforeRegister: rate limited (429)") + } + // consider any HTTP code as “host reachable” + Timber.d("pingBeforeRegister $url -> HTTP %d", resp.code) + resp.code in 100..599 + } + }.getOrElse { + Timber.w(it, "Ping failed for $url") + false + } + } + + +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TellaPeerToPeerServer.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TellaPeerToPeerServer.kt new file mode 100644 index 000000000..ab314b6e4 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TellaPeerToPeerServer.kt @@ -0,0 +1,578 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.ApplicationCallPipeline +import io.ktor.server.application.call +import io.ktor.server.application.install +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.applicationEnvironment +import io.ktor.server.engine.embeddedServer +import io.ktor.server.engine.sslConnector +import io.ktor.server.netty.Netty +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.request.receive +import io.ktor.server.request.receiveStream +import io.ktor.server.response.respond +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.put +import io.ktor.server.routing.routing +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.horizontal.tella.mobile.certificate.CertificateUtils +import org.horizontal.tella.mobile.data.peertopeer.PeerToPeerConstants.NEARBY_SHARING_TLS_PORT +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerToPeerManager +import org.horizontal.tella.mobile.data.peertopeer.model.P2PFileStatus +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSession +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState +import org.horizontal.tella.mobile.data.peertopeer.model.ProgressFile +import org.horizontal.tella.mobile.data.peertopeer.model.SessionStatus +import org.horizontal.tella.mobile.data.peertopeer.remote.PeerApiRoutes +import org.horizontal.tella.mobile.data.peertopeer.remote.PrepareUploadRequest +import org.horizontal.tella.mobile.domain.peertopeer.FileInfo +import org.horizontal.tella.mobile.domain.peertopeer.KeyStoreConfig +import org.horizontal.tella.mobile.domain.peertopeer.NearbySharingTransferConfig +import org.horizontal.tella.mobile.domain.peertopeer.PeerEventManager +import org.horizontal.tella.mobile.domain.peertopeer.PeerPrepareUploadResponse +import org.horizontal.tella.mobile.domain.peertopeer.PeerRegisterPayload +import org.horizontal.tella.mobile.domain.peertopeer.PeerResponse +import org.horizontal.tella.mobile.domain.peertopeer.TellaServer +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.state.UploadProgressState +import timber.log.Timber +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.security.KeyPair +import java.security.KeyStore +import java.security.cert.X509Certificate +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +const val port = NEARBY_SHARING_TLS_PORT + +private const val MAX_REGISTER_PIN_ATTEMPTS = 3 + +class TellaPeerToPeerServer( + /** + * IPv4 shown in QR and used for TLS cert SAN; the Netty server binds to [P2PNetworkAddressPolicy.INADDR_ANY_IPV4]. + */ + private val advertisedHost: String, + private val serverPort: Int = port, + private val pin: String, + private val keyPair: KeyPair, + private val certificate: X509Certificate, + private val keyStoreConfig: KeyStoreConfig, + private val peerToPeerManager: PeerToPeerManager, + private val p2PSharedState: P2PSharedState, + private val receiveDir: File, + private val rateLimitConfig: PeerServerRateLimitConfig = PeerServerRateLimitConfig.DEFAULT, +) : TellaServer { + + private var serverSession: PeerResponse? = null + private var embedded: EmbeddedServer<*, *>? = null + private val transferNonceManager = TransferNonceManager() + private val registerPinFailuresByNonce = ConcurrentHashMap() + private val rateLimiter = PeerTimedRateLimiter(rateLimitConfig) + + override val certificatePem: String + get() = CertificateUtils.certificateToPem(certificate) + + private suspend fun emitReceiveProgress(session: P2PSession) { + val totalTransferred = session.files.values.sumOf { it.bytesTransferred.toLong() } + val totalSize = session.files.values.sumOf { it.file.size } + val percent = + if (totalSize > 0) ((totalTransferred * 100) / totalSize).toInt() else 0 + PeerEventManager.onUploadProgressState( + UploadProgressState( + title = session.title.orEmpty(), + percent = percent, + sessionStatus = session.status, + files = session.files.values.toList() + ) + ) + } + + override fun start() { + Timber.e( + "SERVER STARTED bind=%s port=%d advertisedToPeer=%s", + P2PNetworkAddressPolicy.INADDR_ANY_IPV4, + serverPort, + advertisedHost, + ) + val keyStore = KeyStore.getInstance("PKCS12").apply { + load(null, null) + setKeyEntry( + keyStoreConfig.alias, keyPair.private, keyStoreConfig.password, arrayOf(certificate) + ) + } + + embedded = embeddedServer( + Netty, + environment = applicationEnvironment { }, + configure = { + enableHttp2 = false + enableH2c = false + sslConnector( + keyStore = keyStore, + keyAlias = keyStoreConfig.alias, + keyStorePassword = { keyStoreConfig.password }, + privateKeyPassword = { keyStoreConfig.password }, + ) { + host = P2PNetworkAddressPolicy.INADDR_ANY_IPV4 + port = serverPort + enabledProtocols = listOf("TLSv1.3", "TLSv1.2") + } + }, + ) { + install(ContentNegotiation) { + json(kotlinx.serialization.json.Json { + ignoreUnknownKeys = true + isLenient = true + }) + } + + intercept(ApplicationCallPipeline.Call) { + val clientIp = call.peerClientIpForRateLimit() + val routePath = call.peerRoutePathForRateLimit() + if (rateLimiter.isLimited(clientIp, routePath)) { + rateLimitConfig.retryAfterSecondsWhenLimited?.let { secs -> + call.response.headers.append(HttpHeaders.RetryAfter, secs.toString()) + } + call.respondText( + """{"error":"Too many requests"}""", + ContentType.Application.Json, + HttpStatusCode.TooManyRequests, + ) + finish() + return@intercept + } + } + + routing { + // Sanity probe + get("/") { + call.respondText("The server is running securely over HTTPS.") + } + + // Client presence hint (optional) + post(PeerApiRoutes.PING) { + call.peerClientIpForRateLimit() + CoroutineScope(Dispatchers.IO).launch { + peerToPeerManager.notifyClientConnected(p2PSharedState.hash) + } + call.respondText("ping", status = HttpStatusCode.OK) + } + + // 1) Register a session + post(PeerApiRoutes.REGISTER) { + try { + val request = try { + call.receive() + } catch (_: Exception) { + call.respond(HttpStatusCode.BadRequest, "Invalid request format") + return@post + } + + // Active session, then nonce / PIN limits + if (serverSession != null) { + call.respond(HttpStatusCode.Conflict, "Active session already exists") + return@post + } + + val regNonce = request.nonce + if (regNonce == null || regNonce.isEmpty()) { + call.respond(HttpStatusCode.BadRequest, "Invalid request format") + return@post + } + + if ((registerPinFailuresByNonce[regNonce] + ?: 0) >= MAX_REGISTER_PIN_ATTEMPTS + ) { + call.respond(HttpStatusCode.TooManyRequests, "Too many requests") + return@post + } + + if (!isValidPin(request.pin) || pin != request.pin) { + val count = (registerPinFailuresByNonce[regNonce] ?: 0) + 1 + registerPinFailuresByNonce[regNonce] = count + if (count >= MAX_REGISTER_PIN_ATTEMPTS) { + call.respond(HttpStatusCode.TooManyRequests, "Too many requests") + } else { + call.respond(HttpStatusCode.Unauthorized, "Invalid PIN") + } + return@post + } + + registerPinFailuresByNonce.remove(regNonce) + + val sessionId = UUID.randomUUID().toString() + val session = PeerResponse(sessionId) + if (p2PSharedState.session == null) { + p2PSharedState.session = P2PSession() + } + p2PSharedState.session?.sessionId = sessionId + serverSession = session + + val accepted = try { + PeerEventManager.emitIncomingRegistrationRequest(sessionId, request) + } catch (e: Exception) { + call.respond(HttpStatusCode.InternalServerError, "Internal error") + return@post + } + + if (!accepted) { + call.respond( + HttpStatusCode.Forbidden, "Receiver rejected the registration" + ) + return@post + } + + launch { PeerEventManager.emitRegistrationSuccess() } + call.respond(HttpStatusCode.OK, session) + } catch (e: Exception) { + try { + call.respond( + HttpStatusCode.InternalServerError, + "Internal server error", + ) + } catch (_: Exception) { + // Response may already be committed + } + } + } + + // 2) Prepare upload → create receiving session, STATUS = SENDING + post(PeerApiRoutes.PREPARE_UPLOAD) { + try { + val request = try { + call.receive() + } catch (e: Exception) { + call.respond(HttpStatusCode.BadRequest, "Invalid body: ${e.message}") + return@post + } + + if (request.title.isBlank() || request.sessionId.isBlank() || request.files.isEmpty()) { + call.respond(HttpStatusCode.BadRequest, "Missing required fields") + return@post + } + + if (request.files.any { it.sha256.isNullOrBlank() }) { + call.respond(HttpStatusCode.BadRequest, "Missing file hash") + return@post + } + + if (request.files.size > NearbySharingTransferConfig.Standard.maxFileCount) { + call.respond(HttpStatusCode.PayloadTooLarge, "Content too large") + return@post + } + if (request.files.any { it.size > NearbySharingTransferConfig.Standard.maxFileSizeBytes }) { + call.respond(HttpStatusCode.PayloadTooLarge, "Content too large") + return@post + } + + if (request.sessionId != serverSession?.sessionId) { + call.respond(HttpStatusCode.Unauthorized, "Invalid session ID") + return@post + } + + when (transferNonceManager.tryAdd(request.nonce)) { + TransferNonceManager.AddResult.Empty, + TransferNonceManager.AddResult.Reused -> { + call.respond(HttpStatusCode.Conflict, "Invalid nonce") + return@post + } + + TransferNonceManager.AddResult.Success -> { /* continue */ + } + } + + val accepted = PeerEventManager.emitPrepareUploadRequest(request) + if (!accepted) { + call.respond(HttpStatusCode.Forbidden, "Transfer rejected by receiver") + return@post + } + + val session = P2PSession( + title = request.title, sessionId = request.sessionId + ).also { it.status = SessionStatus.SENDING } + + val responseFiles = request.files.map { file -> + val transmissionId = UUID.randomUUID().toString() + val receivingFile = + ProgressFile(file = file, transmissionId = transmissionId) + session.files[transmissionId] = receivingFile + FileInfo(id = file.id, transmissionId = transmissionId) + } + + p2PSharedState.session = session + call.respond( + HttpStatusCode.OK, PeerPrepareUploadResponse(files = responseFiles) + ) + } catch (e: Exception) { + try { + call.respond( + HttpStatusCode.InternalServerError, + "Internal server error", + ) + } catch (_: Exception) { + } + } + } + + // 3) Upload each file (transport-only; recipient will SAVE later) + put(PeerApiRoutes.UPLOAD) { + try { + val sessionId = call.parameters["sessionId"] + val fileId = call.parameters["fileId"] + val transmissionId = call.parameters["transmissionId"] + val uploadNonce = call.parameters["nonce"] + + + // beginUploadFromRequest: required ids first (400), then nonce (409), then session + if (sessionId == null || fileId == null || transmissionId == null || + sessionId.isEmpty() || fileId.isEmpty() || transmissionId.isEmpty() + ) { + call.respond(HttpStatusCode.BadRequest, "Missing path parameters") + return@put + } + + when (transferNonceManager.tryAdd(uploadNonce)) { + TransferNonceManager.AddResult.Empty, + TransferNonceManager.AddResult.Reused -> { + call.respond(HttpStatusCode.Conflict, "Invalid nonce") + return@put + } + + TransferNonceManager.AddResult.Success -> { /* continue */ + } + } + + val session = p2PSharedState.session + if (sessionId != session?.sessionId) { + call.respond(HttpStatusCode.Unauthorized, "Invalid session ID") + return@put + } + + val progressFile = session.files[transmissionId] + + if (transmissionId != progressFile?.transmissionId) { + call.respond(HttpStatusCode.Forbidden, "Invalid transmission ID") + return@put + } + + if (progressFile.file.id != fileId) { + call.respond(HttpStatusCode.NotFound, "File not found in session") + return@put + } + + if (progressFile.status == P2PFileStatus.FINISHED) { + call.respond(HttpStatusCode.Conflict, "Transfer already completed") + return@put + } + + val declaredSize = progressFile.file.size + if (declaredSize > NearbySharingTransferConfig.Standard.maxFileSizeBytes || + declaredSize < 0 + ) { + progressFile.status = P2PFileStatus.FAILED + emitReceiveProgress(session) + call.respond(HttpStatusCode.PayloadTooLarge, "Content too large") + return@put + } + + val tmpFile = File.createTempFile( + sanitizeFileIdForTempPrefix(fileId), + ".tmp", + receiveDir + ) + var completed = false + var input: BufferedInputStream? = null + var output: BufferedOutputStream? = null + try { + output = tmpFile.outputStream().buffered() + input = call.receiveStream().buffered() + var bytesRead = 0L + val buffer = ByteArray(8192) + + while (true) { + val read = input.read(buffer) + if (read == -1) break + if (bytesRead + read > declaredSize) { + progressFile.status = P2PFileStatus.FAILED + emitReceiveProgress(session) + call.respond( + HttpStatusCode.PayloadTooLarge, + "Content too large" + ) + return@put + } + output.write(buffer, 0, read) + bytesRead += read + progressFile.bytesTransferred = bytesRead.toInt() + emitReceiveProgress(session) + } + + // Ensure all bytes are on disk before hashing (BufferedOutputStream). + output.flush() + + val expected = progressFile.file.sha256?.trim().orEmpty() + if (expected.isEmpty()) { + progressFile.status = P2PFileStatus.FAILED + emitReceiveProgress(session) + call.respond(HttpStatusCode.NotAcceptable, "File hash mismatch") + return@put + } + + val actual = PeerFileHash.sha256Hex(tmpFile) + if (!actual.equals(expected, ignoreCase = true)) { + progressFile.status = P2PFileStatus.FAILED + emitReceiveProgress(session) + call.respond(HttpStatusCode.NotAcceptable, "File hash mismatch") + return@put + } + + progressFile.status = P2PFileStatus.FINISHED + progressFile.path = tmpFile.path + + emitReceiveProgress(session) + + completed = true + call.respond(HttpStatusCode.OK, "Upload complete") + } catch (e: Exception) { + progressFile.status = P2PFileStatus.FAILED + emitReceiveProgress(session) + call.respond( + HttpStatusCode.InternalServerError, "Upload failed: ${e.message}" + ) + } finally { + try { + input?.close() + } catch (_: Exception) { + Timber.w("P2P upload: input close failed") + } + try { + output?.flush() + } catch (_: Exception) { + // ignore + } + try { + output?.close() + } catch (_: Exception) { + Timber.w("P2P upload: output close failed") + } + if (!completed) { + if (!tmpFile.delete()) { + // Rare: FS delay; avoid noisy warning for benign leftovers in cache. + Timber.d( + "P2P upload: temp delete deferred or failed %s", + tmpFile.path + ) + } + } + } + } catch (e: Exception) { + Timber.e(e, "P2P /upload unhandled exception") + try { + call.respond( + HttpStatusCode.InternalServerError, + "Internal server error", + ) + } catch (_: Exception) { + } + } + } + + // 4) Close session (transport finished/cancelled by sender) + post(PeerApiRoutes.CLOSE) { + val payload = try { + call.receive>() + } catch (_: Exception) { + call.respond( + HttpStatusCode.BadRequest, "Missing or invalid JSON payload" + ) + return@post + } + + val sessionId = payload["sessionId"] + if (sessionId.isNullOrBlank()) { + call.respond(HttpStatusCode.BadRequest, "Invalid request format") + return@post + } + + val current = serverSession + if (current == null || current.sessionId != sessionId) { + call.respond(HttpStatusCode.Unauthorized, "Invalid session ID") + return@post + } + + if (p2PSharedState.session?.status == SessionStatus.CLOSED) { + call.respond(HttpStatusCode.Forbidden, "Session already closed") + return@post + } + + try { + p2PSharedState.session?.status = SessionStatus.CLOSED + serverSession = null + transferNonceManager.clear() + launch { PeerEventManager.emitCloseConnection() } + call.respond(HttpStatusCode.OK, mapOf("success" to true)) + } catch (e: Exception) { + Timber.e(e, "Error while closing session") + call.respond(HttpStatusCode.InternalServerError, "Server error") + } + } + + } + }.start(wait = false) + + CoroutineScope(Dispatchers.IO).launch { + delay(2_000) + Timber.e( + "SERVER READY bind=%s port=%d advertisedToPeer=%s", + P2PNetworkAddressPolicy.INADDR_ANY_IPV4, + serverPort, + advertisedHost, + ) + } + } + + override fun stop() { + transferNonceManager.clear() + registerPinFailuresByNonce.clear() + try { + embedded?.stop(1000, 5000) + } finally { + embedded = null + } + } + + private fun isValidPin(pin: String) = pin.length == 6 + + /** + * [File.createTempFile] requires prefix length ≥ 3; strip path-like characters for safety. + */ + private fun sanitizeFileIdForTempPrefix(fileId: String): String { + val sb = StringBuilder(fileId.length.coerceAtMost(128)) + for (c in fileId) { + when { + c.isLetterOrDigit() -> sb.append(c) + c == '-' || c == '_' -> sb.append(c) + else -> sb.append('_') + } + } + var base = sb.toString().trim('_') + if (base.length < 3) { + base = "p2p_$base".trim('_') + } + if (base.length < 3) { + base = "p2precv" + } + return base.take(120) + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TransferNonceManager.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TransferNonceManager.kt new file mode 100644 index 000000000..a3f6914eb --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/TransferNonceManager.kt @@ -0,0 +1,28 @@ +package org.horizontal.tella.mobile.data.peertopeer + +import java.util.concurrent.ConcurrentHashMap + +/** + * Single-use nonce tracking for P2P prepare-upload and upload (protocol §5.2). + * Matches iOS [NonceManager]: null/empty string rejected; no trimming (whitespace is a distinct nonce). + */ +class TransferNonceManager { + + private val seen = ConcurrentHashMap.newKeySet() + + enum class AddResult { + Success, + Empty, + Reused + } + + fun tryAdd(nonce: String?): AddResult { + if (nonce == null || nonce.isEmpty()) return AddResult.Empty + if (!seen.add(nonce)) return AddResult.Reused + return AddResult.Success + } + + fun clear() { + seen.clear() + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/PeerServerStarterManager.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/PeerServerStarterManager.kt new file mode 100644 index 000000000..78789c11f --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/PeerServerStarterManager.kt @@ -0,0 +1,148 @@ +package org.horizontal.tella.mobile.data.peertopeer.managers + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import org.horizontal.tella.mobile.data.peertopeer.PeerToPeerConstants +import org.horizontal.tella.mobile.data.peertopeer.PeerServerRateLimitConfig +import org.horizontal.tella.mobile.data.peertopeer.TellaPeerToPeerServer +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState +import org.horizontal.tella.mobile.domain.peertopeer.KeyStoreConfig +import java.io.File +import java.nio.charset.StandardCharsets +import java.security.KeyPair +import java.security.MessageDigest +import java.security.cert.X509Certificate +import javax.inject.Inject +import javax.inject.Singleton +import timber.log.Timber + +@Singleton +class PeerServerStarterManager @Inject constructor( + private val peerToPeerManager: PeerToPeerManager, + @param:ApplicationContext private val appContext: Context, +) { + private var server: TellaPeerToPeerServer? = null + + /** Last credentials the running server was started with (avoids redundant stop/start). */ + private var listeningAdvertisedHost: String? = null + /** SHA-256 of UTF-8 PIN; raw PIN is not retained after a successful start. */ + private var listeningPinSha256: ByteArray? = null + + /** + * Starts or restarts the embedded server so its PIN matches the QR payload. + * If [advertisedHost] and a SHA-256 of [pin] match the already-running server, returns true without tearing down Netty + * (avoids port bind races and duplicate work when [setupServerAndQr] is triggered twice). + */ + @Synchronized + fun startServer( + /** IPv4 the peer should connect to (QR / manual), not the TLS listen address. */ + advertisedHost: String, + keyPair: KeyPair, + pin: String, + cert: X509Certificate, + config: KeyStoreConfig, + p2PSharedState: P2PSharedState, + rateLimitConfig: PeerServerRateLimitConfig = PeerServerRateLimitConfig.DEFAULT, + ): Boolean { + val lastPin = listeningPinSha256 + if (server != null && + listeningAdvertisedHost == advertisedHost && + lastPin != null && + pinSha256Equals(lastPin, hashPinUtf8Sha256(pin)) + ) { + return true + } + val hadServer = server != null + server?.let { + try { + it.stop() + } catch (e: Exception) { + Timber.d(e, "P2P embedded server: stop before restart failed") + } + } + server = null + listeningAdvertisedHost = null + listeningPinSha256 = null + if (hadServer) { + try { + Thread.sleep(120) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + return try { + val receiveDir = p2pReceiveDir().apply { mkdirs() } + clearStaleReceiveFiles(receiveDir) + // Legacy location (pre–noBackupFilesDir); age-clean only so in-flight paths stay valid until import. + val legacyDir = File(appContext.cacheDir, PeerToPeerConstants.P2P_RECEIVE_SUBDIR) + if (legacyDir.exists()) clearStaleReceiveFiles(legacyDir) + server = TellaPeerToPeerServer( + advertisedHost = advertisedHost, + keyPair = keyPair, + pin = pin, + certificate = cert, + keyStoreConfig = config, + peerToPeerManager = peerToPeerManager, + p2PSharedState = p2PSharedState, + receiveDir = receiveDir, + rateLimitConfig = rateLimitConfig, + ) + server?.start() + listeningAdvertisedHost = advertisedHost + listeningPinSha256 = hashPinUtf8Sha256(pin) + true + } catch (e: Exception) { + Timber.e(e, "P2P embedded server: start failed") + server = null + listeningAdvertisedHost = null + listeningPinSha256 = null + false + } + } + + /** Prefer calling from a background thread; Netty shutdown can take a short time. */ + @Synchronized + fun stopServer() { + server?.let { + try { + Timber.d("P2P embedded server: stop requested") + it.stop() + } catch (e: Exception) { + Timber.e(e, "P2P embedded server: stop failed") + } + } + server = null + listeningAdvertisedHost = null + listeningPinSha256 = null + Timber.d("P2P embedded server: stopped (holder cleared)") + } + + fun isRunning(): Boolean = server != null + + private fun p2pReceiveDir(): File = + File(appContext.noBackupFilesDir, PeerToPeerConstants.P2P_RECEIVE_SUBDIR) + + /** + * Removes only receive temp files older than [PeerToPeerConstants.P2P_RECEIVE_STALE_MAX_AGE_MS] + * so recent uploads (awaiting vault import) are not deleted on server restart. + */ + private fun clearStaleReceiveFiles(dir: File) { + val now = System.currentTimeMillis() + val maxAge = PeerToPeerConstants.P2P_RECEIVE_STALE_MAX_AGE_MS + dir.listFiles()?.forEach { child -> + if (!child.isFile) return@forEach + val age = now - child.lastModified() + if (age > maxAge) { + if (!child.delete()) { + Timber.d("P2P receive: stale file delete failed") + } + } + } + } + + private fun hashPinUtf8Sha256(pin: String): ByteArray { + return MessageDigest.getInstance("SHA-256").digest(pin.toByteArray(StandardCharsets.UTF_8)) + } + + private fun pinSha256Equals(a: ByteArray, b: ByteArray): Boolean = MessageDigest.isEqual(a, b) +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/PeerToPeerManager.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/PeerToPeerManager.kt new file mode 100644 index 000000000..bc353ea1f --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/managers/PeerToPeerManager.kt @@ -0,0 +1,18 @@ +package org.horizontal.tella.mobile.data.peertopeer.managers + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class PeerToPeerManager { + // Keep the latest ping/cert-hash event for late collectors (manual recipient flow can subscribe after iOS ping). + private val _clientConnected = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + val clientConnected = _clientConnected.asSharedFlow() + + suspend fun notifyClientConnected(hash : String) { + _clientConnected.emit(hash) + } + + fun clearClientConnected() { + _clientConnected.resetReplayCache() + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PFileStatus.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PFileStatus.kt new file mode 100644 index 000000000..766205915 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PFileStatus.kt @@ -0,0 +1,5 @@ +package org.horizontal.tella.mobile.data.peertopeer.model + +enum class P2PFileStatus { + QUEUE, SENDING, FINISHED, SAVED, FAILED +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PSession.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PSession.kt new file mode 100644 index 000000000..c14abecd6 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PSession.kt @@ -0,0 +1,8 @@ +package org.horizontal.tella.mobile.data.peertopeer.model + +data class P2PSession( + var sessionId: String = "", + var status: SessionStatus = SessionStatus.WAITING, + var files: MutableMap = mutableMapOf(), + var title: String? = null +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PSharedState.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PSharedState.kt new file mode 100644 index 000000000..f3b0a621a --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/P2PSharedState.kt @@ -0,0 +1,34 @@ +package org.horizontal.tella.mobile.data.peertopeer.model + +class P2PSharedState( + var ip: String = "", + var port: String = "", + var hash: String = "", + var pin: String? = null, + var session: P2PSession? = null, + private var failedAttempts: Int = 0, + var isUsingManualConnection: Boolean = false, +) { + + companion object { + fun Companion.createNewSession(): P2PSession { + return P2PSession( + sessionId = "", + title = "", + files = mutableMapOf(), + status = SessionStatus.SENDING + ) + } + } + + + fun clear() { + ip = "" + port = "" + hash = "" + pin = null + session = null + failedAttempts = 0 + isUsingManualConnection = false + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/ProgressFile.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/ProgressFile.kt new file mode 100644 index 000000000..6c6a83f67 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/ProgressFile.kt @@ -0,0 +1,13 @@ +package org.horizontal.tella.mobile.data.peertopeer.model + +import com.hzontal.tella_vault.VaultFile +import org.horizontal.tella.mobile.domain.peertopeer.P2PFile + +data class ProgressFile( + var file: P2PFile, + var vaultFile : VaultFile? = null, + var status: P2PFileStatus = P2PFileStatus.QUEUE, + var transmissionId: String? = null, + var path: String? = null, + var bytesTransferred: Int = 0 +) diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/SessionStatus.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/SessionStatus.kt new file mode 100644 index 000000000..f745c5398 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/model/SessionStatus.kt @@ -0,0 +1,10 @@ +package org.horizontal.tella.mobile.data.peertopeer.model + +enum class SessionStatus { + WAITING, + SENDING, + SAVING, + FINISHED, + FINISHED_WITH_ERRORS, + CLOSED +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/network/ProgressRequestBody.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/network/ProgressRequestBody.kt new file mode 100644 index 000000000..f2e53d5e0 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/network/ProgressRequestBody.kt @@ -0,0 +1,30 @@ +package org.horizontal.tella.mobile.data.peertopeer.network + +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okio.BufferedSink +import java.io.InputStream + +class ProgressRequestBody( + private val inputStream: InputStream, + private val contentLength: Long, + private val onProgress: (Long, Long) -> Unit +) : RequestBody() { + + override fun contentType(): MediaType? = "application/octet-stream".toMediaTypeOrNull() + + override fun contentLength(): Long = contentLength + + override fun writeTo(sink: BufferedSink) { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var uploaded = 0L + var read: Int + + while (inputStream.read(buffer).also { read = it } != -1) { + sink.write(buffer, 0, read) + uploaded += read + onProgress(uploaded, contentLength) + } + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerApiRoutes.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerApiRoutes.kt new file mode 100644 index 000000000..0b8b98424 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerApiRoutes.kt @@ -0,0 +1,40 @@ +package org.horizontal.tella.mobile.data.peertopeer.remote + +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +object PeerApiRoutes { + const val REGISTER = "/api/v1/register" + const val PREPARE_UPLOAD = "/api/v1/prepare-upload" + const val UPLOAD = "/api/v1/upload" + const val PING = "/api/v1/ping" + const val CLOSE = "/api/v1/close-connection" + + + fun buildUrl(ip: String, port: String, endpoint: String, secure: Boolean = true): String { + val scheme = if (secure) "https" else "http" + // Ensure endpoint starts with "/" + val normalized = if (endpoint.startsWith("/")) endpoint else "/$endpoint" + return "$scheme://$ip:$port$normalized" + } + + + fun buildUploadUrl( + ip: String, + port: String, + sessionId: String, + fileId: String, + transmissionId: String, + nonce: String, + ): String { + val base = buildUrl(ip, port, UPLOAD).toHttpUrlOrNull() + ?: return "${buildUrl(ip, port, UPLOAD)}?sessionId=$sessionId&fileId=$fileId&transmissionId=$transmissionId&nonce=$nonce" + return base.newBuilder() + .addQueryParameter("sessionId", sessionId) + .addQueryParameter("fileId", fileId) + .addQueryParameter("transmissionId", transmissionId) + .addQueryParameter("nonce", nonce) + .build() + .toString() + } + +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerUploadOutcome.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerUploadOutcome.kt new file mode 100644 index 000000000..d139ed2fc --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PeerUploadOutcome.kt @@ -0,0 +1,8 @@ +package org.horizontal.tella.mobile.data.peertopeer.remote + +sealed class PeerUploadOutcome { + data object Success : PeerUploadOutcome() + data object Failed : PeerUploadOutcome() + data object TooManyRequests : PeerUploadOutcome() + data object PayloadTooLarge : PeerUploadOutcome() +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PrepareUploadRequest.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PrepareUploadRequest.kt new file mode 100644 index 000000000..f74d908b6 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PrepareUploadRequest.kt @@ -0,0 +1,14 @@ +package org.horizontal.tella.mobile.data.peertopeer.remote + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.horizontal.tella.mobile.domain.peertopeer.P2PFile + +@Serializable +data class PrepareUploadRequest( + val title: String, + @SerialName("sessionId") val sessionId: String, + /** Optional like iOS `PrepareUploadRequest`; missing/null → replay check fails with conflict. */ + val nonce: String? = null, + val files: List +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PrepareUploadResult.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PrepareUploadResult.kt new file mode 100644 index 000000000..e2d3d8597 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/PrepareUploadResult.kt @@ -0,0 +1,18 @@ +package org.horizontal.tella.mobile.data.peertopeer.remote + +import org.horizontal.tella.mobile.domain.peertopeer.FileInfo + + +/** + * Created by wafa on 2/7/2025. + */ +sealed class PrepareUploadResult { + data class Success(val transmissions: List) : PrepareUploadResult() + data object Forbidden : PrepareUploadResult() + data object BadRequest : PrepareUploadResult() + data object Conflict : PrepareUploadResult() + data object TooManyRequests : PrepareUploadResult() + data object PayloadTooLarge : PrepareUploadResult() + data object ServerError : PrepareUploadResult() + data class Failure(val exception: Throwable) : PrepareUploadResult() +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/RegisterPeerResult.kt b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/RegisterPeerResult.kt new file mode 100644 index 000000000..204056f2f --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/peertopeer/remote/RegisterPeerResult.kt @@ -0,0 +1,12 @@ +package org.horizontal.tella.mobile.data.peertopeer.remote + +sealed class RegisterPeerResult { + data class Success(val sessionId: String) : RegisterPeerResult() + data object InvalidFormat : RegisterPeerResult() // 400 + data object InvalidPin : RegisterPeerResult() // 401 + data object Conflict : RegisterPeerResult() // 409 + data object TooManyRequests : RegisterPeerResult() // 429 + data object ServerError : RegisterPeerResult() // 500 + data object RejectedByReceiver : RegisterPeerResult() // 403 + data class Failure(val exception: Throwable) : RegisterPeerResult() +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/Preferences.java b/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/Preferences.java index 224fb8bf7..71e515b7d 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/Preferences.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/Preferences.java @@ -126,6 +126,22 @@ public static void setEraseForms(boolean value) { setBoolean(SharedPrefs.ERASE_FORMS, value); } + public static boolean isEnableHomeNearby() { + return getBoolean(SharedPrefs.ENABLE_HOME_NEARBY_SHARING, true); + } + + public static void setEnableHomeNearby(boolean value) { + setBoolean(SharedPrefs.ENABLE_HOME_NEARBY_SHARING, value); + } + + public static boolean isShowP2pWifiConfirmationSheet() { + return getBoolean(SharedPrefs.SHOW_P2P_WIFI_CONFIRMATION_SHEET, true); + } + + public static void setShowP2pWifiConfirmationSheet(boolean value) { + setBoolean(SharedPrefs.SHOW_P2P_WIFI_CONFIRMATION_SHEET, value); + } + public static boolean isPanicGeolocationActive() { return getBoolean(SharedPrefs.PANIC_GEOLOCATION, true); } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/SharedPrefs.java b/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/SharedPrefs.java index e2bcac965..1a3bd77b7 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/SharedPrefs.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/data/sharedpref/SharedPrefs.java @@ -32,6 +32,9 @@ public class SharedPrefs { static final String REMAINING_UNLOCK_ATTEMPTS = "remaining_unlock_attempts"; static final String ERASE_FORMS = "erase_forms"; + + static final String ENABLE_HOME_NEARBY_SHARING = "enable_home_nearby_sharing"; + static final String SHOW_P2P_WIFI_CONFIRMATION_SHEET = "show_p2p_wifi_confirmation_sheet"; //private static final String AUTO_SAVE_DRAFT_FORM = "auto_save_draft_form"; private static final String LANGUAGE = "language"; static final String SECRET_MODE_ENABLED = "secret_password_enabled"; diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/ServerType.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/ServerType.kt index 31466c64b..99ff73a43 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/ServerType.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/ServerType.kt @@ -1,5 +1,5 @@ package org.horizontal.tella.mobile.domain.entity enum class ServerType { - UNKNOWN, ODK_COLLECT, TELLA_UPLOAD, TELLA_RESORCES, UWAZI, GOOGLE_DRIVE, DROP_BOX, NEXTCLOUD, + UNKNOWN, ODK_COLLECT, TELLA_UPLOAD, TELLA_RESORCES, UWAZI, GOOGLE_DRIVE, DROP_BOX, NEXTCLOUD, PEERTOPEER, ADD_BUTTON } \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/collect/FormMediaFile.java b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/collect/FormMediaFile.java index 2db77b42f..1c51dc4ac 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/collect/FormMediaFile.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/collect/FormMediaFile.java @@ -9,12 +9,15 @@ public class FormMediaFile extends VaultFile { public FormMediaFileStatus status; // break away from getters/setters :) public boolean uploading; public long uploadedSize; + public String transmissionId; + public FormMediaFile() { super(); status = FormMediaFileStatus.UNKNOWN; uploading = true; uploadedSize = 0; + transmissionId = ""; } public static FormMediaFile fromMediaFile(@NonNull VaultFile vaultFile) { diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/peertopeer/PeerToPeerInstance.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/peertopeer/PeerToPeerInstance.kt new file mode 100644 index 000000000..18d539ed8 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/peertopeer/PeerToPeerInstance.kt @@ -0,0 +1,13 @@ +package org.horizontal.tella.mobile.domain.entity.peertopeer + +import org.horizontal.tella.mobile.domain.entity.EntityStatus +import org.horizontal.tella.mobile.domain.entity.collect.FormMediaFile +import org.horizontal.tella.mobile.domain.entity.collect.FormMediaFileStatus + +class PeerToPeerInstance( + var status: EntityStatus = EntityStatus.UNKNOWN, + var widgetMediaFiles: List = mutableListOf(), + var formPartStatus: FormMediaFileStatus = FormMediaFileStatus.UNKNOWN, + var title: String = "", + var sessionID: String = "" +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/peertopeer/QRCodeInfos.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/peertopeer/QRCodeInfos.kt new file mode 100644 index 000000000..2a85a6eb7 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/entity/peertopeer/QRCodeInfos.kt @@ -0,0 +1,15 @@ +package org.horizontal.tella.mobile.domain.entity.peertopeer + +data class QRCodeInfos( + val ipAddress: String, + val pin: String, + val hash: String +) { + override fun equals(other: Any?): Boolean { + return (other as? QRCodeInfos)?.ipAddress == ipAddress + } + + override fun hashCode(): Int { + return ipAddress.hashCode() + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/ByteArrayFlexibleSerializer.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/ByteArrayFlexibleSerializer.kt new file mode 100644 index 000000000..a2264763f --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/ByteArrayFlexibleSerializer.kt @@ -0,0 +1,57 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import android.os.Build +import androidx.annotation.RequiresApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* +import kotlinx.serialization.json.* +import java.util.Base64 // On Android <26, switch to android.util.Base64 + +object ByteArrayFlexibleSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("ByteArrayFlexible", PrimitiveKind.STRING) + + @RequiresApi(Build.VERSION_CODES.O) + override fun serialize(encoder: Encoder, value: ByteArray) { + // Always serialize as Base64 string to keep responses compact/consistent + val b64 = Base64.getEncoder().encodeToString(value) + encoder.encodeString(b64) + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun deserialize(decoder: Decoder): ByteArray { + if (decoder !is JsonDecoder) + throw SerializationException("Expected JsonDecoder") + + val el = decoder.decodeJsonElement() + return when (el) { + is JsonPrimitive -> { + // Base64 string + val s = el.content + try { + Base64.getDecoder().decode(s) + } catch (e: Exception) { + throw SerializationException("Invalid Base64 in thumbnail", e) + } + } + is JsonArray -> { + // [0..255, 0..255, ...] + val out = ByteArray(el.size) + el.forEachIndexed { i, jp -> + val n = jp.jsonPrimitive.int + // clamp to 0..255 and convert to signed byte + val b = (n.coerceIn(0, 255) and 0xFF).toByte() + out[i] = b + } + out + } + is JsonNull -> { + // shouldn't reach here for non-null type; handled by .nullable wrapper + throw SerializationException("thumbnail was null") + } + else -> throw SerializationException("thumbnail must be Base64 string or int[]") + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/IncomingRegistration.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/IncomingRegistration.kt new file mode 100644 index 000000000..bc983a3f3 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/IncomingRegistration.kt @@ -0,0 +1,6 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +data class IncomingRegistration( + val registrationId: String, + val payload: PeerRegisterPayload +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/KeyStoreConfig.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/KeyStoreConfig.kt new file mode 100644 index 000000000..8d067c115 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/KeyStoreConfig.kt @@ -0,0 +1,26 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import java.util.UUID + +data class KeyStoreConfig( + val alias: String = "tella-alias", + val password: CharArray = UUID.randomUUID().toString().toCharArray() +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as KeyStoreConfig + + if (alias != other.alias) return false + if (!password.contentEquals(other.password)) return false + + return true + } + + override fun hashCode(): Int { + var result = alias.hashCode() + result = 31 * result + password.contentHashCode() + return result + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/NearbySharingIpPreference.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/NearbySharingIpPreference.kt new file mode 100644 index 000000000..9f4bb4199 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/NearbySharingIpPreference.kt @@ -0,0 +1,38 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +/** + * Picks a sensible order for QR-encoded IPs on the **sender**: try addresses on the same + * IPv4 /24-style subnet as a local interface first (matches iOS / product heuristic). + */ +object NearbySharingIpPreference { + + /** For IPv4 `a.b.c.d`, returns `a.b.c`. Non-IPv4 → `null`. */ + fun ipv4ThreeOctetPrefix(ip: String): String? { + val parts = ip.split('.') + if (parts.size != 4) return null + if (parts.any { it.isEmpty() || it.any { c -> !c.isDigit() } }) return null + return "${parts[0]}.${parts[1]}.${parts[2]}" + } + + /** + * Puts QR IPs whose subnet matches any local IPv4 interface first, then the rest in original order. + */ + fun preferredNearbySharingIPOrder( + qrAddresses: List, + localDeviceIPv4Addresses: List, + ): List { + if (qrAddresses.isEmpty()) return emptyList() + val localSubnets = localDeviceIPv4Addresses.mapNotNull { ipv4ThreeOctetPrefix(it) }.toSet() + if (localSubnets.isEmpty()) return qrAddresses.distinct() + + val preferred = mutableListOf() + val rest = mutableListOf() + for (ip in qrAddresses) { + val p = ipv4ThreeOctetPrefix(ip) + if (p != null && localSubnets.contains(p)) preferred.add(ip) + else rest.add(ip) + } + val seen = mutableSetOf() + return (preferred + rest).filter { seen.add(it) } + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/NearbySharingTransferConfig.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/NearbySharingTransferConfig.kt new file mode 100644 index 000000000..26a878f6a --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/NearbySharingTransferConfig.kt @@ -0,0 +1,25 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import com.hzontal.tella_vault.VaultFile + +/** + * Limits for Nearby Sharing (prepare + upload), aligned with Tella-Desktop defaults + * (`maxFileSizeBytes` / `maxFileCount` in app config) `NearbySharingTransferConfig.standard`. + */ +object NearbySharingTransferConfig { + + object Standard { + /** Default 3 GB — matches desktop `config.defaultMaxFileSize`. */ + const val maxFileSizeBytes: Long = 3_000_000_000L + + /** Matches desktop `defaultMaxFileCount`. */ + const val maxFileCount: Int = 1000 + } + + /** Pre-check using vault metadata before we stream for plaintext hash (conservative on size). */ + fun isSelectionWithinVaultLimits(files: List): Boolean { + if (files.size > Standard.maxFileCount) return false + if (files.any { it.size > Standard.maxFileSizeBytes }) return false + return true + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/P2PFile.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/P2PFile.kt new file mode 100644 index 000000000..b0a8d08d0 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/P2PFile.kt @@ -0,0 +1,44 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class P2PFile( + val id: String, + val fileName: String, + val size: Long, + val fileType: String, + @SerialName("sha256") val sha256: String? = null, + @Serializable(with = ByteArrayFlexibleSerializer::class) + val thumbnail: ByteArray? = null +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as P2PFile + + if (id != other.id) return false + if (fileName != other.fileName) return false + if (size != other.size) return false + if (fileType != other.fileType) return false + if (sha256 != other.sha256) return false + if (thumbnail != null) { + if (other.thumbnail == null) return false + if (!thumbnail.contentEquals(other.thumbnail)) return false + } else if (other.thumbnail != null) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + fileName.hashCode() + result = 31 * result + size.hashCode() + result = 31 * result + fileType.hashCode() + result = 31 * result + (sha256?.hashCode() ?: 0) + result = 31 * result + (thumbnail?.contentHashCode() ?: 0) + return result + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerConnectionQrCodec.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerConnectionQrCodec.kt new file mode 100644 index 000000000..4e44d1d44 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerConnectionQrCodec.kt @@ -0,0 +1,83 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser + +/** + * QR payload aligned with iOS [ConnectionInfo] JSON ( Swift `JSONEncoder` + [CodingKeys] ): + * `ip_address` (JSON array of IPv4 strings), `port` (number), `certificate_hash` (hex), `pin` (string). + * The default `port` must match [org.horizontal.tella.mobile.data.peertopeer.PeerToPeerConstants.NEARBY_SHARING_TLS_PORT] + * and the bound P2P HTTPS listener. + */ +data class ParsedPeerQr( + val ipAddresses: List, + val port: Int, + val certificateHash: String?, + val pin: String, +) + +object PeerConnectionQrCodec { + + fun parse(qrContent: String): ParsedPeerQr? { + return try { + val obj = JsonParser.parseString(qrContent).asJsonObject + + val ipEl = obj.get("ip_address") ?: return null + val ips = when { + ipEl.isJsonArray -> ipEl.asJsonArray.mapNotNull { + it.takeIf { it.isJsonPrimitive }?.asString?.trim() + } + ipEl.isJsonPrimitive -> listOf(ipEl.asString.trim()) + else -> emptyList() + }.filter { it.isNotEmpty() } + + if (ips.isEmpty()) return null + + val port = obj.get("port") + ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber } + ?.asInt ?: return null + + val cert = obj.get("certificate_hash") + ?.takeUnless { it.isJsonNull } + ?.asString ?: return null + + val pin = obj.get("pin") + ?.takeUnless { it.isJsonNull } + ?.asString ?: return null + + ParsedPeerQr(ips, port, cert, pin) + } catch (e: Exception) { + null + } + } + + fun toJson( + ipAddresses: List, + port: Int, + certificateHash: String, + pin: String + ): String { + require(ipAddresses.isNotEmpty()) { "ipAddresses cannot be empty" } + require(port in 1..65535) { "Invalid port" } + require(certificateHash.isNotBlank()) { "certificateHash cannot be blank" } + require(pin.isNotBlank()) { "pin cannot be blank" } + + val obj = JsonObject() + + val ipArray = JsonArray() + ipAddresses + .map { it.trim() } + .filter { it.isNotEmpty() } + .forEach { ipArray.add(it) } + + require(ipArray.size() > 0) { "All IPs are empty" } + + obj.add("ip_address", ipArray) + obj.addProperty("port", port) + obj.addProperty("certificate_hash", certificateHash) + obj.addProperty("pin", pin) + + return obj.toString() + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerEventManager.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerEventManager.kt new file mode 100644 index 000000000..a93549c11 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerEventManager.kt @@ -0,0 +1,91 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState +import org.horizontal.tella.mobile.data.peertopeer.remote.PrepareUploadRequest +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.state.UploadProgressState + +/** + * Created by wafa on 3/6/2025. + */ +object PeerEventManager { + + // Used to signal clearing of registration replayed value + private val EMPTY_REGISTRATION_REQUEST = "" to PeerRegisterPayload.EMPTY + + val registrationEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) + + val closeConnectionEvent = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) + + // Replays the last actual registration request to new collectors + private val _registrationRequests = MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 1 + ) + val registrationRequests = _registrationRequests.asSharedFlow() + + + private val _prepareUploadRequests = + MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val prepareUploadRequests = _prepareUploadRequests.asSharedFlow() + + private val decisionMap = mutableMapOf>() + private val registrationDecisionMap = mutableMapOf>() + + private val _uploadProgressStateFlow = MutableSharedFlow(replay = 0) + val uploadProgressStateFlow = _uploadProgressStateFlow.asSharedFlow() + + // Upload progress + suspend fun onUploadProgressState(state: UploadProgressState) { + _uploadProgressStateFlow.emit(state) + } + + // Trigger one-time event for registration success + suspend fun emitRegistrationSuccess() { + registrationEvents.emit(true) + } + + suspend fun emitCloseConnection() { + closeConnectionEvent.emit(true) + } + + suspend fun emitPrepareUploadRequest(request: PrepareUploadRequest): Boolean { + val deferred = CompletableDeferred() + decisionMap[request.sessionId] = deferred + _prepareUploadRequests.emit(request) // emit actual payload; late subscribers will receive it + return deferred.await() // await user decision on recipient + } + + + fun resolveUserDecision(sessionId: String, accepted: Boolean) { + decisionMap.remove(sessionId)?.complete(accepted) + } + + // Registration request with real payload + suspend fun emitIncomingRegistrationRequest( + registrationId: String, + payload: PeerRegisterPayload + ): Boolean { + val deferred = CompletableDeferred() + registrationDecisionMap[registrationId] = deferred + _registrationRequests.emit(registrationId to payload) + return deferred.await() + } + + // Clears the replayed registration request by emitting a dummy one + suspend fun clearRegistrationRequest() { + _registrationRequests.emit(EMPTY_REGISTRATION_REQUEST) + } + + fun confirmRegistration(registrationId: String, accepted: Boolean) { + registrationDecisionMap.remove(registrationId)?.complete(accepted) + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerPrepareUploadResponse.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerPrepareUploadResponse.kt new file mode 100644 index 000000000..549628dd8 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerPrepareUploadResponse.kt @@ -0,0 +1,13 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import kotlinx.serialization.Serializable + +@Serializable +data class FileInfo( + val id: String, + val transmissionId: String +) +@Serializable +data class PeerPrepareUploadResponse( + val files: List +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerRegisterPayload.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerRegisterPayload.kt new file mode 100644 index 000000000..1faf3bdd8 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerRegisterPayload.kt @@ -0,0 +1,14 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import kotlinx.serialization.Serializable + +@Serializable +data class PeerRegisterPayload( + val pin: String, + /** Optional on the wire like iOS `RegisterRequest`; sender always supplies a value. */ + val nonce: String? = null, +) { + companion object { + val EMPTY = PeerRegisterPayload(pin = "", nonce = null) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerResponse.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerResponse.kt new file mode 100644 index 000000000..9891126b7 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/PeerResponse.kt @@ -0,0 +1,8 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +import kotlinx.serialization.Serializable + +@Serializable +data class PeerResponse( + val sessionId: String +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/TellaServer.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/TellaServer.kt new file mode 100644 index 000000000..655edf426 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/TellaServer.kt @@ -0,0 +1,7 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +interface TellaServer { + fun start() + fun stop() + val certificatePem: String +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/UploadProgressEvent.kt b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/UploadProgressEvent.kt new file mode 100644 index 000000000..15d888c50 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/domain/peertopeer/UploadProgressEvent.kt @@ -0,0 +1,7 @@ +package org.horizontal.tella.mobile.domain.peertopeer + +data class UploadProgressEvent( + val fileId: String, + val bytesWritten: Long, + val totalBytes: Long +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/javarosa/JavaRosa.java b/mobile/src/main/java/org/horizontal/tella/mobile/javarosa/JavaRosa.java index 8ea93e1c8..f7e443f95 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/javarosa/JavaRosa.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/javarosa/JavaRosa.java @@ -26,31 +26,35 @@ public class JavaRosa { * Classes needed to serialize objects. Need to put anything from JR in here. */ private static final String[] SERIALIABLE_CLASSES = { - "org.javarosa.core.services.locale.ResourceFileDataSource", // JavaRosaCoreModule - "org.javarosa.core.services.locale.TableLocaleSource", // JavaRosaCoreModule + "org.javarosa.core.model.SubmissionProfile", "org.javarosa.core.model.FormDef", - "org.javarosa.core.model.SubmissionProfile", // CoreModelModule - "org.javarosa.core.model.QuestionDef", // CoreModelModule - "org.javarosa.core.model.GroupDef", // CoreModelModule - "org.javarosa.core.model.instance.FormInstance", // CoreModelModule - "org.javarosa.core.model.data.BooleanData", // CoreModelModule - "org.javarosa.core.model.data.DateData", // CoreModelModule - "org.javarosa.core.model.data.DateTimeData", // CoreModelModule - "org.javarosa.core.model.data.DecimalData", // CoreModelModule - "org.javarosa.core.model.data.GeoPointData", // CoreModelModule - "org.javarosa.core.model.data.GeoShapeData", // CoreModelModule - "org.javarosa.core.model.data.GeoTraceData", // CoreModelModule - "org.javarosa.core.model.data.IntegerData", // CoreModelModule - "org.javarosa.core.model.data.LongData", // CoreModelModule - "org.javarosa.core.model.data.MultiPointerAnswerData", // CoreModelModule - "org.javarosa.core.model.data.PointerAnswerData", // CoreModelModule - "org.javarosa.core.model.data.SelectMultiData", // CoreModelModule - "org.javarosa.core.model.data.SelectOneData", // CoreModelModule - "org.javarosa.core.model.data.StringData", // CoreModelModule - "org.javarosa.core.model.data.TimeData", // CoreModelModule - "org.javarosa.core.model.data.UncastData", // CoreModelModule - "org.javarosa.core.model.data.helper.BasicDataPointer", // CoreModelModule - "org.javarosa.core.model.actions.SetValueAction" // CoreModelModule + "org.javarosa.core.model.QuestionDef", + "org.javarosa.core.model.RangeQuestion", + "org.javarosa.core.model.GroupDef", + "org.javarosa.core.model.instance.TreeReference", + "org.javarosa.core.model.instance.FormInstance", + "org.javarosa.core.model.instance.ExternalDataInstance", + "org.javarosa.core.model.data.BooleanData", + "org.javarosa.core.model.data.DateData", + "org.javarosa.core.model.data.DateTimeData", + "org.javarosa.core.model.data.DecimalData", + "org.javarosa.core.model.data.GeoPointData", + "org.javarosa.core.model.data.GeoShapeData", + "org.javarosa.core.model.data.GeoTraceData", + "org.javarosa.core.model.data.IntegerData", + "org.javarosa.core.model.data.LongData", + "org.javarosa.core.model.data.MultiPointerAnswerData", + "org.javarosa.core.model.data.PointerAnswerData", + "org.javarosa.core.model.data.SelectMultiData", + "org.javarosa.core.model.data.MultipleItemsData", + "org.javarosa.core.model.data.SelectOneData", + "org.javarosa.core.model.data.StringData", + "org.javarosa.core.model.data.TimeData", + "org.javarosa.core.model.data.UncastData", + "org.javarosa.core.model.data.helper.BasicDataPointer", + "org.javarosa.core.model.actions.SetValueAction", + "org.javarosa.core.model.actions.setgeopoint.StubSetGeopointAction", + "org.javarosa.core.model.actions.recordaudio.RecordAudioAction" }; private static boolean isJavaRosaInitialized = false; diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/media/MediaFileHandler.java b/mobile/src/main/java/org/horizontal/tella/mobile/media/MediaFileHandler.java index d8c823b31..5c0e25e0d 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/media/MediaFileHandler.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/media/MediaFileHandler.java @@ -111,51 +111,49 @@ public static void emptyTmp(final Context context) { } public static void startSelectMediaActivity(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode) { + launchFilePicker(activity, type, extraMimeType, true, requestCode); + } + + private static void launchFilePicker( + @NonNull Activity activity, + @NonNull String type, + @Nullable String[] extraMimeType, + boolean allowMultiple, + int requestCode) { PackageManager pm = activity.getPackageManager(); - - // Try ACTION_OPEN_DOCUMENT first (preferred for Android 4.4+) - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); - intent.setType(type); - intent.addCategory(Intent.CATEGORY_OPENABLE); - + + Intent getContent = new Intent(Intent.ACTION_GET_CONTENT) + .setType(type) + .addCategory(Intent.CATEGORY_OPENABLE) + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple); if (extraMimeType != null) { - intent.putExtra(Intent.EXTRA_MIME_TYPES, extraMimeType); + getContent.putExtra(Intent.EXTRA_MIME_TYPES, extraMimeType); } - - // Check if any activity can handle this intent - if (intent.resolveActivity(pm) != null) { - // Use createChooser to show all available file pickers (including Fossify, etc.) - Intent chooser = Intent.createChooser(intent, activity.getString(R.string.select_file_picker)); + if (getContent.resolveActivity(pm) != null) { try { - activity.startActivityForResult(chooser, requestCode); + activity.startActivityForResult(getContent, requestCode); return; } catch (ActivityNotFoundException e) { - Timber.d(e, "ACTION_OPEN_DOCUMENT chooser failed"); + Timber.d(e, "ACTION_GET_CONTENT failed"); } } - - // Fallback to ACTION_GET_CONTENT (works with more apps, including older file managers) - Intent fallbackIntent = new Intent(Intent.ACTION_GET_CONTENT); - fallbackIntent.setType(type); - fallbackIntent.addCategory(Intent.CATEGORY_OPENABLE); - fallbackIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); - + + Intent openDocument = new Intent(Intent.ACTION_OPEN_DOCUMENT) + .setType(type) + .addCategory(Intent.CATEGORY_OPENABLE) + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple); if (extraMimeType != null) { - fallbackIntent.putExtra(Intent.EXTRA_MIME_TYPES, extraMimeType); + openDocument.putExtra(Intent.EXTRA_MIME_TYPES, extraMimeType); } - - // Check if any activity can handle fallback intent - if (fallbackIntent.resolveActivity(pm) != null) { - Intent chooser = Intent.createChooser(fallbackIntent, activity.getString(R.string.select_file_picker)); + if (openDocument.resolveActivity(pm) != null) { try { - activity.startActivityForResult(chooser, requestCode); + activity.startActivityForResult(openDocument, requestCode); return; } catch (ActivityNotFoundException e) { - Timber.d(e, "ACTION_GET_CONTENT chooser failed"); + Timber.d(e, "ACTION_OPEN_DOCUMENT failed"); } } - - // If both fail, show helpful error message + DialogUtils.showBottomMessage( activity, activity.getString(R.string.gallery_toast_fail_import_no_file_manager), @@ -416,6 +414,17 @@ public static Single importPhotoUri(Context context, Uri uri, @Nullab } public static Single saveJpegPhoto(@NonNull byte[] jpegPhoto, @Nullable String parent) throws Exception { + return saveJpegPhoto(jpegPhoto, parent, null); + } + + /** + * @param vaultFileName when non-null and non-blank (e.g. P2P receive), stored under this vault name instead of a random id-based name + */ + public static Single saveJpegPhoto( + @NonNull byte[] jpegPhoto, + @Nullable String parent, + @Nullable String vaultFileName + ) throws Exception { // create thumb BitmapFactory.Options opt = new BitmapFactory.Options(); opt.inSampleSize = 8; @@ -438,16 +447,20 @@ public static Single saveJpegPhoto(@NonNull byte[] jpegPhoto, @Nullab input.reset(); String uid = UUID.randomUUID().toString(); + boolean useSenderName = !TextUtils.isEmpty(vaultFileName); + String name = useSenderName ? vaultFileName.trim() : uid + ".jpg"; RxVault rxVault = MyApplication.keyRxVault.getRxVault().blockingFirst(); RxVaultFileBuilder rxVaultFileBuilder = rxVault .builder(input) .setMimeType("image/jpeg") - .setName(uid + ".jpg") - .setAnonymous(true) + .setName(name) + .setAnonymous(!useSenderName) .setType(VaultFile.Type.FILE) - .setId(uid) .setThumb(getThumbByteArray(thumb)); + if (!useSenderName) { + rxVaultFileBuilder.setId(uid); + } // Ensure the Bitmap object is recycled to free up memory thumb.recycle(); @@ -467,6 +480,18 @@ public static Single saveJpegPhoto(@NonNull byte[] jpegPhoto, @Nullab public static VaultFile savePngImage(@NonNull byte[] pngImage) { + return savePngImage(pngImage, null, null); + } + + /** + * @param parent when non-null, file is created under this folder; when null, behavior matches legacy {@link #savePngImage(byte[])} (vault root) + * @param vaultFileName when non-null and non-blank, stored under this vault name instead of a random id-based name + */ + public static VaultFile savePngImage( + @NonNull byte[] pngImage, + @Nullable String parent, + @Nullable String vaultFileName + ) { // create thumb ByteArrayOutputStream stream = new ByteArrayOutputStream(); BitmapFactory.Options opt = new BitmapFactory.Options(); @@ -478,17 +503,26 @@ public static VaultFile savePngImage(@NonNull byte[] pngImage) { // encode png InputStream input = new ByteArrayInputStream(pngImage); String uid = UUID.randomUUID().toString(); + boolean useSenderName = !TextUtils.isEmpty(vaultFileName); + String name = useSenderName ? vaultFileName.trim() : uid + ".png"; RxVault rxVault = MyApplication.keyRxVault.getRxVault().blockingFirst(); - return rxVault + RxVaultFileBuilder b = rxVault .builder(input) - .setId(uid) .setMimeType("image/png") - .setName(uid + ".png") - .setAnonymous(true) + .setName(name) + .setAnonymous(!useSenderName) .setType(VaultFile.Type.FILE) - .setThumb(getThumbByteArray(thumb)) - .build() + .setThumb(getThumbByteArray(thumb)); + if (!useSenderName) { + b.setId(uid); + } + if (parent == null) { + return b.build() + .subscribeOn(Schedulers.io()) + .blockingGet(); + } + return b.build(parent) .subscribeOn(Schedulers.io()) .blockingGet(); } @@ -558,6 +592,7 @@ public static Single importOthersUri(Context context, Uri uri, String InputStream is = context.getContentResolver().openInputStream(uri); RxVault rxVault = MyApplication.keyRxVault.getRxVault().blockingFirst(); + assert DocumentFile.fromSingleUri(context, uri) != null; return rxVault .builder(is) .setMimeType(mimeType) @@ -575,6 +610,27 @@ public static Single importOthersUri(Context context, Uri uri, String @WorkerThread public static VaultFile saveMp4Video(File video, String parent) throws IOException { + return saveMp4Video(video, parent, true); + } + + /** + * @param deleteSourceAfterImport false for P2P receive temps (caller deletes after vault import). + */ + @WorkerThread + public static VaultFile saveMp4Video(File video, String parent, boolean deleteSourceAfterImport) throws IOException { + return saveMp4Video(video, parent, deleteSourceAfterImport, null); + } + + /** + * @param vaultFileName when non-null and non-blank (e.g. P2P receive), stored under this vault name instead of a random id-based name + */ + @WorkerThread + public static VaultFile saveMp4Video( + File video, + String parent, + boolean deleteSourceAfterImport, + @Nullable String vaultFileName + ) throws IOException { FileInputStream vis = null; MediaMetadataRetriever retriever = new MediaMetadataRetriever(); @@ -582,24 +638,34 @@ public static VaultFile saveMp4Video(File video, String parent) throws IOExcepti vis = new FileInputStream(video); retriever.setDataSource(vis.getFD()); - // duration String time = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + long durationMs = 0L; + if (time != null && !time.isEmpty()) { + try { + durationMs = Long.parseLong(time); + } catch (NumberFormatException e) { + Timber.w("saveMp4Video: invalid duration, using 0"); + } + } - // thumbnail byte[] thumb = getThumbByteArray(retriever.getFrameAtTime()); String uid = UUID.randomUUID().toString(); + boolean useSenderName = !TextUtils.isEmpty(vaultFileName); + String name = useSenderName ? vaultFileName.trim() : uid + ".mp4"; RxVault rxVault = MyApplication.keyRxVault.getRxVault().blockingFirst(); RxVaultFileBuilder rxVaultFileBuilder = rxVault .builder(new FileInputStream(video)) .setAnonymous(false) - .setId(uid) - .setDuration(Long.parseLong(time)) + .setDuration(durationMs) .setType(VaultFile.Type.FILE) - .setName(uid + ".mp4") + .setName(name) .setMimeType("video/mp4") .setThumb(thumb); + if (!useSenderName) { + rxVaultFileBuilder.setId(uid); + } if (parent == null) { return rxVaultFileBuilder @@ -615,9 +681,15 @@ public static VaultFile saveMp4Video(File video, String parent) throws IOExcepti Timber.e(e, MediaFileHandler.class.getName()); throw e; + } catch (RuntimeException e) { + CrashReporterProvider.INSTANCE.get().recordException(e); + Timber.e(e, MediaFileHandler.class.getName()); + throw new IOException("Video import failed", e); } finally { FileUtil.close(vis); - FileUtil.delete(video); + if (deleteSourceAfterImport) { + FileUtil.delete(video); + } try { retriever.release(); } catch (Exception ignore) { @@ -865,13 +937,11 @@ public static Observable> getLastVaultFileFromDb() { } public static void startImportFiles(Activity context, Boolean multipleFile, String type) { - Intent intent = new Intent() - .setType(type) - .setAction(Intent.ACTION_OPEN_DOCUMENT) - .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multipleFile); - - context.startActivityForResult( - Intent.createChooser(intent, "Import files"), + launchFilePicker( + context, + type != null ? type : "*/*", + null, + Boolean.TRUE.equals(multipleFile), C.IMPORT_MULTIPLE_FILES ); } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/odk/FormController.java b/mobile/src/main/java/org/horizontal/tella/mobile/odk/FormController.java index 2eff718b8..45f39176a 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/odk/FormController.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/odk/FormController.java @@ -297,14 +297,6 @@ public FormEntryCaption getCaptionPrompt() { } - /** - * This fires off the jr:preload actions and events to save values like the - * end time of a form. - */ - public boolean postProcessInstance() { - return formEntryController.getModel().getForm().postProcessInstance(); - } - /** * TODO: We need a good description of what this does, exactly, and why. diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/util/ConnectionType.kt b/mobile/src/main/java/org/horizontal/tella/mobile/util/ConnectionType.kt new file mode 100644 index 000000000..ade9b35aa --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/util/ConnectionType.kt @@ -0,0 +1,344 @@ +package org.horizontal.tella.mobile.util + +import android.annotation.SuppressLint +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import java.util.LinkedHashSet +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import java.net.Inet4Address +import java.net.NetworkInterface +import java.util.Locale +import org.horizontal.tella.mobile.data.peertopeer.P2PNetworkAddressPolicy +import org.horizontal.tella.mobile.data.peertopeer.PeerToPeerConstants + +enum class ConnectionType { + WIFI, CELLULAR, HOTSPOT, NONE +} + +/** + * Snapshot of connectivity for Nearby Sharing UI. + * + * [ipAddress] is only populated for [ConnectionType.WIFI] and [ConnectionType.HOTSPOT] when a + * policy-eligible LAN IPv4 is available for QR/connection; otherwise null (including [ConnectionType.NONE]). + */ +data class NetworkInfo( + val connectionType: ConnectionType, + val ipAddress: String?, + var port: String = PeerToPeerConstants.NEARBY_SHARING_TLS_PORT.toString() +) + +class NetworkInfoManager(private val context: Context) { + + private val _networkInfo = MutableLiveData() + val networkInfo: LiveData = _networkInfo + + private var currentNetworkInfo: NetworkInfo = NetworkInfo(ConnectionType.NONE, null) + + @RequiresApi(Build.VERSION_CODES.M) + @SuppressLint("MissingPermission") + fun fetchCurrentNetworkInfo() { + val cm = ContextCompat.getSystemService(context, ConnectivityManager::class.java) + val network: Network? = cm?.activeNetwork + val caps: NetworkCapabilities? = cm?.getNetworkCapabilities(network) + + if (caps == null) { + post(ConnectionType.NONE, null) + return + } + + when { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> { + post(ConnectionType.WIFI, computeDisplayCandidateIpv4(cm, network)) + } + + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> { + if (isLikelyWifiHotspotWhileCellularDefault()) { + post( + ConnectionType.HOTSPOT, + computeDisplayCandidateIpv4(cm, network), + ) + } else { + post(ConnectionType.NONE, null) + } + } + + else -> { + post(ConnectionType.NONE, null) + } + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun computeDisplayCandidateIpv4( + cm: ConnectivityManager?, + network: Network?, + ): String? { + val linkIps = getAllActiveIpv4FromDefaultNetwork(cm, network) + return pickDisplayIpv4FromSources(linkIps, collectIpv4FromFilteredUpInterfaces()) + } + + private fun post(type: ConnectionType, ip: String?) { + val safeIp = when (type) { + ConnectionType.WIFI, ConnectionType.HOTSPOT -> ip + else -> null + } + val info = NetworkInfo(type, safeIp) + _networkInfo.postValue(info) + currentNetworkInfo = info + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun getAllActiveIpv4FromDefaultNetwork( + cm: ConnectivityManager?, + network: Network? + ): List { + if (cm == null || network == null) return emptyList() + val caps: NetworkCapabilities = cm.getNetworkCapabilities(network) ?: return emptyList() + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return emptyList() + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) && + !caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + !caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + ) { + return emptyList() + } + val lp: LinkProperties = cm.getLinkProperties(network) ?: return emptyList() + if (!lp.interfaceName.isNullOrEmpty() && isExcludedFromSensitiveInterfaceScan(lp.interfaceName)) { + return emptyList() + } + return lp.linkAddresses + .mapNotNull { it.address } + .filter { it is Inet4Address && !it.isLoopbackAddress } + .mapNotNull { it.hostAddress?.substringBefore('%')?.takeIf { h -> h.isNotBlank() } } + .distinct() + .take(MAX_ADVERTISE_IPV4_COLLECT) + } + + /** + * Best address for UI/QR: RFC1918 only, excludes 100.64/10, link-local, etc. + */ + @RequiresApi(Build.VERSION_CODES.M) + private fun pickDisplayIpv4FromSources( + linkIpv4: List, + interfaceFallback: List + ): String? { + val merged = (linkIpv4 + interfaceFallback).distinct().take(MAX_ADVERTISE_IPV4_COLLECT) + return P2PNetworkAddressPolicy.filterAndOrderForAdvertise(merged).firstOrNull() + } + + private fun collectIpv4FromFilteredUpInterfaces(): List { + return try { + NetworkInterface.getNetworkInterfaces()?.toList().orEmpty() + .filter { it.isUp && !it.isLoopback && !isExcludedFromSensitiveInterfaceScan(it.name) } + .flatMap { iface -> + iface.interfaceAddresses.mapNotNull { ia -> + val addr = ia.address + if (addr !is Inet4Address || addr.isLoopbackAddress) return@mapNotNull null + val h = addr.hostAddress?.substringBefore('%')?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + if (!P2PNetworkAddressPolicy.isEligibleForAdvertise(h)) return@mapNotNull null + h + } + } + .distinct() + .take(MAX_ADVERTISE_IPV4_COLLECT) + } catch (_: Exception) { + emptyList() + } + } + + private fun isExcludedFromSensitiveInterfaceScan(interfaceName: String?): Boolean { + if (interfaceName == null) return false + val name = interfaceName.lowercase(Locale.US) + if (name.startsWith("rmnet")) return true + if (name.startsWith("ccmni")) return true + if (name.startsWith("pdp")) return true + if (name.startsWith("tun")) return true + if (name.startsWith("wg")) return true + if (name.startsWith("ppp")) return true + if (name.startsWith("l2tp")) return true + if (name.startsWith("ipsec")) return true + if (name.startsWith("utun")) return true + if (name.startsWith("awdl")) return true + if (name.startsWith("lo")) return true + if (name.startsWith("dummy")) return true + return false + } + + private fun nearbySharingInterfacePriority(interfaceName: String): Int? { + if (isExcludedFromSensitiveInterfaceScan(interfaceName)) return null + val name = interfaceName.lowercase(Locale.US) + if (name.startsWith("wlan")) return 0 + if (name.startsWith("ap")) return 1 + if (name.startsWith("swlan")) return 1 + if (name.startsWith("bridge")) return 2 + if (name.startsWith("eth") || name.startsWith("en")) return 2 + return null + } + + /** + * iOS-like collection for Nearby Sharing: + * - excludes cellular/tunnel interfaces + * - prefers Wi-Fi/hotspot interfaces + * - falls back to broader local IPv4 collection if filtered result is empty + */ + fun collectNearbySharingIpv4Addresses(): List { + val bestScoreByIp = linkedMapOf() + val candidates = try { + NetworkInterface.getNetworkInterfaces()?.toList().orEmpty() + } catch (_: Exception) { + emptyList() + } + + for (iface in candidates) { + val score = nearbySharingInterfacePriority(iface.name) ?: continue + if (!iface.isUp || iface.isLoopback) continue + val addresses = iface.interfaceAddresses.orEmpty() + for (ia in addresses) { + val addr = ia.address ?: continue + if (addr !is Inet4Address || addr.isLoopbackAddress) continue + val ip = + addr.hostAddress?.substringBefore('%')?.takeIf { it.isNotBlank() } ?: continue + if (!P2PNetworkAddressPolicy.isEligibleForAdvertise(ip)) continue + val prev = bestScoreByIp[ip] + if (prev == null || score < prev) bestScoreByIp[ip] = score + } + } + + if (bestScoreByIp.isNotEmpty()) { + return P2PNetworkAddressPolicy + .filterAndOrderForAdvertise(bestScoreByIp.keys.toList()) + .take(MAX_ADVERTISE_IPV4_COLLECT) + } + + // Broaden to link + interface addresses, still RFC1918-only for what we show/share. + val fromAll = collectAllLocalIpv4AddressesRaw() + return P2PNetworkAddressPolicy.filterAndOrderForAdvertise(fromAll) + .take(MAX_ADVERTISE_IPV4_COLLECT) + } + + /** + * IPv4 addresses on active links (API 23+) plus interface fallback. Used to match QR multi-IP against local subnets. + */ + @SuppressLint("MissingPermission") + fun collectAllLocalIpv4Addresses(): List { + return P2PNetworkAddressPolicy + .filterAndOrderForAdvertise(collectAllLocalIpv4AddressesRaw()) + .take(MAX_ADVERTISE_IPV4_COLLECT) + } + + /** + * Internal collection for candidate RFC1918 addresses. Not returned unfiltered from public APIs. + */ + @SuppressLint("MissingPermission") + private fun collectAllLocalIpv4AddressesRaw(): List { + val out = LinkedHashSet() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val cm = ContextCompat.getSystemService(context, ConnectivityManager::class.java) + if (cm != null) { + for (network in cm.allNetworks) { + if (!isNetworkEligibleForLocalIpv4Collection(cm, network)) continue + val lp = cm.getLinkProperties(network) ?: continue + if (!lp.interfaceName.isNullOrEmpty() && isExcludedFromSensitiveInterfaceScan(lp.interfaceName)) { + continue + } + for (la in lp.linkAddresses) { + val a = la.address + if (a is Inet4Address && !a.isLoopbackAddress) { + val h = a.hostAddress?.substringBefore('%') + if (!h.isNullOrBlank() && P2PNetworkAddressPolicy.isEligibleForAdvertise( + h + ) + ) { + out.add(h) + } + } + } + } + } + } + if (out.isEmpty()) { + out.addAll(collectIpv4FromFilteredUpInterfaces()) + } + return out.toList().take(MAX_ADVERTISE_IPV4_COLLECT) + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun isNetworkEligibleForLocalIpv4Collection( + cm: ConnectivityManager, + network: Network + ): Boolean { + val caps = cm.getNetworkCapabilities(network) ?: return false + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return false + return caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + } + + /** + * Detects portable Wi-Fi hotspot without hidden APIs: look for a separate Wi-Fi network with + * a soft-AP-style interface and an advertisable RFC1918 address while the default network is cellular. + */ + @RequiresApi(Build.VERSION_CODES.M) + private fun isLikelyWifiHotspotWhileCellularDefault(): Boolean { + val cm = ContextCompat.getSystemService(context, ConnectivityManager::class.java) + ?: return isLikelySoftApByInterfaceScan() + val active = cm.activeNetwork ?: return isLikelySoftApByInterfaceScan() + val activeCaps = cm.getNetworkCapabilities(active) ?: return isLikelySoftApByInterfaceScan() + if (!activeCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) return false + for (network in cm.allNetworks) { + if (network == active) continue + val c = cm.getNetworkCapabilities(network) ?: continue + if (!c.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) continue + val lp = cm.getLinkProperties(network) ?: continue + val ifn = lp.interfaceName?.lowercase(Locale.US) ?: continue + if (!isLikelySoftwareAccessPointInterface(ifn)) continue + val hasAdvertisable = lp.linkAddresses.any { la -> + val a = la.address + a is Inet4Address && + P2PNetworkAddressPolicy.isEligibleForAdvertise( + a.hostAddress?.substringBefore('%')?.trim().orEmpty() + ) + } + if (hasAdvertisable) return true + } + return isLikelySoftApByInterfaceScan() + } + + private fun isLikelySoftwareAccessPointInterface(name: String): Boolean { + val n = name.lowercase(Locale.US) + if (n.startsWith("softap")) return true + if (n.startsWith("swlan")) return true + if (n == "ap0" || n == "ap1" || n.startsWith("ap_br")) return true + return n.matches(AP_NUMBERED_IFACE) + } + + private fun isLikelySoftApByInterfaceScan(): Boolean { + return try { + NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().any { ni -> + if (!ni.isUp || ni.isLoopback) return@any false + if (isExcludedFromSensitiveInterfaceScan(ni.name)) return@any false + if (!isLikelySoftwareAccessPointInterface(ni.name)) return@any false + ni.interfaceAddresses.any { ia -> + val a = ia.address + a is Inet4Address && + P2PNetworkAddressPolicy.isEligibleForAdvertise( + a.hostAddress?.substringBefore('%')?.trim().orEmpty() + ) + } + } + } catch (_: Exception) { + false + } + } + + private companion object { + private const val MAX_ADVERTISE_IPV4_COLLECT = 5 + private val AP_NUMBERED_IFACE = Regex("^ap\\d+$") + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/util/Event.kt b/mobile/src/main/java/org/horizontal/tella/mobile/util/Event.kt new file mode 100644 index 000000000..d3649b743 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/util/Event.kt @@ -0,0 +1,20 @@ +package org.horizontal.tella.mobile.util + +/** + * Created by wafa on 23/6/2025. + */ +class Event(private val content: T) { + private var hasBeenHandled = false + + /** Returns the content if not handled, else null */ + fun getContentIfNotHandled(): T? { + return if (hasBeenHandled) null + else { + hasBeenHandled = true + content + } + } + + /** Always returns the content, even if already handled */ + fun peekContent(): T = content +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/util/Extensions.kt b/mobile/src/main/java/org/horizontal/tella/mobile/util/Extensions.kt index b58572151..9124e08de 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/util/Extensions.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/util/Extensions.kt @@ -13,16 +13,14 @@ import android.view.WindowManager import android.view.accessibility.AccessibilityManager import android.widget.ImageView import androidx.annotation.ColorRes +import androidx.annotation.IdRes import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentManager import androidx.navigation.NavController +import org.horizontal.tella.mobile.R import com.google.gson.Gson import com.google.gson.JsonParseException import com.google.gson.reflect.TypeToken -import org.horizontal.tella.mobile.R -/*import org. cleaninsights.sdk.Campaign -import org.cleaninsights.sdk.CleanInsights -import org.cleaninsights.sdk.CleanInsightsConfiguration*/ import timber.log.Timber @@ -145,8 +143,28 @@ fun Context.isScreenReaderOn(): Boolean { return false } -fun NavController.navigateSafe(destinationId: Int, bundle: Bundle? = null) { - navigate(destinationId, bundle,) +fun NavController.navigateSafe(@IdRes destinationId: Int, args: Bundle? = null) { + try { + navigate(destinationId, args) + } catch (e: IllegalArgumentException) { + val destName = try { + context.resources.getResourceEntryName(destinationId) + } catch (ex: Exception) { + destinationId.toString() + } + Timber.w( + "[NavigationSafe] Skipped navigation to '$destName' from '${currentDestination?.label}' – $e" + ) + } +} + +fun String.formatHash(): String { + return this + .take(64) // take only the first 64 characters + .chunked(4) // split into groups of 4 + .chunked(4) // make 4 lines + .joinToString("\n") { it.joinToString(" ") } + } private const val NAME_DUPLICATE_VAULT_FILE = "com.hzontal.tella_vault.exceptions.DuplicateVaultFileException" diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/util/FileUtil.java b/mobile/src/main/java/org/horizontal/tella/mobile/util/FileUtil.java index 80201e071..54a0c4930 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/util/FileUtil.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/util/FileUtil.java @@ -62,9 +62,9 @@ public static String getPrimaryMime(String mimeType) { @Nullable public static String getMimeType(@NonNull String filename) { - return MimeTypeMap.getSingleton().getMimeTypeFromExtension( - MimeTypeMap.getFileExtensionFromUrl(filename.toLowerCase()) - ); + String extension = MimeTypeMap.getFileExtensionFromUrl(filename.toLowerCase()); + // Return the corresponding MIME type or null if unknown + return extension != null ? MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) : null; } /* public static MediaFile.Type getMediaFileType(@NonNull String filename) { diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/util/NavigationManager.kt b/mobile/src/main/java/org/horizontal/tella/mobile/util/NavigationManager.kt index ff632cd91..376828d64 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/util/NavigationManager.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/util/NavigationManager.kt @@ -1,6 +1,7 @@ package org.horizontal.tella.mobile.util import android.os.Bundle +import androidx.navigation.navOptions import org.horizontal.tella.mobile.R import org.horizontal.tella.mobile.views.fragment.reports.di.NavControllerProvider @@ -55,9 +56,11 @@ class NavigationManager( fun navigateFromGoogleDriveEntryScreenToGoogleDriveSendScreen() { navigateToWithBundle(R.id.action_newGoogleDriveScreen_to_googleDriveSendScreen) } + fun navigateFromNextCloudEntryScreenToNextCloudSendScreen() { navigateToWithBundle(R.id.action_newNextCloudScreen_to_nextCloudSendScreen) } + fun navigateFromGoogleDriveMainScreenToGoogleDriveSendScreen() { navigateToWithBundle(R.id.action_googleDriveScreen_to_googleDriveSendScreen) } @@ -65,6 +68,7 @@ class NavigationManager( fun navigateFromNextCloudMainScreenToNextCloudSendScreen() { navigateToWithBundle(R.id.action_nextCloudScreen_to_nextCloudSendScreen) } + fun navigateFromGoogleDriveScreenToGoogleDriveSubmittedScreen() { navigateToWithBundle(R.id.action_googleDriveScreen_to_googleDriveSubmittedScreen) } @@ -72,6 +76,7 @@ class NavigationManager( fun navigateFromNextCloudScreenToNextCloudSubmittedScreen() { navigateToWithBundle(R.id.action_nextCloudScreen_to_nextCloudSubmittedScreen) } + fun navigateFromDropBoxScreenToDropBoxSubmittedScreen() { navigateToWithBundle(R.id.action_dropBoxScreen_to_dropBoxSubmittedScreen) } @@ -140,7 +145,8 @@ class NavigationManager( fun navigateToNextCloudCreateFolderScreen() { navigateToWithBundle(R.id.action_loginNextCloudScreen_to_nextCloudNewFolderScreen) } - fun actionNextCloudNewFolderScreenToSuccessfulScreen(){ + + fun actionNextCloudNewFolderScreenToSuccessfulScreen() { navigateToWithBundle(R.id.action_nextCloudNewFolderScreen_to_successfulSetServerFragment) } @@ -152,6 +158,104 @@ class NavigationManager( navigateToWithBundle(R.id.action_selectSharedDriveFragment_to_googleDriveConnectedServerFragment) } + fun navigateFromStartNearBySharingFragmentToConnectHotspotFragment() { + navigateToWithBundle(R.id.action_startNearBySharingFragment_to_connectHotspotFragment) + } + + fun navigateFromStartNearBySharingFragmentToQrCodeScreen() { + navigateToWithBundle(R.id.action_startNearBySharingFragment_to_qrCodeScreen) + } + + fun navigateFromStartNearBySharingFragmentToScanQrCodeScreen() { + navigateToWithBundle(R.id.action_startNearBySharingFragment_to_scanQrCodeScreen) + } + + fun navigateFromStartNearBySharingFragmentToTipsToConnectFragment() { + navigateToWithBundle(R.id.action_startNearBySharingFragment_to_tipsToConnectFragment) + } + + fun navigateFromActionConnectHotspotScreenToQrCodeScreen() { + navigateToWithBundle(R.id.action_connectHotspotScreen_to_qrCodeScreen) + } + + fun navigateFromActionConnectHotspotScreenToScanQrCodeScreen() { + navigateToWithBundle(R.id.action_connectHotspotScreen_to_scanQrCodeScreen) + } + + fun navigateFromScanQrCodeToDeviceInfo() { + navigateToWithBundle(R.id.action_qrCodeScreen_to_deviceInfoScreen) + } + + fun navigateFromScanQrCodeToPrepareUploadFragment() { + navigateToWithBundle(R.id.action_scanQrCodeScreen_to_prepareUploadFragment) + } + + fun navigateFromScanQrCodeToSenderManualConnectionScreen() { + navigateToWithBundle(R.id.action_scanQrCodeScreen_to_senderManualConnectionScreen) + } + + fun navigateFromSenderManualConnectionToConnectManuallyVerification() { + navigateToWithBundle(R.id.action_senderManualConnectionScreen_to_connectManuallyVerificationScreen) + } + + fun navigateFromDeviceInfoScreenTRecipientVerificationScreen() { + navigateToWithBundle(R.id.action_deviceInfoScreen_to_recipientVerificationScreen) + } + + fun navigateFromQrCodeScreenToWaitingReceiverFragment() { + navigateToWithBundle(R.id.action_qrCodeScreen_to_waitingReceiverFragment) + } + + fun navigateFromPrepareUploadFragmentToWaitingSenderFragment() { + navigateToWithBundle(R.id.action_prepareUploadFragment_to_waitingSenderFragment) + } + + fun navigateConnectManuallyVerificationFragmentToprepareUploadFragment() { + navigateToWithBundle(R.id.action_connectManuallyVerificationFragment_to_prepareUploadFragment) + } + + fun navigateFromRecipientVerificationScreenToWaitingReceiverFragment() { + navigateToWithBundle(R.id.action_recipientVerificationScreen_to_waitingReceiverFragment) + } + + fun navigateFromWaitingReceiverFragmentToRecipientSuccessFragment() { + navigateToWithBundle(R.id.action_waitingReceiverFragment_to_recipientSuccessFragment) + } + + fun navigateFromWaitingSenderFragmentToUploadFilesFragment() { + navigateToWithBundle(R.id.action_waitingSenderFragment_to_uploadFilesFragment) + } + + fun navigateFromUploadSenderFragmentToPeerToPeerResultFragment() { + navigateToWithBundle(R.id.action_uploadSenderFragment_to_peerToPeerResultFragment) + } + + fun navigateFromRecipientUploadFilesFragmentToPeerToPeerResultFragment() { + navigateToWithBundle(R.id.action_recipientUploadFilesFragment_to_peerToPeerResultFragment) + } + + fun navigateFromRecipientSuccessFragmentToRecipientUploadFilesFragment() { + navigateToWithBundle(R.id.action_recipientSuccessFragment_to_recipientUploadFilesFragment) + } + + fun popFromRecipientSuccessToWaitingReceiver(): Boolean = + navControllerProvider.navController.popBackStack(R.id.waitingReceiverFragment, false) + + fun navigateFromConnectHotspotScreenToTipsToConnectFragment() { + navigateToWithBundle(R.id.action_connectHotspotScreen_to_tipsToConnectFragment) + } + + fun navigateBackToStartNearBySharingFragmentAndClearBackStack() { + navControllerProvider.navController.navigate( + R.id.startNearBySharingFragment, + bundle, + navOptions { + popUpTo(R.id.startNearBySharingFragment) { + inclusive = true + } + } + ) + } fun navigateFromGoogleDriveConnectFragmentToCreateFolderFragment() { navigateToWithBundle(R.id.action_googleDriveConnectFragment_to_createFolderFragment) } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/MainActivity.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/MainActivity.kt index 14cf21368..3d2c8c283 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/MainActivity.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/MainActivity.kt @@ -15,6 +15,7 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.navigation.NavController import androidx.navigation.NavDestination +import androidx.navigation.findNavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.NavigationUI.setupWithNavController import com.google.android.material.bottomnavigation.BottomNavigationView @@ -44,6 +45,7 @@ import org.horizontal.tella.mobile.views.fragment.main_connexions.base.BaseRepor import org.horizontal.tella.mobile.views.fragment.main_connexions.base.BaseReportsEntryFragment import org.horizontal.tella.mobile.views.fragment.main_connexions.base.BaseReportsSendFragment import org.horizontal.tella.mobile.views.fragment.main_connexions.base.MainReportFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow.PeerToPeerFlags import org.horizontal.tella.mobile.views.fragment.recorder.MicFragment import org.horizontal.tella.mobile.views.fragment.reports.send.ReportsSendFragment import org.horizontal.tella.mobile.views.fragment.uwazi.SubmittedPreviewFragment @@ -54,6 +56,7 @@ import org.horizontal.tella.mobile.views.fragment.uwazi.entry.UwaziEntryPrompt import org.horizontal.tella.mobile.views.fragment.uwazi.send.UwaziSendFragment import org.horizontal.tella.mobile.views.fragment.uwazi.widgets.OnSelectEntitiesClickListener import org.horizontal.tella.mobile.views.fragment.vault.attachements.AttachmentsFragment +import org.horizontal.tella.mobile.views.fragment.vault.attachements.VAULT_PARENT_ID import org.horizontal.tella.mobile.views.fragment.vault.home.VAULT_FILTER import org.horizontal.tella.mobile.views.interfaces.IMainNavigationInterface import org.horizontal.tella.mobile.views.interfaces.VerificationWorkStatusCallback @@ -68,6 +71,8 @@ class MainActivity : MetadataActivity(), IMetadataAttachPresenterContract.IView, IMainNavigationInterface, VerificationWorkStatusCallback, OnSelectEntitiesClickListener { companion object { const val PHOTO_VIDEO_FILTER = "gallery_filter" + const val EXTRA_NAVIGATE_TO = "navigateTo" + const val EXTRA_VAULT_PARENT_ID = "vault_parent_id" } private var isBackgroundWorkInProgress: Boolean = false @@ -130,6 +135,7 @@ class MainActivity : MetadataActivity(), IMetadataAttachPresenterContract.IView, bundle.putString(VAULT_FILTER, FilterType.PHOTO_VIDEO.name) navController.navigate(R.id.action_homeScreen_to_attachments_screen, bundle) } + handleNavigationIntent(intent) onBackPressedDispatcher.addCallback(this, onBackPressedCallback) initObservers() } @@ -254,6 +260,7 @@ class MainActivity : MetadataActivity(), IMetadataAttachPresenterContract.IView, supportFragmentManager.primaryNavigationFragment?.childFragmentManager?.fragments?.forEach { it.onActivityResult(requestCode, resultCode, data) } + } private fun isLocationSettingsRequestCode(requestCode: Int): Boolean { @@ -369,6 +376,10 @@ class MainActivity : MetadataActivity(), IMetadataAttachPresenterContract.IView, super.onResume() startLocationMetadataListening() mOrientationEventListener!!.enable() + if (PeerToPeerFlags.cancelled) { + PeerToPeerFlags.cancelled = false + DialogUtils.showBottomMessage(this, "Nearby sharing was cancelled.", false) + } } override fun onPause() { @@ -467,4 +478,22 @@ class MainActivity : MetadataActivity(), IMetadataAttachPresenterContract.IView, .e(getString(R.string.could_not_find_uwazientryfragment)) } } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + handleNavigationIntent(intent) + } + + private fun handleNavigationIntent(intent: Intent?) { + intent?.getStringExtra(EXTRA_NAVIGATE_TO)?.let { destination -> + if (destination == "attachments_screen") { + val bundle = Bundle().apply { + intent.getStringExtra(EXTRA_VAULT_PARENT_ID)?.let { putString(VAULT_PARENT_ID, it) } + } + if (navController.currentDestination?.id != R.id.attachments_screen) { + navController.navigate(R.id.attachments_screen, bundle) + } + } + } + } } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/ServersSettingsActivity.java b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/ServersSettingsActivity.java index 1c0a7fe23..215b5b820 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/ServersSettingsActivity.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/ServersSettingsActivity.java @@ -150,6 +150,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { } initNextCloudEvents(); initListeners(); + initNearbySharingView(); } private void initObservers() { @@ -201,6 +202,19 @@ private void initObservers() { nextCloudServersViewModel.getServerRemoved().observe(this, this::onRemovedNextCloudServer); } + private void initNearbySharingView() { + binding.nearbySharingSwitch.mSwitch.setChecked(Preferences.isEnableHomeNearby()); + binding.nearbySharingSwitch.setTextAndAction(R.string.action_learn_more, () -> { + maybeChangeTemporaryTimeout(() -> { + Util.startBrowserIntent(getApplicationContext(), getString(R.string.config_nearby_sharing_url)); + return null; + }); + return Unit.INSTANCE; + }); + + binding.nearbySharingSwitch.mSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> Preferences.setEnableHomeNearby(isChecked)); + } + private void initDropBoxEvents() { INSTANCE.getCreateDropBoxServer().observe(this, server -> { if (server != null) { @@ -412,8 +426,7 @@ public void onRefreshBlankFormsError(Throwable error) { private void showChooseServerTypeDialog() { BottomSheetUtils.showBinaryTypeSheet(this.getSupportFragmentManager(), this, getString(R.string.settings_add_server_selection_dialog_title), getString(R.string.settings_add_server_selection_dialog_title), getString(R.string.Connections_description_selection), getString(R.string.Connections_description), this::browseIntent, getString(R.string.action_cancel), //TODO CHECk THIS - getString(R.string.action_ok), - getString(R.string.settings_docu_add_server_dialog_select_odk), getString(R.string.settings_docu_add_server_dialog_select_tella_web), getString(R.string.settings_docu_add_server_dialog_select_tella_uwazi), getString(R.string.settings_docu_add_server_dialog_select_tella_google_drive), getString(R.string.settings_docu_add_server_dialog_select_tella_dropbox), getString(R.string.settings_docu_add_server_dialog_select_next_cloud), getString(R.string.unavailable_connections), getString(R.string.unavailable_connections_desc), servers.stream().anyMatch(server -> server instanceof GoogleDriveServer), servers.stream().anyMatch(server -> server instanceof DropBoxServer), servers.stream().anyMatch(server -> server instanceof NextCloudServer), new BottomSheetUtils.IServerChoiceActions() { + getString(R.string.action_ok), getString(R.string.settings_docu_add_server_dialog_select_odk), getString(R.string.settings_docu_add_server_dialog_select_tella_web), getString(R.string.settings_docu_add_server_dialog_select_tella_uwazi), getString(R.string.settings_docu_add_server_dialog_select_tella_google_drive), getString(R.string.settings_docu_add_server_dialog_select_tella_dropbox), getString(R.string.settings_docu_add_server_dialog_select_next_cloud), getString(R.string.unavailable_connections), getString(R.string.unavailable_connections_desc), servers.stream().anyMatch(server -> server instanceof GoogleDriveServer), servers.stream().anyMatch(server -> server instanceof DropBoxServer), servers.stream().anyMatch(server -> server instanceof NextCloudServer), new BottomSheetUtils.IServerChoiceActions() { @Override public void addDropBoxServer() { diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/SettingsActivity.java b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/SettingsActivity.java index 115e151b5..9429116ec 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/SettingsActivity.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/SettingsActivity.java @@ -72,14 +72,14 @@ protected void onCreate(Bundle savedInstanceState) { } disposables = MyApplication.bus().createCompositeDisposable(); - disposables.wire(LocaleChangedEvent.class, new EventObserver() { + disposables.wire(LocaleChangedEvent.class, new EventObserver<>() { @Override public void onNext(@NotNull LocaleChangedEvent event) { recreate(); } }); - disposables.wire(CloseSettingsActivityEvent.class, new EventObserver() { + disposables.wire(CloseSettingsActivityEvent.class, new EventObserver<>() { @Override public void onNext(@NotNull CloseSettingsActivityEvent event) { finish(); diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/camera/SharedCameraViewModel.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/camera/SharedCameraViewModel.kt index d7c543b8b..afc8a5783 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/camera/SharedCameraViewModel.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/camera/SharedCameraViewModel.kt @@ -69,7 +69,8 @@ class SharedCameraViewModel @Inject constructor() : ViewModel() { thumb = null ) _addingInProgress.postValue(true) - MyApplication.bus().post(RecentBackgroundActivitiesEvent(mutableListOf(backgroundVideoFile))) + MyApplication.bus() + .post(RecentBackgroundActivitiesEvent(mutableListOf(backgroundVideoFile))) } .observeOn(AndroidSchedulers.mainThread()) .doFinally { _addingInProgress.postValue(false) } @@ -82,7 +83,6 @@ class SharedCameraViewModel @Inject constructor() : ViewModel() { ) } - fun addMp4Video(file: File, parent: String?) { disposables.add(Observable.fromCallable { MediaFileHandler.saveMp4Video(file, parent) } .subscribeOn(Schedulers.io()).doOnSubscribe { @@ -145,11 +145,13 @@ class SharedCameraViewModel @Inject constructor() : ViewModel() { disposables.add( MediaFileHandler.getLastVaultFileFromDb().subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ vaultFiles -> if (!vaultFiles.isNullOrEmpty()) { - _lastMediaFileSuccess.postValue(vaultFiles[0]) - } else { - _lastMediaFileError.postValue(Throwable("No media files found")) - } }, + .subscribe({ vaultFiles -> + if (!vaultFiles.isNullOrEmpty()) { + _lastMediaFileSuccess.postValue(vaultFiles[0]) + } else { + _lastMediaFileError.postValue(Throwable("No media files found")) + } + }, { throwable -> _lastMediaFileError.postValue(throwable) }) ) } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/onboarding/OnBoardNearbySharingFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/onboarding/OnBoardNearbySharingFragment.kt new file mode 100644 index 000000000..c0fec77a6 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/onboarding/OnBoardNearbySharingFragment.kt @@ -0,0 +1,34 @@ +package org.horizontal.tella.mobile.views.activity.onboarding + +import android.os.Bundle +import android.view.View +import org.horizontal.tella.mobile.databinding.OnboardNearbySharingFragmentBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment + +class OnBoardNearbySharingFragment : + BaseBindingFragment(OnboardNearbySharingFragmentBinding::inflate) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initView() + } + + override fun onResume() { + super.onResume() + (baseActivity as OnBoardActivityInterface).enableSwipe( + isSwipeable = true, isTabLayoutVisible = true + ) + (baseActivity as OnBoardActivityInterface).showButtons( + isNextButtonVisible = true, isBackButtonVisible = true + ) + } + + private fun initView() { + binding.backBtn.setOnClickListener { + baseActivity.onBackPressed() + } + binding.nextBtn.setOnClickListener { + (baseActivity as OnBoardingActivity).onNextPressed() + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/onboarding/OnBoardingActivity.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/onboarding/OnBoardingActivity.kt index fa7c21de6..b61893ab4 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/onboarding/OnBoardingActivity.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/activity/onboarding/OnBoardingActivity.kt @@ -28,7 +28,7 @@ import org.horizontal.tella.mobile.views.base_ui.BaseActivity import org.horizontal.tella.mobile.views.dialog.CollectServerDialogFragment import org.horizontal.tella.mobile.views.dialog.SharedLiveData.createReportsServer import org.horizontal.tella.mobile.views.dialog.SharedLiveData.createServer -import org.horizontal.tella.mobile.views.dialog.TellaUploadServerDialogFragment.TellaUploadServerDialogHandler +import org.horizontal.tella.mobile.views.dialog.TellaUploadServerDialogHandler import org.horizontal.tella.mobile.views.dialog.reports.ReportsConnectFlowActivity import org.horizontal.tella.mobile.views.dialog.uwazi.UwaziConnectFlowActivity import org.hzontal.shared_ui.bottomsheet.BottomSheetUtils @@ -39,7 +39,9 @@ private const val ONBOARDING_CAMERA_VIEW_INDEX = 1 private const val ONBOARDING_RECORDER_VIEW_INDEX = 2 private const val ONBOARDING_FILES_VIEW_INDEX = 3 private const val ONBOARDING_COLLECT_DATA_VIEW = 4 -private const val ONBOARDING_LOCK_VIEW_INDEX = 5 +private const val ONBOARDING_NEARBY_SHARING_VIEW_INDEX = 5 +private const val ONBOARDING_LOCK_VIEW_INDEX = 6 + @AndroidEntryPoint class OnBoardingActivity : BaseActivity(), OnBoardActivityInterface, @@ -62,7 +64,7 @@ class OnBoardingActivity : BaseActivity(), OnBoardActivityInterface, setContentView(binding.root) applyEdgeToEdge(binding.root) // Instantiate a ViewPager and a Tablayout - if (!isOnboardLockSet && !isFromSettings) initViewPager(6) + if (!isOnboardLockSet && !isFromSettings) initViewPager(7) // Instantiate next and back buttons initButtons() @@ -361,6 +363,7 @@ class OnBoardingActivity : BaseActivity(), OnBoardActivityInterface, ONBOARDING_RECORDER_VIEW_INDEX -> OnBoardRecorderFragment() ONBOARDING_FILES_VIEW_INDEX -> OnBoardFilesFragment() ONBOARDING_COLLECT_DATA_VIEW -> OnboardCollectDataFragment() + ONBOARDING_NEARBY_SHARING_VIEW_INDEX -> OnBoardNearbySharingFragment() ONBOARDING_LOCK_VIEW_INDEX -> OnBoardLockFragment() else -> OnBoardIntroFragment() } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/adapters/reports/ReportsFilesRecyclerViewAdapter.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/adapters/reports/ReportsFilesRecyclerViewAdapter.kt index 933892b0c..1f211c8a6 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/adapters/reports/ReportsFilesRecyclerViewAdapter.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/adapters/reports/ReportsFilesRecyclerViewAdapter.kt @@ -6,6 +6,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView +import androidx.annotation.DrawableRes import androidx.core.content.res.ResourcesCompat import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide @@ -113,7 +114,7 @@ open class ReportsFilesRecyclerViewAdapter( } private fun showVideoInfo(vaultFile: VaultFile) { - filePreviewImg.loadImage(vaultFile.thumb) + filePreviewImg.loadImage(vaultFile.thumb, R.drawable.ic_video) icAttachmentImg.setBackgroundResource(R.drawable.ic_play) } @@ -126,12 +127,14 @@ open class ReportsFilesRecyclerViewAdapter( } private fun showImageInfo(vaultFile: VaultFile) { - filePreviewImg.loadImage(vaultFile.thumb) + filePreviewImg.loadImage(vaultFile.thumb, R.drawable.ic_gallery) } - fun ImageView.loadImage(thumb: ByteArray) { + fun ImageView.loadImage(thumb: ByteArray?, @DrawableRes fallback: Int) { Glide.with(this) .load(thumb) + .fallback(fallback) + .error(fallback) .diskCacheStrategy(DiskCacheStrategy.NONE) .skipMemoryCache(true) .into(this) diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseActivity.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseActivity.kt index 25ab40f1b..ed7116a9d 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseActivity.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseActivity.kt @@ -38,7 +38,8 @@ abstract class BaseActivity : AppCompatActivity() { var isManualOrientation = false private lateinit var container: ViewGroup private lateinit var loading: View - @Inject lateinit var divviupUtils : DivviupUtils + @Inject + lateinit var divviupUtils: DivviupUtils override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { @@ -160,7 +161,7 @@ abstract class BaseActivity : AppCompatActivity() { .commitAllowingStateLoss() } - fun addFragment(container : Int, fragment: Fragment, tag : String ){ + fun addFragment(container: Int, fragment: Fragment, tag: String) { val existingFragment = supportFragmentManager.findFragmentByTag(tag) if (existingFragment == null) { supportFragmentManager.beginTransaction() diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseBindingFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseBindingFragment.kt index 3feb62630..b87ad0b1f 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseBindingFragment.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/base_ui/BaseBindingFragment.kt @@ -111,5 +111,4 @@ abstract class BaseBindingFragment( _binding = null isViewInitialized = false } - } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/collect/widgets/RangeWidget.java b/mobile/src/main/java/org/horizontal/tella/mobile/views/collect/widgets/RangeWidget.java new file mode 100644 index 000000000..29b6b9482 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/collect/widgets/RangeWidget.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2009 University of Washington + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package org.horizontal.tella.mobile.views.collect.widgets; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.view.LayoutInflater; +import android.widget.LinearLayout; +import android.widget.SeekBar; +import android.widget.TextView; + +import org.horizontal.tella.mobile.R; +import org.javarosa.core.model.Constants; +import org.javarosa.core.model.RangeQuestion; +import org.javarosa.core.model.data.IAnswerData; +import org.javarosa.core.model.data.IntegerData; +import org.javarosa.form.api.FormEntryPrompt; + + +/** + * Based on ODK Collect SelectOneWidget. + */ +@SuppressLint("ViewConstructor") +public class RangeWidget extends QuestionWidget + implements SeekBar.OnSeekBarChangeListener +{ + private SeekBar inputElement; + + private TextView rangeText; + + private int rangeStart; + private int range; + + public RangeWidget(Context context, FormEntryPrompt prompt) { + super(context, prompt); + + LinearLayout linearLayout = new LinearLayout(context); + linearLayout.setOrientation(LinearLayout.VERTICAL); + + LayoutInflater inflater = LayoutInflater.from(getContext()); + + inflater.inflate(R.layout.collect_widget_range, linearLayout, true); + + + RangeQuestion q = (RangeQuestion) prompt.getQuestion(); + + rangeStart = q.getRangeStart().intValue(); + final int rangeEnd = q.getRangeEnd().intValue(); + range = Math.abs(rangeEnd-rangeStart); + + final int rangeType = prompt.getDataType(); + if(rangeType == Constants.DATATYPE_INTEGER){ + inputElement = linearLayout.findViewById(R.id.seekBarDiscrete); + }//todo: handle non integer types? + + inputElement.setId(QuestionWidget.newUniqueId()); + inputElement.setMax(range); + + rangeText = linearLayout.findViewById(R.id.rangeText); + rangeText.setId(QuestionWidget.newUniqueId()); + + if (formEntryPrompt.getAnswerValue() == null) { + setAnswer(range/2); + } else { + rangeText.setText(formEntryPrompt.getAnswerText()); + Integer value = ((Integer)formEntryPrompt.getAnswerValue().getValue()); + inputElement.setProgress(Math.abs(q.getRangeStart().intValue())+value); + } + + inputElement.setOnSeekBarChangeListener(this); + + addAnswerView(linearLayout); + } + + protected void setAnswer(int value) { + inputElement.setProgress(value); + rangeText.setText(String.valueOf(value)); + } + + @Override + public void clearAnswer() { + setAnswer(range/2); + } + + @Override + public IAnswerData getAnswer() { + return new IntegerData(inputElement.getProgress() + rangeStart); + } + + @Override + public void setFocus(Context context) { + + } + + @Override + public void onProgressChanged(SeekBar seekBar, int i, boolean b) { + rangeText.setText(String.valueOf(seekBar.getProgress() - rangeStart)); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) {} + + @Override + public void onStopTrackingTouch(SeekBar seekBar) {} +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/collect/widgets/WidgetFactory.java b/mobile/src/main/java/org/horizontal/tella/mobile/views/collect/widgets/WidgetFactory.java index 57700cc92..c126945e8 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/collect/widgets/WidgetFactory.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/collect/widgets/WidgetFactory.java @@ -118,6 +118,10 @@ public static QuestionWidget createWidgetFromPrompt(FormEntryPrompt fep, Context questionWidget = new DocMediaWidget(context, fep); break; + case Constants.CONTROL_RANGE: + questionWidget = new RangeWidget(context, fep); + break; + default: questionWidget = new StringWidget(context, fep, readOnlyOverride); break; diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/dialog/TellaUploadServerDialogHandler.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/dialog/TellaUploadServerDialogHandler.kt new file mode 100644 index 000000000..2bc1361db --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/dialog/TellaUploadServerDialogHandler.kt @@ -0,0 +1,13 @@ +package org.horizontal.tella.mobile.views.dialog + +import org.horizontal.tella.mobile.domain.entity.reports.TellaReportServer + +/** + * Implemented by activities that host [TellaUploadServerDialogFragment] (playstore / fdroid). + * Defined in main so shared code (e.g. onboarding) can reference it without depending on flavor sources. + */ +interface TellaUploadServerDialogHandler { + fun onTellaUploadServerDialogCreate(server: TellaReportServer?) + fun onTellaUploadServerDialogUpdate(server: TellaReportServer?) + fun onDialogDismiss() +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/dialog/nextcloud/sslalert/X509CertificateViewAdapter.java b/mobile/src/main/java/org/horizontal/tella/mobile/views/dialog/nextcloud/sslalert/X509CertificateViewAdapter.java index 7bd700ba5..c3a826250 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/dialog/nextcloud/sslalert/X509CertificateViewAdapter.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/dialog/nextcloud/sslalert/X509CertificateViewAdapter.java @@ -37,7 +37,7 @@ */ public class X509CertificateViewAdapter implements SslUntrustedCertDialog.CertificateViewAdapter { - private X509Certificate mCertificate; + private final X509Certificate mCertificate; private static final String TAG = X509CertificateViewAdapter.class.getSimpleName(); diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/CameraFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/CameraFragment.kt deleted file mode 100644 index 7aa74f02c..000000000 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/CameraFragment.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.horizontal.tella.mobile.views.fragment - -import android.os.Bundle -import androidx.fragment.app.Fragment -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import org.horizontal.tella.mobile.R - -// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER -private const val ARG_PARAM1 = "param1" -private const val ARG_PARAM2 = "param2" - -class CameraFragment : Fragment() { - private var param1: String? = null - private var param2: String? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - arguments?.let { - param1 = it.getString(ARG_PARAM1) - param2 = it.getString(ARG_PARAM2) - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?): View? { - // Inflate the layout for this fragment - return inflater.inflate(R.layout.fragment_camera, container, false) - } - - companion object { - @JvmStatic - fun newInstance(param1: String, param2: String) = - CameraFragment().apply { - arguments = Bundle().apply { - putString(ARG_PARAM1, param1) - putString(ARG_PARAM2, param2) - } - } - } -} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/ReportsFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/ReportsFragment.kt deleted file mode 100644 index b604058d5..000000000 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/ReportsFragment.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.horizontal.tella.mobile.views.fragment - -import android.os.Bundle -import androidx.fragment.app.Fragment -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import org.horizontal.tella.mobile.R - -// TODO: Rename parameter arguments, choose names that match -// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER -private const val ARG_PARAM1 = "param1" -private const val ARG_PARAM2 = "param2" - -/** - * A simple [Fragment] subclass. - * Use the [ReportsFragment.newInstance] factory method to - * create an instance of this fragment. - */ -class ReportsFragment : Fragment() { - // TODO: Rename and change types of parameters - private var param1: String? = null - private var param2: String? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - arguments?.let { - param1 = it.getString(ARG_PARAM1) - param2 = it.getString(ARG_PARAM2) - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?): View? { - // Inflate the layout for this fragment - return inflater.inflate(R.layout.fragment_reports, container, false) - } - - companion object { - /** - * Use this factory method to create a new instance of - * this fragment using the provided parameters. - * - * @param param1 Parameter 1. - * @param param2 Parameter 2. - * @return A new instance of fragment ReportsFragment. - */ - // TODO: Rename and change types and number of parameters - @JvmStatic - fun newInstance(param1: String, param2: String) = - ReportsFragment().apply { - arguments = Bundle().apply { - putString(ARG_PARAM1, param1) - putString(ARG_PARAM2, param2) - } - } - } -} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/StartNearBySharingFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/StartNearBySharingFragment.kt new file mode 100644 index 000000000..93761d4e0 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/StartNearBySharingFragment.kt @@ -0,0 +1,116 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.widget.CheckBox +import android.widget.TextView +import androidx.fragment.app.activityViewModels +import com.hzontal.tella_locking_ui.ui.pin.pinview.ResourceUtils.getColor +import com.google.android.material.bottomsheet.BottomSheetDialog +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.sharedpref.Preferences +import org.horizontal.tella.mobile.databinding.StartNearBySharingFragmentBinding +import org.horizontal.tella.mobile.util.Util +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow.PeerToPeerParticipant +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel + +class StartNearBySharingFragment : BaseBindingFragment( + StartNearBySharingFragmentBinding::inflate +) { + private val viewModel: PeerToPeerViewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initViews() + } + + private fun initViews() { + binding.apply { + nextBtn.setTextColor(getColor(baseActivity, R.color.wa_white_40)) + toolbar.backClickListener = { baseActivity.onBackPressed() } + toolbar.onRightClickListener = { + navManager().navigateFromStartNearBySharingFragmentToTipsToConnectFragment() + } + learnMoreTextView.setOnClickListener { + baseActivity.maybeChangeTemporaryTimeout() + Util.startBrowserIntent(context, getString(R.string.peerToPeer_documentation_url)) + } + + sendFilesBtn.setOnClickListener { selectOption(true) } + receiveFilesBtn.setOnClickListener { selectOption(false) } + nextBtn.setOnClickListener { } + } + } + + private fun selectOption(isSend: Boolean) { + binding.apply { + sendFilesBtn.isChecked = isSend + receiveFilesBtn.isChecked = !isSend + nextBtn.setOnClickListener { onNextClicked() } + nextBtn.setTextColor(getColor(baseActivity, R.color.wa_white)) + } + } + + private fun onNextClicked() { + with(binding) { + when { + sendFilesBtn.isChecked -> { + viewModel.peerToPeerParticipant = PeerToPeerParticipant.SENDER + maybeShowWifiConfirmation() + } + + receiveFilesBtn.isChecked -> { + viewModel.peerToPeerParticipant = PeerToPeerParticipant.RECIPIENT + maybeShowWifiConfirmation() + } + + else -> {} + } + } + } + + private fun maybeShowWifiConfirmation() { + if (Preferences.isShowP2pWifiConfirmationSheet()) { + showWifiConfirmationBottomSheet() + } else { + navigateToNextStep() + } + } + + private fun showWifiConfirmationBottomSheet() { + val context = context ?: return + val dialog = BottomSheetDialog( + context, + org.hzontal.shared_ui.R.style.AppBottomSheetDialogTheme + ) + val contentView = LayoutInflater.from(context) + .inflate(R.layout.bottom_sheet_p2p_wifi_confirmation, null, false) + val dontShowAgainCheckBox = contentView.findViewById(R.id.dontShowAgainCheckBox) + val noButton = contentView.findViewById(R.id.cancelButton) + val yesButton = contentView.findViewById(R.id.continueButton) + + noButton.setOnClickListener { dialog.dismiss() } + yesButton.setOnClickListener { + if (dontShowAgainCheckBox.isChecked) { + Preferences.setShowP2pWifiConfirmationSheet(false) + } + dialog.dismiss() + navigateToNextStep() + } + + dialog.setContentView(contentView) + dialog.show() + } + + private fun navigateToNextStep() { + if (viewModel.peerToPeerParticipant == PeerToPeerParticipant.RECIPIENT) { + navManager().navigateFromStartNearBySharingFragmentToQrCodeScreen() + } else { + navManager().navigateFromStartNearBySharingFragmentToScanQrCodeScreen() + } + } +} + + diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/activity/PeerToPeerActivity.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/activity/PeerToPeerActivity.kt new file mode 100644 index 000000000..83ef9108e --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/activity/PeerToPeerActivity.kt @@ -0,0 +1,102 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.activity + +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import com.google.gson.Gson +import com.hzontal.tella_vault.VaultFile +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.databinding.ActivityPeerToPeerBinding +import org.horizontal.tella.mobile.mvvm.media.MediaImportViewModel +import org.horizontal.tella.mobile.util.C +import org.horizontal.tella.mobile.util.crash.CrashReporterProvider +import org.horizontal.tella.mobile.views.base_ui.BaseLockActivity +import org.horizontal.tella.mobile.views.fragment.uwazi.attachments.VAULT_FILE_KEY +import org.hzontal.shared_ui.utils.DialogUtils +import timber.log.Timber +import javax.inject.Inject + +@AndroidEntryPoint +class PeerToPeerActivity : BaseLockActivity() { + + private lateinit var binding: ActivityPeerToPeerBinding + private val mediaImportViewModel: MediaImportViewModel by viewModels() + + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityPeerToPeerBinding.inflate(layoutInflater) + setContentView(binding.getRoot()) + initObservers() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + // Handle import results + if (requestCode == C.IMPORT_VIDEO || requestCode == C.IMPORT_IMAGE || requestCode == C.IMPORT_FILE) { + handleImportResult(requestCode, data) + return + } + + // Delegate onActivityResult to child fragments + supportFragmentManager.primaryNavigationFragment?.childFragmentManager?.fragments?.forEach { + it.onActivityResult(requestCode, resultCode, data) + } + + } + + private fun handleImportResult(requestCode: Int, data: Intent?) { + try { + if (data != null) { + val uri = data.data + if (uri != null) { + divviupUtils.runFileImportEvent() + when (requestCode) { + C.IMPORT_FILE -> mediaImportViewModel.importFile(uri) + } + } + } + } catch (e: NullPointerException) { + // Handle null pointer exception + DialogUtils.showBottomMessage( + this, + getString(R.string.gallery_toast_fail_importing_file), + false + ) + CrashReporterProvider.get().recordException(e) + Timber.e(e, "NullPointerException occurred: ${e.message}") + } catch (e: Exception) { + // Handle other exceptions + CrashReporterProvider.get().recordException(e) + Timber.e(e, "NullPointerException occurred: ${e.message}") + } + } + + private fun onMediaFileImported(vaultFile: VaultFile) { + val list: MutableList = ArrayList() + list.add(vaultFile.id) + onActivityResult( + C.MEDIA_FILE_ID, RESULT_OK, Intent().putExtra(VAULT_FILE_KEY, Gson().toJson(list)) + ) + } + + private fun onImportError(throwable: Throwable?) { + Timber.d(throwable) + } + + private fun initObservers() { + mediaImportViewModel.mediaFileLiveData.observe(this, ::onMediaFileImported) + mediaImportViewModel.importError.observe(this, ::onImportError) + } + + override fun onDestroy() { + super.onDestroy() + peerServerStarterManager.stopServer() + } + +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/common/TipsConnectFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/common/TipsConnectFragment.kt new file mode 100644 index 000000000..c7364365d --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/common/TipsConnectFragment.kt @@ -0,0 +1,112 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.common + +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextPaint +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.LeadingMarginSpan +import android.view.View +import androidx.activity.addCallback +import androidx.core.content.ContextCompat +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.util.Util +import org.horizontal.tella.mobile.databinding.FragmentTipsConnectBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment + +class TipsConnectFragment : + BaseBindingFragment(FragmentTipsConnectBinding::inflate) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + + binding.toolbar.backClickListener = + { requireActivity().onBackPressedDispatcher.onBackPressed() } + + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + isEnabled = false + requireActivity().onBackPressedDispatcher.onBackPressed() + } + + binding.section1List.text = buildNumberedConnectText() + binding.section3Bullets.text = buildMoreTipsText() + binding.section3Bullets.movementMethod = LinkMovementMethod.getInstance() + } + + private fun buildNumberedConnectText(): CharSequence { + val builder = SpannableStringBuilder() + builder.append(getString(R.string.nearbysharing_tips_to_connect_intro)) + builder.append("\n") + appendNumberedItem(builder, 1, getString(R.string.nearbysharing_tips_to_connect_item_1)) + builder.append("\n") + appendNumberedItem(builder, 2, getString(R.string.nearbysharing_tips_to_connect_item_2)) + return builder + } + + private fun appendNumberedItem(builder: SpannableStringBuilder, number: Int, text: String) { + val start = builder.length + builder.append(" $number. ") + builder.append(text) + builder.setSpan( + LeadingMarginSpan.Standard(0, dpToPx(20)), + start, + builder.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + private fun buildMoreTipsText(): CharSequence { + val builder = SpannableStringBuilder() + appendBulletedItem(builder, getString(R.string.tips_more_item_1)) + builder.append("\n") + appendBulletedItem(builder, getString(R.string.tips_more_item_2)) + + val fullText = builder.toString() + val clickableWord = "documentation" + val start = fullText.indexOf(clickableWord) + if (start >= 0) { + val end = start + clickableWord.length + builder.setSpan( + object : ClickableSpan() { + override fun onClick(widget: View) { + Util.startBrowserIntent(context, getString(R.string.peerToPeer_documentation_url)) + } + + override fun updateDrawState(ds: TextPaint) { + ds.isUnderlineText = true + } + }, + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + builder.setSpan( + ForegroundColorSpan( + ContextCompat.getColor(requireContext(), R.color.wa_yellow) + ), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + return builder + } + + private fun appendBulletedItem(builder: SpannableStringBuilder, text: String) { + val start = builder.length + builder.append(" • ") + builder.append(text) + builder.setSpan( + LeadingMarginSpan.Standard(0, dpToPx(20)), + start, + builder.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + private fun dpToPx(dp: Int): Int { + return (dp * resources.displayMetrics.density).toInt() + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/di/PeerModule.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/di/PeerModule.kt new file mode 100644 index 000000000..4d971ae93 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/di/PeerModule.kt @@ -0,0 +1,44 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.horizontal.tella.mobile.data.peertopeer.TellaPeerToPeerClient +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerToPeerManager +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSession +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PeerModule { + + @Provides + @Singleton + fun providePeerClient(@ApplicationContext context: Context): TellaPeerToPeerClient { + return TellaPeerToPeerClient(context) + } + + @Provides + @Singleton + fun providePeerToPeerManager(): PeerToPeerManager { + return PeerToPeerManager() + } + + @Provides + @Singleton + fun provideSession(): P2PSession { + return P2PSession() + } + + @Provides + @Singleton + fun provideP2PServerState(p2PSession: P2PSession): P2PSharedState { + val p2PSharedState = P2PSharedState() + p2PSharedState.session = p2PSession + return p2PSharedState + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/ConnectHotspotFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/ConnectHotspotFragment.kt new file mode 100644 index 000000000..08d72989f --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/ConnectHotspotFragment.kt @@ -0,0 +1,91 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +import android.os.Build +import android.os.Bundle +import android.view.View +import androidx.annotation.RequiresApi +import androidx.fragment.app.activityViewModels +import com.hzontal.tella_locking_ui.ui.pin.pinview.ResourceUtils.getColor +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.databinding.ConnectHotspotLayoutBinding +import org.horizontal.tella.mobile.util.ConnectionType +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow.PeerToPeerParticipant +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel + +@AndroidEntryPoint +class ConnectHotspotFragment : + BaseBindingFragment(ConnectHotspotLayoutBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + private var isCheckboxChecked = false + + + @RequiresApi(Build.VERSION_CODES.M) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.updateNetworkInfo() + initObservers() + initListeners() + } + + private fun initObservers() { + viewModel.networkInfo.observe(viewLifecycleOwner) { info -> + viewModel.currentNetworkInfo = info + when (info.connectionType) { + ConnectionType.HOTSPOT, ConnectionType.WIFI, ConnectionType.CELLULAR -> { + updateNextButtonState(info.connectionType) + } + + ConnectionType.NONE -> { + updateNextButtonState(ConnectionType.NONE) + } + } + } + } + + private fun initListeners() { + binding.currentWifi.setOnCheckedChangeListener { isChecked -> + isCheckboxChecked = isChecked + val currentType = viewModel.networkInfo.value?.connectionType ?: ConnectionType.NONE + updateNextButtonState(currentType) + } + + binding.toolbar.backClickListener = { baseActivity.onBackPressed() } + binding.tipsCard.setOnTipsClick { + navManager().navigateFromConnectHotspotScreenToTipsToConnectFragment() + } + } + + private fun updateNextButtonState(connectionType: ConnectionType?) { + val isEligibleConnection = + connectionType == ConnectionType.WIFI || connectionType == ConnectionType.HOTSPOT + + binding.currentWifi.setCheckboxEnabled(isEligibleConnection) + + val shouldEnable = isEligibleConnection && isCheckboxChecked + + binding.nextBtn.isEnabled = shouldEnable + binding.nextBtn.isClickable = shouldEnable + + binding.nextBtn.alpha = if (shouldEnable) 1f else 0.5f + + binding.nextBtn.setOnClickListener( + if (shouldEnable) { { onNextClicked() } } else { { /* no-op */ } } + ) + + binding.nextBtn.setTextColor( + getColor(baseActivity, if (shouldEnable) R.color.wa_white else R.color.wa_white_40) + ) + } + + + private fun onNextClicked() { + if (viewModel.peerToPeerParticipant == PeerToPeerParticipant.RECIPIENT) + navManager().navigateFromActionConnectHotspotScreenToQrCodeScreen() + else navManager().navigateFromActionConnectHotspotScreenToScanQrCodeScreen() + + } + +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/PeerToPeerFlags.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/PeerToPeerFlags.kt new file mode 100644 index 000000000..3f95c004b --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/PeerToPeerFlags.kt @@ -0,0 +1,8 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +/** + * Created by wafa on 16/7/2025. + */ +object PeerToPeerFlags { + var cancelled = false +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/QRCodeFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/QRCodeFragment.kt new file mode 100644 index 000000000..929bc65fa --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/QRCodeFragment.kt @@ -0,0 +1,245 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.view.View +import androidx.core.graphics.createBitmap +import androidx.core.view.isVisible +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.horizontal.tella.mobile.certificate.CertificateUtils +import org.horizontal.tella.mobile.data.peertopeer.P2PNetworkAddressPolicy +import org.horizontal.tella.mobile.data.peertopeer.PeerKeyProvider +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerToPeerManager +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState +import org.horizontal.tella.mobile.data.peertopeer.port +import org.horizontal.tella.mobile.databinding.FragmentQrCodeBinding +import org.horizontal.tella.mobile.domain.peertopeer.KeyStoreConfig +import org.horizontal.tella.mobile.domain.peertopeer.PeerConnectionQrCodec +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import timber.log.Timber +import javax.inject.Inject +import kotlin.math.roundToInt + +@AndroidEntryPoint +class QRCodeFragment : BaseBindingFragment(FragmentQrCodeBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + private var qrPayload: String? = null + + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + @Inject + lateinit var peerToPeerManager: PeerToPeerManager + + @Inject + lateinit var p2PSharedState: P2PSharedState + + /** Ensures only one setup runs at a time so server PIN and QR payload cannot diverge. */ + private val qrSetupMutex = Mutex() + + private var qrSetupStarted = false + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + qrPayload?.let { cached -> + generateQrCode(cached) + setQrRegenerationLoading(false) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + viewModel.networkInfo.observe(viewLifecycleOwner) { info -> + val ip = info.ipAddress + if (!qrSetupStarted || qrPayload == null) { + qrSetupStarted = true + setQrRegenerationLoading(true) + viewLifecycleOwner.lifecycleScope.launch { + try { + qrSetupMutex.withLock { + setupServerAndQr(ip.orEmpty()) + } + } finally { + if (isAdded) { + setQrRegenerationLoading(false) + } + } + } + } + } + viewModel.updateNetworkInfo() + } else { + val ip = viewModel.currentNetworkInfo?.ipAddress + qrSetupStarted = true + setQrRegenerationLoading(true) + viewLifecycleOwner.lifecycleScope.launch { + try { + qrSetupMutex.withLock { + setupServerAndQr(ip.orEmpty()) + } + } finally { + if (isAdded) { + setQrRegenerationLoading(false) + } + } + } + } + handleBack() + handleConnectManually() + + viewModel.isManualConnection = false + + viewModel.registrationServerSuccess.observe(viewLifecycleOwner) { success -> + if (success) { + // Navigate to the next screen + navManager().navigateFromQrCodeScreenToWaitingReceiverFragment() + // reset the LiveData state if we want to consume event once + viewModel.resetRegistrationState() + } + } + initObservers() + } + + private fun setQrRegenerationLoading(loading: Boolean) { + binding.progressCircular.isVisible = loading + binding.connectManuallyButton.isEnabled = !loading + if (loading) { + binding.qrCodeImageView.setImageDrawable(null) + } + } + + private suspend fun setupServerAndQr(primaryIpHint: String) { + // New receiver session: drop any stale ping event from a previous attempt. + peerToPeerManager.clearClientConnected() + Timber.d("P2P receiver session started: cleared stale ping replay cache") + + val discovered = viewModel.collectLocalIpv4AddressesForNearbySharing() + val hint = primaryIpHint.trim() + val mergedForSelection = buildList { + if (hint.isNotEmpty()) add(hint) + for (a in discovered) { + if (a.isNotBlank() && a !in this) add(a) + } + } + val allIps = P2PNetworkAddressPolicy.filterAndOrderForAdvertise(mergedForSelection) + if (allIps.isEmpty()) { + Timber.e( + "P2P QR: no site-local (RFC1918) IPv4 for certificate/QR after policy filter; raw merged=%s", + mergedForSelection.joinToString(), + ) + return + } + val advertiseToPeerPrimary = allIps.first() + val keyPair = PeerKeyProvider.getKeyPair() + val certificate = PeerKeyProvider.getCertificate(allIps) + val config = KeyStoreConfig() + + val certHash = CertificateUtils.getLeafCertificateDerSha256Hex(certificate) + val pin = (100000..999999).random() + val port = port + val pinString = pin.toString() + + val started = withContext(Dispatchers.IO) { + peerServerStarterManager.startServer( + advertiseToPeerPrimary, + keyPair, + pinString, + certificate, + config, + p2PSharedState + ) + } + if (!started) { + Timber.e("P2P QR: server failed to start; not updating PIN/QR") + return + } + + p2PSharedState.pin = pinString + p2PSharedState.port = port.toString() + p2PSharedState.hash = certHash + p2PSharedState.ip = advertiseToPeerPrimary + + val json = PeerConnectionQrCodec.toJson(allIps, port, certHash, pinString) + qrPayload = json + generateQrCode(json) + } + + + private fun generateQrCode(content: String) { + try { + val sizePx = (215f * resources.displayMetrics.density).roundToInt().coerceAtLeast(215) + val hints = mapOf( + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.H, + EncodeHintType.MARGIN to 0, + ) + val bitMatrix = QRCodeWriter().encode( + content, + BarcodeFormat.QR_CODE, + sizePx, + sizePx, + hints, + ) + val w = bitMatrix.width + val h = bitMatrix.height + val pixels = IntArray(w * h) + for (y in 0 until h) { + val offset = y * w + for (x in 0 until w) { + pixels[offset + x] = + if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE + } + } + val bitmap = createBitmap(w, h) + bitmap.setPixels(pixels, 0, w, 0, 0, w, h) + binding.qrCodeImageView.setImageBitmap(bitmap) + } catch (e: Exception) { + Timber.e(e, "P2P QR: encode failed") + } + } + + private fun handleBack() { + val leave = { + viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { + peerServerStarterManager.stopServer() + } + nav().popBackStack() + } + } + binding.toolbar.backClickListener = { leave() } + } + + private fun handleConnectManually() { + binding.connectManuallyButton.setOnClickListener { + connectManually() + } + } + + private fun connectManually() { + val json = qrPayload ?: return + bundle.putString("payload", json) + navManager().navigateFromScanQrCodeToDeviceInfo() + } + + private fun initObservers() { + viewModel.registrationSuccess.observe(viewLifecycleOwner) { success -> + if (success) { + navManager().navigateFromQrCodeScreenToWaitingReceiverFragment() + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientSuccessFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientSuccessFragment.kt new file mode 100644 index 000000000..fdc969150 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientSuccessFragment.kt @@ -0,0 +1,54 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.databinding.FragmentRecipientSuccessBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel + +/** + * Created by wafa on 3/6/2025. + */ +class RecipientSuccessFragment : BaseBindingFragment(FragmentRecipientSuccessBinding::inflate){ + private val viewModel: PeerToPeerViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + initView() + } + private fun initView() { + val fileCount = arguments?.getInt("fileCount") ?: 0 + val sessionId = arguments?.getString("sessionId").orEmpty() + + with(binding) { + // Set the dynamic message + waitingText.text = getString(R.string.prepare_upload_message, fileCount) + + // Handle Accept/Reject buttons + acceptBtn.setOnClickListener { + + viewModel.confirmPrepareUpload(sessionId, true) + + navManager().navigateFromRecipientSuccessFragmentToRecipientUploadFilesFragment() + } + + rejectBtn.setOnClickListener { + viewModel.confirmPrepareUpload(sessionId, false) + findNavController().previousBackStackEntry + ?.savedStateHandle + ?.set("receiverDeclined", true) + if (!navManager().popFromRecipientSuccessToWaitingReceiver()) { + findNavController().popBackStack() + } + } + } + } + + +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientUploadFilesFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientUploadFilesFragment.kt new file mode 100644 index 000000000..e5486ffeb --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientUploadFilesFragment.kt @@ -0,0 +1,115 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +import android.os.Bundle +import android.view.View +import androidx.activity.addCallback +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.MutableLiveData +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.MyApplication +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.data.peertopeer.model.SessionStatus +import org.horizontal.tella.mobile.databinding.FragmentUploadFilesBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow.PeerToPeerParticipant +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import org.horizontal.tella.mobile.views.fragment.uwazi.widgets.PeerToPeerEndView +import org.hzontal.shared_ui.bottomsheet.BottomSheetUtils.showStandardSheet +import javax.inject.Inject + +@AndroidEntryPoint +class RecipientUploadFilesFragment : + BaseBindingFragment(FragmentUploadFilesBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + private lateinit var endView: PeerToPeerEndView + private val progressPercentLiveData = MutableLiveData() + private var sheetShown = false + + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initializeUI() + setupToolbar() + setupCancelButton() + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + showStopSharingConfirmation() + } + } + + private fun initializeUI() { + showFormEndView() + observeUploadProgress() + // observeBottomSheetProgress() + viewModel.peerToPeerParticipant = PeerToPeerParticipant.RECIPIENT + } + + private fun setupToolbar() { + binding.toolbar.setStartTextTitle(getString(R.string.receiving_and_encrypting_files)) + binding.toolbar.backClickListener = { showStopSharingConfirmation() } + } + + private fun setupCancelButton() { + binding.cancel.setOnClickListener { showStopSharingConfirmation() } + } + + private fun showStopSharingConfirmation() { + showStandardSheet( + baseActivity.supportFragmentManager, + getString(R.string.nearbysharing_stop_receiving_files), + getString(R.string.nearbysharing_stop_receiving_files_description), + getString(R.string.action_continue).uppercase(), + getString(R.string.stop).uppercase(), + onConfirmClick = {}, + onCancelClick = { stopServerAndNavigate() } + ) + } + + private fun stopServerAndNavigate() { + peerServerStarterManager.stopServer() + viewModel.peerToPeerParticipant = PeerToPeerParticipant.RECIPIENT + navManager().navigateFromRecipientUploadFilesFragmentToPeerToPeerResultFragment() + } + + private fun showFormEndView() { + val session = viewModel.p2PState.session ?: return + val files = session.files.values.toList() + + endView = PeerToPeerEndView(baseActivity, session.title) + endView.setFiles(files, MyApplication.isConnectedToInternet(baseActivity), false) + + binding.endViewContainer.removeAllViews() + binding.endViewContainer.addView(endView) + } + + private fun observeUploadProgress() { + viewModel.uploadProgress.observe(viewLifecycleOwner) { state -> + if (state == null) return@observe + + val files = state.files + val percent = state.percent.coerceIn(0, 100) + endView.setUploadProgress(files, percent / 100f) + + val isTerminal = when (state.sessionStatus) { + SessionStatus.FINISHED, + SessionStatus.FINISHED_WITH_ERRORS, + SessionStatus.CLOSED -> true + + else -> false // WAITING, SENDING, SAVING → not yet + } + + if (isTerminal) { + // Sheet only auto-dismisses when progress == file count; hash/save failures can leave savedCount at 0. + if (files.isNotEmpty()) { + progressPercentLiveData.value = files.size + } + stopServerAndNavigate() + } + } + } + + +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientVerificationFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientVerificationFragment.kt new file mode 100644 index 000000000..3584401b0 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/RecipientVerificationFragment.kt @@ -0,0 +1,98 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.databinding.ConnectManuallyVerificationBinding +import org.horizontal.tella.mobile.util.formatHash +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import javax.inject.Inject + +@AndroidEntryPoint +class RecipientVerificationFragment : + BaseBindingFragment(ConnectManuallyVerificationBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initUI() + initListeners() + initObservers() + } + + private fun initUI() = with(binding) { + // Simpler instruction text (update your string as needed) + sequenceDescTextView.text = getString(R.string.nearbySharing_verifyConnection_recipient) + hashContentTextView.text = viewModel.p2PState.hash.formatHash() + + // IMPORTANT: button is enabled immediately + confirmAndConnectBtn.isEnabled = true + confirmAndConnectBtn.setText(getString(R.string.confirm_and_connect)) + } + + private fun initListeners() = with(binding) { + toolbar.backClickListener = { navigateBackAndStopServer() } + discardBtn.setOnClickListener { navigateBackAndStopServer() } + + // Tap immediately — even if no incoming request yet + confirmAndConnectBtn.setOnClickListener { + confirmAndConnectBtn.isEnabled = false + confirmAndConnectBtn.setText(getString(R.string.waiting_for_the_sender)) + viewModel.onRecipientConfirmTapped() + } + } + + private fun initObservers() = with(binding) { + // Manual mode so we don't auto-accept + viewModel.p2PState.isUsingManualConnection = true + + + // Navigate ONLY after server confirms (both sides done) + viewModel.registrationServerSuccess.observe(viewLifecycleOwner) { success -> + if (success) { + navManager().navigateFromRecipientVerificationScreenToWaitingReceiverFragment() + } + } + + viewModel.closeConnection.observe(viewLifecycleOwner) { closeConnection -> + if (closeConnection) navigateBackAndStopServer() + } + + // Optional: reflect VM UI flags if you want the button text/state to be VM-driven + viewModel.waitingForOtherSide.observe(viewLifecycleOwner) { waiting -> + if (waiting) { + confirmAndConnectBtn.isEnabled = false + confirmAndConnectBtn.setText(getString(R.string.waiting_for_the_sender)) + } + } + viewModel.canTapConfirm.observe(viewLifecycleOwner) { canTap -> + if (canTap) { + confirmAndConnectBtn.isEnabled = true + confirmAndConnectBtn.setText(getString(R.string.confirm_and_connect)) + } + } + } + + private fun navigateBackAndStopServer() { + viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { + peerServerStarterManager.stopServer() + } + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + } + } + + +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/ShowDeviceInfoFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/ShowDeviceInfoFragment.kt new file mode 100644 index 000000000..7ccf0374d --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/ShowDeviceInfoFragment.kt @@ -0,0 +1,69 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import org.horizontal.tella.mobile.databinding.ShowDeviceInfoLayoutBinding +import org.horizontal.tella.mobile.domain.peertopeer.ParsedPeerQr +import org.horizontal.tella.mobile.domain.peertopeer.PeerConnectionQrCodec +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import timber.log.Timber + +class ShowDeviceInfoFragment : + BaseBindingFragment(ShowDeviceInfoLayoutBinding::inflate) { + private val viewModel: PeerToPeerViewModel by activityViewModels() + + private var parsedQr: ParsedPeerQr? = null + private var movedToVerification = false + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + arguments?.getString("payload")?.let { payloadJson -> + parsedQr = PeerConnectionQrCodec.parse(payloadJson) + } + initListeners() + initView() + initObservers() + } + + private fun initView() { + val parsed = parsedQr + if (parsed != null) { + binding.connectCode.setRightText(parsed.ipAddresses.joinToString(", ")) + binding.pin.setRightText(parsed.pin) + binding.port.setRightText(parsed.port.toString()) + } else { + binding.connectCode.setRightText(viewModel.p2PState.ip) + binding.pin.setRightText(viewModel.p2PState.pin) + binding.port.setRightText(viewModel.p2PState.port) + } + } + + private fun initListeners() { + binding.toolbar.backClickListener = { nav().popBackStack() } + } + + private fun initObservers() { + lifecycleScope.launch { + viewModel.clientHash.collect { clientHash -> + with(viewModel.p2PState) { + val p = parsedQr + ip = p?.ipAddresses?.firstOrNull().orEmpty() + port = p?.port?.toString().orEmpty() + pin = p?.pin + hash = clientHash + } + moveToVerificationIfNeeded() + } + } + } + + private fun moveToVerificationIfNeeded() { + if (movedToVerification) return + movedToVerification = true + navManager().navigateFromDeviceInfoScreenTRecipientVerificationScreen() + } +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/WaitingReceiverFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/WaitingReceiverFragment.kt new file mode 100644 index 000000000..a7905da6d --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/receipentflow/WaitingReceiverFragment.kt @@ -0,0 +1,110 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.databinding.FragmentWaitingBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import org.hzontal.shared_ui.utils.DialogUtils +import javax.inject.Inject + +/** + * Created by wafa on 3/6/2025. + */ + +@AndroidEntryPoint +class WaitingReceiverFragment : + BaseBindingFragment(FragmentWaitingBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupToolbar() + observeIncomingPrepareRequest() + observeReceiverRejection() + observeSenderCloseConnection() + } + + private fun setupToolbar() { + binding.toolbar.apply { + setStartTextTitle(getString(R.string.receive_files)) + backClickListener = { + viewModel.resetRegistrationState() + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + } + } + binding.waitingText.text = getString(R.string.waiting_for_the_sender_to_share_files) + } + + private fun observeIncomingPrepareRequest() { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.RESUMED) { + viewModel.incomingPrepareRequest.collect { request -> + if (!viewModel.shouldSkipWaitingToPrepareSuccessNavigation() && + isAdded && + findNavController().currentDestination?.id == R.id.waitingReceiverFragment + ) { + viewModel.markNavigatedFromWaitingToPrepareSuccess() + + bundle.putInt("fileCount", request.files.size) + bundle.putString("sessionId", request.sessionId) + + navManager().navigateFromWaitingReceiverFragmentToRecipientSuccessFragment() + } + } + } + } + } + + private fun observeReceiverRejection() { + val navBackStackEntry = findNavController().currentBackStackEntry + navBackStackEntry?.savedStateHandle + ?.getLiveData("receiverDeclined") + ?.observe(viewLifecycleOwner) { wasRejected -> + if (wasRejected) { + DialogUtils.showBottomMessage( + baseActivity, + getString(R.string.sender_files_rejected), + isError = false + ) + } + } + } + + /** + * Sender called [org.horizontal.tella.mobile.data.peertopeer.remote.PeerApiRoutes.CLOSE] on our + * embedded server; tear down the listener and return to the nearby-sharing start screen. + */ + private fun observeSenderCloseConnection() { + viewModel.closeConnection.observe(viewLifecycleOwner) { closed -> + if (closed != true) return@observe + if (!isAdded || + findNavController().currentDestination?.id != R.id.waitingReceiverFragment + ) { + return@observe + } + viewLifecycleOwner.lifecycleScope.launch { + viewModel.resetRegistrationState() + viewModel.discardStalePrepareOfferReplayAndNavigationGate() + withContext(Dispatchers.IO) { + peerServerStarterManager.stopServer() + } + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + } + } + } + +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PeerToPeerParticipant.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PeerToPeerParticipant.kt new file mode 100644 index 000000000..18bbfdc23 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PeerToPeerParticipant.kt @@ -0,0 +1,9 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +/** + * Created by wafa on 16/7/2025. + */ +enum class PeerToPeerParticipant { + SENDER, + RECIPIENT +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PeerToPeerResultFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PeerToPeerResultFragment.kt new file mode 100644 index 000000000..888ab00c7 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PeerToPeerResultFragment.kt @@ -0,0 +1,147 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.content.Intent +import android.graphics.PorterDuff +import android.os.Bundle +import android.view.View +import androidx.activity.addCallback +import androidx.core.content.ContextCompat +import androidx.fragment.app.activityViewModels +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.model.P2PFileStatus +import org.horizontal.tella.mobile.data.peertopeer.model.ProgressFile +import org.horizontal.tella.mobile.databinding.FragmentPeerToPeerResultBinding +import org.horizontal.tella.mobile.views.activity.MainActivity +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel + +class PeerToPeerResultFragment : + BaseBindingFragment(FragmentPeerToPeerResultBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + private var transferredFiles: List? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Get values from shared state + transferredFiles = viewModel.p2PState.session?.files?.values?.toList() + + setupImage() + setupTexts() + setupButton() + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { baseActivity.finish() } + } + + + private fun setupImage() { + binding.setupImgV.run { + val isSuccess = allFilesTransferred + setImageResource(if (isSuccess) R.drawable.checked_circle else R.drawable.ic_warning_orange) + + if (!isSuccess) { + val whiteColor = ContextCompat.getColor(context, R.color.wa_white) + setColorFilter(whiteColor, PorterDuff.Mode.SRC_IN) + } else { + clearColorFilter() + } + } + } + + private fun setupTexts() { + binding.toolbar.setStartTextTitle(getString(R.string.nearby_sharing_results_title)) + val titleResId = when { + allFilesTransferred -> R.string.success_title + else -> R.string.transfer_interrupted_title + } + binding.tileTv.text = getString(titleResId) + binding.descriptionTv.text = computeSubtitle() + } + + private fun setupButton() { + binding.toolbar.backClickListener = { baseActivity.finish() } + + val isSender = viewModel.peerToPeerParticipant == PeerToPeerParticipant.SENDER + val showBackToHome = isSender || noFilesTransferred + + binding.backToHomeBtn.apply { + visibility = if (showBackToHome) View.VISIBLE else View.GONE + setOnClickListener { navigateToHome() } + } + + binding.viewFilesBtn.apply { + val isRecipient = viewModel.peerToPeerParticipant == PeerToPeerParticipant.RECIPIENT + + if (!noFilesTransferred && isRecipient) { + setText(getString(R.string.view_files_action)) + visibility = View.VISIBLE + setOnClickListener { + val transferFolderId = viewModel.getTransferFolderId() + val intent = Intent(requireContext(), MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(MainActivity.EXTRA_NAVIGATE_TO, "attachments_screen") + if (!transferFolderId.isNullOrBlank()) { + putExtra(MainActivity.EXTRA_VAULT_PARENT_ID, transferFolderId) + } + } + startActivity(intent) + requireActivity().finish() // finish PeerToPeerActivity + } + } else { + visibility = View.GONE + } + } + } + + private fun navigateToHome() { + val intent = Intent(requireContext(), MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + startActivity(intent) + requireActivity().finish() + } + + private fun computeSubtitle(): String { + val total = transferredFiles?.size ?: 0 + val successCount = transferredFiles?.count { it.status == successStatus } ?: 0 + val failureCount = total - successCount + + return when { + // Case 1: All successful + successCount == total -> { + val resId = if (viewModel.peerToPeerParticipant == PeerToPeerParticipant.RECIPIENT) + R.plurals.success_file_received_from_sender + else + R.plurals.success_file_sent_to_recipient + + resources.getQuantityString(resId, total, total) + } + + // Case 2: All failed + failureCount == total -> { + resources.getQuantityString(R.plurals.failure_file_received_expl, failureCount, failureCount) + } + + //Case 3: Partial success + else -> { + getString(R.string.partial_success_summary, successCount, failureCount) + } + } + } + + private val successStatus: P2PFileStatus + get() = if (viewModel.peerToPeerParticipant == PeerToPeerParticipant.RECIPIENT) + P2PFileStatus.SAVED + else + P2PFileStatus.FINISHED + + private val allFilesTransferred: Boolean + get() = transferredFiles?.all { it.status == successStatus } == true + + private val noFilesTransferred: Boolean + get() = transferredFiles?.none { it.status == successStatus } == true + + private val isPartialTransfer: Boolean + get() = !allFilesTransferred && !noFilesTransferred + +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PrepareUploadFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PrepareUploadFragment.kt new file mode 100644 index 000000000..20da9457a --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/PrepareUploadFragment.kt @@ -0,0 +1,383 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.activity.addCallback +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.setFragmentResultListener +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import com.hzontal.tella_locking_ui.common.extensions.onChange +import com.hzontal.tella_vault.VaultFile +import com.hzontal.tella_vault.filter.FilterType +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.MyApplication +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.bus.EventObserver +import org.horizontal.tella.mobile.bus.event.AudioRecordEvent +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.data.peertopeer.model.P2PFileStatus +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSession +import org.horizontal.tella.mobile.data.peertopeer.model.ProgressFile +import org.horizontal.tella.mobile.databinding.FragmentPrepareUploadBinding +import org.horizontal.tella.mobile.domain.peertopeer.NearbySharingTransferConfig +import org.horizontal.tella.mobile.domain.peertopeer.P2PFile +import org.horizontal.tella.mobile.media.MediaFileHandler +import org.horizontal.tella.mobile.util.C +import org.horizontal.tella.mobile.util.crash.CrashReporterProvider +import org.horizontal.tella.mobile.views.activity.camera.CameraActivity +import org.horizontal.tella.mobile.views.activity.camera.CameraActivity.Companion.CAPTURE_WITH_AUTO_UPLOAD +import org.horizontal.tella.mobile.views.adapters.reports.ReportsFilesRecyclerViewAdapter +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.main_connexions.base.BUNDLE_REPORT_AUDIO +import org.horizontal.tella.mobile.views.fragment.main_connexions.base.BUNDLE_REPORT_VAULT_FILE +import org.horizontal.tella.mobile.views.fragment.main_connexions.base.OnNavBckListener +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.FileTransferViewModel +import org.horizontal.tella.mobile.views.fragment.recorder.MicActivity +import org.horizontal.tella.mobile.views.fragment.recorder.REPORT_ENTRY +import org.horizontal.tella.mobile.views.fragment.uwazi.attachments.AttachmentsActivitySelector +import org.horizontal.tella.mobile.views.fragment.uwazi.attachments.VAULT_FILES_FILTER +import org.horizontal.tella.mobile.views.fragment.uwazi.attachments.VAULT_FILE_KEY +import org.horizontal.tella.mobile.views.fragment.uwazi.attachments.VAULT_PICKER_SINGLE +import org.horizontal.tella.mobile.views.interfaces.IReportAttachmentsHandler +import org.hzontal.shared_ui.bottomsheet.BottomSheetUtils +import org.hzontal.shared_ui.bottomsheet.VaultSheetUtils.IVaultFilesSelector +import org.hzontal.shared_ui.bottomsheet.VaultSheetUtils.showVaultSelectFilesSheet +import org.hzontal.shared_ui.utils.DialogUtils +import javax.inject.Inject + +var PREPARE_UPLOAD_ENTRY = "PREPARE_UPLOAD_ENTRY" + +@AndroidEntryPoint +class PrepareUploadFragment : + BaseBindingFragment(FragmentPrepareUploadBinding::inflate), + IReportAttachmentsHandler, OnNavBckListener { + private lateinit var gridLayoutManager: GridLayoutManager + private var isTitleEnabled = false + private val viewModel: FileTransferViewModel by activityViewModels() + private var disposables = + MyApplication.bus().createCompositeDisposable() + + private val filesRecyclerViewAdapter: ReportsFilesRecyclerViewAdapter by lazy { + ReportsFilesRecyclerViewAdapter(this) + } + + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setFragmentResultListener(BUNDLE_REPORT_AUDIO) { _, bundle -> + val file = bundle.get(BUNDLE_REPORT_VAULT_FILE) as VaultFile + bundle.remove(BUNDLE_REPORT_VAULT_FILE) + putFiles(listOf(file)) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + initView() + onAudioRecordingListener() + } + + private fun initView() { + gridLayoutManager = GridLayoutManager(context, 3) + binding.filesRecyclerView.apply { + layoutManager = gridLayoutManager + adapter = filesRecyclerViewAdapter + } + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + showExitNearbySharingConfirmation() + } + + //TODO HANDLE THIS IN THE NAVMANAGER + val navBackStackEntry = findNavController().currentBackStackEntry + navBackStackEntry?.savedStateHandle + ?.getLiveData("transferRejectedMessageRes") + ?.observe(viewLifecycleOwner) { messageRes -> + if (messageRes != null && messageRes != 0) { + DialogUtils.showBottomMessage( + baseActivity, + getString(messageRes), + false + ) + navBackStackEntry.savedStateHandle.remove("transferRejectedMessageRes") + } + } + navBackStackEntry?.savedStateHandle + ?.getLiveData("prepareTransferRecipientRejected") + ?.observe(viewLifecycleOwner) { show -> + if (show == true) { + DialogUtils.showBottomMessage( + baseActivity, + getString(R.string.recipient_rejected_the_files), + false + ) + navBackStackEntry.savedStateHandle.remove("prepareTransferRecipientRejected") + } + } + findNavController().currentBackStackEntry + ?.savedStateHandle + ?.getLiveData("registrationSuccess") + ?.observe(viewLifecycleOwner) { success -> + if (success == true) { + DialogUtils.showBottomMessage( + baseActivity, + getString(R.string.nearby_sharing_connection_successful), + false + ) + + // Remove it so it's not shown again on back navigation + findNavController().currentBackStackEntry?.savedStateHandle + ?.remove("registrationSuccess") + } + } + binding.toolbar.backClickListener = { showExitNearbySharingConfirmation() } + highLightButtonsInit() + } + + private fun showExitNearbySharingConfirmation() { + BottomSheetUtils.showConfirmSheet( + baseActivity.supportFragmentManager, + getString(R.string.exit_nearby_sharing), + getString(R.string.your_progress_will_be_lost), + getString(R.string.action_exit), + getString(R.string.action_cancel), + object : BottomSheetUtils.ActionConfirmed { + override fun accept(isConfirmed: Boolean) { + if (isConfirmed) { + discontinueNearbySharingSession() + } + } + } + ) + } + + private fun discontinueNearbySharingSession() { + peerServerStarterManager.stopServer() + viewModel.closePeerConnection() + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + } + + private fun highLightButtonsInit() { + binding.apply { + reportTitleEt.let { title -> + isTitleEnabled = title.length() > 0 + } + + reportTitleEt.onChange { title -> + isTitleEnabled = title.isNotEmpty() + highLightButtons() + } + + } + } + + private fun showSelectFilesSheet() { + showVaultSelectFilesSheet(baseActivity.supportFragmentManager, + baseActivity.getString(R.string.Uwazi_WidgetMedia_Take_Photo), + baseActivity.getString(R.string.Vault_RecordAudio_SheetAction), + baseActivity.getString(R.string.Uwazi_WidgetMedia_Select_From_Device), + baseActivity.getString(R.string.Uwazi_WidgetMedia_Select_From_Tella), + null, + baseActivity.getString(R.string.Uwazi_MiltiFileWidget_SelectFiles), + object : IVaultFilesSelector { + override fun importFromVault() { + showAttachmentsActivity() + } + + override fun goToRecorder() { + showAudioRecorderActivity() + } + + override fun goToCamera() { + showCameraActivity() + } + + override fun importFromDevice() { + importMedia() + } + }) + } + + + private fun showAttachmentsActivity() { + try { + baseActivity.startActivityForResult( + Intent(activity, AttachmentsActivitySelector::class.java) + // .putExtra(VAULT_FILE_KEY, Gson().toJson(ids)) + .putExtra( + VAULT_FILES_FILTER, FilterType.ALL_WITHOUT_DIRECTORY + ).putExtra(VAULT_PICKER_SINGLE, false), C.MEDIA_FILE_ID + ) + } catch (e: Exception) { + CrashReporterProvider.get().recordException(e) + } + } + + private fun showCameraActivity() { + try { + // Match importMedia(): with "lock immediately", ProcessLifecycleOwner pauses before + // CameraActivity.onResume can apply maybeChangeTemporaryTimeout — key is cleared while + // the system camera permission dialog is shown unless we extend timeout first. + baseActivity.maybeChangeTemporaryTimeout { + val intent = Intent(context, CameraActivity::class.java) + intent.apply { + putExtra(CameraActivity.INTENT_MODE, CameraActivity.IntentMode.COLLECT.name) + putExtra(CAPTURE_WITH_AUTO_UPLOAD, false) + } + baseActivity.startActivityForResult(intent, C.MEDIA_FILE_ID) + } + } catch (e: java.lang.Exception) { + CrashReporterProvider.get().recordException(e) + } + } + + private fun importMedia() { + baseActivity.maybeChangeTemporaryTimeout { + MediaFileHandler.startSelectMediaActivity( + activity, "image/* video/* audio/*", + arrayOf("image/*", "video/*", "audio/*"), C.IMPORT_FILE + ) + } + } + + private fun showAudioRecorderActivity() { + try { + bundle.putBoolean(REPORT_ENTRY, true) + this.navManager().navigateToMicro() + } catch (e: java.lang.Exception) { + CrashReporterProvider.get().recordException(e) + } + val intent = Intent(activity, MicActivity::class.java) + intent.putExtra(PREPARE_UPLOAD_ENTRY, true) + baseActivity.startActivity(intent) + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == C.MEDIA_FILE_ID && resultCode == Activity.RESULT_OK) { + val vaultFile = data?.getStringExtra(VAULT_FILE_KEY) ?: "" + putFiles(viewModel.putVaultFilesInForm(vaultFile).blockingGet()) + } + } + + private fun putFiles(vaultFileList: List) { + vaultFileList.forEach { file -> + filesRecyclerViewAdapter.insertAttachment(file) + } + + // Ensure visibility and highlight buttons + binding.filesRecyclerView.visibility = View.VISIBLE + highLightButtons() + } + + override fun removeFiles() { + highLightButtons() + } + + private fun highLightButtons() { + val isSubmitEnabled = + isTitleEnabled && filesRecyclerViewAdapter.getFiles() + .isNotEmpty() + + val disabled: Float = context?.getString(R.string.alpha_disabled)?.toFloat() ?: 1.0f + val enabled: Float = context?.getString(R.string.alpha_enabled)?.toFloat() ?: 1.0f + + binding.sendReportBtn.setBackgroundResource(if (isSubmitEnabled) R.drawable.bg_round_orange_btn else R.drawable.bg_round_orange16_btn) + binding.sendReportBtn.alpha = (if (isSubmitEnabled) enabled else disabled) + + initClickListeners(isSubmitEnabled) + } + + private fun initClickListeners(isSubmitEnabled: Boolean) { + binding.sendReportBtn.setOnClickListener { + if (isSubmitEnabled) { + val selectedFiles = filesRecyclerViewAdapter.getFiles() + viewModel.p2PSharedState.session?.title = binding.reportTitleEt.text.toString() + + if (selectedFiles.isNotEmpty()) { + if (!NearbySharingTransferConfig.isSelectionWithinVaultLimits(selectedFiles)) { + DialogUtils.showBottomMessage( + baseActivity, + getString(R.string.nearby_sharing_sender_transfer_content_too_large), + true, + ) + return@setOnClickListener + } + val session = viewModel.p2PSharedState.session ?: P2PSession().also { + viewModel.p2PSharedState.session = it + } + + // Drop removed files; otherwise a stale ProgressFile (e.g. no hash) stays after user deletes it from the list. + session.files.clear() + + selectedFiles.forEach { vaultFile -> + val p2pFile = P2PFile( + id = vaultFile.id, + fileName = vaultFile.name, + size = vaultFile.size, + fileType = vaultFile.mimeType ?: "application/octet-stream", + sha256 = vaultFile.hash, + thumbnail = vaultFile.thumb ?: ByteArray(0) + ) + + val progressFile = ProgressFile( + file = p2pFile, + vaultFile = vaultFile, + status = P2PFileStatus.QUEUE + ) + + session.files[vaultFile.id] = progressFile + } + + // Navigate + navManager().navigateFromPrepareUploadFragmentToWaitingSenderFragment() + } else { + DialogUtils.showBottomMessage( + baseActivity, + getString(R.string.no_file_selected), + false + ) + } + } else { + showSubmitReportErrorSnackBar() + } + } + } + + + private fun showSubmitReportErrorSnackBar() { + val errorRes = R.string.Snackbar_Submit_Files_Error + + DialogUtils.showBottomMessage( + baseActivity, + getString(errorRes), + false + ) + } + + override fun playMedia(mediaFile: VaultFile?) { + } + + override fun addFiles() { + showSelectFilesSheet() + } + + override fun onBackPressed(): Boolean { + showExitNearbySharingConfirmation() + return true + } + + private fun onAudioRecordingListener() { + disposables.wire( + AudioRecordEvent::class.java, + object : EventObserver() { + override fun onNext(event: AudioRecordEvent) { + putFiles(listOf(event.vaultFile)) + } + }) + } + +} \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/ScanQrCodeFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/ScanQrCodeFragment.kt new file mode 100644 index 000000000..ac3a7e419 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/ScanQrCodeFragment.kt @@ -0,0 +1,154 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.view.View +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import com.journeyapps.barcodescanner.BarcodeCallback +import com.journeyapps.barcodescanner.BarcodeResult +import com.journeyapps.barcodescanner.CompoundBarcodeView +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.domain.peertopeer.PeerConnectionQrCodec +import org.horizontal.tella.mobile.databinding.ScanQrcodeFragmentBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import org.hzontal.shared_ui.bottomsheet.BottomSheetUtils.showStandardSheet +import org.hzontal.shared_ui.utils.DialogUtils + +class ScanQrCodeFragment : + BaseBindingFragment(ScanQrcodeFragmentBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + private lateinit var barcodeView: CompoundBarcodeView + + companion object { + private const val CAMERA_REQUEST_CODE = 1001 + } + + @RequiresApi(Build.VERSION_CODES.M) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + barcodeView = CompoundBarcodeView(requireContext()) + barcodeView = binding.qrCodeScanView + barcodeView.statusView.visibility = View.GONE + barcodeView.viewFinder.visibility = View.GONE + + if (ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + ) { + startScanning() + } else { + baseActivity.maybeChangeTemporaryTimeout { + requestPermissions(arrayOf(Manifest.permission.CAMERA), CAMERA_REQUEST_CODE) + } + } + handleBack() + initListeners() + initObservers() + } + + private fun startScanning() { + barcodeView.decodeContinuous(object : BarcodeCallback { + + override fun barcodeResult(result: BarcodeResult?) { + result?.text?.let { raw -> + val trimmed = raw.trim() + val parsed = PeerConnectionQrCodec.parse(trimmed) ?: return@let + val cert = parsed.certificateHash?.takeIf { it.isNotBlank() } ?: return@let + + barcodeView.pause() + + viewModel.p2PState.pin = parsed.pin + viewModel.p2PState.port = parsed.port.toString() + viewModel.p2PState.hash = cert + viewModel.p2PState.ip = parsed.ipAddresses.firstOrNull().orEmpty() + + viewModel.startRegistrationWithIpCandidates( + rawCandidates = parsed.ipAddresses, + port = parsed.port.toString(), + hash = cert, + pin = parsed.pin, + ) + } + } + + override fun possibleResultPoints(resultPoints: MutableList?) { + } + }) + + barcodeView.resume() + } + + override fun onPause() { + super.onPause() + barcodeView.pause() + } + + override fun onResume() { + super.onResume() + barcodeView.resume() + } + + override fun onDestroyView() { + barcodeView.pauseAndWait() + super.onDestroyView() + } + + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == CAMERA_REQUEST_CODE && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + startScanning() + } + } + + private fun handleBack() { + binding.toolbar.backClickListener = { nav().popBackStack() } + } + + private fun initListeners() { + binding.connectManuallyButton.setOnClickListener { + navManager().navigateFromScanQrCodeToSenderManualConnectionScreen() + } + } + + private fun initObservers() { + viewModel.registrationSuccess.observe(viewLifecycleOwner) { success -> + if (success) { + findNavController().currentBackStackEntry?.savedStateHandle + ?.set("registrationSuccess", true) + navManager().navigateFromScanQrCodeToPrepareUploadFragment() + } + } + + viewModel.bottomMessageError.observe(viewLifecycleOwner) { message -> + DialogUtils.showBottomMessage(baseActivity, message, false) + } + + viewModel.bottomSheetError.observe(viewLifecycleOwner) { (title, description) -> + showStandardSheet( + baseActivity.supportFragmentManager, + title, + description, + getString(R.string.try_again), + null, + onConfirmClick = { + if (isAdded) { + viewModel.resetRegistrationState() + barcodeView.resume() + } + }, + onCancelClick = null, + ) + } + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderManualConnectionFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderManualConnectionFragment.kt new file mode 100644 index 000000000..825498534 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderManualConnectionFragment.kt @@ -0,0 +1,107 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.os.Bundle +import android.view.View +import androidx.core.content.ContextCompat +import androidx.fragment.app.activityViewModels +import com.hzontal.tella_locking_ui.common.extensions.onChange +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.PeerKeyProvider +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.databinding.SenderManualConnectionBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import org.hzontal.shared_ui.bottomsheet.BottomSheetUtils.showStandardSheet +import org.hzontal.shared_ui.bottomsheet.KeyboardUtil +import org.hzontal.shared_ui.utils.DialogUtils +import javax.inject.Inject + +@AndroidEntryPoint +class SenderManualConnectionFragment : + BaseBindingFragment(SenderManualConnectionBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + peerServerStarterManager.stopServer() + PeerKeyProvider.reset() + viewModel.p2PState.clear() + initView() + initListeners() + initObservers() + } + + private fun initView() = with(binding) { + ipAddress.onChange { updateNextButtonState() } + pin.onChange { updateNextButtonState() } + port.onChange { updateNextButtonState() } + + updateNextButtonState() + KeyboardUtil(root) + } + + private fun initListeners() = with(binding) { + toolbar.backClickListener = { nav().popBackStack() } + + nextBtn.setOnClickListener { + val ip = ipAddress.text.toString() + val port = port.text.toString() + val pin = this.pin.text.toString() + + viewModel.p2PState.apply { + this.ip = ip + this.port = port + this.pin = pin + } + + viewModel.handleCertificate(ip, port, pin) + } + } + + private fun initObservers() { + viewModel.getHashSuccess.observe(viewLifecycleOwner) { hash -> + bundle.putString("payload", hash) + navManager().navigateFromSenderManualConnectionToConnectManuallyVerification() + } + viewModel.bottomMessageError.observe(viewLifecycleOwner) { message -> + DialogUtils.showBottomMessage(baseActivity, message, false) + } + + viewModel.bottomSheetError.observe(viewLifecycleOwner) { (title, description) -> + showStandardSheet( + baseActivity.supportFragmentManager, + title, + description, + getString(R.string.action_ok), + null, + onConfirmClick = { + viewModel.resetRegistrationState() + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + }, + onCancelClick = null, + ) + } + } + + private fun isInputValid(): Boolean = with(binding) { + ipAddress.text?.isNotBlank() == true && + pin.text?.isNotBlank() == true + } + + private fun updateNextButtonState() = with(binding) { + val enabled = isInputValid() + nextBtn.isEnabled = enabled + nextBtn.setTextColor( + ContextCompat.getColor( + baseActivity, + if (enabled) android.R.color.white else android.R.color.darker_gray + ) + ) + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderUploadFilesFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderUploadFilesFragment.kt new file mode 100644 index 000000000..8a557cc83 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderUploadFilesFragment.kt @@ -0,0 +1,90 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.os.Bundle +import android.view.View +import androidx.activity.addCallback +import androidx.fragment.app.activityViewModels +import org.horizontal.tella.mobile.MyApplication +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.model.P2PFileStatus +import org.horizontal.tella.mobile.data.peertopeer.model.SessionStatus +import org.horizontal.tella.mobile.databinding.FragmentUploadFilesBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.receipentflow.PeerToPeerFlags +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.FileTransferViewModel +import org.horizontal.tella.mobile.views.fragment.uwazi.widgets.PeerToPeerEndView +import org.hzontal.shared_ui.bottomsheet.BottomSheetUtils.showStandardSheet +import org.hzontal.shared_ui.utils.DialogUtils + +class SenderUploadFilesFragment : + BaseBindingFragment(FragmentUploadFilesBinding::inflate) { + + private val viewModel: FileTransferViewModel by activityViewModels() + private lateinit var endView: PeerToPeerEndView + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.peerToPeerParticipant = PeerToPeerParticipant.SENDER + showFormEndView() + observeUploadProgress() + viewModel.transferPayloadTooLarge.observe(viewLifecycleOwner) { + DialogUtils.showBottomMessage( + baseActivity, + getString(R.string.nearby_sharing_sender_transfer_content_too_large), + false + ) + } + viewModel.uploadAllFiles() + val stopSharing: () -> Unit = { + showStandardSheet( + baseActivity.supportFragmentManager, + getString(R.string.stop_sharing_files), + getString(R.string.nearby_sharing_will_be_stopped_the_recipient_will_not_have_access_to_files_that_were_not_fully_transferred), + getString(R.string.action_continue).uppercase(), + getString(R.string.stop).uppercase(), + {}, + { + viewModel.closePeerConnection() + PeerToPeerFlags.cancelled = true + baseActivity.finish() + }) + } + binding.cancel.setOnClickListener { stopSharing() } + binding.toolbar.backClickListener = { stopSharing() } + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { stopSharing() } + } + + private fun showFormEndView() { + val session = viewModel.p2PSharedState.session ?: return + val files = session.files.values.toList() + + endView = PeerToPeerEndView( + baseActivity, session.title ?: "Transfer" + ) + + endView.setFiles(files, MyApplication.isConnectedToInternet(baseActivity), false) + binding.endViewContainer.removeAllViews() + binding.endViewContainer.addView(endView) + endView.clearPartsProgress(files, session.status) + } + + private fun observeUploadProgress() { + viewModel.uploadProgress.observe(viewLifecycleOwner) { state -> + val files = state.files + val percentFloat = state.percent / 100f + endView.setUploadProgress(files, percentFloat) + + + val filesSettled = state.files.all { + it.status == P2PFileStatus.FINISHED || it.status == P2PFileStatus.FAILED + } + val sessionDone = state.sessionStatus == SessionStatus.FINISHED || + state.sessionStatus == SessionStatus.FINISHED_WITH_ERRORS + + if (sessionDone && filesSettled) { + viewModel.peerToPeerParticipant = PeerToPeerParticipant.SENDER + navManager().navigateFromUploadSenderFragmentToPeerToPeerResultFragment() + } + } + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderVerificationFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderVerificationFragment.kt new file mode 100644 index 000000000..07cfd3eda --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/SenderVerificationFragment.kt @@ -0,0 +1,102 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.databinding.ConnectManuallyVerificationBinding +import org.horizontal.tella.mobile.util.formatHash +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PeerToPeerViewModel +import org.hzontal.shared_ui.bottomsheet.BottomSheetUtils.showStandardSheet +import org.hzontal.shared_ui.utils.DialogUtils +import javax.inject.Inject + +@AndroidEntryPoint +class SenderVerificationFragment : + BaseBindingFragment(ConnectManuallyVerificationBinding::inflate) { + + private val viewModel: PeerToPeerViewModel by activityViewModels() + + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initListeners() + initView() + initObservers() + } + + private fun initView() { + binding.sequenceDescTextView.text = getString(R.string.nearbySharing_verifyConnection_sender) + binding.confirmAndConnectBtn.setText(getString(R.string.confirm_and_connect)) + binding.hashContentTextView.text = viewModel.p2PState.hash.formatHash() + } + + private fun initListeners() { + binding.confirmAndConnectBtn.setOnClickListener { + // Disable & show waiting immediately + binding.confirmAndConnectBtn.isEnabled = false + binding.confirmAndConnectBtn.setText(getString(R.string.waiting_for_the_recipient)) + viewModel.onUserTappedConfirmAndConnect() + } + + binding.discardBtn.setOnClickListener { + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + } + } + + private fun initObservers() { + // Manual mode + viewModel.isManualConnection = true + + // Button enable/disable from VM + viewModel.canTapConfirm.observe(viewLifecycleOwner) { canTap -> + binding.confirmAndConnectBtn.isEnabled = canTap + if (canTap) { + binding.confirmAndConnectBtn.setText(getString(R.string.confirm_and_connect)) + } + } + viewModel.waitingForOtherSide.observe(viewLifecycleOwner) { waiting -> + if (waiting) { + binding.confirmAndConnectBtn.isEnabled = false + binding.confirmAndConnectBtn.setText(getString(R.string.waiting_for_the_recipient)) + } + } + + viewModel.registrationSuccess.observe(viewLifecycleOwner) { success -> + if (success) { + findNavController().currentBackStackEntry?.savedStateHandle?.set("registrationSuccess", true) + navManager().navigateConnectManuallyVerificationFragmentToprepareUploadFragment() + } + } + + viewModel.bottomMessageError.observe(viewLifecycleOwner) { message -> + DialogUtils.showBottomMessage(baseActivity, message, false) + } + + viewModel.bottomSheetError.observe(viewLifecycleOwner) { (title, description) -> + showStandardSheet( + baseActivity.supportFragmentManager, + title, + description, + getString(R.string.action_ok), + null, + onConfirmClick = { + viewModel.resetRegistrationState() + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + }, + onCancelClick = null, + ) + } + } + + override fun onDestroyView() { + super.onDestroyView() + peerServerStarterManager.stopServer() + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/WaitingSenderFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/WaitingSenderFragment.kt new file mode 100644 index 000000000..1742765b6 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/senderflow/WaitingSenderFragment.kt @@ -0,0 +1,82 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow + +import android.os.Bundle +import android.view.View +import androidx.activity.addCallback +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerServerStarterManager +import org.horizontal.tella.mobile.databinding.FragmentWaitingBinding +import org.horizontal.tella.mobile.views.base_ui.BaseBindingFragment +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.FileTransferViewModel +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.PrepareFailureKind +import org.hzontal.shared_ui.utils.DialogUtils +import javax.inject.Inject + +/** + * Created by wafa on 3/6/2025. + */ +@AndroidEntryPoint +class WaitingSenderFragment : + BaseBindingFragment(FragmentWaitingBinding::inflate) { + private val viewModel: FileTransferViewModel by activityViewModels() + + @Inject + lateinit var peerServerStarterManager: PeerServerStarterManager + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.toolbar.setStartTextTitle(getString(R.string.send_files)) + binding.waitingText.text = getString(R.string.waiting_for_the_recipient_to_accept_files) + + val discontinueSession: () -> Unit = { + peerServerStarterManager.stopServer() + viewModel.closePeerConnection() + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + } + binding.toolbar.backClickListener = discontinueSession + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + discontinueSession() + } + + viewModel.prepareFailure.observe(viewLifecycleOwner) { event -> + event.getContentIfNotHandled()?.let { kind -> + when (kind) { + PrepareFailureKind.RECIPIENT_REJECTED -> { + findNavController().previousBackStackEntry + ?.savedStateHandle + ?.set("prepareTransferRecipientRejected", true) + findNavController().popBackStack() + } + PrepareFailureKind.GENERIC -> { + navManager().navigateBackToStartNearBySharingFragmentAndClearBackStack() + DialogUtils.showBottomMessage(baseActivity, getString(R.string.failure_title), false) + } + } + } + } + + viewModel.prepareRejected.observe(viewLifecycleOwner) { event -> + event.getContentIfNotHandled()?.let { messageRes -> + findNavController().previousBackStackEntry + ?.savedStateHandle + ?.set("transferRejectedMessageRes", messageRes) + findNavController().popBackStack() + } + } + + viewModel.prepareResults.observe(viewLifecycleOwner) { response -> + val fileInfos = response.files + fileInfos.forEach { fileInfo -> + viewModel.p2PSharedState.session?.files?.get(fileInfo.id)?.let { pf -> + pf.transmissionId = fileInfo.transmissionId + } + } + navManager().navigateFromWaitingSenderFragmentToUploadFilesFragment() + } + + // Start prepare only after observers are registered so SingleLiveEvent results are not lost. + viewModel.prepareUploadsFromVaultFiles() + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/FileTransferViewModel.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/FileTransferViewModel.kt new file mode 100644 index 000000000..44b07e1e6 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/FileTransferViewModel.kt @@ -0,0 +1,351 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.hzontal.tella_vault.VaultFile +import dagger.hilt.android.lifecycle.HiltViewModel +import io.reactivex.Observable +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.MyApplication +import org.horizontal.tella.mobile.bus.SingleLiveEvent +import org.horizontal.tella.mobile.data.peertopeer.PeerFileHash +import org.horizontal.tella.mobile.data.peertopeer.TellaPeerToPeerClient +import org.horizontal.tella.mobile.data.peertopeer.model.P2PFileStatus +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSession +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState +import org.horizontal.tella.mobile.data.peertopeer.model.SessionStatus +import org.horizontal.tella.mobile.data.peertopeer.remote.PeerUploadOutcome +import org.horizontal.tella.mobile.data.peertopeer.remote.PrepareUploadResult +import org.horizontal.tella.mobile.domain.peertopeer.NearbySharingTransferConfig +import org.horizontal.tella.mobile.domain.peertopeer.PeerPrepareUploadResponse +import org.horizontal.tella.mobile.domain.peertopeer.P2PFile +import org.horizontal.tella.mobile.media.MediaFileHandler +import org.horizontal.tella.mobile.util.Event +import org.horizontal.tella.mobile.util.fromJsonToObjectList +import org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow.PeerToPeerParticipant +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.state.UploadProgressState +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class FileTransferViewModel @Inject constructor( + private val peerClient: TellaPeerToPeerClient, + var p2PSharedState: P2PSharedState +) : ViewModel() { + private val _prepareResults = SingleLiveEvent() + val prepareResults: SingleLiveEvent = _prepareResults + /** 403 → recipient-rejected handling; other failures → pop to Nearby Sharing root + generic error. */ + private val _prepareFailure = SingleLiveEvent>() + val prepareFailure: SingleLiveEvent> = _prepareFailure + /** Message string resource when prepare is aborted (e.g. recipient rejected or missing vault hash). */ + private val _prepareRejected = SingleLiveEvent>() + val prepareRejected: SingleLiveEvent> = _prepareRejected + + private val _transferPayloadTooLarge = SingleLiveEvent() + val transferPayloadTooLarge: SingleLiveEvent get() = _transferPayloadTooLarge + + private val _uploadProgress = SingleLiveEvent() + val uploadProgress: SingleLiveEvent get() = _uploadProgress + var peerToPeerParticipant: PeerToPeerParticipant = PeerToPeerParticipant.SENDER + + fun putVaultFilesInForm(vaultFileList: String): Single> { + return Single.fromCallable { + vaultFileList.fromJsonToObjectList(String::class.java) ?: emptyList() + } + .flatMap { fileIds -> + MyApplication.keyRxVault.rxVault + .firstOrError() + .flatMap { rxVault -> + Observable.fromIterable(fileIds) + .flatMapSingle { fileId -> + rxVault[fileId] + .subscribeOn(Schedulers.io()) + .onErrorReturn { null } + } + .filter { true } + .map { it } + .toList() + } + } + .subscribeOn(Schedulers.io()) + } + + fun prepareUploadsFromVaultFiles() { + Timber.d("session id ***prepareUploadsFromVaultFiles ${p2PSharedState.session?.sessionId}") + + viewModelScope.launch { + val missingHash = withContext(Dispatchers.IO) { + syncSha256FromVaultForPrepare() + } + if (missingHash) { + Timber.w("prepareUploadsFromVaultFiles: skipped — one or more vault files lack a hash") + withContext(Dispatchers.Main) { + _prepareRejected.value = Event(R.string.peer_to_peer_missing_file_hash) + } + return@launch + } + + val sessionForLimits = p2PSharedState.session + if (sessionForLimits != null && exceedsNearbySharingTransferLimits(sessionForLimits)) { + Timber.w("prepareUploadsFromVaultFiles: over max file size or count") + withContext(Dispatchers.Main) { + _prepareRejected.value = Event(R.string.nearby_sharing_sender_transfer_content_too_large) + } + return@launch + } + + when (val result = peerClient.prepareUpload( + ip = p2PSharedState.ip, + port = p2PSharedState.port, + expectedFingerprint = p2PSharedState.hash, + title = getTitleFromState(), + files = getP2pFilesForPrepare(), + sessionId = getSessionId() + )) { + is PrepareUploadResult.Success -> { + _prepareResults.postValue(PeerPrepareUploadResponse(result.transmissions)) + } + + is PrepareUploadResult.Forbidden -> { + withContext(Dispatchers.Main) { + Timber.w("Rejected") + _prepareFailure.value = Event(PrepareFailureKind.RECIPIENT_REJECTED) + } + } + + is PrepareUploadResult.BadRequest -> { + withContext(Dispatchers.Main) { + Timber.e("Bad request – possibly invalid data") + _prepareFailure.value = Event(PrepareFailureKind.GENERIC) + } + } + + is PrepareUploadResult.Conflict -> { + withContext(Dispatchers.Main) { + Timber.e("Upload conflict – another session may be active") + _prepareFailure.value = Event(PrepareFailureKind.GENERIC) + } + } + + is PrepareUploadResult.TooManyRequests -> { + withContext(Dispatchers.Main) { + Timber.w("Prepare upload rate limited (429)") + _prepareFailure.value = Event(PrepareFailureKind.GENERIC) + } + } + + is PrepareUploadResult.PayloadTooLarge -> { + withContext(Dispatchers.Main) { + Timber.w("Prepare upload rejected: payload too large (413)") + _prepareRejected.value = Event(R.string.nearby_sharing_sender_transfer_content_too_large) + } + } + + is PrepareUploadResult.ServerError -> { + withContext(Dispatchers.Main) { + Timber.e("Internal server error – try again later") + _prepareFailure.value = Event(PrepareFailureKind.GENERIC) + } + } + + is PrepareUploadResult.Failure -> { + withContext(Dispatchers.Main) { + Timber.e(result.exception, "Unhandled error during upload") + _prepareFailure.value = Event(PrepareFailureKind.GENERIC) + } + } + + } + } + } + + /** + * Sets [ProgressFile.file] sha256 and size from the same decrypted stream we PUT on upload. + * Vault [VaultFile.hash] is the digest of ciphertext on disk, so it must not be sent to desktop. + */ + private fun syncSha256FromVaultForPrepare(): Boolean { + val session = p2PSharedState.session ?: return true + val rxVault = try { + MyApplication.keyRxVault.getRxVault().blockingFirst() + } catch (e: Exception) { + Timber.e(e, "syncSha256FromVaultForPrepare: vault not ready") + return true + } + for (pf in session.files.values) { + val vf = pf.vaultFile ?: return true + val latest = try { + rxVault.get(vf.id).blockingGet() + } catch (e: Exception) { + Timber.d(e, "syncSha256FromVaultForPrepare: refresh failed for ${vf.id}") + vf + } + val stream = MediaFileHandler.getStream(latest) ?: return true + val (hex, len) = try { + stream.use { PeerFileHash.sha256HexAndLength(it) } + } catch (e: Exception) { + Timber.e(e, "syncSha256FromVaultForPrepare: hash stream failed for ${latest.id}") + return true + } + if (len <= 0L) return true + val mimeType = latest.mimeType ?: "application/octet-stream" + pf.vaultFile = latest + pf.file = P2PFile( + id = latest.id, + fileName = latest.name, + size = len, + fileType = mimeType, + sha256 = hex, + thumbnail = latest.thumb, + ) + } + return false + } + + private fun exceedsNearbySharingTransferLimits(session: P2PSession): Boolean { + if (session.files.size > NearbySharingTransferConfig.Standard.maxFileCount) return true + return session.files.values.any { it.file.size > NearbySharingTransferConfig.Standard.maxFileSizeBytes } + } + + fun uploadAllFiles() { + viewModelScope.launch { + val session = p2PSharedState.session ?: return@launch + val ip = p2PSharedState.ip + val port = p2PSharedState.port + val fingerprint = p2PSharedState.hash + + val totalSize = session.files.values.sumOf { it.file.size } + + fun postProgress() { + val uploaded = session.files.values.sumOf { it.bytesTransferred } + val percent = if (totalSize > 0) ((uploaded * 100) / totalSize).toInt() else 0 + _uploadProgress.postValue( + UploadProgressState( + title = session.title.orEmpty(), + percent = percent, + sessionStatus = session.status, + files = session.files.values.toList() + ) + ) + } + + for (pf in session.files.values) { + val vf = pf.vaultFile ?: continue + val input = MediaFileHandler.getStream(vf) + pf.status = P2PFileStatus.SENDING + postProgress() + + try { + if (input != null) { + when ( + peerClient.uploadFileWithProgress( + ip = ip, + port = port, + expectedFingerprint = fingerprint, + sessionId = session.sessionId.orEmpty(), + fileId = pf.file.id, + transmissionId = pf.transmissionId.orEmpty(), + inputStream = input, + fileSize = pf.file.size, + fileName = vf.name, + ) { written, _ -> + pf.bytesTransferred = written.toInt() + postProgress() + } + ) { + PeerUploadOutcome.Success -> + pf.status = P2PFileStatus.FINISHED + PeerUploadOutcome.Failed -> + pf.status = P2PFileStatus.FAILED + PeerUploadOutcome.TooManyRequests -> { + pf.status = P2PFileStatus.FAILED + postProgress() + return@launch + } + PeerUploadOutcome.PayloadTooLarge -> { + pf.status = P2PFileStatus.FAILED + _transferPayloadTooLarge.postValue(Unit) + postProgress() + return@launch + } + } + } else { + pf.status = P2PFileStatus.FAILED + } + } catch (e: Exception) { + pf.status = P2PFileStatus.FAILED + Timber.e(e, "Upload failed for ${pf.file.fileName}") + } finally { + input?.close() + } + postProgress() + } + + val anyFailed = session.files.values.any { it.status == P2PFileStatus.FAILED } + val allFinished = session.files.values.all { it.status == P2PFileStatus.FINISHED } + session.status = when { + anyFailed -> SessionStatus.FINISHED_WITH_ERRORS + allFinished -> SessionStatus.FINISHED + else -> SessionStatus.FINISHED_WITH_ERRORS + } + val uploadedSum = session.files.values.sumOf { it.bytesTransferred.toLong() } + val percent = + if (totalSize > 0) ((uploadedSum * 100) / totalSize).toInt().coerceIn(0, 100) else 0 + _uploadProgress.postValue( + UploadProgressState( + title = session.title.orEmpty(), + percent = if (allFinished && !anyFailed) 100 else percent, + sessionStatus = session.status, + files = session.files.values.toList() + ) + ) + } + } + + private fun getP2pFilesForPrepare(): List { + return p2PSharedState.session?.files?.values?.map { it.file }.orEmpty() + } + + private fun getTitleFromState(): String { + return p2PSharedState.session?.title ?: "" + } + + private fun getSessionId(): String { + return p2PSharedState.session?.sessionId ?: "" + } + + override fun onCleared() { + super.onCleared() + p2PSharedState.clear() + } + + fun closePeerConnection() { + viewModelScope.launch { + val ip = p2PSharedState.ip + val port = p2PSharedState.port + val fingerprint = p2PSharedState.hash + val success = peerClient.closeConnection( + ip = ip, + port = port, + expectedFingerprint = fingerprint, + sessionId = p2PSharedState.session?.sessionId ?: "" + ) + if (!success) Timber.e("Failed to close peer connection.") + } + } + +} + +enum class PrepareFailureKind { + /** HTTP 403 — recipient rejected; return to prepare with a specific toast. */ + RECIPIENT_REJECTED, + + /** Other failures (e.g. 429, 5xx, network) — exit flow and show generic error. */ + GENERIC +} + + diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/PeerToPeerViewModel.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/PeerToPeerViewModel.kt new file mode 100644 index 000000000..af01e912a --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/PeerToPeerViewModel.kt @@ -0,0 +1,833 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.hzontal.tella_vault.VaultFile +import com.hzontal.tella_vault.rx.RxVault +import com.hzontal.utils.MediaFile.isImageFileType +import com.hzontal.utils.MediaFile.isVideoFileType +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.MyApplication +import org.horizontal.tella.mobile.bus.SingleLiveEvent +import org.horizontal.tella.mobile.data.peertopeer.FingerprintFetcher +import org.horizontal.tella.mobile.data.peertopeer.FingerprintResult +import org.horizontal.tella.mobile.data.peertopeer.ServerPinger +import org.horizontal.tella.mobile.data.peertopeer.TellaPeerToPeerClient +import org.horizontal.tella.mobile.data.peertopeer.managers.PeerToPeerManager +import org.horizontal.tella.mobile.data.peertopeer.model.P2PFileStatus +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState +import org.horizontal.tella.mobile.data.peertopeer.model.P2PSharedState.Companion.createNewSession +import org.horizontal.tella.mobile.data.peertopeer.model.ProgressFile +import org.horizontal.tella.mobile.data.peertopeer.model.SessionStatus +import org.horizontal.tella.mobile.data.peertopeer.remote.PrepareUploadRequest +import org.horizontal.tella.mobile.data.peertopeer.remote.RegisterPeerResult +import org.horizontal.tella.mobile.domain.peertopeer.IncomingRegistration +import org.horizontal.tella.mobile.domain.peertopeer.NearbySharingIpPreference +import org.horizontal.tella.mobile.domain.peertopeer.PeerEventManager +import org.horizontal.tella.mobile.media.MediaFileHandler +import org.horizontal.tella.mobile.util.NetworkInfo +import org.horizontal.tella.mobile.util.NetworkInfoManager +import org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow.PeerToPeerParticipant +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.state.BottomSheetProgressState +import org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.state.UploadProgressState +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.util.Collections +import java.util.Locale +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +@HiltViewModel +class PeerToPeerViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val peerClient: TellaPeerToPeerClient, + peerToPeerManager: PeerToPeerManager, + val p2PState: P2PSharedState +) : ViewModel() { + + // ------------------- Public state / deps ------------------- + var peerToPeerParticipant: PeerToPeerParticipant = PeerToPeerParticipant.SENDER + var isManualConnection: Boolean = true + var currentNetworkInfo: NetworkInfo? = null + + private var hasNavigatedFromWaitingToPrepareSuccess: Boolean = false + + fun markNavigatedFromWaitingToPrepareSuccess() { + hasNavigatedFromWaitingToPrepareSuccess = true + } + + fun clearWaitingToPrepareSuccessNavigationBlock() { + hasNavigatedFromWaitingToPrepareSuccess = false + } + + fun discardStalePrepareOfferReplayAndNavigationGate() { + _incomingPrepareRequest.resetReplayCache() + clearWaitingToPrepareSuccessNavigationBlock() + } + + fun shouldSkipWaitingToPrepareSuccessNavigation(): Boolean = + hasNavigatedFromWaitingToPrepareSuccess + + val clientHash = peerToPeerManager.clientConnected + private val networkInfoManager = NetworkInfoManager(context) + val networkInfo: LiveData get() = networkInfoManager.networkInfo + + // ------------------- Events to the UI ------------------- + private val _registrationSuccess = SingleLiveEvent() + val registrationSuccess: SingleLiveEvent get() = _registrationSuccess + + private val _registrationServerSuccess = SingleLiveEvent() + val registrationServerSuccess: SingleLiveEvent get() = _registrationServerSuccess + + private val _getHashSuccess = SingleLiveEvent() + val getHashSuccess: SingleLiveEvent get() = _getHashSuccess + + val bottomMessageError = SingleLiveEvent() + val bottomSheetError = SingleLiveEvent>() + + private val _incomingPrepareRequest = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + val incomingPrepareRequest: SharedFlow = _incomingPrepareRequest.asSharedFlow() + + private val _incomingRequest = MutableStateFlow(null) + val incomingRequest: StateFlow get() = _incomingRequest + + private val _uploadProgress = SingleLiveEvent() + val uploadProgress: SingleLiveEvent get() = _uploadProgress + + private val _bottomSheetProgress = MutableLiveData() + val bottomSheetProgress: LiveData get() = _bottomSheetProgress + + private val _closeConnection = SingleLiveEvent() + val closeConnection: SingleLiveEvent get() = _closeConnection + + // ------------------- Manual verify UI flags ------------------- + private val _canTapConfirm = MutableLiveData(false) + val canTapConfirm: LiveData get() = _canTapConfirm + + private val _waitingForOtherSide = MutableLiveData(false) + val waitingForOtherSide: LiveData get() = _waitingForOtherSide + + // Cache for "pre-accept" when recipient taps before the request arrives + private var preConfirmRegistration: Boolean = false + + // Keep connection params until user taps Confirm (sender path) + private data class PendingConnectParams( + val ip: String, + val port: String, + val hash: String, + val pin: String + ) + private var pendingParams: PendingConnectParams? = null + + /** Reuse registration nonce for the same target until registration succeeds (iOS RegistrationNonceContext). */ + private data class RegistrationNonceContext( + val ip: String, + val port: String, + val pin: String, + val nonce: String, + ) { + fun matches(ip: String, port: String, pin: String) = + this.ip == ip && this.port == port && this.pin == pin + } + + private var registrationNonceContext: RegistrationNonceContext? = null + + private fun registrationNonceFor(ip: String, port: String, pin: String): String { + val normalizedPin = pin.trim() + val existing = registrationNonceContext + if (existing != null && existing.matches(ip, port, normalizedPin)) { + return existing.nonce + } + val nonce = UUID.randomUUID().toString() + registrationNonceContext = RegistrationNonceContext(ip, port, normalizedPin, nonce) + return nonce + } + + // ------------------- Save counters ------------------- + private val savingOrDone: MutableSet = + Collections.newSetFromMap(ConcurrentHashMap()) + private var totalFilesExpected = 0 + private var savedCount = 0 + private var targetFolderId: String? = null + + fun getTransferFolderId(): String? = targetFolderId + + private val p2pVaultImportLock = Any() + + // ------------------- Init: subscribe to streams ------------------- + init { + observePrepareUploadEvents() + observeRegistrationEvents() + observeRegistrationRequests() + observeUploadProgress() + observeCloseConnectionEvents() + } + + // ------------------- Observers ------------------- + private fun observePrepareUploadEvents() { + viewModelScope.launch { + PeerEventManager.prepareUploadRequests.collect { request -> + _incomingPrepareRequest.tryEmit(request) // your _incomingPrepareRequest already has replay = 1 + } + } + } + + private fun observeRegistrationEvents() { + viewModelScope.launch { + PeerEventManager.registrationEvents.collect { success -> + _registrationServerSuccess.postValue(success) + if (success) { + _waitingForOtherSide.postValue(false) + _canTapConfirm.postValue(false) // will navigate away + } + } + } + } + + private fun observeCloseConnectionEvents() { + viewModelScope.launch { + PeerEventManager.closeConnectionEvent.collect { success -> + if (success) { + p2PState.session?.status = SessionStatus.CLOSED + _closeConnection.postValue(true) + emitFinalIfReady(SessionStatus.CLOSED) + } + } + } + } + + private fun observeRegistrationRequests() { + viewModelScope.launch { + PeerEventManager.registrationRequests.collect { (registrationId, payload) -> + if (registrationId.isEmpty()) return@collect + + _incomingRequest.value = IncomingRegistration(registrationId, payload) + + if (!p2PState.isUsingManualConnection) { + // Auto mode: accept immediately + PeerEventManager.confirmRegistration(registrationId, true) + _registrationSuccess.postValue(true) + PeerEventManager.clearRegistrationRequest() + return@collect + } + + // Manual mode: if the recipient tapped confirm earlier, accept now. + if (preConfirmRegistration) { + PeerEventManager.confirmRegistration(registrationId, true) + PeerEventManager.clearRegistrationRequest() + preConfirmRegistration = false + } else { + // Otherwise, allow tapping now (if UI wants to reflect it) + _canTapConfirm.postValue(true) + } + } + } + } + + private fun observeUploadProgress() { + viewModelScope.launch { + PeerEventManager.uploadProgressStateFlow.collect { state -> + initCountersIfNeeded() + _uploadProgress.postValue(state) + + // Save each FINISHED file exactly once (by transmissionId) + state.files.forEach { pf -> + val txId = pf.transmissionId + if (txId != null && + pf.status == P2PFileStatus.FINISHED && + !savingOrDone.contains(txId) + ) { + viewModelScope.launch(Dispatchers.IO) { saveOneFile(pf) } + } + } + + emitFinalIfReady(state.sessionStatus) + } + } + } + + // ------------------- Manual verification entry points ------------------- + + /** + * Called after IP/port/PIN are entered and TLS cert is fetched. + * In manual mode we DO NOT auto-register. We enable the "Confirm & connect" button instead. + */ + fun handleCertificate(ip: String, port: String, pin: String) { + viewModelScope.launch { + val reachable = runCatching { peerClient.pingBeforeRegister(ip, port) }.getOrDefault(false) + if (!reachable) { + bottomSheetError.postValue( + "Connection failed" to "Host not reachable on this Wi-Fi. Check IP/Port and that both devices are on the same network." + ) + return@launch + } + + val fpRes: Result = FingerprintFetcher.fetch(context, ip, port.toInt()) + if (fpRes.isFailure) { + bottomSheetError.postValue( + "Connection failed" to ("Couldn’t read peer certificate. " + (fpRes.exceptionOrNull()?.message ?: "")) + ) + return@launch + } + + val fp = fpRes.getOrNull()!! + p2PState.hash = fp.certHex + _getHashSuccess.postValue(fp.certHex) + + val pinnedPingOk = runCatching { + ServerPinger.notifyServerPinnedByCert( + context = context, + ip = ip, + port = port.toInt(), + expectedCertSha256Hex = fp.certHex + ) + }.isSuccess + + // Manual verification path: wait for user tap + p2PState.isUsingManualConnection = true + pendingParams = PendingConnectParams(ip, port, fp.certHex, pin) + _canTapConfirm.postValue(true) + _waitingForOtherSide.postValue(false) + } + } + + /** Sender tapped confirm: actually initiate /register on peer (using cached params). */ + // In PeerToPeerViewModel + fun onUserTappedConfirmAndConnect() { + _canTapConfirm.postValue(false) + _waitingForOtherSide.postValue(true) + + val params = pendingParams + if (params != null) { + startRegistration(params.ip, params.port, params.hash, params.pin) + return + } + + // Fallback: try using current state or re-run handshake + val ip = p2PState.ip + val port = p2PState.port + val pin = p2PState.pin.orEmpty() + val hash = p2PState.hash + + if (hash.isNotBlank()) { + startRegistration(ip, port, hash, pin) + } else { + viewModelScope.launch { + handleCertificate(ip, port, pin) + pendingParams?.let { startRegistration(it.ip, it.port, it.hash, it.pin) } + ?: run { + _waitingForOtherSide.postValue(false) + _canTapConfirm.postValue(true) + } + } + } + } + + + + /** Recipient tapped confirm: allow pre-accept before request arrives. */ + fun onRecipientConfirmTapped() { + _canTapConfirm.postValue(false) + _waitingForOtherSide.postValue(true) // "Waiting for the sender…" + + val current = _incomingRequest.value + if (current != null) { + onUserConfirmedRegistration(current.registrationId) + } else { + preConfirmRegistration = true + } + } + + /** Recipient send acceptance to server. Do NOT post local registrationSuccess here. */ + fun onUserConfirmedRegistration(registrationId: String) { + viewModelScope.launch { + PeerEventManager.confirmRegistration(registrationId, true) + PeerEventManager.clearRegistrationRequest() + } + } + + fun onUserRejectedRegistration(registrationId: String) { + viewModelScope.launch { + PeerEventManager.confirmRegistration(registrationId, false) + PeerEventManager.clearRegistrationRequest() + } + } + + fun collectLocalIpv4AddressesForNearbySharing(): List = + networkInfoManager.collectNearbySharingIpv4Addresses() + + /** Sender/initiator path: single IP (manual entry or legacy QR). */ + fun startRegistration(ip: String, port: String, hash: String, pin: String) { + startRegistrationWithIpCandidates(listOf(ip), port, hash, pin) + } + + /** + * Tries `/register` for each candidate IP in order. Candidates are reordered so addresses on the same + * IPv4 /24-style subnet as this device are tried first, then the rest. + */ + fun startRegistrationWithIpCandidates(rawCandidates: List, port: String, hash: String, pin: String) { + val distinct = rawCandidates.map { it.trim() }.filter { it.isNotEmpty() }.distinct() + if (distinct.isEmpty()) return + val candidates = NearbySharingIpPreference.preferredNearbySharingIPOrder( + qrAddresses = distinct, + localDeviceIPv4Addresses = networkInfoManager.collectAllLocalIpv4Addresses(), + ) + val pinTrimmed = pin.trim() + viewModelScope.launch { + candidates.forEachIndexed { index, ip -> + val nonce = registrationNonceFor(ip, port, pinTrimmed) + when (val result = peerClient.registerPeerDevice(ip, port, hash, pinTrimmed, nonce)) { + is RegisterPeerResult.Success -> { + registrationNonceContext = null + if (p2PState.session == null) p2PState.session = P2PSharedState.createNewSession() + p2PState.session?.sessionId = result.sessionId + p2PState.ip = ip + _registrationSuccess.postValue(true) + return@launch + } + RegisterPeerResult.InvalidPin -> { + bottomMessageError.postValue(context.getString(R.string.peer_to_peer_invalid_pin)) + return@launch + } + RegisterPeerResult.InvalidFormat -> { + bottomMessageError.postValue(context.getString(R.string.peer_to_peer_invalid_request_format)) + return@launch + } + RegisterPeerResult.RejectedByReceiver -> { + bottomMessageError.postValue(context.getString(R.string.peer_to_peer_receiver_rejected_registration)) + return@launch + } + RegisterPeerResult.Conflict -> { + if (index < candidates.lastIndex && shouldRetryRegisterWithNextIp(result)) return@forEachIndexed + bottomMessageError.postValue(context.getString(R.string.peer_to_peer_active_session_exists)) + return@launch + } + RegisterPeerResult.TooManyRequests -> { + if (index < candidates.lastIndex && shouldRetryRegisterWithNextIp(result)) return@forEachIndexed + bottomSheetError.postValue( + "Connection failed" to "Please make sure your connection details are correct and that you are on the same Wi-Fi network." + ) + return@launch + } + RegisterPeerResult.ServerError -> { + if (index < candidates.lastIndex && shouldRetryRegisterWithNextIp(result)) return@forEachIndexed + bottomMessageError.postValue(context.getString(R.string.peer_to_peer_server_error_try_later)) + return@launch + } + is RegisterPeerResult.Failure -> { + if (index < candidates.lastIndex && shouldRetryRegisterWithNextIp(result)) return@forEachIndexed + Timber.e(result.exception, "Connection failure") + bottomSheetError.postValue( + "Connection failed" to "Please make sure your connection details are correct and that you are on the same Wi-Fi network." + ) + return@launch + } + } + } + } + } + + private fun shouldRetryRegisterWithNextIp(result: RegisterPeerResult): Boolean = when (result) { + RegisterPeerResult.Conflict, + RegisterPeerResult.TooManyRequests, + RegisterPeerResult.ServerError, + is RegisterPeerResult.Failure, + -> true + else -> false + } + + // ------------------- Prepare/Upload/Save logic (unchanged from your version) ------------------- + + private fun initCountersIfNeeded() { + val session = p2PState.session ?: return + if (totalFilesExpected == 0) { + totalFilesExpected = session.files.size + savedCount = session.files.values.count { it.status == P2PFileStatus.SAVED } + session.files.values.forEach { pf -> + val tx = pf.transmissionId + if (!tx.isNullOrBlank() && (pf.status == P2PFileStatus.SAVED || pf.status == P2PFileStatus.FAILED)) { + savingOrDone.add(tx) + } + } + postBottomSheetProgress() + } + } + + private fun maybeFinalizeAfterSave() { + val session = p2PState.session ?: return + if (!allFilesSavedOrFailed()) return + + val final = when (session.status) { + SessionStatus.CLOSED -> SessionStatus.CLOSED + SessionStatus.SENDING, SessionStatus.SAVING -> computeFinalStatus() + SessionStatus.FINISHED, SessionStatus.FINISHED_WITH_ERRORS -> session.status + else -> computeFinalStatus() + } + + session.status = final + + _uploadProgress.postValue( + UploadProgressState( + title = session.title.orEmpty(), + sessionStatus = final, + files = session.files.values.toList(), + percent = 100 + ) + ) + completeBottomSheetImportIfTerminal() + } + + private fun appendIndexedDisplayName(baseName: String, index: Int): String { + val dot = baseName.lastIndexOf('.') + return if (dot > 0) { + baseName.substring(0, dot) + " (" + index + ")" + baseName.substring(dot) + } else { + "$baseName ($index)" + } + } + + /** Picks a vault name not present in [existingNames] (case-sensitive, matches vault rules). */ + private fun uniqueVaultSiblingName(desired: String, existingNames: Set): String { + val base = desired.trim().ifBlank { "file" } + if (base !in existingNames) return base + var i = 1 + while (true) { + val candidate = appendIndexedDisplayName(base, i++) + if (candidate !in existingNames) return candidate + } + } + + private fun obtainTargetFolderId(): String { + synchronized(p2pVaultImportLock) { + targetFolderId?.let { return it } + val title = (p2PState.session?.title ?: "").trim() + val finalTitle = title.ifEmpty { "Transfer" } + + val vault = MyApplication.keyRxVault.rxVault.blockingFirst() + val root = vault.root.blockingGet() + val siblingNames = + vault.list(root).blockingGet().mapNotNull { it.name }.toHashSet() + val folderName = uniqueVaultSiblingName(finalTitle, siblingNames) + val folder = vault.builder() + .setName(folderName) + .setType(VaultFile.Type.DIRECTORY) + .build(root.id) + .blockingGet() + targetFolderId = folder.id + return folder.id + } + } + + /** P2P bytes are written to a temp file; vault entry should use the sender’s name. */ + private fun vaultDisplayNameForP2pReceive(pf: ProgressFile, tempReceiveFile: File): String { + val raw = pf.file.fileName.trim() + val base = if (raw.isNotEmpty()) File(raw).name else tempReceiveFile.name + return base.ifBlank { "file" } + } + + /** + * QuickTime / `.mov` is not handled by [MediaFileHandler.saveMp4Video] (MP4 pipeline). Store the file as-is + * so it lands in the transfer folder; in-app playback depends on codecs, same as other opaque imports. + */ + private fun shouldStoreP2pVideoAsOpaqueContainer(pf: ProgressFile, receivedFile: File): Boolean { + val mime = pf.file.fileType.lowercase(Locale.US) + if (mime.contains("quicktime")) return true + return vaultDisplayNameForP2pReceive(pf, receivedFile).lowercase(Locale.US).endsWith(".mov") + } + + private fun importP2pReceivedFileAsVaultFile( + vault: RxVault, + receivedFile: File, + pf: ProgressFile, + folderId: String, + vaultDisplayName: String, + ): VaultFile? = try { + FileInputStream(receivedFile).use { input -> + vault.builder(input) + .setName(vaultDisplayName) + .setMimeType(pf.file.fileType) + .setAnonymous(false) + .setType(VaultFile.Type.FILE) + .build(folderId) + .blockingGet() + } + } catch (e: Exception) { + Timber.e(e, "P2P vault import failed for ${pf.file.fileName}") + null + } + + private fun saveOneFile(pf: ProgressFile) { + val txId = pf.transmissionId ?: return + if (!savingOrDone.add(txId)) return + + if (p2PState.session?.status == SessionStatus.SENDING) { + p2PState.session?.status = SessionStatus.SAVING + _uploadProgress.postValue( + UploadProgressState( + title = p2PState.session?.title.orEmpty(), + sessionStatus = SessionStatus.SAVING, + files = p2PState.session?.files?.values?.toList().orEmpty(), + percent = 100 + ) + ) + } + + val tempPath = pf.path + if (tempPath == null) { + markProgressFileSaveFailed(pf) + return + } + val receivedFile = File(tempPath) + if (!receivedFile.exists()) { + clearP2pReceiveHandoff(pf) + markProgressFileSaveFailed(pf) + return + } + + try { + val vaultFile = synchronized(p2pVaultImportLock) { + val folderId = obtainTargetFolderId() + val vault = MyApplication.keyRxVault.rxVault.blockingFirst() + val parent = vault.get(folderId).blockingGet() + val taken = + vault.list(parent).blockingGet().mapNotNull { it.name }.toHashSet() + val displayName = + uniqueVaultSiblingName(vaultDisplayNameForP2pReceive(pf, receivedFile), taken) + + try { + when { + isImageFileType(pf.file.fileType) -> { + val bytes = receivedFile.readBytes() + if (pf.file.fileType.contains("png", true)) { + MediaFileHandler.savePngImage(bytes, folderId, displayName) + } else { + MediaFileHandler.saveJpegPhoto(bytes, folderId, displayName) + .blockingGet() + } + } + + isVideoFileType(pf.file.fileType) -> { + if (shouldStoreP2pVideoAsOpaqueContainer(pf, receivedFile)) { + importP2pReceivedFileAsVaultFile( + vault, + receivedFile, + pf, + folderId, + displayName, + ) + } else { + MediaFileHandler.saveMp4Video( + receivedFile, + folderId, + false, + displayName, + ) + } + } + + else -> { + importP2pReceivedFileAsVaultFile( + vault, + receivedFile, + pf, + folderId, + displayName, + ) + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to save file ${pf.file.fileName}") + null + } + } + + val txKey = pf.transmissionId + if (vaultFile != null) { + if (txKey != null) { + p2PState.session?.files?.get(txKey)?.status = P2PFileStatus.SAVED + } + pf.status = P2PFileStatus.SAVED + pf.vaultFile = vaultFile + savedCount++ + clearP2pReceiveHandoff(pf, receivedFile) + } else { + if (txKey != null) { + p2PState.session?.files?.get(txKey)?.status = P2PFileStatus.FAILED + } + pf.status = P2PFileStatus.FAILED + clearP2pReceiveHandoff(pf, receivedFile) + } + } catch (e: Exception) { + Timber.e(e, "Saving to vault failed for file ${pf.file.fileName}") + pf.status = P2PFileStatus.FAILED + clearP2pReceiveHandoff(pf, receivedFile) + } finally { + postBottomSheetProgress() + maybeFinalizeAfterSave() + } + } + + private fun markProgressFileSaveFailed(pf: ProgressFile) { + val txKey = pf.transmissionId + if (txKey != null) { + p2PState.session?.files?.get(txKey)?.status = P2PFileStatus.FAILED + } + pf.status = P2PFileStatus.FAILED + } + + /** + * After vault/media import (or on failure), remove the P2P receive temp file and clear paths + * so content does not linger on disk longer than necessary. + */ + private fun clearP2pReceiveHandoff(pf: ProgressFile, receivedFile: File? = null) { + receivedFile?.let { f -> + runCatching { + if (f.exists() && !f.delete()) { + Timber.w("P2P handoff: temp delete failed %s", f.path) + } + } + } + pf.path = null + val tid = pf.transmissionId + if (tid != null) { + p2PState.session?.files?.get(tid)?.path = null + } + } + + private fun postBottomSheetProgress() { + _bottomSheetProgress.postValue( + BottomSheetProgressState( + current = savedCount, + total = totalFilesExpected, + percent = if (totalFilesExpected > 0) (savedCount * 100 / totalFilesExpected) else 0 + ) + ) + } + + private fun sessionIsTerminal(sessionStatus: SessionStatus): Boolean = + sessionStatus == SessionStatus.FINISHED || + sessionStatus == SessionStatus.FINISHED_WITH_ERRORS || + sessionStatus == SessionStatus.CLOSED + + private fun allFilesSavedOrFailed(): Boolean = + p2PState.session?.files?.values?.all { + it.status == P2PFileStatus.SAVED || it.status == P2PFileStatus.FAILED + } == true + + private fun computeFinalStatus(): SessionStatus { + val files = p2PState.session?.files?.values.orEmpty() + val anyFailed = files.any { it.status == P2PFileStatus.FAILED } + return if (anyFailed) SessionStatus.FINISHED_WITH_ERRORS else SessionStatus.FINISHED + } + + private fun emitFinalIfReady(triggerStatus: SessionStatus) { + val session = p2PState.session ?: return + if (!allFilesSavedOrFailed()) return + + // Server progress still says SENDING after a failed hash check; we must still close the UI. + val canFinalize = + sessionIsTerminal(triggerStatus) || + triggerStatus == SessionStatus.SENDING || + triggerStatus == SessionStatus.SAVING + if (!canFinalize) return + + if (session.status == SessionStatus.FINISHED || + session.status == SessionStatus.FINISHED_WITH_ERRORS + ) { + return + } + + val final = when (session.status) { + SessionStatus.CLOSED -> SessionStatus.CLOSED + else -> computeFinalStatus() + } + session.status = final + + _uploadProgress.postValue( + UploadProgressState( + title = session.title.orEmpty(), + sessionStatus = final, + files = session.files.values.toList(), + percent = 100 + ) + ) + completeBottomSheetImportIfTerminal() + } + + /** + * The import sheet only dismisses when current == total ([BottomSheetUtils.showProgressImportSheet]). + * Hash failures never call [saveOneFile], so [savedCount] stays 0 and the sheet would hang at 0/N. + */ + private fun completeBottomSheetImportIfTerminal() { + val session = p2PState.session ?: return + if (session.status != SessionStatus.FINISHED && + session.status != SessionStatus.FINISHED_WITH_ERRORS && + session.status != SessionStatus.CLOSED + ) { + return + } + if (totalFilesExpected <= 0) return + _bottomSheetProgress.postValue( + BottomSheetProgressState( + current = totalFilesExpected, + total = totalFilesExpected, + percent = 100 + ) + ) + } + + // ------------------- Misc ------------------- + fun confirmPrepareUpload(sessionId: String, accepted: Boolean) { + PeerEventManager.resolveUserDecision(sessionId, accepted) + if (!accepted) { + _incomingPrepareRequest.resetReplayCache() + clearWaitingToPrepareSuccessNavigationBlock() + } + } + + fun resetRegistrationState() { + _registrationServerSuccess.postValue(false) + _registrationSuccess.postValue(false) + } + + @RequiresApi(Build.VERSION_CODES.M) + fun updateNetworkInfo() { + networkInfoManager.fetchCurrentNetworkInfo() + } + + fun closePeerConnection() { + viewModelScope.launch { + val ip = p2PState.ip + val port = p2PState.port + val fingerprint = p2PState.hash + val success = peerClient.closeConnection( + ip = ip, + port = port, + expectedFingerprint = fingerprint, + sessionId = p2PState.session?.sessionId ?: "" + ) + if (!success) Timber.e("Failed to close peer connection.") + } + } + + override fun onCleared() { + super.onCleared() + p2PState.clear() + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/state/BottomSheetProgressState.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/state/BottomSheetProgressState.kt new file mode 100644 index 000000000..e251600c5 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/state/BottomSheetProgressState.kt @@ -0,0 +1,7 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.state + +data class BottomSheetProgressState( + val current: Int, + val total: Int, + val percent: Int +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/state/UploadProgressState.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/state/UploadProgressState.kt new file mode 100644 index 000000000..b41d36381 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/peertopeer/viewmodel/state/UploadProgressState.kt @@ -0,0 +1,11 @@ +package org.horizontal.tella.mobile.views.fragment.peertopeer.viewmodel.state + +import org.horizontal.tella.mobile.data.peertopeer.model.ProgressFile +import org.horizontal.tella.mobile.data.peertopeer.model.SessionStatus + +data class UploadProgressState( + val title: String, + val percent: Int, + val sessionStatus: SessionStatus, + val files: List +) \ No newline at end of file diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/recorder/MicActivity.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/recorder/MicActivity.kt index a1cb2bf09..f1b68c5a5 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/recorder/MicActivity.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/recorder/MicActivity.kt @@ -33,6 +33,7 @@ import org.horizontal.tella.mobile.views.activity.MetadataActivity import org.horizontal.tella.mobile.views.activity.camera.CameraActivity.Companion.VAULT_CURRENT_ROOT_PARENT import org.horizontal.tella.mobile.views.activity.viewer.toolBar import org.horizontal.tella.mobile.views.fragment.main_connexions.base.BUNDLE_REPORT_VAULT_FILE +import org.horizontal.tella.mobile.views.fragment.peertopeer.senderflow.PREPARE_UPLOAD_ENTRY import org.horizontal.tella.mobile.views.interfaces.VerificationWorkStatusCallback import java.util.Locale import java.util.UUID @@ -44,6 +45,7 @@ class MicActivity : MetadataActivity(), private var animator: ObjectAnimator? = null private var isCollect: Boolean = false private var isReport: Boolean = false + private var isPrepareUpload = false private var notRecording = false private var lastUpdateTime: Long = 0 private var isAddingInProgress = false @@ -75,6 +77,7 @@ class MicActivity : MetadataActivity(), if (intent != null) { isCollect = intent.getBooleanExtra(COLLECT_ENTRY, false) isReport = intent.getBooleanExtra(REPORT_ENTRY, false) + isPrepareUpload = intent.getBooleanExtra(PREPARE_UPLOAD_ENTRY, false) currentRootParent = intent.getStringExtra(VAULT_CURRENT_ROOT_PARENT) } @@ -93,11 +96,11 @@ class MicActivity : MetadataActivity(), recordingName = findViewById(R.id.rec_name) toolBar = findViewById(R.id.toolbar) - if (isCollect || isReport || currentRootParent?.isNotEmpty() == true) { + if (isCollect || isReport || isPrepareUpload || currentRootParent?.isNotEmpty() == true) { mPlay.visibility = View.GONE } - if (isCollect || currentRootParent?.isNotEmpty() == true) { + if (isCollect || isPrepareUpload || currentRootParent?.isNotEmpty() == true) { toolBar.navigationIcon = ContextCompat.getDrawable(this, R.drawable.ic_close_white) @@ -306,7 +309,7 @@ class MicActivity : MetadataActivity(), } private fun maybeReturnCollectRecording(vaultFile: VaultFile?) { - if (isCollect) { + if (isCollect || isPrepareUpload) { MyApplication.bus().post(AudioRecordEvent(vaultFile)) } if (isReport) { diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/attachments/AttachmentsActivitySelector.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/attachments/AttachmentsActivitySelector.kt index 7cea6b2b7..0d44a8833 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/attachments/AttachmentsActivitySelector.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/attachments/AttachmentsActivitySelector.kt @@ -43,7 +43,7 @@ class AttachmentsActivitySelector : BaseActivity(), ISelectorVaultHandler, View. private var isOdkSelect = false private var filterType = FilterType.ALL private lateinit var attachmentsAdapter: AttachmentsSelectorAdapter - private var selectMode = SelectMode.SELECT_ALL + private var selectMode = SelectMode.DESELECT_ALL private var isListCheckOn = false override fun onCreate(savedInstanceState: Bundle?) { @@ -67,7 +67,7 @@ class AttachmentsActivitySelector : BaseActivity(), ISelectorVaultHandler, View. attachmentsAdapter = AttachmentsSelectorAdapter( this@AttachmentsActivitySelector, this, - gridLayoutManager, true, isMultiplePicker + gridLayoutManager, false, isMultiplePicker ) with(binding) { @@ -75,14 +75,26 @@ class AttachmentsActivitySelector : BaseActivity(), ISelectorVaultHandler, View. adapter = attachmentsAdapter layoutManager = gridLayoutManager } - toolbar.backClickListener = { handleBackStack() } + toolbar.backClickListener = { + handleBackStack() + } gridCheck.setOnClickListener(this@AttachmentsActivitySelector) listCheck.setOnClickListener(this@AttachmentsActivitySelector) checkBoxList.setOnClickListener(this@AttachmentsActivitySelector) toolbar.onRightClickListener = { setResultAndFinish() } } + + bindSelectAllCheckbox(SelectAllCheckboxVisual.IDLE) updateAttachmentsToolbar(attachmentsAdapter.selectedMediaFiles.size) + // Nearby sharing and reports expect to start with active multi-select. + if (!isMultiplePicker) { + isListCheckOn = true + selectMode = SelectMode.ONE_SELECTION + attachmentsAdapter.enableSelectMode(true) + bindSelectAllCheckbox(SelectAllCheckboxVisual.PARTIAL) + updateAttachmentsToolbar(attachmentsAdapter.selectedMediaFiles.size) + } } private fun initObservers() { @@ -122,6 +134,10 @@ class AttachmentsActivitySelector : BaseActivity(), ISelectorVaultHandler, View. } } + override fun onMoreClicked(vaultFile: VaultFile) { + playMedia(vaultFile) + } + override fun playMedia(vaultFile: VaultFile?) { if (vaultFile == null) { return @@ -167,8 +183,25 @@ class AttachmentsActivitySelector : BaseActivity(), ISelectorVaultHandler, View. } private fun updateAttachmentsToolbar(itemsSize: Int) { + binding.toolbar.setToolbarNavigationIcon(R.drawable.ic_arrow_back_white_24dp) if (itemsSize == 0) { - binding.toolbar.setStartTextTitle(getString(R.string.Vault_Select_Title)) + val titleRes = if (isListCheckOn) { + R.string.Vault_Select_Title + } else { + when (filterType) { + FilterType.ALL -> R.string.Vault_AllFiles_Title + FilterType.ALL_WITHOUT_DIRECTORY -> R.string.Vault_Files_Title + FilterType.PHOTO -> R.string.Vault_Images_Title + FilterType.VIDEO -> R.string.Vault_Videos_Title + FilterType.AUDIO -> R.string.Vault_Audios_Title + FilterType.DOCUMENTS -> R.string.Vault_Documents_Title + FilterType.OTHERS -> R.string.Vault_Others_Title + FilterType.PHOTO_VIDEO -> R.string.Vault_PhotosAndVideos_Title + FilterType.AUDIO_VIDEO -> R.string.Vault_AllFiles_Title + FilterType.PDF -> R.string.Vault_Documents_Title + } + } + binding.toolbar.setStartTextTitle(getString(titleRes)) binding.toolbar.setRightIcon(-1) } else { binding.toolbar.setStartTextTitle( @@ -216,19 +249,47 @@ class AttachmentsActivitySelector : BaseActivity(), ISelectorVaultHandler, View. } private fun handleSelectionModeWhenMediSelected() { - updateAttachmentsToolbar(attachmentsAdapter.selectedMediaFiles.size) - if (attachmentsAdapter.selectedMediaFiles.isNullOrEmpty() && selectMode == SelectMode.SELECT_ALL) { - selectMode = SelectMode.DESELECT_ALL - handleSelectMode() - } else if (attachmentsAdapter.selectedMediaFiles.size == attachmentsAdapter.itemCount && selectMode != SelectMode.SELECT_ALL) { + val selected = attachmentsAdapter.selectedMediaFiles.size + updateAttachmentsToolbar(selected) + val selectable = attachmentsAdapter.selectableNonDirectoryCount() + + if (selectMode == SelectMode.SELECT_ALL && selected == 0) { + syncSelectionChromeLeavingSelectModeFully() + return + } + if (selectable == 0) return + + if (selectMode == SelectMode.SELECT_ALL && selected < selectable) { selectMode = SelectMode.ONE_SELECTION - handleSelectMode() - } else if (attachmentsAdapter.selectedMediaFiles.size < attachmentsAdapter.itemCount && selectMode == SelectMode.SELECT_ALL) { - selectMode = SelectMode.DESELECT_ALL - handleSelectMode() + bindSelectAllCheckbox(SelectAllCheckboxVisual.PARTIAL) + return + } + + if (isListCheckOn && selectMode != SelectMode.SELECT_ALL && selected == selectable) { + selectMode = SelectMode.SELECT_ALL + bindSelectAllCheckbox(SelectAllCheckboxVisual.ALL) } } + private enum class SelectAllCheckboxVisual { IDLE, PARTIAL, ALL } + + private fun bindSelectAllCheckbox(visual: SelectAllCheckboxVisual) { + val icon = when (visual) { + SelectAllCheckboxVisual.IDLE -> R.drawable.ic_check + SelectAllCheckboxVisual.PARTIAL -> R.drawable.ic_check_box_off + SelectAllCheckboxVisual.ALL -> R.drawable.ic_check_box_on + } + binding.checkBoxList.setCheckDrawable(icon, this) + } + + private fun syncSelectionChromeLeavingSelectModeFully() { + selectMode = SelectMode.DESELECT_ALL + isListCheckOn = false + attachmentsAdapter.enableSelectMode(false) + bindSelectAllCheckbox(SelectAllCheckboxVisual.IDLE) + updateAttachmentsToolbar(attachmentsAdapter.selectedMediaFiles.size) + } + private fun handleSelectMode() { changeSelectMode() attachmentsAdapter.enableSelectMode(isListCheckOn) @@ -240,6 +301,8 @@ class AttachmentsActivitySelector : BaseActivity(), ISelectorVaultHandler, View. } SelectMode.ONE_SELECTION -> { + // In select mode with no bulk selection applied. + attachmentsAdapter.clearSelected() binding.checkBoxList.setCheckDrawable(R.drawable.ic_check_box_off, this) } @@ -248,6 +311,7 @@ class AttachmentsActivitySelector : BaseActivity(), ISelectorVaultHandler, View. attachmentsAdapter.selectAll() } } + updateAttachmentsToolbar(attachmentsAdapter.selectedMediaFiles.size) } private fun changeSelectMode() { @@ -263,8 +327,8 @@ class AttachmentsActivitySelector : BaseActivity(), ISelectorVaultHandler, View. } SelectMode.SELECT_ALL -> { - isListCheckOn = false - SelectMode.DESELECT_ALL + isListCheckOn = true + SelectMode.ONE_SELECTION } } } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/attachments/AttachmentsSelectorAdapter.java b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/attachments/AttachmentsSelectorAdapter.java index c33eac42f..42d0aa658 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/attachments/AttachmentsSelectorAdapter.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/attachments/AttachmentsSelectorAdapter.java @@ -83,6 +83,7 @@ public void onBindViewHolder(@NonNull final AttachmentsSelectorAdapter.ViewHolde if (vaultFile.type != VaultFile.Type.DIRECTORY) { checkItemState(holder, vaultFile); } + onMoreSelected(holder, vaultFile); if (vaultFile.mimeType != null) { if (MediaFile.INSTANCE.isImageFileType(vaultFile.mimeType)) { @@ -125,6 +126,15 @@ public void onBindViewHolder(@NonNull final AttachmentsSelectorAdapter.ViewHolde public int getItemCount() { return files.size(); } + public int selectableNonDirectoryCount() { + int n = 0; + for (VaultFile f : files) { + if (f.type != VaultFile.Type.DIRECTORY) { + n++; + } + } + return n; + } public void setFiles(List files) { this.files = files; @@ -198,9 +208,25 @@ public void handleClickMode(AttachmentsSelectorAdapter.ViewHolder holder, VaultF } else { holder.itemView.setOnClickListener(v -> checkboxClickHandler(holder, vaultFile)); } + } else { + if (vaultFile.type == VaultFile.Type.DIRECTORY) { + holder.itemView.setOnClickListener(v -> selectorVaultHandler.openFolder(vaultFile)); + } else { + holder.itemView.setOnClickListener(v -> selectorVaultHandler.playMedia(vaultFile)); + } } } + private void onMoreSelected(AttachmentsSelectorAdapter.ViewHolder holder, VaultFile vaultFile) { + holder.more.setOnClickListener(v -> { + if (vaultFile.type == VaultFile.Type.DIRECTORY) { + selectorVaultHandler.openFolder(vaultFile); + } else { + selectorVaultHandler.onMoreClicked(vaultFile); + } + }); + } + private void removeAllSelections() { for (VaultFile selection : selected) { deselectMediaFile(selection); @@ -210,9 +236,15 @@ private void removeAllSelections() { public void selectAll() { for (VaultFile selection : files) { - selectMediaFile(selection); - selectorVaultHandler.onMediaSelected(selection); + if (selection.type == VaultFile.Type.DIRECTORY) { + continue; + } + if (!selected.contains(selection)) { + selected.add(selection); + } } + notifyDataSetChanged(); + selectorVaultHandler.onSelectionNumChange(selected.size()); } private void checkItemState(AttachmentsSelectorAdapter.ViewHolder holder, VaultFile vaultFile) { @@ -282,13 +314,15 @@ void showFileInfo(VaultFile vaultFile) { } void maybeEnableCheckBox(boolean selectable, VaultFile.Type type) { + // Match vault AttachmentsRecycleViewAdapter: overflow before selection mode, checkboxes in selection mode. if (type != VaultFile.Type.DIRECTORY) { checkBox.setVisibility(selectable ? View.VISIBLE : View.GONE); + more.setVisibility(selectable ? View.GONE : View.VISIBLE); checkBox.setEnabled(selectable); } else { checkBox.setVisibility(View.GONE); + more.setVisibility(selectable ? View.GONE : View.VISIBLE); } - } } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/attachments/ISelectorVaultHandler.java b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/attachments/ISelectorVaultHandler.java index dd5053d96..a86a0efae 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/attachments/ISelectorVaultHandler.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/attachments/ISelectorVaultHandler.java @@ -3,6 +3,8 @@ import com.hzontal.tella_vault.VaultFile; public interface ISelectorVaultHandler { + void onMoreClicked(VaultFile vaultFile); + void playMedia(VaultFile vaultFile); void onSelectionNumChange(int num); void onMediaSelected(VaultFile vaultFile); diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/widgets/PeerToPeerEndView.java b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/widgets/PeerToPeerEndView.java new file mode 100644 index 000000000..dbdd867c2 --- /dev/null +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/uwazi/widgets/PeerToPeerEndView.java @@ -0,0 +1,201 @@ +package org.horizontal.tella.mobile.views.fragment.uwazi.widgets; + +import android.content.Context; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.google.android.material.progressindicator.LinearProgressIndicator; + +import org.horizontal.tella.mobile.R; +import org.horizontal.tella.mobile.data.peertopeer.model.P2PFileStatus; +import org.horizontal.tella.mobile.data.peertopeer.model.ProgressFile; +import org.horizontal.tella.mobile.data.peertopeer.model.SessionStatus; +import org.horizontal.tella.mobile.util.FileUtil; +import org.hzontal.shared_ui.submission.SubmittingItem; + +import java.util.List; + +public class PeerToPeerEndView extends FrameLayout { + private final LinearProgressIndicator totalProgress; + private final TextView titleView; + private final TextView formSizeView; + private LinearLayout partsListView; + + private final String title; + private boolean previewUploaded; + + public PeerToPeerEndView(Context context, String title) { + super(context); + inflate(context, R.layout.reports_form_end_view, this); + + this.title = title; + + titleView = findViewById(R.id.title); + titleView.setText(title); + + totalProgress = findViewById(R.id.totalProgress); + formSizeView = findViewById(R.id.formSize); + } + + public void setFiles(List progressFiles, boolean offline, boolean previewUploaded) { + this.previewUploaded = previewUploaded; + + titleView.setText(title); + + partsListView = findViewById(R.id.formPartsList); + partsListView.removeAllViews(); + + for (ProgressFile file : progressFiles) { + partsListView.addView(createProgressFileItemView(file, offline)); + } + + uploadProgressVisibility(progressFiles, true); + setUploadProgress(progressFiles, 0f); + } + + public void setUploadProgress(List progressFiles, float pct) { + if (pct < 0 || pct > 1) return; + + int percentComplete = getTotalUploadedSizePercent(progressFiles); + + for (ProgressFile file : progressFiles) { + String tagId = file.getVaultFile() != null && file.getVaultFile().id != null + ? file.getVaultFile().id + : file.getFile().getId(); + + SubmittingItem item = partsListView.findViewWithTag(tagId); + if (item == null) continue; + + if (isUploadedStatus(file.getStatus())) { + item.setPartUploaded(); + } else { + long size = file.getVaultFile() != null && file.getVaultFile().size > 0 + ? file.getVaultFile().size + : file.getFile().getSize(); + float filePct = size > 0 ? (file.getBytesTransferred() * 1f / size) : 0f; + item.setUploadProgress(Math.min(1f, Math.max(0f, filePct))); + } + } + + totalProgress.setProgressCompat(percentComplete, true); + setFormSizeLabel(progressFiles, percentComplete); + } + + public void clearPartsProgress(List progressFiles, SessionStatus sessionStatus) { + for (ProgressFile file : progressFiles) { + String tagId = file.getVaultFile() != null && file.getVaultFile().id != null + ? file.getVaultFile().id + : file.getFile().getId(); + + SubmittingItem item = partsListView.findViewWithTag(tagId); + if (item != null) { + if (sessionStatus == SessionStatus.FINISHED || isUploadedStatus(file.getStatus())) { + item.setPartUploaded(); + } else { + item.setPartCleared(); + } + } + } + } + + private int getTotalUploadedSizePercent(List progressFiles) { + long totalUploadedSize = 0; + long totalSize = 0; + + for (ProgressFile file : progressFiles) { + totalUploadedSize += file.getBytesTransferred(); + + long size = file.getVaultFile() != null && file.getVaultFile().size > 0 + ? file.getVaultFile().size + : file.getFile().getSize(); + + totalSize += size; + } + + return totalSize > 0 ? Math.round((totalUploadedSize * 1f / totalSize) * 100) : 0; + } + + private void setFormSizeLabel(List files, int percent) { + int count = files.size(); + long totalSize = 0; + long totalUploaded = 0; + + for (ProgressFile file : files) { + long size = file.getVaultFile() != null && file.getVaultFile().size > 0 + ? file.getVaultFile().size + : file.getFile().getSize(); + + totalSize += size; + totalUploaded += file.getBytesTransferred(); + } + + String label = percent + "% " + getContext().getString(R.string.File_Uploaded) + "\n" + + getResources().getQuantityString(R.plurals.upload_main_meta_number_of_files, count, count) + ", " + + FileUtil.getFileSize(totalUploaded) + "/" + FileUtil.getFileSize(totalSize); + + formSizeView.setText(label); + } + + private void uploadProgressVisibility(List files, boolean isOnline) { + if (!isOnline || files.isEmpty()) { + totalProgress.setVisibility(GONE); + } else { + totalProgress.setVisibility(VISIBLE); + } + } + + private View createProgressFileItemView(@NonNull ProgressFile file, boolean offline) { + SubmittingItem item = new SubmittingItem(getContext(), null, 0); + ImageView thumbView = item.findViewById(R.id.fileThumb); + + String tagId = file.getVaultFile() != null && file.getVaultFile().id != null + ? file.getVaultFile().id + : file.getFile().getId(); + item.setTag(tagId); + + String name = file.getVaultFile() != null && file.getVaultFile().name != null + ? file.getVaultFile().name + : file.getFile().getFileName(); + item.setPartName(name); + + long size = file.getVaultFile() != null && file.getVaultFile().size > 0 + ? file.getVaultFile().size + : file.getFile().getSize(); + item.setPartSize(size); + + byte[] thumb = file.getVaultFile() != null && file.getVaultFile().thumb != null + ? file.getVaultFile().thumb + : file.getFile().getThumbnail(); + + if (thumb != null) { + Glide.with(getContext()) + .load(thumb) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .into(thumbView); + } + + if (isUploadedStatus(file.getStatus())) { + item.setPartUploaded(); + } else { + if (previewUploaded) { + item.setPartUploaded(); + } else { + item.setPartPrepared(offline); + } + } + + return item; + } + + private boolean isUploadedStatus(P2PFileStatus status) { + return status == P2PFileStatus.FINISHED; + } +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/VaultAdapter.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/VaultAdapter.kt index 46c88f547..12ae3b81c 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/VaultAdapter.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/VaultAdapter.kt @@ -11,6 +11,7 @@ import org.horizontal.tella.mobile.data.sharedpref.Preferences import org.horizontal.tella.mobile.data.sharedpref.Preferences.hasAcceptedAnalytics import org.horizontal.tella.mobile.data.sharedpref.Preferences.isShowVaultAnalyticsSection import org.horizontal.tella.mobile.data.sharedpref.Preferences.isTimeToShowReminderAnalytics +import org.horizontal.tella.mobile.domain.entity.ServerType import org.horizontal.tella.mobile.domain.entity.collect.CollectForm import org.horizontal.tella.mobile.domain.entity.uwazi.UwaziTemplate import org.horizontal.tella.mobile.views.fragment.vault.adapters.connections.ServerDataItem @@ -57,16 +58,20 @@ class VaultAdapter(private val onClick: VaultClickListener) : return oldList.zip(newList).all { (oldItem, newItem) -> oldItem == newItem } } - fun addConnectionServers(connectionsList: List) { - val sortedConnectionsList = connectionsList.sortedBy { server -> server.type } - val newConnectionsItem = DataItem.ConnectionsItem(sortedConnectionsList) + private fun ServerType.rank(): Int = when (this) { + ServerType.ADD_BUTTON -> Int.MAX_VALUE // always last + else -> ordinal // or a custom map if you want a specific order + } - // Check if the current connections are the same as the new ones - if (favoriteForms.isEmpty() || !areListsEqual( - connectionsList.first().servers, newConnectionsItem.item - ) - ) { - connections = mutableListOf(newConnectionsItem) + fun addConnectionServers(connectionsList: List) { + val ordered = connectionsList.sortedWith( + compareBy { it.type.rank() } + .thenBy { it.type.name } // secondary, harmless + ) + val newItem = DataItem.ConnectionsItem(ordered) + val same = connections.firstOrNull()?.item == newItem.item + if (!same) { + connections = mutableListOf(newItem) updateItems() } } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/attachments/AttachmentsRecycleViewAdapter.java b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/attachments/AttachmentsRecycleViewAdapter.java index ad76c58b5..5b5c81cc1 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/attachments/AttachmentsRecycleViewAdapter.java +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/attachments/AttachmentsRecycleViewAdapter.java @@ -232,9 +232,12 @@ private void removeAllSelections() { public void selectAll() { for (VaultFile selection : files) { - selectMediaFile(selection); - galleryMediaHandler.onMediaSelected(selection); + if (!selected.contains(selection)) { + selected.add(selection); + } } + notifyDataSetChanged(); + galleryMediaHandler.onSelectionNumChange(selected.size()); } private void onMoreSelected(ViewHolder holder, VaultFile vaultFile) { diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/attachments/RecentAttachmentViewHolder.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/attachments/RecentAttachmentViewHolder.kt index e372654cd..58135dd2d 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/attachments/RecentAttachmentViewHolder.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/attachments/RecentAttachmentViewHolder.kt @@ -4,6 +4,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView +import androidx.annotation.DrawableRes import androidx.appcompat.widget.AppCompatImageView import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy @@ -39,14 +40,14 @@ class RecentAttachmentViewHolder(val view: View) : BaseViewHolder(vi if (vaultFile.mimeType == null) return when { isImageFileType(vaultFile.mimeType) -> { - previewImageView.loadImage(vaultFile.thumb) + previewImageView.loadImage(vaultFile.thumb, R.drawable.ic_gallery) } isAudioFileType(vaultFile.mimeType) -> { showAudioInfo(vaultFile) } isVideoFileType(vaultFile.mimeType) -> { showVideoInfo() - previewImageView.loadImage(vaultFile.thumb) + previewImageView.loadImage(vaultFile.thumb, R.drawable.ic_video) } isTextFileType(vaultFile.mimeType) -> { showDocumentInfo(vaultFile) @@ -71,9 +72,11 @@ class RecentAttachmentViewHolder(val view: View) : BaseViewHolder(vi more.visibility = View.VISIBLE } - fun ImageView.loadImage(thumb: ByteArray) { + fun ImageView.loadImage(thumb: ByteArray?, @DrawableRes fallback: Int) { Glide.with(this) .load(thumb) + .fallback(fallback) + .error(fallback) .diskCacheStrategy(DiskCacheStrategy.NONE) .skipMemoryCache(true) .into(this) diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerAdapter.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerAdapter.kt index 8088081dc..9de87ab5b 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerAdapter.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerAdapter.kt @@ -1,21 +1,46 @@ package org.horizontal.tella.mobile.views.fragment.vault.adapters.connections +import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView +import org.horizontal.tella.mobile.R +import org.horizontal.tella.mobile.domain.entity.ServerType import org.horizontal.tella.mobile.views.fragment.vault.adapters.VaultClickListener class ServerAdapter( - val list: List, + private val list: List, private val vaultClickListener: VaultClickListener -) : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ServerViewHolder { - return ServerViewHolder.from(parent) +) : RecyclerView.Adapter() { + + private companion object { + const val TYPE_SERVER = 0 + const val TYPE_ADD = 1 + } + + override fun getItemViewType(position: Int): Int = + if (list[position].type == ServerType.ADD_BUTTON) TYPE_ADD else TYPE_SERVER + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return if (viewType == TYPE_ADD) { + val v = inflater.inflate(R.layout.item_add_connection_card, parent, false) + AddButtonViewHolder(v) + } else { + ServerViewHolder.from(parent) // your existing server card holder + } } - override fun onBindViewHolder(holder: ServerViewHolder, position: Int) { - holder.bind(list[position], vaultClickListener) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = list[position] + if (holder is AddButtonViewHolder) { + holder.itemView.setOnClickListener { vaultClickListener.onServerItemClickListener(item) } + } else if (holder is ServerViewHolder) { + holder.bind(item, vaultClickListener) + } } override fun getItemCount() = list.size -} \ No newline at end of file + private class AddButtonViewHolder(view: View) : RecyclerView.ViewHolder(view) +} diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerViewHolder.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerViewHolder.kt index 7dcb67d74..390877fc8 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerViewHolder.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/adapters/connections/ServerViewHolder.kt @@ -14,15 +14,14 @@ import org.horizontal.tella.mobile.views.fragment.vault.adapters.viewholders.bas class ServerViewHolder(val view: View) : BaseViewHolder(view) { private lateinit var reportTypeTextView: TextView private lateinit var reportTypeImg: ImageView + private lateinit var twoLinesReportTypeTextView: TextView + override fun bind(item: ServerDataItem, vaultClickListener: VaultClickListener) { reportTypeTextView = view.findViewById(R.id.server_name_textView) + twoLinesReportTypeTextView = view.findViewById(R.id.two_line_server_name_textView) reportTypeImg = view.findViewById(R.id.server_img) - // Set the default padding - // val defaultPadding = view.context.resources.getDimensionPixelSize(R.dimen.hide_tella_small_margin) - // view.setPadding(view.paddingLeft, defaultPadding, view.paddingRight, view.paddingBottom) - when (item.type) { ServerType.UWAZI -> { reportTypeTextView.text = view.context.getString(R.string.Home_BottomNav_Uwazi) @@ -34,6 +33,7 @@ class ServerViewHolder(val view: View) : BaseViewHolder(view) { ) ) } + ServerType.TELLA_UPLOAD -> { reportTypeTextView.text = view.context.getText(R.string.Home_BottomNav_Reports) reportTypeImg.setImageDrawable( @@ -44,6 +44,7 @@ class ServerViewHolder(val view: View) : BaseViewHolder(view) { ) ) } + ServerType.TELLA_RESORCES -> { reportTypeTextView.text = view.context.getText(R.string.Home_BottomNav_Resources) reportTypeImg.setImageDrawable( @@ -54,6 +55,7 @@ class ServerViewHolder(val view: View) : BaseViewHolder(view) { ) ) } + ServerType.ODK_COLLECT -> { reportTypeTextView.text = view.context.getText(R.string.Home_BottomNav_Forms) reportTypeImg.setImageDrawable( @@ -64,6 +66,7 @@ class ServerViewHolder(val view: View) : BaseViewHolder(view) { ) ) } + ServerType.GOOGLE_DRIVE -> { reportTypeTextView.text = view.context.getText(R.string.google_drive) reportTypeImg.setImageDrawable( @@ -74,6 +77,7 @@ class ServerViewHolder(val view: View) : BaseViewHolder(view) { ) ) } + ServerType.DROP_BOX -> { reportTypeTextView.text = view.context.getString(R.string.dropbox) reportTypeImg.setImageDrawable( @@ -84,6 +88,7 @@ class ServerViewHolder(val view: View) : BaseViewHolder(view) { ) ) } + ServerType.NEXTCLOUD -> { reportTypeTextView.text = view.context.getString(R.string.NextCloud) reportTypeImg.setImageDrawable( @@ -94,7 +99,19 @@ class ServerViewHolder(val view: View) : BaseViewHolder(view) { ) ) } - else -> { // todo create default server type + + ServerType.PEERTOPEER -> { + twoLinesReportTypeTextView.text = view.context.getText(R.string.NearBySharing) + reportTypeImg.setImageDrawable( + ResourcesCompat.getDrawable( + view.resources, + R.drawable.ic_p2p, + null + ) + ) + } + + else -> { } } diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsFragment.kt index 39a128cca..1c2bb3af5 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsFragment.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsFragment.kt @@ -90,6 +90,7 @@ import org.hzontal.shared_ui.utils.DialogUtils import timber.log.Timber import java.io.FileNotFoundException +const val VAULT_PARENT_ID = "vault_parent_id" @AndroidEntryPoint class AttachmentsFragment : @@ -112,6 +113,7 @@ class AttachmentsFragment : private var importAndDelete = false private var uriToDelete: Uri? = null private var withMetadata = false + private var initialFolderTitle: String? = null private var selectMode = SelectMode.DESELECT_ALL private lateinit var gridLayoutManager: GridLayoutManager private var isLaunchingPicker = false @@ -220,9 +222,18 @@ class AttachmentsFragment : arguments?.getString(VAULT_FILTER)?.let { filterType = FilterType.valueOf(it) } + arguments?.getString(VAULT_PARENT_ID)?.let { parentId -> + currentRootID = parentId + } initSorting() - setToolbarLabel(filterType, binding.toolbar, baseActivity) - viewModel.getRootId() + if (currentRootID == null) { + setToolbarLabel(filterType, binding.toolbar, baseActivity) + } else { + viewModel.getFolderById(currentRootID!!) + } + if (currentRootID == null) { + viewModel.getRootId() + } onFileDeletedEventListener() onFileRenameEventListener() onCaptureEventListener() @@ -349,7 +360,6 @@ class AttachmentsFragment : private fun createVaultManageFilesAction(): VaultSheetUtils.IVaultManageFiles { return object : VaultSheetUtils.IVaultManageFiles { override fun goToCamera() { - val intent = Intent(activity, CameraActivity::class.java) intent.putExtra(VAULT_CURRENT_ROOT_PARENT, currentRootID) baseActivity.startActivity(intent) @@ -431,6 +441,9 @@ class AttachmentsFragment : } SelectMode.ONE_SELECTION -> { + if (isHomeAllFilesSelectionStyle()) { + attachmentsAdapter.clearSelected() + } binding.checkBoxList.setCheckDrawable(R.drawable.ic_check_box_off, baseActivity) } @@ -442,10 +455,29 @@ class AttachmentsFragment : } private fun changeSelectMode() { + if (isHomeAllFilesSelectionStyle()) { + when (selectMode) { + SelectMode.DESELECT_ALL -> { + isListCheckOn = true + selectMode = SelectMode.ONE_SELECTION + } + + SelectMode.ONE_SELECTION -> { + isListCheckOn = true + selectMode = SelectMode.SELECT_ALL + } + + SelectMode.SELECT_ALL -> { + isListCheckOn = true + selectMode = SelectMode.ONE_SELECTION + } + } + return + } when (selectMode) { SelectMode.DESELECT_ALL -> { isListCheckOn = true - selectMode = SelectMode.ONE_SELECTION + selectMode = SelectMode.SELECT_ALL } @@ -479,7 +511,11 @@ class AttachmentsFragment : } } else { binding.toolbar.setToolbarNavigationIcon(R.drawable.ic_arrow_back_white_24dp) - setToolbarLabel(filterType, binding.toolbar, baseActivity) + if (initialFolderTitle != null) { + binding.toolbar.setStartTextTitle(initialFolderTitle!!) + } else { + setToolbarLabel(filterType, binding.toolbar, baseActivity) + } attachmentsAdapter.clearSelected() enableMoveTheme(false) } @@ -561,6 +597,9 @@ class AttachmentsFragment : } override fun onSelectionNumChange(num: Int) { + if (isListCheckOn) { + updateAttachmentsToolbar(true) + } } override fun onMediaSelected(vaultFile: VaultFile) { @@ -571,18 +610,52 @@ class AttachmentsFragment : handleSelectionModeWhenMediSelected() } + /** + * Keeps header checkbox + [selectMode] aligned with adapter selection without calling + * [handleSelectMode] (that would run [changeSelectMode] and re-apply select-all). + */ private fun handleSelectionModeWhenMediSelected() { updateAttachmentsToolbar(true) - if (attachmentsAdapter.selectedMediaFiles.isNullOrEmpty() && selectMode == SelectMode.SELECT_ALL) { - selectMode = SelectMode.DESELECT_ALL - handleSelectMode() - } else if (attachmentsAdapter.selectedMediaFiles.size == attachmentsAdapter.itemCount && selectMode != SelectMode.SELECT_ALL) { + + val selected = attachmentsAdapter.selectedMediaFiles.size + val listSize = attachmentsAdapter.itemCount + + if (selectMode == SelectMode.SELECT_ALL && selected == 0) { + syncSelectionChromeLeavingSelectModeFully() + return + } + if (listSize == 0) return + + if (selectMode == SelectMode.SELECT_ALL && selected < listSize) { selectMode = SelectMode.ONE_SELECTION - handleSelectMode() - } else if (attachmentsAdapter.selectedMediaFiles.size < attachmentsAdapter.itemCount && selectMode == SelectMode.SELECT_ALL) { - selectMode = SelectMode.DESELECT_ALL - handleSelectMode() + bindSelectAllCheckbox(SelectAllCheckboxVisual.PARTIAL) + return + } + + if (isListCheckOn && selectMode != SelectMode.SELECT_ALL && selected == listSize) { + selectMode = SelectMode.SELECT_ALL + bindSelectAllCheckbox(SelectAllCheckboxVisual.ALL) + } + } + + private enum class SelectAllCheckboxVisual { IDLE, PARTIAL, ALL } + + private fun bindSelectAllCheckbox(visual: SelectAllCheckboxVisual) { + val icon = when (visual) { + SelectAllCheckboxVisual.IDLE -> R.drawable.ic_check + SelectAllCheckboxVisual.PARTIAL -> R.drawable.ic_check_box_off + SelectAllCheckboxVisual.ALL -> R.drawable.ic_check_box_on } + binding.checkBoxList.setCheckDrawable(icon, baseActivity) + } + private fun syncSelectionChromeLeavingSelectModeFully() { + selectMode = SelectMode.DESELECT_ALL + isListCheckOn = false + attachmentsAdapter.enableSelectMode(false) + enableMoveTheme(false) + bindSelectAllCheckbox(SelectAllCheckboxVisual.IDLE) + updateAttachmentsToolbar(false) + baseActivity.invalidateOptionsMenu() } override fun onMoreClicked(vaultFile: VaultFile) { @@ -721,6 +794,10 @@ class AttachmentsFragment : viewModel.renameFileSuccess.observe(this, ::onRenameFileSuccess) viewModel.exportState.observe(this, ::onExportStarted) viewModel.mediaExported.observe(this, ::onMediaExported) + viewModel.currentFolder.observe(this) { folder -> + initialFolderTitle = folder.name + binding.toolbar.setStartTextTitle(folder.name) + } viewModel.onConfirmDeleteFiles.observe(this, ::onConfirmDeleteFiles) viewModel.duplicateErrorResId.observe(this, ::onDuplicateErrorResId) viewModel.importError.observe(this, ::onImportError) @@ -917,7 +994,9 @@ class AttachmentsFragment : getString(R.string.Vault_Importing_SheetTitle), totalFilesToImport, resources.getQuantityString( - R.plurals.Vault_Importing_SheetProgress, totalFilesToImport + R.plurals.Vault_Importing_SheetProgress, + totalFilesToImport, + totalFilesToImport ), viewModel.counterData, getString(R.string.action_cancel).uppercase(), @@ -1134,8 +1213,12 @@ class AttachmentsFragment : when { selectedFilesSize > 0 || (isListCheckOn) -> { - selectMode = SelectMode.SELECT_ALL - handleSelectMode() + if (isHomeAllFilesSelectionStyle()) { + syncSelectionChromeLeavingSelectModeFully() + } else { + selectMode = SelectMode.SELECT_ALL + handleSelectMode() + } } breadcrumbsSize > 1 -> { @@ -1239,6 +1322,10 @@ class AttachmentsFragment : } } + private fun isHomeAllFilesSelectionStyle(): Boolean { + return filterType == FilterType.ALL + } + private fun showExportWithMetadataDialog(isMultipleFiles: Boolean, vaultFile: VaultFile?) { val options = LinkedHashMap() options[1] = R.string.verification_share_select_media_and_verification diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsViewModel.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsViewModel.kt index dd3e83957..53ebdfb86 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsViewModel.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/attachements/AttachmentsViewModel.kt @@ -60,11 +60,12 @@ class AttachmentsViewModel @Inject constructor( val folderCreated: LiveData = _folderCreated private val _rootId = MutableLiveData() val rootId: LiveData = _rootId + private val _currentFolder = MutableLiveData() + val currentFolder: LiveData = _currentFolder private val _duplicateErrorResId = MutableLiveData() val duplicateErrorResId: LiveData = _duplicateErrorResId val counterData = MutableLiveData() - //TODO AHLEM FIX THIS val counterData: LiveData = _counterData private val _progressPercent = MutableLiveData>() val progressPercent: LiveData> = _progressPercent private val _mediaImportedWithDelete = MutableLiveData() @@ -113,7 +114,6 @@ class AttachmentsViewModel @Inject constructor( ) } - fun moveFiles(parentId: String?, vaultFiles: List?) { if (vaultFiles == null || parentId == null) return @@ -177,7 +177,6 @@ class AttachmentsViewModel @Inject constructor( ) } - private fun deleteFile(vaultFile: VaultFile): Single { return MyApplication.keyRxVault.rxVault .firstOrError() @@ -186,7 +185,6 @@ class AttachmentsViewModel @Inject constructor( .observeOn(AndroidSchedulers.mainThread()) } - fun createFolder(folderName: String, parent: String) { disposables.add( MyApplication.keyRxVault.rxVault @@ -227,10 +225,26 @@ class AttachmentsViewModel @Inject constructor( ) } + fun getFolderById(folderId: String) { + disposables.add( + MyApplication.keyRxVault.rxVault + .firstOrError() + .flatMap { it.get(folderId) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { vaultFile -> _currentFolder.postValue(vaultFile) }, + { throwable -> + CrashReporterProvider.get().recordException(throwable) + _error.postValue(throwable) + } + ) + ) + } + fun importVaultFiles(uris: List, parentId: String?, deleteOriginal: Boolean) { if (uris.isEmpty()) return - // counterData.value = 0 - // var counter = 1 + var currentUri: Uri? = null var lastImportName: String? = null disposables.add(Flowable.fromIterable(uris).flatMap { uri -> diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/home/HomeVaultFragment.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/home/HomeVaultFragment.kt index 29ac9e5b8..1d58336ea 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/home/HomeVaultFragment.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/fragment/vault/home/HomeVaultFragment.kt @@ -57,9 +57,11 @@ import org.horizontal.tella.mobile.domain.entity.uwazi.UwaziTemplate import org.horizontal.tella.mobile.util.FossFeatureSheetUtils import org.horizontal.tella.mobile.util.LockTimeoutManager import org.horizontal.tella.mobile.util.TopSheetTestUtils.showBackgroundActivitiesSheet +import org.horizontal.tella.mobile.util.navigateSafe import org.horizontal.tella.mobile.util.setMargins import org.horizontal.tella.mobile.views.activity.CollectFormEntryActivity import org.horizontal.tella.mobile.views.activity.MainActivity +import org.horizontal.tella.mobile.views.activity.ServersSettingsActivity import org.horizontal.tella.mobile.views.activity.analytics.AnalyticsActions import org.horizontal.tella.mobile.views.activity.analytics.AnalyticsIntroActivity import org.horizontal.tella.mobile.views.activity.viewer.AudioPlayActivity @@ -104,7 +106,6 @@ class HomeVaultFragment : BaseFragment(), VaultClickListener { private var googleDriveServers: ArrayList? = null private var dropBoxServers: ArrayList? = null private var nextCloudServers: ArrayList? = null - private var favoriteForms: ArrayList? = null private lateinit var disposables: EventCompositeDisposable private var reportServersCounted = false private var collectServersCounted = false @@ -292,7 +293,7 @@ class HomeVaultFragment : BaseFragment(), VaultClickListener { } }) panicModeView.setOnClickListener { onPanicClicked() } - toolbar.onLeftClickListener = { nav().navigate(R.id.main_settings) } + toolbar.onLeftClickListener = { nav().navigateSafe(R.id.main_settings) } toolbar.onRightClickListener = { MyApplication.getMainKeyHolder().timeout = LockTimeoutManager.IMMEDIATE_SHUTDOWN Preferences.setExitTimeout(true) @@ -440,11 +441,11 @@ class HomeVaultFragment : BaseFragment(), VaultClickListener { override fun onServerItemClickListener(item: ServerDataItem) { when (item.type) { ServerType.ODK_COLLECT -> { - nav().navigate(R.id.action_homeScreen_to_forms_screen) + nav().navigateSafe(R.id.action_homeScreen_to_forms_screen) } ServerType.TELLA_UPLOAD -> { - nav().navigate(R.id.action_homeScreen_to_reports_screen) + nav().navigateSafe(R.id.action_homeScreen_to_reports_screen) } ServerType.UWAZI -> { @@ -452,12 +453,12 @@ class HomeVaultFragment : BaseFragment(), VaultClickListener { } ServerType.TELLA_RESORCES -> { - nav().navigate(R.id.action_homeScreen_to_resources_screen) + nav().navigateSafe(R.id.action_homeScreen_to_resources_screen) } ServerType.GOOGLE_DRIVE -> { if (BuildConfig.ENABLE_GOOGLE_DRIVE) { - nav().navigate(R.id.action_homeScreen_to_google_drive_screen) + nav().navigateSafe(R.id.action_homeScreen_to_google_drive_screen) } else { FossFeatureSheetUtils.showFossFeatureUnavailableSheet( baseActivity.supportFragmentManager, requireContext(), null, null) @@ -466,7 +467,7 @@ class HomeVaultFragment : BaseFragment(), VaultClickListener { ServerType.DROP_BOX -> { if (BuildConfig.ENABLE_DROPBOX) { - nav().navigate(R.id.action_homeScreen_to_drop_box_screen) + nav().navigateSafe(R.id.action_homeScreen_to_drop_box_screen) } else { FossFeatureSheetUtils.showFossFeatureUnavailableSheet( baseActivity.supportFragmentManager, requireContext(), null, null) @@ -474,7 +475,20 @@ class HomeVaultFragment : BaseFragment(), VaultClickListener { } ServerType.NEXTCLOUD -> { - nav().navigate(R.id.action_homeScreen_to_next_cloud_screen) + nav().navigateSafe(R.id.action_homeScreen_to_next_cloud_screen) + } + + ServerType.PEERTOPEER -> { + nav().navigateSafe(R.id.action_homeScreen_to_peerToPeer_screen) + } + + ServerType.ADD_BUTTON -> { + baseActivity.startActivity( + Intent( + baseActivity, + ServersSettingsActivity::class.java + ) + ) } else -> {} @@ -493,7 +507,7 @@ class HomeVaultFragment : BaseFragment(), VaultClickListener { ImproveClickOptions.SETTINGS -> { removeImprovementSection() setIsAcceptedAnalytics(true) - nav().navigate(R.id.main_settings) + nav().navigateSafe(R.id.main_settings) } } } @@ -760,14 +774,14 @@ class HomeVaultFragment : BaseFragment(), VaultClickListener { if (serversList?.isEmpty() == false) { // Use the vaultAdapter to check existing connections vaultAdapter.addConnectionServers(serversList!!) - } else { vaultAdapter.removeConnectionServers() } } private fun handleServerCountsSuccess(serverCounts: ServerCounts) { - // Handle each server type + serversList?.clear() + handleGoogleDriveServers(serverCounts.googleDriveServers) handleDropBoxServers(serverCounts.dropBoxServers) handleNextCloudServers(serverCounts.nextCloudServers) @@ -775,10 +789,16 @@ class HomeVaultFragment : BaseFragment(), VaultClickListener { handleCollectServers(serverCounts.collectServers) handleUwaziServers(serverCounts.uwaziServers) - // Check if we need to show connections + if (Preferences.isEnableHomeNearby()) { + serversList?.add(ServerDataItem(emptyList(), ServerType.PEERTOPEER)) + } + + serversList?.add(ServerDataItem(emptyList(), ServerType.ADD_BUTTON)) + maybeShowConnections() } + private fun handleServerCountsError(error: Throwable?) { error?.let { Timber.d("***onServerCountFailed**$it") @@ -878,7 +898,7 @@ class HomeVaultFragment : BaseFragment(), VaultClickListener { private fun navigateToAttachmentsList(bundle: Bundle?) { - findNavController().navigate(R.id.action_homeScreen_to_attachments_screen, bundle) + findNavController().navigateSafe(R.id.action_homeScreen_to_attachments_screen, bundle) } private fun exportVaultFiles(vaultFile: VaultFile) { diff --git a/mobile/src/main/java/org/horizontal/tella/mobile/views/settings/LanguageSettings.kt b/mobile/src/main/java/org/horizontal/tella/mobile/views/settings/LanguageSettings.kt index 015e0d738..e166126d6 100644 --- a/mobile/src/main/java/org/horizontal/tella/mobile/views/settings/LanguageSettings.kt +++ b/mobile/src/main/java/org/horizontal/tella/mobile/views/settings/LanguageSettings.kt @@ -49,7 +49,7 @@ class LanguageSettings : BaseFragment(), View.OnClickListener { private fun createLangViews() { if (languages.isEmpty()) { languages = - ArrayList(Arrays.asList(*resources.getStringArray(R.array.ra_lang_codes))) + ArrayList(listOf(*resources.getStringArray(R.array.ra_lang_codes))) languages.add(0, null) val prefferedLang = LocaleManager.getInstance().languageSetting diff --git a/mobile/src/main/res/drawable-xhdpi/ic_add_connection.png b/mobile/src/main/res/drawable-xhdpi/ic_add_connection.png new file mode 100644 index 000000000..6c0482011 Binary files /dev/null and b/mobile/src/main/res/drawable-xhdpi/ic_add_connection.png differ diff --git a/mobile/src/main/res/drawable-xxhdpi/ic_add_connection.png b/mobile/src/main/res/drawable-xxhdpi/ic_add_connection.png new file mode 100644 index 000000000..9df731cfb Binary files /dev/null and b/mobile/src/main/res/drawable-xxhdpi/ic_add_connection.png differ diff --git a/mobile/src/main/res/drawable/bg_add_connection_dashed.xml b/mobile/src/main/res/drawable/bg_add_connection_dashed.xml new file mode 100644 index 000000000..8c359f35f --- /dev/null +++ b/mobile/src/main/res/drawable/bg_add_connection_dashed.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/mobile/src/main/res/drawable/bg_round_purple_secondary_btn.xml b/mobile/src/main/res/drawable/bg_round_purple_secondary_btn.xml new file mode 100644 index 000000000..fcd6dbfc3 --- /dev/null +++ b/mobile/src/main/res/drawable/bg_round_purple_secondary_btn.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/mobile/src/main/res/drawable/ic_add_connection.png b/mobile/src/main/res/drawable/ic_add_connection.png new file mode 100644 index 000000000..c7506181e Binary files /dev/null and b/mobile/src/main/res/drawable/ic_add_connection.png differ diff --git a/mobile/src/main/res/drawable/ic_add_home.xml b/mobile/src/main/res/drawable/ic_add_home.xml new file mode 100644 index 000000000..270493f5c --- /dev/null +++ b/mobile/src/main/res/drawable/ic_add_home.xml @@ -0,0 +1,11 @@ + + + diff --git a/mobile/src/main/res/drawable/ic_files.xml b/mobile/src/main/res/drawable/ic_files.xml new file mode 100644 index 000000000..684c3c57b --- /dev/null +++ b/mobile/src/main/res/drawable/ic_files.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/mobile/src/main/res/drawable/ic_p2p.xml b/mobile/src/main/res/drawable/ic_p2p.xml new file mode 100644 index 000000000..69ed0f6f2 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_p2p.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/mobile/src/main/res/drawable/ic_p2p_sharing.xml b/mobile/src/main/res/drawable/ic_p2p_sharing.xml new file mode 100644 index 000000000..9bce9a550 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_p2p_sharing.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/mobile/src/main/res/drawable/ic_p2p_small.xml b/mobile/src/main/res/drawable/ic_p2p_small.xml new file mode 100644 index 000000000..689212efc --- /dev/null +++ b/mobile/src/main/res/drawable/ic_p2p_small.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/mobile/src/main/res/drawable/ic_share.xml b/mobile/src/main/res/drawable/ic_share.xml new file mode 100644 index 000000000..1938144ea --- /dev/null +++ b/mobile/src/main/res/drawable/ic_share.xml @@ -0,0 +1,10 @@ + + + diff --git a/mobile/src/main/res/drawable/ic_share_big.xml b/mobile/src/main/res/drawable/ic_share_big.xml new file mode 100644 index 000000000..fd3222f58 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_share_big.xml @@ -0,0 +1,10 @@ + + + diff --git a/mobile/src/main/res/drawable/icon_wifi.xml b/mobile/src/main/res/drawable/icon_wifi.xml new file mode 100644 index 000000000..79f46f896 --- /dev/null +++ b/mobile/src/main/res/drawable/icon_wifi.xml @@ -0,0 +1,13 @@ + + + diff --git a/mobile/src/main/res/drawable/qr_scan_border.xml b/mobile/src/main/res/drawable/qr_scan_border.xml new file mode 100644 index 000000000..4302dd333 --- /dev/null +++ b/mobile/src/main/res/drawable/qr_scan_border.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/mobile/src/main/res/drawable/qr_scan_border_camera.xml b/mobile/src/main/res/drawable/qr_scan_border_camera.xml new file mode 100644 index 000000000..fa868009e --- /dev/null +++ b/mobile/src/main/res/drawable/qr_scan_border_camera.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/src/main/res/layout/activity_documentation_settings.xml b/mobile/src/main/res/layout/activity_documentation_settings.xml index 1d10e0668..b50ae37e5 100644 --- a/mobile/src/main/res/layout/activity_documentation_settings.xml +++ b/mobile/src/main/res/layout/activity_documentation_settings.xml @@ -247,6 +247,19 @@ + + + diff --git a/mobile/src/main/res/layout/activity_peer_to_peer.xml b/mobile/src/main/res/layout/activity_peer_to_peer.xml new file mode 100644 index 000000000..06985463c --- /dev/null +++ b/mobile/src/main/res/layout/activity_peer_to_peer.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/mobile/src/main/res/layout/activity_settings.xml b/mobile/src/main/res/layout/activity_settings.xml index 1f42657af..ecaed4dda 100644 --- a/mobile/src/main/res/layout/activity_settings.xml +++ b/mobile/src/main/res/layout/activity_settings.xml @@ -4,27 +4,27 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="@color/dark_purple" android:fitsSystemWindows="true" android:textDirection="locale" - android:background="@color/dark_purple" tools:context="org.horizontal.tella.mobile.views.activity.SettingsActivity"> + app:layout_constraintTop_toTopOf="parent"> + app:arrowBackIcon="@drawable/ic_arrow_back_white_24dp" + app:arrowBackIconContentDescription="@string/action_go_back" /> @@ -39,5 +39,5 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/appbar" - app:navGraph="@navigation/settings_navigation"/> + app:navGraph="@navigation/settings_navigation" /> diff --git a/mobile/src/main/res/layout/bottom_sheet_p2p_wifi_confirmation.xml b/mobile/src/main/res/layout/bottom_sheet_p2p_wifi_confirmation.xml new file mode 100644 index 000000000..35abdf929 --- /dev/null +++ b/mobile/src/main/res/layout/bottom_sheet_p2p_wifi_confirmation.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/layout/collect_widget_range.xml b/mobile/src/main/res/layout/collect_widget_range.xml new file mode 100644 index 000000000..a32c37f8e --- /dev/null +++ b/mobile/src/main/res/layout/collect_widget_range.xml @@ -0,0 +1,31 @@ + + + + + + + + \ No newline at end of file diff --git a/mobile/src/main/res/layout/connect_hotspot_layout.xml b/mobile/src/main/res/layout/connect_hotspot_layout.xml new file mode 100644 index 000000000..221094c99 --- /dev/null +++ b/mobile/src/main/res/layout/connect_hotspot_layout.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mobile/src/main/res/layout/connect_manually_verification.xml b/mobile/src/main/res/layout/connect_manually_verification.xml new file mode 100644 index 000000000..ee0bee35a --- /dev/null +++ b/mobile/src/main/res/layout/connect_manually_verification.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/layout/fragment_attachments_selector.xml b/mobile/src/main/res/layout/fragment_attachments_selector.xml index ab6e5771b..79d3c268b 100644 --- a/mobile/src/main/res/layout/fragment_attachments_selector.xml +++ b/mobile/src/main/res/layout/fragment_attachments_selector.xml @@ -75,8 +75,9 @@ android:layout_height="wrap_content" android:layout_gravity="end|center_vertical" android:layout_marginHorizontal="@dimen/tella_main_vertical" + android:contentDescription="@string/action_select" android:padding="5dp" - android:src="@drawable/gallery_checkbox_selector" + android:src="@drawable/ic_check" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@id/gridCheck" app:layout_constraintTop_toTopOf="parent" /> diff --git a/mobile/src/main/res/layout/fragment_peer_to_peer_result.xml b/mobile/src/main/res/layout/fragment_peer_to_peer_result.xml new file mode 100644 index 000000000..4ecb3af7b --- /dev/null +++ b/mobile/src/main/res/layout/fragment_peer_to_peer_result.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mobile/src/main/res/layout/fragment_prepare_upload.xml b/mobile/src/main/res/layout/fragment_prepare_upload.xml new file mode 100644 index 000000000..9a97778df --- /dev/null +++ b/mobile/src/main/res/layout/fragment_prepare_upload.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +