From daf41f0a2442003f9db81e4ca38b15783aef31bb Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Mon, 31 Aug 2026 22:31:03 +0300 Subject: [PATCH 1/3] target API 36, modernise dependencies, build only on push and tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google Play requires new apps and updates to target API 36 from 31 Aug 2026, so no further release could be published while the app targeted 35. Existing installs and the listing were never at risk — the API 35 target already met the bar for staying visible to new users — but the next update of any kind needed this first. compileSdk was declared inside defaultConfig, where Groovy's owner-first closure resolution quietly applied it to the android extension instead. The effect was a build compiling against API 33 while targeting 35. It is now set explicitly on android. The applicationVariants block is gone. It existed only to rename the output APK, using the legacy variant API that AGP 9 removes; the replacement there is a PackageApplication doLast hook or an artifact transform, which is a lot of build machinery for a filename. Gradle now emits its default name and the collect step renames once, reading versionName straight out of build.gradle, so the pipeline publishes syncloud-.apk exactly as before. With nothing left depending on the old variant API, AGP moves to 9.3.2, Gradle to 9.7.1 and compileSdk to 37, which AGP 9 requires for the current dependency train. targetSdk stays at 36 because that is what Play requires; compiling against a newer API than you target is normal and does not opt the app into API 37 behaviour. That upgrade is what unpins the three dependencies previously held one release back. androidx.core goes to 1.19.0, okhttp to 5.5.0 and the compose BOM to 2026.08.00, all of which declare minCompileSdk 37 or require AGP 9.1. AGP 9 itself needs JDK 17, which the build image already provides, and Gradle 9.1 or newer, which is why the wrapper moves at the same time. multiDexEnabled is dropped: minSdk is 23 and multidex has been native since 21, so the flag did nothing and no multidex artifact was ever declared. Dependencies, removed: log4j 1.2.17 + de.mindpipe android-logging-log4j 1.0.3 Both unmaintained since 2015 and 2011. The appender only ever wrote to logcat, so an android.util.Log wrapper keeping the Logger.getLogger/info/error shape replaces them with no new dependency and a one-line change per call site. ACRA still captures logcat, so crash reports are unaffected. Tags are truncated to 23 chars, which is the platform limit. guava 27.0.1 Used for Maps.newHashMap, Lists.newArrayList and Sets.newHashSet in three places. Kotlin stdlib covers all three. commons-lang3 and androidx.legacy:legacy-support-v4 No references anywhere in the source. materialloadingprogressbar 0.5.8 Abandoned in 2016 and JCenter-era. Replaced by Material's CircularProgressIndicator, which the app can use because it already depends on Material for the FAB. Themes move to the MaterialComponents Bridge variants. The app was mixing Material components into plain AppCompat themes, which only worked because Material was pinned at 1.3.0; the Bridge themes keep the current AppCompat appearance while supplying the attributes newer Material components require at inflation. okhttp 5 makes Response.body non-null, so the empty-body branch in WebService.convert is unreachable and would fail the build under allWarningsAsErrors. Removed. android.util.Log is stubbed in JVM unit tests, so testOptions.unitTests.returnDefaultValues is enabled — WebServiceTest exercises an error path that now logs. enableJetifier is dropped along with the last support-library dependency. nonTransitiveRClass and nonFinalResIds opt-outs are dropped as they only matter for multi-module builds. Version bumped to 26000/26.00 following the existing year-based scheme, since the point of the change is to make a release possible. Separately, the pipeline had no trigger block, so Drone fell back to its default of building every event and a pull request produced a second identical build alongside the branch push. Restricting to push covers branches and master. Tag has to stay in the list because the github-release step is gated on `when: event: tag`, and a trigger without it would stop tag builds entirely and silently end APK publishing — which is why the bitwarden pipeline's `event: ['push']` cannot be copied here verbatim. androidx.core and okhttp are held one version below latest. core-ktx 1.19.0 and okhttp-android 5.5.0 both declare minCompileSdk 37, and core 1.19.0 additionally requires AGP 9.1.0, so with AGP 8.13.2 and compileSdk 36 they fail dependency resolution outright. 1.18.0 and 5.4.0 are the newest releases whose aar-metadata still accepts compileSdk 36. They move together with AGP: whenever AGP 9 lands here along with the applicationVariants rewrite, both can go to latest. Kotlin is pinned to 2.2.21 rather than latest. On 2.4.10 the R8/D8 bundled with AGP 8.13.2 could not parse the Kotlin metadata it produces, emitting "an error occurred when parsing kotlin metadata" for most of kotlin-stdlib, and compileKotlin failed without printing any source diagnostic at all. 2.2.x is contemporary with this AGP. jackson-module-kotlin is pinned to 2.19.4, not latest. From 2.20.0 the module references java.lang.invoke.MethodHandle, which D8 cannot desugar below API 26 and which fails dexing at minSdk 23 with "increase the minSdkVersion to 26 or above". Core library desugaring does not help — the attribute was set and the transform still failed. 2.19.4 is the newest release whose classes carry no MethodHandle reference. Raising minSdk to 26 would have fixed it too, but that drops Android 6.0 and 7.x devices and is not part of this change. androidx.swiperefreshlayout is now an explicit dependency. It was reaching the app transitively through androidx.legacy:legacy-support-v4, so removing that umbrella as unused broke both device screens and both of their layouts. Depending on the one artifact actually used is the right shape regardless. Logger gains a debug level, which SettingsFragment uses twice. The mDNS resolver moves off NsdManager.resolveService and NsdServiceInfo.host, both deprecated in API 34. Compiling against 33 hid that; at compileSdk 36 with allWarningsAsErrors they are errors. API 34 and above now use registerServiceInfoCallback with hostAddresses, and the old path is kept behind a version check for everything down to minSdk 23. The callback is unregistered once a service resolves, since unlike ResolveListener it otherwise keeps delivering updates. Resolver's queue handling was racy: checkQueue was synchronized but endResolving wrote isBusy outside the lock, and a plain LinkedList was reached from callback threads. It now uses an AtomicBoolean guard and a ConcurrentLinkedQueue. The manifest declared an activity org.acra.CrashReportDialog, a class that has not existed since ACRA 4; the real one is org.acra.dialog.CrashReportDialog and ACRA's own manifest already contributes it. The entry was dead, so DialogTheme never reached the crash dialog. Removed rather than repointed, as ACRA's default theme is what has actually been in use all along. The UI is rewritten in Jetpack Compose with Material 3. That removes every XML layout, both ArrayAdapters, both dialog classes, the PreferenceFragmentCompat settings screen, the options menu, and all findViewById calls, and with them the appcompat, material views, swiperefreshlayout and preference-ktx dependencies. Activities extend ComponentActivity and go edge to edge. SwipeRefreshLayout becomes material3 PullToRefreshBox, CircleProgressBar becomes CircularProgressIndicator, ListView plus ArrayAdapter becomes LazyColumn with ListItem, and the preferences XML becomes a plain Compose screen since it holds four entries. Settings previously reached SharedPreferences through androidx.preference. With that dependency gone the app opens the same file directly, "_preferences", which is the name PreferenceManager used. Anything else would strand existing users' saved credentials and server choice in a file nothing reads. Composables carry testTag identifiers throughout so UI tests select by tag rather than by text or position. Screenshot tests run under Robolectric with Roborazzi, so they render the real composables on the JVM inside the existing gradle test step. An Android emulator was the alternative and was rejected: it needs QEMU with KVM on the build host, and its user mode NAT does not carry multicast, so it could not have exercised mDNS discovery either. Each screen is captured to build/outputs/roborazzi and collected into artifact/screenshots alongside the APK and the test report, which the existing scp step already ships to the artifact server. The collect step runs on failure too, so a failed run still uploads whatever was rendered. Discovery against a real device is deliberately not attempted here. The platform image the other pipelines run as a service does not appear to carry avahi, and multicast would not reach an emulator in any case, so the discovery screen is covered through its Wi-Fi absent state rather than a real announcement. The compose BOM is 2026.06.01 rather than latest. 2026.08.00 pins the compose 1.12.0 train, whose ui-android and foundation-android artifacts declare minCompileSdk 37 and require AGP 9.1.0. 2026.06.01 pins 1.11.4, which needs only compileSdk 35 and AGP 8.6.0, and still carries material3 1.4.0. The constraint lives on the platform specific -android artifacts, not on the ui and foundation aliases, which is where it is easy to miss. DomainTest moves from junit.framework.Assert to org.junit.Assert. The JUnit 3 class is deprecated in 4.13, which allWarningsAsErrors turns into a build failure; the old junit 4.10 predated the deprecation. The screenshot rule uses the v2 createComposeRule, as the original is deprecated in this compose version. v2 drives effects on a StandardTestDispatcher rather than an unconfined one, so composables that load in a LaunchedEffect settle on waitForIdle instead of during composition. Screenshot tests run against a plain Application. Robolectric would otherwise instantiate SyncloudApplication, whose attachBaseContext initialises ACRA and whose onCreate builds the redirect service stack; none of that is wanted when the composables under test take their dependencies as parameters. Unit test tasks log failures with full stack traces, since a bare "RuntimeException at RoboMonitoringInstrumentation" in the CI output says nothing about which of the two it was. CI runs testDebugUnitTest rather than test. The compose test rule launches ComponentActivity through ActivityScenario, and that activity is contributed by androidx.compose.ui:ui-test-manifest, which belongs on debugImplementation so it never reaches a shipped APK. Running the whole test task also ran testReleaseUnitTest, where that manifest entry does not exist and every screenshot test failed with "unable to resolve activity for Intent ... ComponentActivity". --- .drone.jsonnet | 30 +- build.gradle | 8 +- gradle.properties | 21 -- gradle/wrapper/gradle-wrapper.properties | 2 +- syncloud/build.gradle | 79 ++-- syncloud/proguard-android.txt | 2 - syncloud/src/main/AndroidManifest.xml | 12 +- .../org/syncloud/android/ConfigureLog4J.kt | 17 - .../main/java/org/syncloud/android/Logger.kt | 32 ++ .../java/org/syncloud/android/Preferences.kt | 15 +- .../syncloud/android/SyncloudApplication.kt | 8 +- .../android/core/common/WebService.kt | 31 +- .../android/core/common/http/HttpClient.kt | 2 +- .../android/core/platform/Internal.kt | 2 +- .../android/core/redirect/RedirectService.kt | 2 +- .../android/core/redirect/UserStorage.kt | 2 +- .../android/discovery/DiscoveryManager.kt | 2 +- .../android/discovery/MulticastLock.kt | 2 +- .../discovery/nsd/EventToDeviceConverter.kt | 5 +- .../android/discovery/nsd/NsdDiscovery.kt | 2 +- .../android/discovery/nsd/Resolver.kt | 128 +++++-- .../org/syncloud/android/ui/AuthActivity.kt | 214 ++++++----- .../android/ui/AuthCredentialsActivity.kt | 348 ++++++++++-------- .../android/ui/DevicesDiscoveryActivity.kt | 285 ++++++++------ .../android/ui/DevicesSavedActivity.kt | 217 +++++++---- .../syncloud/android/ui/SettingsActivity.kt | 174 ++++++++- .../syncloud/android/ui/SettingsFragment.kt | 82 ----- .../ui/adapters/DevicesDiscoveredAdapter.kt | 24 -- .../ui/adapters/DevicesSavedAdapter.kt | 25 -- .../syncloud/android/ui/dialog/ErrorDialog.kt | 31 -- .../syncloud/android/ui/dialog/WifiDialog.kt | 50 --- .../org/syncloud/android/ui/theme/Theme.kt | 32 ++ .../src/main/res/layout/activity_auth.xml | 112 ------ .../res/layout/activity_auth_credentials.xml | 78 ---- .../res/layout/activity_devices_discovery.xml | 72 ---- .../res/layout/activity_devices_saved.xml | 68 ---- syncloud/src/main/res/layout/dialog_error.xml | 24 -- .../main/res/layout/layout_device_item.xml | 32 -- syncloud/src/main/res/menu/main.xml | 13 - .../src/main/res/values/attrs_app_view.xml | 8 - syncloud/src/main/res/values/strings.xml | 3 +- syncloud/src/main/res/values/styles.xml | 19 +- syncloud/src/main/res/xml/preferences.xml | 43 --- .../core/redirect/RedirectServiceTest.kt | 5 - .../android/core/redirect/model/DomainTest.kt | 13 +- .../org/syncloud/android/ui/ScreenshotTest.kt | 159 ++++++++ 46 files changed, 1224 insertions(+), 1311 deletions(-) delete mode 100644 syncloud/src/main/java/org/syncloud/android/ConfigureLog4J.kt create mode 100644 syncloud/src/main/java/org/syncloud/android/Logger.kt delete mode 100644 syncloud/src/main/java/org/syncloud/android/ui/SettingsFragment.kt delete mode 100644 syncloud/src/main/java/org/syncloud/android/ui/adapters/DevicesDiscoveredAdapter.kt delete mode 100644 syncloud/src/main/java/org/syncloud/android/ui/adapters/DevicesSavedAdapter.kt delete mode 100644 syncloud/src/main/java/org/syncloud/android/ui/dialog/ErrorDialog.kt delete mode 100644 syncloud/src/main/java/org/syncloud/android/ui/dialog/WifiDialog.kt create mode 100644 syncloud/src/main/java/org/syncloud/android/ui/theme/Theme.kt delete mode 100644 syncloud/src/main/res/layout/activity_auth.xml delete mode 100644 syncloud/src/main/res/layout/activity_auth_credentials.xml delete mode 100644 syncloud/src/main/res/layout/activity_devices_discovery.xml delete mode 100644 syncloud/src/main/res/layout/activity_devices_saved.xml delete mode 100644 syncloud/src/main/res/layout/dialog_error.xml delete mode 100644 syncloud/src/main/res/layout/layout_device_item.xml delete mode 100644 syncloud/src/main/res/menu/main.xml delete mode 100644 syncloud/src/main/res/values/attrs_app_view.xml delete mode 100644 syncloud/src/main/res/xml/preferences.xml create mode 100644 syncloud/src/test/java/org/syncloud/android/ui/ScreenshotTest.kt diff --git a/.drone.jsonnet b/.drone.jsonnet index c0b9ce68..7828d08a 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -28,10 +28,25 @@ local build() = { }, }, commands: [ - "sdkmanager 'build-tools;35.0.0'", - "./gradlew clean test assemble" + "sdkmanager 'build-tools;37.0.0' 'platforms;android-37'", + "./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 cp $apk artifact/syncloud-$VERSION.apk; done", + "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 +54,7 @@ local build() = { api_key: { from_secret: "github_token" }, - files: "syncloud/build/outputs/apk/release/*", + files: "artifact/*.apk", overwrite: true, file_exists: "overwrite" }, @@ -61,14 +76,17 @@ 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" ] + } }; [ 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..b5fcfde3 100644 --- a/syncloud/build.gradle +++ b/syncloud/build.gradle @@ -1,10 +1,14 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' android { namespace "org.syncloud.android" + compileSdk 37 + compileOptions { sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlin { @@ -15,18 +19,14 @@ android { } defaultConfig { - compileSdk 33 minSdkVersion 23 - targetSdkVersion 35 - versionCode 24000 - versionName "24.00" - multiDexEnabled true + targetSdkVersion 36 + versionCode 26000 + versionName "26.00" } - applicationVariants.configureEach { variant -> - variant.outputs.configureEach { - outputFileName = "syncloud-${variant.versionName}.apk" - } + buildFeatures { + compose true } signingConfigs { @@ -49,31 +49,52 @@ android { signingConfig signingConfigs.release } } - packagingOptions { + + 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 + } + } + } + } + + 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'] } } } 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' } 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/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 @@ - - - - - - - - - - - - - - - -