diff --git a/.drone.jsonnet b/.drone.jsonnet index c0b9ce68..4952d77a 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -28,10 +28,24 @@ local build() = { }, }, commands: [ - "sdkmanager 'build-tools;35.0.0'", - "./gradlew clean test assemble" + "./gradlew clean testDebugUnitTest assemble" ] }, + { + name: "collect", + image: "debian:bookworm-slim", + commands: [ + "mkdir -p artifact/screenshots", + "VERSION=$(grep versionName syncloud/build.gradle | head -1 | cut -d'\"' -f2)", + "for apk in syncloud/build/outputs/apk/release/*.apk; do [ -f \"$apk\" ] && cp \"$apk\" artifact/syncloud-$VERSION.apk; done || true", + "cp syncloud/build/outputs/roborazzi/*.png artifact/screenshots/ || true", + "cp -r syncloud/build/reports/tests artifact/test-report || true", + "ls -R artifact" + ], + when: { + status: [ "failure", "success" ] + } + }, { name: "publish to github", image: "plugins/github-release:1.0.0", @@ -39,7 +53,7 @@ local build() = { api_key: { from_secret: "github_token" }, - files: "syncloud/build/outputs/apk/release/*", + files: "artifact/*.apk", overwrite: true, file_exists: "overwrite" }, @@ -61,16 +75,80 @@ local build() = { timeout: "2m", command_timeout: "2m", target: "/home/artifact/repo/android/${DRONE_BUILD_NUMBER}", - source: "syncloud/build/outputs/apk/release/*.apk", - strip_components: 5 + source: "artifact/*", + strip_components: 1 }, when: { status: [ "failure", "success" ] } } - ] + ], + trigger: { + event: [ "push", "tag" ] + } +}; + +local platform = "26.08.01"; +local distro = "bookworm"; + +local probe() = { + kind: "pipeline", + type: "docker", + name: "probe", + + platform: { + os: "linux", + arch: "amd64" + }, + steps: [ + { + name: "capabilities", + image: "debian:bookworm-slim", + privileged: true, + volumes: [ { name: "dev", path: "/dev" } ], + commands: [ + "echo '--- kvm ---'", + "ls -l /dev/kvm || echo 'NO /dev/kvm'", + "echo '--- kernel modules for redroid ---'", + "grep -E 'binder|ashmem' /proc/modules || echo 'NO binder/ashmem modules'", + "echo '--- kernel ---'", + "uname -a", + "echo '--- cpu virt flags ---'", + "grep -o -m1 -E 'vmx|svm' /proc/cpuinfo || echo 'NO vmx/svm'", + "echo '--- network ---'", + "ip addr || (apt-get update -qq && apt-get install -y -qq iproute2 && ip addr)" + ] + }, + { + name: "mdns", + image: "python:3.12-slim-bookworm", + commands: [ + "pip install --quiet zeroconf", + "python3 test/mdns_probe.py" + ] + } + ], + services: [ + { + name: "device." + distro + ".com", + image: "syncloud/platform-" + distro + ":" + platform, + privileged: true, + volumes: [ + { name: "dbus", path: "/var/run/dbus" }, + { name: "dev", path: "/dev" } + ] + } + ], + volumes: [ + { name: "dbus", host: { path: "/var/run/dbus" } }, + { name: "dev", host: { path: "/dev" } } + ], + trigger: { + event: [ "push" ] + } }; [ - build() + build(), + probe() ] diff --git a/build.gradle b/build.gradle index 9ae2a8b5..a64839a5 100644 --- a/build.gradle +++ b/build.gradle @@ -4,11 +4,12 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.5.2' + classpath 'com.android.tools.build:gradle:9.3.2' } } plugins { - id 'org.jetbrains.kotlin.jvm' version '2.0.10' + id 'org.jetbrains.kotlin.jvm' version '2.2.21' + id 'org.jetbrains.kotlin.plugin.compose' version '2.2.21' apply false } allprojects { repositories { @@ -16,6 +17,3 @@ allprojects { mavenCentral() } } -repositories { - mavenCentral() -} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 0446e057..8be74a44 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,23 +1,2 @@ -# Project-wide Gradle settings. - -# IDE (e.g. Android Studio) users: -# Settings specified in this file will override any Gradle settings -# configured through the IDE. - -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html - -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -# Default value: -Xmx10248m -XX:MaxPermSize=256m -# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xms1024m -Xmx4096m - -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true -android.enableJetifier=true android.useAndroidX=true -android.nonTransitiveRClass=false -android.nonFinalResIds=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4ceac765..d3b9b13d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sun Aug 11 21:18:02 BST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/syncloud/build.gradle b/syncloud/build.gradle index 1b9a931c..cb184921 100644 --- a/syncloud/build.gradle +++ b/syncloud/build.gradle @@ -1,32 +1,25 @@ apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' android { - namespace "org.syncloud.android" - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - } + namespace = "org.syncloud.android" + compileSdk = 37 - kotlin { - jvmToolchain 17 - compilerOptions { - allWarningsAsErrors = true - } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } defaultConfig { - compileSdk 33 - minSdkVersion 23 - targetSdkVersion 35 - versionCode 24000 - versionName "24.00" - multiDexEnabled true + minSdk = 23 + targetSdk = 36 + versionCode = 26000 + versionName = "26.00" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } - applicationVariants.configureEach { variant -> - variant.outputs.configureEach { - outputFileName = "syncloud-${variant.versionName}.apk" - } + buildFeatures { + compose = true } signingConfigs { @@ -35,45 +28,74 @@ android { if (env.containsKey('KEY_STORE')) { def sf = new File(env['ANDROID_STORE_FILE']) sf.append(env['KEY_STORE'].decodeBase64()) - storeFile sf - storePassword env['ANDROID_STORE_PASSWORD'] - keyAlias env['ANDROID_KEY_ALIAS'] - keyPassword env['ANDROID_KEY_PASSWORD'] + storeFile = sf + storePassword = env['ANDROID_STORE_PASSWORD'] + keyAlias = env['ANDROID_KEY_ALIAS'] + keyPassword = env['ANDROID_KEY_PASSWORD'] } } } buildTypes { release { - debuggable false - signingConfig signingConfigs.release + debuggable = false + signingConfig = signingConfigs.release + } + } + + testOptions { + unitTests { + returnDefaultValues = true + includeAndroidResources = true + all { + it.systemProperty 'robolectric.graphicsMode', 'NATIVE' + it.systemProperty 'roborazzi.test.record', 'true' + it.testLogging { + events 'failed' + exceptionFormat 'full' + showStackTraces true + showCauses true + } + } } } - packagingOptions { + + packaging { resources { - excludes += ['META-INF/LICENSE', 'META-INF/LICENSE.txt', 'META-INF/NOTICE', 'META-INF/NOTICE.txt', 'log4j.properties', 'about.html', 'META-INF/beans.xml'] + excludes += ['META-INF/LICENSE', 'META-INF/LICENSE.txt', 'META-INF/NOTICE', 'META-INF/NOTICE.txt', 'about.html', 'META-INF/beans.xml'] } } } +kotlin { + jvmToolchain(17) + compilerOptions { + allWarningsAsErrors = true + } +} dependencies { - implementation 'log4j:log4j:1.2.17' - implementation 'ch.acra:acra-mail:5.11.3' - implementation 'ch.acra:acra-dialog:5.11.3' - implementation 'de.mindpipe.android:android-logging-log4j:1.0.3' - implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.9.8' - implementation 'org.apache.commons:commons-lang3:3.3.2' - implementation 'com.google.guava:guava:27.0.1-android' - implementation 'com.google.android.material:material:1.3.0' - implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'androidx.appcompat:appcompat:1.3.0' - implementation 'androidx.preference:preference-ktx:1.1.1' - implementation 'com.lsjwzh:materialloadingprogressbar:0.5.8-RELEASE' - implementation "androidx.core:core-ktx:1.7.0" - implementation "com.squareup.okhttp3:okhttp:4.9.0" - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.8' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3' - testImplementation 'junit:junit:4.10' - testImplementation "io.mockk:mockk:1.12.0" + implementation platform('androidx.compose:compose-bom:2026.08.00') + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.ui:ui' + implementation 'androidx.activity:activity-compose:1.13.0' + implementation 'androidx.core:core-ktx:1.19.0' + implementation 'ch.acra:acra-mail:5.13.1' + implementation 'ch.acra:acra-dialog:5.13.1' + implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.19.4' + implementation 'com.squareup.okhttp3:okhttp:5.5.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0' + testImplementation platform('androidx.compose:compose-bom:2026.08.00') + testImplementation 'junit:junit:4.13.2' + testImplementation 'io.mockk:mockk:1.14.11' + testImplementation 'org.robolectric:robolectric:4.16.1' + testImplementation 'androidx.test:core:1.7.0' + testImplementation 'androidx.compose.ui:ui-test-junit4' + debugImplementation 'androidx.compose.ui:ui-test-manifest' + testImplementation 'io.github.takahirom.roborazzi:roborazzi:1.73.0' + testImplementation 'io.github.takahirom.roborazzi:roborazzi-compose:1.73.0' + testImplementation 'io.github.takahirom.roborazzi:roborazzi-junit-rule:1.73.0' + androidTestImplementation 'androidx.test.ext:junit:1.3.0' + androidTestImplementation 'androidx.test:runner:1.7.0' } diff --git a/syncloud/proguard-android.txt b/syncloud/proguard-android.txt index 334087f9..155fe552 100644 --- a/syncloud/proguard-android.txt +++ b/syncloud/proguard-android.txt @@ -78,7 +78,6 @@ -keep class org.syncloud.** { *; } -keep class org.eclipse.jetty.** { *; } --keep class org.apache.log4j.** { *; } -keep class org.fourthline.cling.** { *; } -keep class org.seamless.** { *; } -keep class com.google.common.base.** { *; } @@ -88,7 +87,6 @@ -keep class com.jcraft.** { *; } -keepnames class com.fasterxml.jackson.** { *; } --dontwarn org.apache.log4j.** -dontwarn org.fourthline.cling.** -dontwarn org.seamless.** -dontwarn com.google.common.base.** diff --git a/syncloud/src/androidTest/java/org/syncloud/android/discovery/DiscoveryInstrumentedTest.kt b/syncloud/src/androidTest/java/org/syncloud/android/discovery/DiscoveryInstrumentedTest.kt new file mode 100644 index 00000000..493d735e --- /dev/null +++ b/syncloud/src/androidTest/java/org/syncloud/android/discovery/DiscoveryInstrumentedTest.kt @@ -0,0 +1,35 @@ +package org.syncloud.android.discovery + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.wifi.WifiManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Collections + +const val DISCOVERY_SECONDS = 30 + +@RunWith(AndroidJUnit4::class) +class DiscoveryInstrumentedTest { + + @Test + fun discoversSyncloudDeviceOnTheNetwork() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val manager = DiscoveryManager( + context.getSystemService(Context.WIFI_SERVICE) as WifiManager, + context.getSystemService(Context.NSD_SERVICE) as NsdManager + ) + + val devices = Collections.synchronizedList(mutableListOf()) + manager.run(DISCOVERY_SECONDS) { device -> devices.add(device) } + + assertTrue( + "no device discovered over mdns in $DISCOVERY_SECONDS seconds", + devices.isNotEmpty() + ) + } +} diff --git a/syncloud/src/main/AndroidManifest.xml b/syncloud/src/main/AndroidManifest.xml index dfdffaa8..f0ed56c3 100644 --- a/syncloud/src/main/AndroidManifest.xml +++ b/syncloud/src/main/AndroidManifest.xml @@ -42,8 +42,7 @@ android:name=".ui.AuthActivity" android:label="@string/app_name" android:screenOrientation="portrait" - android:exported="true" - android:theme="@style/NoActionBarTheme" > + android:exported="true" > @@ -63,15 +62,6 @@ android:exported="true" android:windowSoftInputMode="adjustResize|stateVisible" > - - - diff --git a/syncloud/src/main/java/org/syncloud/android/ConfigureLog4J.kt b/syncloud/src/main/java/org/syncloud/android/ConfigureLog4J.kt deleted file mode 100644 index 4e23c9e5..00000000 --- a/syncloud/src/main/java/org/syncloud/android/ConfigureLog4J.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.syncloud.android - -import org.apache.log4j.EnhancedPatternLayout -import de.mindpipe.android.logging.log4j.LogCatAppender -import org.apache.log4j.Layout -import org.apache.log4j.Logger - -object ConfigureLog4J { - @JvmStatic - fun configure() { - val root = Logger.getRootLogger() - val messageLayout: Layout = EnhancedPatternLayout("%m%n") - val tagLayout: Layout = EnhancedPatternLayout("%c{1}") - val logCatAppender = LogCatAppender(messageLayout, tagLayout) - root.addAppender(logCatAppender) - } -} \ No newline at end of file diff --git a/syncloud/src/main/java/org/syncloud/android/Logger.kt b/syncloud/src/main/java/org/syncloud/android/Logger.kt new file mode 100644 index 00000000..31eba4e4 --- /dev/null +++ b/syncloud/src/main/java/org/syncloud/android/Logger.kt @@ -0,0 +1,32 @@ +package org.syncloud.android + +import android.util.Log + +class Logger(tag: String) { + + private val tag = tag.take(MAX_TAG_LENGTH) + + fun debug(message: String?) { + Log.d(tag, message ?: "") + } + + fun info(message: String?) { + Log.i(tag, message ?: "") + } + + fun error(message: String?) { + Log.e(tag, message ?: "") + } + + fun error(message: String?, e: Throwable) { + Log.e(tag, message ?: "", e) + } + + companion object { + private const val MAX_TAG_LENGTH = 23 + + fun getLogger(clazz: Class<*>): Logger = Logger(clazz.simpleName) + + fun getLogger(name: String): Logger = Logger(name.substringAfterLast('.')) + } +} diff --git a/syncloud/src/main/java/org/syncloud/android/Preferences.kt b/syncloud/src/main/java/org/syncloud/android/Preferences.kt index 51740572..79b636f1 100644 --- a/syncloud/src/main/java/org/syncloud/android/Preferences.kt +++ b/syncloud/src/main/java/org/syncloud/android/Preferences.kt @@ -4,7 +4,7 @@ import android.content.SharedPreferences import org.syncloud.android.ui.PreferencesConstants class Preferences(private val preferences: SharedPreferences) { - val mainDomain: String get() = preferences.getString(PreferencesConstants.KEY_PREF_MAIN_DOMAIN, "syncloud.it")!! + val mainDomain: String get() = preferences.getString(PreferencesConstants.KEY_PREF_MAIN_DOMAIN, DEFAULT_MAIN_DOMAIN)!! val redirectEmail: String? get() = preferences.getString(PreferencesConstants.KEY_PREF_EMAIL, null) val redirectPassword: String? get() = preferences.getString(PreferencesConstants.KEY_PREF_PASSWORD, null) @@ -14,4 +14,15 @@ class Preferences(private val preferences: SharedPreferences) { editor.putString(PreferencesConstants.KEY_PREF_PASSWORD, password) editor.apply() } -} \ No newline at end of file + + fun setMainDomain(domain: String) { + val editor = preferences.edit() + editor.putString(PreferencesConstants.KEY_PREF_MAIN_DOMAIN, domain) + editor.apply() + } + + companion object { + const val DEFAULT_MAIN_DOMAIN = "syncloud.it" + val MAIN_DOMAINS = listOf("syncloud.it", "syncloud.info") + } +} diff --git a/syncloud/src/main/java/org/syncloud/android/SyncloudApplication.kt b/syncloud/src/main/java/org/syncloud/android/SyncloudApplication.kt index 41b36ffa..6e0cce12 100644 --- a/syncloud/src/main/java/org/syncloud/android/SyncloudApplication.kt +++ b/syncloud/src/main/java/org/syncloud/android/SyncloudApplication.kt @@ -5,7 +5,6 @@ import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities -import androidx.preference.PreferenceManager import org.acra.ACRA import org.acra.BuildConfig import org.acra.ReportField @@ -13,8 +12,6 @@ import org.acra.config.dialog import org.acra.config.mailSender import org.acra.data.StringFormat import org.acra.ktx.initAcra -import org.apache.log4j.Logger -import org.syncloud.android.ConfigureLog4J.configure import org.syncloud.android.core.common.WebService import org.syncloud.android.core.common.http.HttpClient import org.syncloud.android.core.redirect.IUserService @@ -39,13 +36,10 @@ class SyncloudApplication : Application() { } override fun onCreate() { - configure() val logger = Logger.getLogger(SyncloudApplication::class.java) logger.info("Starting Syncloud App") super.onCreate() - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) - PreferenceManager.setDefaultValues(this, R.xml.preferences, false) - preferences = Preferences(sharedPreferences) + preferences = Preferences(getSharedPreferences("${packageName}_preferences", MODE_PRIVATE)) _userStorage = UserStorage(File(applicationContext.filesDir, "user.json")) userServiceCached = webServiceAuthWithFileBackedCache() } diff --git a/syncloud/src/main/java/org/syncloud/android/core/common/WebService.kt b/syncloud/src/main/java/org/syncloud/android/core/common/WebService.kt index 99beab32..2fa623c5 100644 --- a/syncloud/src/main/java/org/syncloud/android/core/common/WebService.kt +++ b/syncloud/src/main/java/org/syncloud/android/core/common/WebService.kt @@ -7,7 +7,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response -import org.apache.log4j.Logger +import org.syncloud.android.Logger import org.syncloud.android.core.common.http.HttpClient import java.io.IOException @@ -32,25 +32,18 @@ open class WebService(private val client: HttpClient) { private fun convert(response: Response): String { response.use { resp -> - val responseBody = resp.body - if (responseBody != null) { - val json = responseBody.string() - try { - val jsonBaseResponse = mapper.readValue(json) - if (!jsonBaseResponse.success) { - logger.error("${jsonBaseResponse.message} $json") - throw SyncloudResultException(jsonBaseResponse.message, jsonBaseResponse) - } - return json - - } catch (e: IOException) { - val message = "Failed to deserialize json" - logger.error("$message $json", e) - throw SyncloudException(message) + val json = resp.body.string() + try { + val jsonBaseResponse = mapper.readValue(json) + if (!jsonBaseResponse.success) { + logger.error("${jsonBaseResponse.message} $json") + throw SyncloudResultException(jsonBaseResponse.message, jsonBaseResponse) } - } else { - val message = "empty response" - logger.error(message) + return json + + } catch (e: IOException) { + val message = "Failed to deserialize json" + logger.error("$message $json", e) throw SyncloudException(message) } } diff --git a/syncloud/src/main/java/org/syncloud/android/core/common/http/HttpClient.kt b/syncloud/src/main/java/org/syncloud/android/core/common/http/HttpClient.kt index d37b184e..42a1e38a 100644 --- a/syncloud/src/main/java/org/syncloud/android/core/common/http/HttpClient.kt +++ b/syncloud/src/main/java/org/syncloud/android/core/common/http/HttpClient.kt @@ -4,7 +4,7 @@ import android.annotation.SuppressLint import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import org.apache.log4j.Logger +import org.syncloud.android.Logger import org.syncloud.android.core.common.WebService import java.security.SecureRandom import java.security.cert.X509Certificate diff --git a/syncloud/src/main/java/org/syncloud/android/core/platform/Internal.kt b/syncloud/src/main/java/org/syncloud/android/core/platform/Internal.kt index 33dab79c..2cdb8b25 100644 --- a/syncloud/src/main/java/org/syncloud/android/core/platform/Internal.kt +++ b/syncloud/src/main/java/org/syncloud/android/core/platform/Internal.kt @@ -3,7 +3,7 @@ package org.syncloud.android.core.platform import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue -import org.apache.log4j.Logger +import org.syncloud.android.Logger import org.syncloud.android.core.common.Result import org.syncloud.android.core.common.WebService import org.syncloud.android.core.platform.model.Identification diff --git a/syncloud/src/main/java/org/syncloud/android/core/redirect/RedirectService.kt b/syncloud/src/main/java/org/syncloud/android/core/redirect/RedirectService.kt index 9d907633..595640c2 100644 --- a/syncloud/src/main/java/org/syncloud/android/core/redirect/RedirectService.kt +++ b/syncloud/src/main/java/org/syncloud/android/core/redirect/RedirectService.kt @@ -3,7 +3,7 @@ package org.syncloud.android.core.redirect import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue -import org.apache.log4j.Logger +import org.syncloud.android.Logger import org.syncloud.android.core.common.SyncloudException import org.syncloud.android.core.common.WebService import org.syncloud.android.core.redirect.model.User diff --git a/syncloud/src/main/java/org/syncloud/android/core/redirect/UserStorage.kt b/syncloud/src/main/java/org/syncloud/android/core/redirect/UserStorage.kt index 353b479e..a4c71cb6 100644 --- a/syncloud/src/main/java/org/syncloud/android/core/redirect/UserStorage.kt +++ b/syncloud/src/main/java/org/syncloud/android/core/redirect/UserStorage.kt @@ -1,6 +1,6 @@ package org.syncloud.android.core.redirect -import org.apache.log4j.Logger +import org.syncloud.android.Logger import org.syncloud.android.core.common.jackson.Jackson.createObjectMapper import org.syncloud.android.core.redirect.UserStorage import org.syncloud.android.core.redirect.model.User diff --git a/syncloud/src/main/java/org/syncloud/android/discovery/DiscoveryManager.kt b/syncloud/src/main/java/org/syncloud/android/discovery/DiscoveryManager.kt index fe95dc2e..64b32c12 100644 --- a/syncloud/src/main/java/org/syncloud/android/discovery/DiscoveryManager.kt +++ b/syncloud/src/main/java/org/syncloud/android/discovery/DiscoveryManager.kt @@ -3,7 +3,7 @@ package org.syncloud.android.discovery import android.net.nsd.NsdManager import android.net.wifi.WifiManager import kotlinx.coroutines.delay -import org.apache.log4j.Logger +import org.syncloud.android.Logger import org.syncloud.android.discovery.nsd.NsdDiscovery class DiscoveryManager(wifi: WifiManager, private val manager: NsdManager) { diff --git a/syncloud/src/main/java/org/syncloud/android/discovery/MulticastLock.kt b/syncloud/src/main/java/org/syncloud/android/discovery/MulticastLock.kt index 35b92caf..9664d74f 100644 --- a/syncloud/src/main/java/org/syncloud/android/discovery/MulticastLock.kt +++ b/syncloud/src/main/java/org/syncloud/android/discovery/MulticastLock.kt @@ -1,7 +1,7 @@ package org.syncloud.android.discovery import android.net.wifi.WifiManager -import org.apache.log4j.Logger +import org.syncloud.android.Logger import java.lang.Exception class MulticastLock(private val wifi: WifiManager) { diff --git a/syncloud/src/main/java/org/syncloud/android/discovery/nsd/EventToDeviceConverter.kt b/syncloud/src/main/java/org/syncloud/android/discovery/nsd/EventToDeviceConverter.kt index c1b0201e..e09e7c11 100644 --- a/syncloud/src/main/java/org/syncloud/android/discovery/nsd/EventToDeviceConverter.kt +++ b/syncloud/src/main/java/org/syncloud/android/discovery/nsd/EventToDeviceConverter.kt @@ -2,10 +2,9 @@ package org.syncloud.android.discovery.nsd import android.net.nsd.NsdManager import android.net.nsd.NsdManager.DiscoveryListener -import com.google.common.collect.Lists import org.syncloud.android.discovery.nsd.EventToDeviceConverter import android.net.nsd.NsdServiceInfo -import org.apache.log4j.Logger +import org.syncloud.android.Logger class EventToDeviceConverter( private val manager: NsdManager, @@ -13,7 +12,7 @@ class EventToDeviceConverter( private val resolver: Resolver ) : DiscoveryListener { private val lookForServiceName: String = lookForServiceNameInput.lowercase() - private val discoveredServices: MutableList = Lists.newArrayList() + private val discoveredServices: MutableList = mutableListOf() override fun onStartDiscoveryFailed(s: String, i: Int) { val text = "start discovery failed $s" diff --git a/syncloud/src/main/java/org/syncloud/android/discovery/nsd/NsdDiscovery.kt b/syncloud/src/main/java/org/syncloud/android/discovery/nsd/NsdDiscovery.kt index 65d65213..1f1918ce 100644 --- a/syncloud/src/main/java/org/syncloud/android/discovery/nsd/NsdDiscovery.kt +++ b/syncloud/src/main/java/org/syncloud/android/discovery/nsd/NsdDiscovery.kt @@ -1,7 +1,7 @@ package org.syncloud.android.discovery.nsd import android.net.nsd.NsdManager -import org.apache.log4j.Logger +import org.syncloud.android.Logger import org.syncloud.android.discovery.Discovery const val TYPE = "_ssh._tcp." diff --git a/syncloud/src/main/java/org/syncloud/android/discovery/nsd/Resolver.kt b/syncloud/src/main/java/org/syncloud/android/discovery/nsd/Resolver.kt index 2d62cb93..f66d05de 100644 --- a/syncloud/src/main/java/org/syncloud/android/discovery/nsd/Resolver.kt +++ b/syncloud/src/main/java/org/syncloud/android/discovery/nsd/Resolver.kt @@ -2,73 +2,131 @@ package org.syncloud.android.discovery.nsd import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo +import android.os.Build +import androidx.annotation.RequiresApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.apache.log4j.Logger +import org.syncloud.android.Logger import java.net.Inet6Address -import java.util.* +import java.net.InetAddress +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean class Resolver( - private val manager: NsdManager, - val added: suspend (endpoint: String) -> Unit + private val manager: NsdManager, + val added: suspend (endpoint: String) -> Unit ) { - private var isBusy = false - private val queue: Queue = LinkedList() - private val resolveListener: ResolveListener = ResolveListener() + private val busy = AtomicBoolean(false) + private val queue = ConcurrentLinkedQueue() + private val callbackExecutor = Executors.newSingleThreadExecutor() fun resolve(serviceInfo: NsdServiceInfo) { queue.add(serviceInfo) checkQueue() } - @Synchronized private fun checkQueue() { - if (isBusy) return + if (!busy.compareAndSet(false, true)) return val serviceInfo = queue.poll() - if (serviceInfo != null) { - isBusy = true - manager.resolveService(serviceInfo, resolveListener) + if (serviceInfo == null) { + busy.set(false) + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + registerCallback(serviceInfo) + } else { + resolveLegacy(serviceInfo) } } private fun endResolving() { - isBusy = false + busy.set(false) checkQueue() } - private suspend fun deviceFound(device: String) = added(device) - - inner class ResolveListener : NsdManager.ResolveListener { - override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { - val text = - "resolve failed for service: " + serviceInfo.serviceName + ", error code: " + errorCode - logger.error(text) - endResolving() + private fun resolved(serviceName: String, addresses: List) { + logger.info("service: $serviceName resolved") + val host = addresses.firstOrNull() + if (host == null) { + logger.error("service: $serviceName has no address") + return } + val address = + if (host is Inet6Address) + "[" + host.hostAddress + "]" + else + host.hostAddress + address ?: return + CoroutineScope(Dispatchers.IO).launch { + added(address) + } + } - override fun onServiceResolved(serviceInfo: NsdServiceInfo) { - val serviceName = serviceInfo.serviceName - val text = "service: $serviceName resolved" - logger.info(text) - val host = serviceInfo.host - if (host != null) { - val address = - if (host is Inet6Address) - "[" + host.hostAddress + "]" - else - host.hostAddress + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + private fun registerCallback(serviceInfo: NsdServiceInfo) { + val serviceName = serviceInfo.serviceName + val callback = object : NsdManager.ServiceInfoCallback { + private var done = false - CoroutineScope(Dispatchers.IO).launch { - deviceFound(address) + private fun finish() { + if (done) return + done = true + try { + manager.unregisterServiceInfoCallback(this) + } catch (e: IllegalArgumentException) { + logger.error("callback already unregistered for $serviceName", e) } + endResolving() + } + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + logger.error("registration failed for service: $serviceName, error code: $errorCode") + if (!done) { + done = true + endResolving() + } } + + override fun onServiceUpdated(info: NsdServiceInfo) { + resolved(serviceName, info.hostAddresses) + finish() + } + + override fun onServiceLost() { + logger.error("service lost while resolving: $serviceName") + finish() + } + + override fun onServiceInfoCallbackUnregistered() { + } + } + try { + manager.registerServiceInfoCallback(serviceInfo, callbackExecutor, callback) + } catch (e: IllegalArgumentException) { + logger.error("failed to register callback for service: $serviceName", e) endResolving() } } + @Suppress("DEPRECATION") + private fun resolveLegacy(serviceInfo: NsdServiceInfo) { + manager.resolveService(serviceInfo, object : NsdManager.ResolveListener { + override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) { + logger.error("resolve failed for service: ${info.serviceName}, error code: $errorCode") + endResolving() + } + + override fun onServiceResolved(info: NsdServiceInfo) { + val host = info.host + resolved(info.serviceName, if (host == null) emptyList() else listOf(host)) + endResolving() + } + }) + } + companion object { private val logger = Logger.getLogger(Resolver::class.java.name) } -} \ No newline at end of file +} diff --git a/syncloud/src/main/java/org/syncloud/android/ui/AuthActivity.kt b/syncloud/src/main/java/org/syncloud/android/ui/AuthActivity.kt index e477dcdf..49de3e01 100644 --- a/syncloud/src/main/java/org/syncloud/android/ui/AuthActivity.kt +++ b/syncloud/src/main/java/org/syncloud/android/ui/AuthActivity.kt @@ -3,115 +3,149 @@ package org.syncloud.android.ui import android.content.Intent import android.net.Uri import android.os.Bundle -import android.text.method.LinkMovementMethod -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.LinearLayout -import android.widget.TextView -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import com.lsjwzh.widget.materialloadingprogressbar.CircleProgressBar -import kotlinx.coroutines.CoroutineScope +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.syncloud.android.Preferences import org.syncloud.android.R import org.syncloud.android.SyncloudApplication import org.syncloud.android.core.redirect.IUserService +import org.syncloud.android.ui.theme.SyncloudTheme -class AuthActivity : AppCompatActivity() { - private lateinit var preferences: Preferences - private lateinit var progressBar: CircleProgressBar - private lateinit var signInOrOut: LinearLayout - private lateinit var userService: IUserService - private lateinit var askCredentialsLauncher: ActivityResultLauncher +class AuthActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_auth) + enableEdgeToEdge() val application = application as SyncloudApplication - preferences = application.preferences - userService = application.userServiceCached - progressBar = findViewById(R.id.progress) as CircleProgressBar - progressBar.setColorSchemeResources(R.color.logo_blue, R.color.logo_green) - signInOrOut = findViewById(R.id.sign_in_or_up) as LinearLayout - val singUpBtn = findViewById(R.id.sign_up_button) - singUpBtn.setOnClickListener{ - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.${preferences.mainDomain}/register"))) - } - val signInBtn = findViewById(R.id.sign_in_button) - signInBtn.setOnClickListener{ - startActivity(Intent(this, AuthCredentialsActivity::class.java)) - } - val learnMoreText = findViewById(R.id.auth_learn_more) as TextView - learnMoreText.movementMethod = LinkMovementMethod.getInstance() - askCredentialsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - finish() + setContent { + SyncloudTheme { + AuthScreen( + preferences = application.preferences, + userService = application.userServiceCached, + onSignedIn = { + startActivity(Intent(this, DevicesSavedActivity::class.java)) + finish() + }, + onCredentialsNeeded = { checkExisting -> + val intent = Intent(this, AuthCredentialsActivity::class.java) + intent.putExtra(AuthConstants.PARAM_CHECK_EXISTING, checkExisting) + startActivity(intent) + } + ) + } } - proceedWithLogin() } +} - private fun proceedWithLogin() { - val redirectEmail = preferences.redirectEmail - val redirectPassword = preferences.redirectPassword - if (redirectEmail != null && redirectPassword != null) { - login(redirectEmail, redirectPassword) +@Composable +fun AuthScreen( + preferences: Preferences, + userService: IUserService, + onSignedIn: () -> Unit, + onCredentialsNeeded: (Boolean) -> Unit +) { + val context = LocalContext.current + var busy by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + val email = preferences.redirectEmail + val password = preferences.redirectPassword + if (email != null && password != null) { + busy = true + val user = withContext(Dispatchers.IO) { + runCatching { userService.getUser(email, password) }.getOrNull() + } + busy = false + if (user != null) onSignedIn() else onCredentialsNeeded(true) } } - private fun login(email: String, password: String) { - progressStart() - CoroutineScope(Dispatchers.IO).launch { - try { - val user = userService.getUser(email, password) - withContext(Dispatchers.Main) { - progressStop() - if (user != null) { - val intent = Intent(this@AuthActivity, DevicesSavedActivity::class.java) - startActivity(intent) - finish() - } else { - askCredentials() - } + Scaffold { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(24.dp) + .testTag("auth_screen"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(R.drawable.syncloud_logo), + contentDescription = stringResource(R.string.logo_image) + ) + Spacer(Modifier.height(32.dp)) + if (busy) { + CircularProgressIndicator(modifier = Modifier.testTag("auth_progress")) + } else { + Button( + onClick = { onCredentialsNeeded(false) }, + modifier = Modifier + .fillMaxWidth() + .testTag("sign_in_button") + ) { + Text(stringResource(R.string.action_sign_in)) + } + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://www.${preferences.mainDomain}/register") + ) + ) + }, + modifier = Modifier + .fillMaxWidth() + .testTag("sign_up_button") + ) { + Text(stringResource(R.string.action_sign_up)) } - } catch (e: Throwable) { - withContext(Dispatchers.Main) { - progressStop() - askCredentials() + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.build_your_own_server), + style = MaterialTheme.typography.bodyMedium + ) + TextButton(onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://syncloud.org")) + ) + }) { + Text(stringResource(R.string.learn_more)) } } } } - - private fun askCredentials() { - val intent = Intent(this@AuthActivity, AuthCredentialsActivity::class.java) - intent.putExtra(AuthConstants.PARAM_CHECK_EXISTING, true) - startActivity(intent) - } - - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.main, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - return if (id == R.id.action_settings) { - true - } else super.onOptionsItemSelected(item) - } - - private fun progressStop() { - signInOrOut.visibility = View.VISIBLE - progressBar.visibility = View.INVISIBLE - } - - private fun progressStart() { - signInOrOut.visibility = View.INVISIBLE - progressBar.visibility = View.VISIBLE - } -} \ No newline at end of file +} diff --git a/syncloud/src/main/java/org/syncloud/android/ui/AuthCredentialsActivity.kt b/syncloud/src/main/java/org/syncloud/android/ui/AuthCredentialsActivity.kt index a56ca619..dbf7cd9b 100644 --- a/syncloud/src/main/java/org/syncloud/android/ui/AuthCredentialsActivity.kt +++ b/syncloud/src/main/java/org/syncloud/android/ui/AuthCredentialsActivity.kt @@ -1,205 +1,229 @@ package org.syncloud.android.ui -import android.app.AlertDialog import android.content.Intent import android.os.Bundle -import android.text.TextUtils -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.inputmethod.EditorInfo -import android.widget.Button -import android.widget.EditText -import android.widget.LinearLayout -import android.widget.TextView.OnEditorActionListener -import androidx.appcompat.app.AppCompatActivity -import com.lsjwzh.widget.materialloadingprogressbar.CircleProgressBar -import kotlinx.coroutines.CoroutineScope +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.apache.log4j.Logger +import org.syncloud.android.Logger import org.syncloud.android.Preferences import org.syncloud.android.R import org.syncloud.android.SyncloudApplication import org.syncloud.android.core.common.SyncloudResultException import org.syncloud.android.core.redirect.IUserService -import org.syncloud.android.core.redirect.model.User +import org.syncloud.android.ui.theme.SyncloudTheme -class AuthCredentialsActivity : AppCompatActivity() { - private lateinit var preferences: Preferences - private lateinit var userService: IUserService - private lateinit var emailLoginFormView: LinearLayout - private lateinit var emailView: EditText - private lateinit var passwordView: EditText - private lateinit var signInButton: Button - private lateinit var progressBar: CircleProgressBar +private val logger = Logger.getLogger(AuthCredentialsActivity::class.java) + +class AuthCredentialsActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_auth_credentials) + enableEdgeToEdge() val application = application as SyncloudApplication - preferences = application.preferences - userService = application.userServiceCached - emailLoginFormView = findViewById(R.id.email_login_form) as LinearLayout - emailView = findViewById(R.id.email) as EditText - passwordView = findViewById(R.id.password) as EditText - passwordView.setOnEditorActionListener(OnEditorActionListener { _, id, _ -> - if (id == R.id.login || id == EditorInfo.IME_NULL) { - attemptLogin() - return@OnEditorActionListener true - } - false - }) - signInButton = findViewById(R.id.sign_in_button) as Button - signInButton.setOnClickListener { attemptLogin() } - progressBar = findViewById(R.id.progress) as CircleProgressBar - progressBar.setColorSchemeResources(R.color.logo_blue, R.color.logo_green) - val intent = intent - setTitle(R.string.action_sign_in) - signInButton.setText(R.string.action_sign_in) - - val redirectEmail = preferences.redirectEmail - val redirectPassword = preferences.redirectPassword - if (redirectEmail != null && redirectPassword != null) { - emailView.setText(redirectEmail) - passwordView.setText(redirectPassword) - val checkExisting = intent.getBooleanExtra(AuthConstants.PARAM_CHECK_EXISTING, false) - if (checkExisting) { - AlertDialog.Builder(this@AuthCredentialsActivity) - .setTitle(getString(R.string.check_credentials)) - .setMessage(getString(R.string.sign_in_failed)) - .setPositiveButton(android.R.string.ok, null) - .show() + val checkExisting = intent.getBooleanExtra(AuthConstants.PARAM_CHECK_EXISTING, false) + setContent { + SyncloudTheme { + AuthCredentialsScreen( + preferences = application.preferences, + userService = application.userServiceCached, + checkExisting = checkExisting, + onSignedIn = { + startActivity(Intent(this, DevicesSavedActivity::class.java)) + finish() + }, + onSettings = { startActivity(Intent(this, SettingsActivity::class.java)) } + ) } } } - - private fun setLayoutEnabled(layout: LinearLayout?, enabled: Boolean) { - for (i in 0 until layout!!.childCount) { - val view = layout.getChildAt(i) - view.isEnabled = enabled - } - } - - private fun showProgress(show: Boolean) { - progressBar.visibility = if (show) View.VISIBLE else View.INVISIBLE - setLayoutEnabled(emailLoginFormView, !show) - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.main, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - if (id == R.id.action_settings) { - startActivity(Intent(this, SettingsActivity::class.java)) - } - return super.onOptionsItemSelected(item) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AuthCredentialsScreen( + preferences: Preferences, + userService: IUserService, + checkExisting: Boolean, + onSignedIn: () -> Unit, + onSettings: () -> Unit +) { + val scope = rememberCoroutineScope() + var email by remember { mutableStateOf(preferences.redirectEmail ?: "") } + var password by remember { mutableStateOf(preferences.redirectPassword ?: "") } + var emailError by remember { mutableStateOf(null) } + var passwordError by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var dialog by remember { + mutableStateOf( + if (checkExisting && preferences.redirectEmail != null) { + "Sign in with these credentials failed. Please check and correct credentials." + } else { + null + } + ) } - private fun isEmailValid(email: String): Boolean = email.contains("@") - private fun isPasswordValid(password: String): Boolean = password.length > 4 + val emailRequired = stringResource(R.string.error_field_required) + val emailInvalid = stringResource(R.string.error_invalid_email) + val passwordRequired = stringResource(R.string.error_field_required) + val passwordInvalid = stringResource(R.string.error_invalid_password) - private fun validate(): Boolean { - emailView.error = null - passwordView.error = null - val email = emailView.text.toString() - val password = passwordView.text.toString() - var hasErrors = false - var focusView: View? = null - if (TextUtils.isEmpty(password)) { - passwordView.error = getString(R.string.error_field_required) - focusView = passwordView - hasErrors = true - } else if (!isPasswordValid(password)) { - passwordView.error = getString(R.string.error_invalid_password) - focusView = passwordView - hasErrors = true - } - if (TextUtils.isEmpty(email)) { - emailView.error = getString(R.string.error_field_required) - focusView = emailView - hasErrors = true - } else if (!isEmailValid(email)) { - emailView.error = getString(R.string.error_invalid_email) - focusView = emailView - hasErrors = true + fun validate(): Boolean { + emailError = when { + email.isEmpty() -> emailRequired + !email.contains("@") -> emailInvalid + else -> null } - if (hasErrors) { - focusView!!.requestFocus() - return false + passwordError = when { + password.isEmpty() -> passwordRequired + password.length <= 4 -> passwordInvalid + else -> null } - return true + return emailError == null && passwordError == null } - private fun attemptLogin() { - if (!validate()) - return - - val email = emailView.text.toString() - val password = passwordView.text.toString() - showProgress(true) - CoroutineScope(Dispatchers.IO).launch { - try { - val user = doGetUser(email, password) - withContext(Dispatchers.Main) { - showProgress(false) + fun submit() { + if (!validate()) return + busy = true + scope.launch { + val outcome = withContext(Dispatchers.IO) { + runCatching { userService.getUser(email, password) } + } + busy = false + outcome + .onSuccess { user -> if (user != null) { preferences.setCredentials(email, password) - val intent = Intent(this@AuthCredentialsActivity, DevicesSavedActivity::class.java) - startActivity(intent) + onSignedIn() } else { - showErrorDialog("User not found") + dialog = "User not found" } } - } catch (e: Throwable) { - withContext(Dispatchers.Main) { - showProgress(false) - showError(e) + .onFailure { error -> + val messages = (error as? SyncloudResultException)?.result?.parameters_messages + if (messages != null) { + messages.forEach { pm -> + val text = pm.messages?.joinToString("\n") + if (pm.parameter == "email") emailError = text + if (pm.parameter == "password") passwordError = text + } + } else { + logger.error("auth error", error) + dialog = "Wrong password or user does not exist (${error.message})" + } } - } } } - private fun doGetUser(email: String, password: String): User? { - return userService.getUser(email, password) - } - - private fun showErrorDialog(message: String?) { - AlertDialog.Builder(this) - .setTitle("Failed") - .setMessage(message ?: "Unable to login") - .setPositiveButton(android.R.string.ok, null) - .show() - } - - private fun getControl(parameter: String?): EditText? { - if (parameter == "email") return emailView - return if (parameter == "password") passwordView else null - } - - private fun showError(error: Throwable) { - if (error is SyncloudResultException) { - if (error.result.parameters_messages != null) { - for (pm in error.result.parameters_messages?: listOf()) { - val control = getControl(pm.parameter) - if (control != null) { - val message = pm.messages?.joinToString("\n") - control.error = message - control.requestFocus() + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.action_sign_in)) }, + actions = { + TextButton( + onClick = onSettings, + modifier = Modifier.testTag("settings_action") + ) { + Text(stringResource(R.string.action_settings)) } } - return + ) + } + ) { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(24.dp) + .testTag("credentials_screen"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + OutlinedTextField( + value = email, + onValueChange = { email = it }, + enabled = !busy, + isError = emailError != null, + supportingText = { emailError?.let { Text(it) } }, + label = { Text(stringResource(R.string.prompt_email)) }, + modifier = Modifier + .fillMaxWidth() + .testTag("email_field") + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + enabled = !busy, + isError = passwordError != null, + supportingText = { passwordError?.let { Text(it) } }, + label = { Text(stringResource(R.string.prompt_password)) }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier + .fillMaxWidth() + .testTag("password_field") + ) + Spacer(Modifier.height(24.dp)) + if (busy) { + CircularProgressIndicator(modifier = Modifier.testTag("credentials_progress")) + } else { + Button( + onClick = { submit() }, + modifier = Modifier + .fillMaxWidth() + .testTag("submit_button") + ) { + Text(stringResource(R.string.action_sign_in)) + } } } - logger.error("auth error", error) - showErrorDialog("Wrong password or user does not exist (${error.message})") } - companion object { - private val logger = Logger.getLogger(AuthCredentialsActivity::class.java) + dialog?.let { message -> + AlertDialog( + onDismissRequest = { dialog = null }, + title = { Text(stringResource(R.string.check_credentials)) }, + text = { Text(message) }, + confirmButton = { + TextButton( + onClick = { dialog = null }, + modifier = Modifier.testTag("dialog_ok") + ) { + Text("OK") + } + } + ) } -} \ No newline at end of file +} diff --git a/syncloud/src/main/java/org/syncloud/android/ui/DevicesDiscoveryActivity.kt b/syncloud/src/main/java/org/syncloud/android/ui/DevicesDiscoveryActivity.kt index 4ab8ae7d..32e56f4e 100644 --- a/syncloud/src/main/java/org/syncloud/android/ui/DevicesDiscoveryActivity.kt +++ b/syncloud/src/main/java/org/syncloud/android/ui/DevicesDiscoveryActivity.kt @@ -1,28 +1,53 @@ package org.syncloud.android.ui +import android.content.Context import android.content.Intent import android.net.Uri import android.net.nsd.NsdManager import android.net.wifi.WifiManager import android.os.Bundle import android.provider.Settings -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.AdapterView.OnItemClickListener -import android.widget.ListView -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.appcompat.app.AppCompatActivity -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.google.android.material.floatingactionbutton.FloatingActionButton -import com.google.common.collect.Maps -import kotlinx.coroutines.CoroutineScope +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.apache.log4j.Logger -import org.syncloud.android.Preferences import org.syncloud.android.R import org.syncloud.android.SyncloudApplication import org.syncloud.android.core.common.WebService @@ -30,131 +55,157 @@ import org.syncloud.android.core.common.http.HttpClient import org.syncloud.android.core.platform.Internal import org.syncloud.android.core.platform.model.IdentifiedEndpoint import org.syncloud.android.discovery.DiscoveryManager -import org.syncloud.android.ui.adapters.DevicesDiscoveredAdapter -import org.syncloud.android.ui.dialog.WifiDialog +import org.syncloud.android.ui.theme.SyncloudTheme +const val DISCOVERY_TIMEOUT_SECONDS = 20 -class DevicesDiscoveryActivity : AppCompatActivity(), - WifiDialog.NoticeDialogListener { - private lateinit var preferences: Preferences +class DevicesDiscoveryActivity : ComponentActivity() { private lateinit var discoveryManager: DiscoveryManager - private lateinit var refreshBtn: FloatingActionButton - private lateinit var listAdapter: DevicesDiscoveredAdapter - private lateinit var swipeRefreshLayout: SwipeRefreshLayout - private lateinit var emptyView: View - private lateinit var resultsList: ListView - private lateinit var deviceToId: MutableMap - private lateinit var internal: Internal - private lateinit var application: SyncloudApplication - private lateinit var settingsLauncher: ActivityResultLauncher - private lateinit var wifiSettingsLauncher: ActivityResultLauncher - private val logger = Logger.getLogger(DevicesDiscoveryActivity::class.java.name) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_devices_discovery) - application = getApplication() as SyncloudApplication - preferences = application.preferences - internal = Internal(WebService(HttpClient())) - swipeRefreshLayout = findViewById(R.id.swipe_refresh_layout) as SwipeRefreshLayout - swipeRefreshLayout.setColorSchemeResources(R.color.logo_blue, R.color.logo_green) - swipeRefreshLayout.setOnRefreshListener { checkWiFiAndDiscover() } - emptyView = findViewById(android.R.id.empty) - resultsList = findViewById(R.id.devices_discovered) as ListView - refreshBtn = findViewById(R.id.discovery_refresh_btn) - refreshBtn.setOnClickListener { checkWiFiAndDiscover() } - listAdapter = DevicesDiscoveredAdapter(this) - resultsList.adapter = listAdapter - resultsList.onItemClickListener = OnItemClickListener { _, _, position, _ -> - val obj = resultsList.getItemAtPosition(position) - val ie = obj as IdentifiedEndpoint - open(ie) - } - deviceToId = Maps.newHashMap() + enableEdgeToEdge() + val application = application as SyncloudApplication discoveryManager = DiscoveryManager( - applicationContext.getSystemService(WIFI_SERVICE) as WifiManager, - applicationContext.getSystemService(NSD_SERVICE) as NsdManager + applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager, + applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager ) - swipeRefreshLayout.post { checkWiFiAndDiscover() } - - settingsLauncher = registerForActivityResult(StartActivityForResult()) { - finish() - } - wifiSettingsLauncher = registerForActivityResult(StartActivityForResult()) { - checkWiFiAndDiscover() - } - } - - private fun checkWiFiAndDiscover() { - listAdapter.clear() - if (application.isWifiConnected()) { - refreshBtn.visibility = View.GONE - swipeRefreshLayout.isRefreshing = true - emptyView.visibility = View.GONE - resultsList.emptyView = null - listAdapter.clear() - - CoroutineScope(Dispatchers.Main).launch { - discover() + setContent { + SyncloudTheme { + DevicesDiscoveryScreen( + discoveryManager = discoveryManager, + platform = Internal(WebService(HttpClient())), + isWifiConnected = { application.isWifiConnected() }, + onCancel = { finish() } + ) } - } else { - val dialog = WifiDialog("Discovery is only possible on Wi-Fi.") - dialog.show(supportFragmentManager, "discovery_wifi_dialog") } } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.main, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - if (id == R.id.action_settings) { - settingsLauncher.launch(Intent(this, SettingsActivity::class.java)) - } - return super.onOptionsItemSelected(item) - } - - private fun open(endpoint: IdentifiedEndpoint) { - val browserIntent = - Intent(Intent.ACTION_VIEW, Uri.parse("https://" + endpoint.device)) - startActivity(browserIntent) - } - override fun onDestroy() { super.onDestroy() - logger.info("leaving the screen") discoveryManager.cancel() } - - private suspend fun discover() { +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DevicesDiscoveryScreen( + discoveryManager: DiscoveryManager, + platform: Internal, + isWifiConnected: () -> Boolean, + onCancel: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val endpoints = remember { mutableStateListOf() } + var searching by remember { mutableStateOf(false) } + var finished by remember { mutableStateOf(false) } + var wifiPrompt by remember { mutableStateOf(false) } + + val wifiSettingsLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { } + + suspend fun discover() { + if (!isWifiConnected()) { + wifiPrompt = true + return + } + endpoints.clear() + finished = false + searching = true withContext(Dispatchers.IO) { - discoveryManager.run(20) { e -> added(e) } + discoveryManager.run(DISCOVERY_TIMEOUT_SECONDS) { device -> + val id = platform.getId(device) + if (id != null) { + withContext(Dispatchers.Main) { + if (endpoints.none { it.device == device }) { + endpoints.add(IdentifiedEndpoint(device, id)) + } + } + } + } } - emptyView.visibility = View.VISIBLE - resultsList.emptyView = emptyView - swipeRefreshLayout.isRefreshing = false - refreshBtn.visibility = View.VISIBLE - + searching = false + finished = true } - private suspend fun added(device: String) { - val id = internal.getId(device) - if (id != null) { - val ie = IdentifiedEndpoint(device, id) - withContext(Dispatchers.Main) { - deviceToId[device] = ie - listAdapter.add(ie) + LaunchedEffect(Unit) { discover() } + + Scaffold( + topBar = { TopAppBar(title = { Text(stringResource(R.string.title_activity_discovery)) }) }, + floatingActionButton = { + FloatingActionButton( + onClick = { scope.launch { discover() } }, + modifier = Modifier.testTag("discovery_refresh_button") + ) { + Icon( + painter = painterResource(R.drawable.ic_refresh_white_24dp), + contentDescription = stringResource(R.string.refresh_button) + ) + } + } + ) { contentPadding -> + PullToRefreshBox( + isRefreshing = searching, + onRefresh = { scope.launch { discover() } }, + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .testTag("devices_discovery_screen") + ) { + if (endpoints.isEmpty() && finished) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.no_devices_found), + textAlign = TextAlign.Center, + modifier = Modifier + .padding(32.dp) + .testTag("devices_discovery_empty") + ) + } + } + LazyColumn(Modifier.fillMaxSize()) { + items(endpoints) { endpoint -> + ListItem( + headlineContent = { Text(endpoint.id.title ?: endpoint.device) }, + supportingContent = { Text(endpoint.device) }, + modifier = Modifier + .clickable { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://${endpoint.device}")) + ) + } + .testTag("discovered_${endpoint.device}") + ) + HorizontalDivider() + } } } } - override fun onDialogPositiveClick() { - wifiSettingsLauncher.launch(Intent(Settings.ACTION_WIFI_SETTINGS)) - } - - override fun onDialogNegativeClick() { - finish() + if (wifiPrompt) { + AlertDialog( + onDismissRequest = { wifiPrompt = false }, + title = { Text("Wi-Fi Connection") }, + text = { Text("You are not connected to Wi-Fi network. Discovery is only possible on Wi-Fi.") }, + confirmButton = { + TextButton(onClick = { + wifiPrompt = false + wifiSettingsLauncher.launch(Intent(Settings.ACTION_WIFI_SETTINGS)) + }) { + Text("Wi-Fi Settings") + } + }, + dismissButton = { + TextButton(onClick = { + wifiPrompt = false + onCancel() + }) { + Text("Cancel") + } + } + ) } -} \ No newline at end of file +} diff --git a/syncloud/src/main/java/org/syncloud/android/ui/DevicesSavedActivity.kt b/syncloud/src/main/java/org/syncloud/android/ui/DevicesSavedActivity.kt index dc0dff62..150a31f3 100644 --- a/syncloud/src/main/java/org/syncloud/android/ui/DevicesSavedActivity.kt +++ b/syncloud/src/main/java/org/syncloud/android/ui/DevicesSavedActivity.kt @@ -3,17 +3,43 @@ package org.syncloud.android.ui import android.content.Intent import android.net.Uri import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.AdapterView -import android.widget.ListView -import androidx.activity.result.ActivityResultLauncher +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.google.android.material.floatingactionbutton.FloatingActionButton -import kotlinx.coroutines.CoroutineScope +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -21,88 +47,123 @@ import org.syncloud.android.Preferences import org.syncloud.android.R import org.syncloud.android.SyncloudApplication import org.syncloud.android.core.platform.model.DomainModel +import org.syncloud.android.core.redirect.IUserService import org.syncloud.android.core.redirect.model.toModels -import org.syncloud.android.ui.adapters.DevicesSavedAdapter +import org.syncloud.android.ui.theme.SyncloudTheme -class DevicesSavedActivity : AppCompatActivity() { - private lateinit var listview: ListView - private lateinit var adapter: DevicesSavedAdapter - private lateinit var application: SyncloudApplication - private lateinit var preferences: Preferences - private lateinit var swipeRefreshLayout: SwipeRefreshLayout - private lateinit var btnDiscovery: FloatingActionButton - private lateinit var emptyView: View - private lateinit var activityLauncher: ActivityResultLauncher +class DevicesSavedActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_devices_saved) - emptyView = findViewById(android.R.id.empty) - listview = findViewById(R.id.devices_saved) - listview.setOnItemClickListener { _, _, position, _ -> - val domain = listview.getItemAtPosition(position) as DomainModel - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(domain.dnsUrl()))) - } - btnDiscovery = findViewById(R.id.discovery_btn) - btnDiscovery.setOnClickListener { - activityLauncher.launch(Intent(this, DevicesDiscoveryActivity::class.java)) + enableEdgeToEdge() + val application = application as SyncloudApplication + setContent { + SyncloudTheme { + DevicesSavedScreen( + preferences = application.preferences, + userService = application.userServiceCached + ) + } } - adapter = DevicesSavedAdapter(this) - listview.adapter = adapter - application = getApplication() as SyncloudApplication - preferences = application.preferences - swipeRefreshLayout = findViewById(R.id.swipe_refresh_layout) - swipeRefreshLayout.setColorSchemeResources(R.color.logo_blue, R.color.logo_green) - swipeRefreshLayout.setOnRefreshListener { refreshDevices() } - swipeRefreshLayout.post { refreshDevices() } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DevicesSavedScreen( + preferences: Preferences, + userService: IUserService +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val domains = remember { mutableStateListOf() } + var refreshing by remember { mutableStateOf(false) } + var loaded by remember { mutableStateOf(false) } - activityLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - refreshDevices() + suspend fun refresh() { + val email = preferences.redirectEmail + val password = preferences.redirectPassword + if (email == null || password == null) return + refreshing = true + val loadedDomains = withContext(Dispatchers.IO) { + val user = runCatching { userService.getUser(email, password) }.getOrNull() + user?.domains?.toModels().orEmpty().sortedBy { it.name } } + domains.clear() + domains.addAll(loadedDomains) + refreshing = false + loaded = true } - private fun refreshDevices() { - val userService = application.userServiceCached - val redirectEmail = preferences.redirectEmail - val redirectPassword = preferences.redirectPassword - if (redirectEmail != null && redirectPassword != null) { - emptyView.visibility = View.GONE - listview.emptyView = null - adapter.clear() + val discoveryLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { scope.launch { refresh() } } - swipeRefreshLayout.isRefreshing = true - listview.isEnabled = false - btnDiscovery.visibility = View.GONE + LaunchedEffect(Unit) { refresh() } - CoroutineScope(Dispatchers.IO).launch { - val user = userService.getUser(redirectEmail, redirectPassword) - val domains = user?.domains?.toModels() ?: listOf() - val sortedDomains = domains.sortedWith { first, second -> - first.name.compareTo(second.name) - } - withContext(Dispatchers.Main) { - adapter.clear() - adapter.addAll(sortedDomains) - swipeRefreshLayout.isRefreshing = false - listview.isEnabled = true - btnDiscovery.visibility = View.VISIBLE - emptyView.visibility = View.VISIBLE - listview.emptyView = emptyView + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_devices)) }, + actions = { + TextButton( + onClick = { context.startActivity(Intent(context, SettingsActivity::class.java)) }, + modifier = Modifier.testTag("settings_action") + ) { + Text(stringResource(R.string.action_settings)) + } } + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { + discoveryLauncher.launch(Intent(context, DevicesDiscoveryActivity::class.java)) + }, + modifier = Modifier.testTag("discovery_button") + ) { + Icon( + painter = painterResource(R.drawable.ic_add_white_24dp), + contentDescription = stringResource(R.string.discovery_button) + ) } } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.main, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - if (id == R.id.action_settings) { - activityLauncher.launch(Intent(this, SettingsActivity::class.java)) + ) { contentPadding -> + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { scope.launch { refresh() } }, + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .testTag("devices_saved_screen") + ) { + if (domains.isEmpty() && loaded) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.no_devices_found), + textAlign = TextAlign.Center, + modifier = Modifier + .padding(32.dp) + .testTag("devices_saved_empty") + ) + } + } + LazyColumn(Modifier.fillMaxSize()) { + items(domains) { domain -> + ListItem( + headlineContent = { Text(domain.name) }, + supportingContent = { Text(domain.title) }, + modifier = Modifier + .clickable { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(domain.dnsUrl())) + ) + } + .testTag("device_${domain.name}") + ) + HorizontalDivider() + } + } } - return super.onOptionsItemSelected(item) } -} \ No newline at end of file +} diff --git a/syncloud/src/main/java/org/syncloud/android/ui/SettingsActivity.kt b/syncloud/src/main/java/org/syncloud/android/ui/SettingsActivity.kt index 7f3b83f3..af762554 100644 --- a/syncloud/src/main/java/org/syncloud/android/ui/SettingsActivity.kt +++ b/syncloud/src/main/java/org/syncloud/android/ui/SettingsActivity.kt @@ -1,14 +1,176 @@ package org.syncloud.android.ui +import android.content.Intent import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import org.syncloud.android.Preferences +import org.syncloud.android.SyncloudApplication +import org.syncloud.android.ui.theme.SyncloudTheme -class SettingsActivity : AppCompatActivity() { +class SettingsActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - supportFragmentManager.beginTransaction() - .replace(android.R.id.content, SettingsFragment()) - .commit() + enableEdgeToEdge() + val application = application as SyncloudApplication + setContent { + SyncloudTheme { + SettingsScreen( + preferences = application.preferences, + onSendReport = { application.reportError() }, + onSignedOut = { + val intent = Intent(this, AuthActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(intent) + } + ) + } + } } -} \ No newline at end of file +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + preferences: Preferences, + onSendReport: () -> Unit, + onSignedOut: () -> Unit +) { + var email by remember { mutableStateOf(preferences.redirectEmail) } + var mainDomain by remember { mutableStateOf(preferences.mainDomain) } + var pickingDomain by remember { mutableStateOf(false) } + + Scaffold( + topBar = { TopAppBar(title = { Text("Settings") }) } + ) { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .verticalScroll(rememberScrollState()) + .testTag("settings_screen") + ) { + SettingsCategory("ACCOUNT") + ListItem( + headlineContent = { Text("Email") }, + supportingContent = { Text(email ?: "Not specified yet") }, + modifier = Modifier.testTag("settings_email") + ) + ListItem( + headlineContent = { + Text( + "Sign out from Syncloud", + color = if (email == null) MaterialTheme.colorScheme.outline + else MaterialTheme.colorScheme.onSurface + ) + }, + supportingContent = { Text("Removes Syncloud account information") }, + modifier = Modifier + .clickable(enabled = email != null) { + preferences.setCredentials(null, null) + email = null + onSignedOut() + } + .testTag("settings_sign_out") + ) + + SettingsCategory("FEEDBACK") + ListItem( + headlineContent = { Text("Send log file") }, + supportingContent = { Text("Sends developers application log") }, + modifier = Modifier + .clickable { onSendReport() } + .testTag("settings_send_log") + ) + + SettingsCategory("ADVANCED") + ListItem( + headlineContent = { Text("Server") }, + supportingContent = { Text(mainDomain) }, + modifier = Modifier + .clickable { pickingDomain = true } + .testTag("settings_server") + ) + } + } + + if (pickingDomain) { + AlertDialog( + onDismissRequest = { pickingDomain = false }, + title = { Text("Server") }, + text = { + Column { + Preferences.MAIN_DOMAINS.forEach { domain -> + DomainOption( + selected = domain == mainDomain, + label = domain, + onSelect = { + preferences.setMainDomain(domain) + mainDomain = domain + pickingDomain = false + } + ) + } + } + }, + confirmButton = { + TextButton(onClick = { pickingDomain = false }) { Text("Cancel") } + } + ) + } +} + +@Composable +private fun DomainOption(selected: Boolean, label: String, onSelect: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected, onClick = onSelect) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = selected, onClick = onSelect) + Text(label) + } +} + +@Composable +private fun SettingsCategory(title: String) { + HorizontalDivider() + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) +} diff --git a/syncloud/src/main/java/org/syncloud/android/ui/SettingsFragment.kt b/syncloud/src/main/java/org/syncloud/android/ui/SettingsFragment.kt deleted file mode 100644 index 2b1288a0..00000000 --- a/syncloud/src/main/java/org/syncloud/android/ui/SettingsFragment.kt +++ /dev/null @@ -1,82 +0,0 @@ -package org.syncloud.android.ui - -import android.content.Intent -import android.content.SharedPreferences -import android.content.SharedPreferences.OnSharedPreferenceChangeListener -import android.os.Bundle -import androidx.preference.Preference -import androidx.preference.Preference.OnPreferenceClickListener -import androidx.preference.PreferenceFragmentCompat -import com.google.common.collect.Sets -import org.apache.log4j.Logger -import org.syncloud.android.* - -class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeListener { - private var removeAccountPref: Preference? = null - private var feedbackPref: Preference? = null - private lateinit var application: SyncloudApplication - private val summaryUpdatable: Set = Sets.newHashSet(PreferencesConstants.KEY_PREF_MAIN_DOMAIN) - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - application = activity?.application as SyncloudApplication - - addPreferencesFromResource(R.xml.preferences) - removeAccountPref = findPreference(PreferencesConstants.KEY_PREF_ACCOUNT_REMOVE) - removeAccountPref?.onPreferenceClickListener = OnPreferenceClickListener { - val preferences = preferenceScreen.sharedPreferences - val editor = preferences?.edit() - editor?.putString(PreferencesConstants.KEY_PREF_EMAIL, null) - editor?.putString(PreferencesConstants.KEY_PREF_PASSWORD, null) - editor?.apply() - updateSummary(preferences, PreferencesConstants.KEY_PREF_EMAIL) - val intent = Intent(this@SettingsFragment.activity, AuthActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivity(intent) - true - } - feedbackPref = findPreference(PreferencesConstants.KEY_PREF_FEEDBACK_SEND) - feedbackPref?.onPreferenceClickListener = OnPreferenceClickListener { - application.reportError() - true - } - val preferences = preferenceScreen.sharedPreferences - preferences?.registerOnSharedPreferenceChangeListener(this) - for (pref in summaryUpdatable) { - updateSummary(preferences, pref) - } - updateSummary(preferences, PreferencesConstants.KEY_PREF_EMAIL) - updateRemoveAccountPref(preferences) - } - - private fun updateRemoveAccountPref(sharedPreferences: SharedPreferences?) { - val email = sharedPreferences?.getString(PreferencesConstants.KEY_PREF_EMAIL, null) - removeAccountPref?.isEnabled = email != null - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - if (key == PreferencesConstants.KEY_PREF_EMAIL) { - updateRemoveAccountPref(sharedPreferences) - } else if (summaryUpdatable.contains(key)) { - updateSummary(sharedPreferences, key) - } - } - - private fun updateSummary(sharedPreferences: SharedPreferences?, key: String?) { - key ?: return - logger.debug("updating: $key") - val summary = getSummary(sharedPreferences, key) - logger.debug("summary: $summary") - val findPreference: Preference? = findPreference(key) - findPreference?.summary = summary - } - - private fun getSummary(sharedPreferences: SharedPreferences?, key: String?): String { - val summary = sharedPreferences?.getString(key, null) - if (summary != null) return summary - return if (key == PreferencesConstants.KEY_PREF_EMAIL) "Not specified yet" else "None" - } - - companion object { - private val logger = Logger.getLogger(SettingsFragment::class.java.name) - } -} \ No newline at end of file diff --git a/syncloud/src/main/java/org/syncloud/android/ui/adapters/DevicesDiscoveredAdapter.kt b/syncloud/src/main/java/org/syncloud/android/ui/adapters/DevicesDiscoveredAdapter.kt deleted file mode 100644 index 2c98764d..00000000 --- a/syncloud/src/main/java/org/syncloud/android/ui/adapters/DevicesDiscoveredAdapter.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.syncloud.android.ui.adapters - -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.TextView -import org.syncloud.android.R -import org.syncloud.android.core.platform.model.IdentifiedEndpoint -import org.syncloud.android.ui.DevicesDiscoveryActivity - -class DevicesDiscoveredAdapter(private val activity: DevicesDiscoveryActivity) : - ArrayAdapter(activity, R.layout.layout_device_item) { - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = convertView - ?: activity.layoutInflater.inflate(R.layout.layout_device_item, parent, false) - val txtBoldTitle = view.findViewById(R.id.txt_bold_title) as TextView - val txtAdditionalLine = view.findViewById(R.id.txt_additional_line) as TextView - val ie = getItem(position)!! - txtBoldTitle.text = ie.id.title - txtAdditionalLine.text = ie.device - return view - } -} \ No newline at end of file diff --git a/syncloud/src/main/java/org/syncloud/android/ui/adapters/DevicesSavedAdapter.kt b/syncloud/src/main/java/org/syncloud/android/ui/adapters/DevicesSavedAdapter.kt deleted file mode 100644 index c58f807f..00000000 --- a/syncloud/src/main/java/org/syncloud/android/ui/adapters/DevicesSavedAdapter.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.syncloud.android.ui.adapters - -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.TextView -import org.syncloud.android.R -import org.syncloud.android.core.platform.model.DomainModel -import org.syncloud.android.ui.DevicesSavedActivity - -class DevicesSavedAdapter(private val activity: DevicesSavedActivity) : - ArrayAdapter(activity, R.layout.layout_device_item) { - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = convertView - ?: activity.layoutInflater.inflate(R.layout.layout_device_item, parent, false) - val txtBoldTitle = view.findViewById(R.id.txt_bold_title) - val txtAdditionalLine = view.findViewById(R.id.txt_additional_line) - val domain = getItem(position) - val fullDomainName = domain?.name - txtBoldTitle.text = fullDomainName - txtAdditionalLine.text = domain?.title - return view - } -} \ No newline at end of file diff --git a/syncloud/src/main/java/org/syncloud/android/ui/dialog/ErrorDialog.kt b/syncloud/src/main/java/org/syncloud/android/ui/dialog/ErrorDialog.kt deleted file mode 100644 index 04bf711c..00000000 --- a/syncloud/src/main/java/org/syncloud/android/ui/dialog/ErrorDialog.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.syncloud.android.ui.dialog - -import android.app.Activity -import android.app.AlertDialog -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.widget.Button -import android.widget.TextView -import org.syncloud.android.R -import org.syncloud.android.SyncloudApplication - -class ErrorDialog(private val context: Activity, private val message: String) : AlertDialog(context) { - private val application: SyncloudApplication = context.application as SyncloudApplication - override fun onCreate(savedInstanceState: Bundle) { - val inflater = LayoutInflater.from(context) - val view = inflater.inflate(R.layout.dialog_error, null) - val viewMessage = view.findViewById(R.id.view_message) as TextView - viewMessage.text = message - val btnReport = view.findViewById(R.id.btn_report) as Button - btnReport.setOnClickListener { reportError() } - setView(view) - super.onCreate(savedInstanceState) - } - - fun reportError() = application.reportError() - - init { - setCancelable(true) - } -} \ No newline at end of file diff --git a/syncloud/src/main/java/org/syncloud/android/ui/dialog/WifiDialog.kt b/syncloud/src/main/java/org/syncloud/android/ui/dialog/WifiDialog.kt deleted file mode 100644 index d0f985d0..00000000 --- a/syncloud/src/main/java/org/syncloud/android/ui/dialog/WifiDialog.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.syncloud.android.ui.dialog - -import android.app.Activity -import android.app.AlertDialog -import android.app.Dialog -import android.content.Context -import android.os.Bundle -import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.DialogFragment - -class WifiDialog(val message: String) : DialogFragment() { - private lateinit var listener: NoticeDialogListener - - interface NoticeDialogListener { - fun onDialogPositiveClick() - fun onDialogNegativeClick() - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val context: Activity? = activity - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - context?.finish() - } - val builder = AlertDialog.Builder(context) - builder.setTitle("Wi-Fi Connection") - builder.setMessage("You are not connected to Wi-Fi network. $message") - .setCancelable(false) - .setPositiveButton("Wi-Fi Settings") { _, _ -> - listener.onDialogPositiveClick() - } - .setNegativeButton("Cancel") { _, _ -> - listener.onDialogNegativeClick() - } - return builder.create() - } - - override fun onAttach(context: Context) { - super.onAttach(context) - // Verify that the host activity implements the callback interface - try { - // Instantiate the NoticeDialogListener so we can send events to the host - listener = context as NoticeDialogListener - } catch (e: ClassCastException) { - // The activity doesn't implement the interface, throw exception - throw ClassCastException((context.toString() + - " must implement NoticeDialogListener")) - } - } - -} \ No newline at end of file diff --git a/syncloud/src/main/java/org/syncloud/android/ui/theme/Theme.kt b/syncloud/src/main/java/org/syncloud/android/ui/theme/Theme.kt new file mode 100644 index 00000000..60ea3af0 --- /dev/null +++ b/syncloud/src/main/java/org/syncloud/android/ui/theme/Theme.kt @@ -0,0 +1,32 @@ +package org.syncloud.android.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +val LogoBlue = Color(0xFF00B0F0) +val LogoGreen = Color(0xFF66BD45) + +private val LightColors = lightColorScheme( + primary = LogoBlue, + secondary = LogoGreen +) + +private val DarkColors = darkColorScheme( + primary = LogoBlue, + secondary = LogoGreen +) + +@Composable +fun SyncloudTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = if (darkTheme) DarkColors else LightColors, + content = content + ) +} diff --git a/syncloud/src/main/res/layout/activity_auth.xml b/syncloud/src/main/res/layout/activity_auth.xml deleted file mode 100644 index 220ae81e..00000000 --- a/syncloud/src/main/res/layout/activity_auth.xml +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - - - - -