diff --git a/.gitignore b/.gitignore index 65b22189..e8c656ed 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ # Java class files *.class +# Kotlin session files +.kotlin/ + # generated files bin/ build/ @@ -33,6 +36,8 @@ eclipse-java-style.xml .classpath .project .settings +settings.json +.vscode # Gradle wrapper gradle/wrapper/gradle/ diff --git a/README.md b/README.md index 4459d2e1..8f6dd37a 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,63 @@ The project is translated on [Hosted Weblate](https://hosted.weblate.org/project ## Dev -Language codes are usually mapped correctly by Weblate itself. The supported -set is different between [Google Play][1] and Android apps. The latter can be -deduced by what the [Android core framework itself supports][2]. New languages +Language codes are usually mapped correctly by Weblate itself. The supported +set is different between [Google Play][1] and Android apps. The latter can be +deduced by what the [Android core framework itself supports][2]. New languages need to be added in the repository first, then appear automatically in Weblate. [1]: https://support.google.com/googleplay/android-developer/table/4419860 [2]: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/res/res/ +# Dev Setup + +To develop using Android Studio, install Android Studio and the Android SDK/NDK, then set persistent environment variables. Use the IDE SDK Manager for GUI installs or the command line for scripted installs. + +1. Install Android Studio + + - Download and install from https://developer.android.com/studio. + - On first run open Tools → SDK Manager: + - Under SDK Platform, install an Android SDK Platform (e.g. Android 36). + - Under SDK Tools, install + - Android SDK Build-Tools + - Android SDK Platform-Tools + - Android SDK Command-line Tools. + - “NDK (Side by side)” and a specific NDK version required by the project (or use the same version referenced in the Dockerfile). + - (Optional) Install CMake if prompted. + +2. Install or update SDK/NDK from the command line (optional) + + - Use the SDK command-line tools (sdkmanager). Example: + ``` + sdkmanager "platform-tools" "build-tools;33.0.2" "platforms;android-33" "ndk;25.2.9519653" "cmdline-tools;latest" + ``` + - Replace versions with the ones your build requires. + +3. Set persistent environment variables + + - Windows (use System → Advanced system settings → Environment Variables or run as Administrator): + - Recommended: open “Edit the system environment variables” UI and add/modify variables. + - `ANDROID_HOME=C:\Users\User\AppData\Local\Android\Sdk` + - `NDK_VERSION=29.0.14206865` + +4. Install Go + + - `winget install GoLang.Go` + +5. Verify installation + ``` + $env:ANDROID_HOME + go version + java -version # must be Java 21 + ``` + +Notes + +- Use the exact SDK/NDK versions required by the project (see docker/Dockerfile or project docs). +- Prefer setting ANDROID_SDK_ROOT (or ANDROID_HOME as a legacy alias) and ANDROID_NDK_HOME so build tools and Gradle can find the SDK/NDK. +- On Windows prefer the Environment Variables UI to avoid PATH truncation issues with setx. +- After setting these variables, Android Studio and command-line builds (./gradlew assembleDebug, ./gradlew buildNative) should find the SDK/NDK automatically. + # Building These dependencies and instructions are necessary for building from the command @@ -46,30 +95,34 @@ follow them separately. ## Dependencies 1. Android SDK and NDK - 1. Download SDK command line tools from https://developer.android.com/studio#command-line-tools-only. - 2. Unpack the downloaded archive to an empty folder. This path is going - to become your `ANDROID_HOME` folder. - 3. Inside the unpacked `cmdline-tools` folder, create yet another folder - called `latest`, then move everything else inside it, so that the final - folder hierarchy looks as follows. - ``` - cmdline-tools/latest/bin - cmdline-tools/latest/lib - cmdline-tools/latest/source.properties - cmdline-tools/latest/NOTICE.txt - ``` - 4. Navigate inside `cmdline-tools/latest/bin`, then execute - ``` - ./sdkmanager "platform-tools" "build-tools;" "platforms;android-" "extras;android;m2repository" "ndk;" - ``` - The required tools and NDK will be downloaded automatically. - - **NOTE:** You should check [Dockerfile](docker/Dockerfile) for the - specific version numbers to insert in the command above. + + 1. Download SDK command line tools from https://developer.android.com/studio#command-line-tools-only. + 2. Unpack the downloaded archive to an empty folder. This path is going + to become your `ANDROID_HOME` folder. + 3. Inside the unpacked `cmdline-tools` folder, create yet another folder + called `latest`, then move everything else inside it, so that the final + folder hierarchy looks as follows. + ``` + cmdline-tools/latest/bin + cmdline-tools/latest/lib + cmdline-tools/latest/source.properties + cmdline-tools/latest/NOTICE.txt + ``` + 4. Navigate inside `cmdline-tools/latest/bin`, then execute + + ``` + ./sdkmanager "platform-tools" "build-tools;" "platforms;android-" "extras;android;m2repository" "ndk;" + ``` + + The required tools and NDK will be downloaded automatically. + + **NOTE:** You should check [Dockerfile](docker/Dockerfile) for the + specific version numbers to insert in the command above. + 2. Go (see https://docs.syncthing.net/dev/building#prerequisites for the required version) -3. Java version 11 (if not present in ``$PATH``, you might need to set - ``$JAVA_HOME`` accordingly) +3. Java version 11 (if not present in `$PATH`, you might need to set + `$JAVA_HOME` accordingly) 4. Python version 3 ## Build instructions diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ceb571e6..2593a351 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,39 +1,43 @@ -import org.gradle.configurationcache.extensions.capitalized - +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") id("com.github.ben-manes.versions") - id("com.github.triplet.play") version "3.7.0" + id("com.github.triplet.play") version "3.12.2" + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.kapt") } dependencies { implementation("eu.chainfire:libsuperuser:1.1.1") - implementation("com.google.android.material:material:1.8.0") - implementation("com.google.code.gson:gson:2.10.1") + implementation("com.google.android.material:material:1.13.0") + implementation("com.google.code.gson:gson:2.13.2") implementation("org.mindrot:jbcrypt:0.4") - implementation("com.google.guava:guava:32.1.3-android") + implementation("com.google.guava:guava:33.5.0-android") implementation("com.annimon:stream:1.2.2") implementation("com.android.volley:volley:1.2.1") - implementation("commons-io:commons-io:2.11.0") + implementation("commons-io:commons-io:2.21.0") + implementation("androidx.documentfile:documentfile:1.1.0") + implementation("androidx.preference:preference-ktx:1.2.1") implementation("com.journeyapps:zxing-android-embedded:4.3.0") { isTransitive = false } - implementation("com.google.zxing:core:3.4.1") + implementation("com.google.zxing:core:3.5.4") - implementation("androidx.constraintlayout:constraintlayout:2.0.4") - implementation("com.google.dagger:dagger:2.49") - annotationProcessor("com.google.dagger:dagger-compiler:2.49") - androidTestImplementation("androidx.test:rules:1.4.0") - androidTestImplementation("androidx.annotation:annotation:1.2.0") + implementation("androidx.constraintlayout:constraintlayout:2.2.1") + implementation("com.google.dagger:dagger:2.57.2") + implementation("androidx.core:core-ktx:1.17.0") + kapt("com.google.dagger:dagger-compiler:2.57.2") + // Align annotation version with other AndroidX constraints to avoid resolution + // conflicts during lint and test configuration resolution. + androidTestImplementation("androidx.annotation:annotation:1.8.1") } android { val ndkVersionShared = rootProject.extra.get("ndkVersionShared") // Changes to these values need to be reflected in `../docker/Dockerfile` - compileSdk = 34 - buildToolsVersion = "34.0.0" - ndkVersion = "${ndkVersionShared}" + compileSdk = 36 + ndkVersion = "$ndkVersionShared" buildFeatures { dataBinding = true @@ -42,10 +46,10 @@ android { defaultConfig { applicationId = "com.nutomic.syncthingandroid" - minSdk = 21 - targetSdk = 33 - versionCode = 4395 - versionName = "1.28.1" + minSdk = 35 + targetSdk = 35 + versionCode = 4396 + versionName = "2.0.9" testApplicationId = "com.nutomic.syncthingandroid.test" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -64,7 +68,6 @@ android { applicationIdSuffix = ".debug" isDebuggable = true isJniDebuggable = true - isRenderscriptDebuggable = true isMinifyEnabled = false } getByName("release") { @@ -75,17 +78,28 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } // Otherwise libsyncthing.so doesn't appear where it should in installs // based on app bundles, and thus nothing works. - packagingOptions { + packaging { jniLibs { useLegacyPackaging = true } } + namespace = "com.nutomic.syncthingandroid" +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } +} + +kapt { + correctErrorTypes = true } play { @@ -113,8 +127,10 @@ tasks.register("deleteUnsupportedPlayTranslations") { } project.afterEvaluate { - android.buildTypes.forEach { - tasks.named("merge${it.name.capitalized()}JniLibFolders") { + android.buildTypes.forEach { buildType -> + tasks.named("merge${ + buildType.name.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + }JniLibFolders") { dependsOn(":syncthing:buildNative") } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bf79283b..01f83fac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,9 @@ + @@ -20,17 +23,24 @@ + - + - + + + + + @@ -59,7 +68,6 @@ - + diff --git a/app/src/main/java/com/nutomic/syncthingandroid/DaggerComponent.java b/app/src/main/java/com/nutomic/syncthingandroid/DaggerComponent.java deleted file mode 100644 index 0561e808..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/DaggerComponent.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.nutomic.syncthingandroid; - -import com.nutomic.syncthingandroid.activities.FirstStartActivity; -import com.nutomic.syncthingandroid.activities.FolderPickerActivity; -import com.nutomic.syncthingandroid.activities.MainActivity; -import com.nutomic.syncthingandroid.activities.SettingsActivity; -import com.nutomic.syncthingandroid.activities.ShareActivity; -import com.nutomic.syncthingandroid.activities.ThemedAppCompatActivity; -import com.nutomic.syncthingandroid.receiver.AppConfigReceiver; -import com.nutomic.syncthingandroid.service.RunConditionMonitor; -import com.nutomic.syncthingandroid.service.EventProcessor; -import com.nutomic.syncthingandroid.service.NotificationHandler; -import com.nutomic.syncthingandroid.service.RestApi; -import com.nutomic.syncthingandroid.service.SyncthingRunnable; -import com.nutomic.syncthingandroid.service.SyncthingService; -import com.nutomic.syncthingandroid.util.Languages; - -import javax.inject.Singleton; - -import dagger.Component; - -@Singleton -@Component(modules = {SyncthingModule.class}) -public interface DaggerComponent { - - void inject(SyncthingApp app); - void inject(MainActivity activity); - void inject(FirstStartActivity activity); - void inject(FolderPickerActivity activity); - void inject(Languages languages); - void inject(SyncthingService service); - void inject(RunConditionMonitor runConditionMonitor); - void inject(EventProcessor eventProcessor); - void inject(SyncthingRunnable syncthingRunnable); - void inject(NotificationHandler notificationHandler); - void inject(AppConfigReceiver appConfigReceiver); - void inject(RestApi restApi); - void inject(SettingsActivity.SettingsFragment fragment); - void inject(ShareActivity activity); - void inject(ThemedAppCompatActivity activity); -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/DaggerComponent.kt b/app/src/main/java/com/nutomic/syncthingandroid/DaggerComponent.kt new file mode 100644 index 00000000..61362e10 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/DaggerComponent.kt @@ -0,0 +1,38 @@ +package com.nutomic.syncthingandroid + +import com.nutomic.syncthingandroid.activities.FirstStartActivity +import com.nutomic.syncthingandroid.activities.FolderPickerActivity +import com.nutomic.syncthingandroid.activities.MainActivity +import com.nutomic.syncthingandroid.activities.SettingsActivity.SettingsFragment +import com.nutomic.syncthingandroid.activities.ShareActivity +import com.nutomic.syncthingandroid.activities.ThemedAppCompatActivity +import com.nutomic.syncthingandroid.receiver.AppConfigReceiver +import com.nutomic.syncthingandroid.service.EventProcessor +import com.nutomic.syncthingandroid.service.NotificationHandler +import com.nutomic.syncthingandroid.service.RestApi +import com.nutomic.syncthingandroid.service.RunConditionMonitor +import com.nutomic.syncthingandroid.service.SyncthingRunnable +import com.nutomic.syncthingandroid.service.SyncthingService +import com.nutomic.syncthingandroid.util.Languages +import dagger.Component +import javax.inject.Singleton + +@Singleton +@Component(modules = [SyncthingModule::class]) +interface DaggerComponent { + fun inject(app: SyncthingApp?) + fun inject(activity: MainActivity?) + fun inject(activity: FirstStartActivity?) + fun inject(activity: FolderPickerActivity?) + fun inject(languages: Languages?) + fun inject(service: SyncthingService?) + fun inject(runConditionMonitor: RunConditionMonitor?) + fun inject(eventProcessor: EventProcessor?) + fun inject(syncthingRunnable: SyncthingRunnable?) + fun inject(notificationHandler: NotificationHandler?) + fun inject(appConfigReceiver: AppConfigReceiver?) + fun inject(restApi: RestApi?) + fun inject(fragment: SettingsFragment?) + fun inject(activity: ShareActivity?) + fun inject(activity: ThemedAppCompatActivity?) +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/SyncthingApp.java b/app/src/main/java/com/nutomic/syncthingandroid/SyncthingApp.java deleted file mode 100644 index 4f3104b9..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/SyncthingApp.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.nutomic.syncthingandroid; - -import android.app.Application; -import android.os.StrictMode; - -import com.google.android.material.color.DynamicColors; -import com.nutomic.syncthingandroid.util.Languages; - -import javax.inject.Inject; - -public class SyncthingApp extends Application { - - @Inject DaggerComponent mComponent; - - @Override - public void onCreate() { - DynamicColors.applyToActivitiesIfAvailable(this); - - super.onCreate(); - - DaggerDaggerComponent.builder() - .syncthingModule(new SyncthingModule(this)) - .build() - .inject(this); - - new Languages(this).setLanguage(this); - - // The main point here is to use a VM policy without - // `detectFileUriExposure`, as that leads to exceptions when e.g. - // opening the ignores file. And it's enabled by default. - // We might want to disable `detectAll` and `penaltyLog` on release (non-RC) builds too. - StrictMode.VmPolicy policy = new StrictMode.VmPolicy.Builder() - .detectAll() - .penaltyLog() - .build(); - StrictMode.setVmPolicy(policy); - } - - public DaggerComponent component() { - return mComponent; - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/SyncthingApp.kt b/app/src/main/java/com/nutomic/syncthingandroid/SyncthingApp.kt new file mode 100644 index 00000000..4302991f --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/SyncthingApp.kt @@ -0,0 +1,41 @@ +package com.nutomic.syncthingandroid + +import android.app.Application +import android.os.StrictMode +import android.os.StrictMode.VmPolicy +import com.google.android.material.color.DynamicColors +import com.nutomic.syncthingandroid.util.Languages +import javax.inject.Inject + +class SyncthingApp : Application() { + @JvmField + @Inject + var mComponent: DaggerComponent? = null + + override fun onCreate() { + DynamicColors.applyToActivitiesIfAvailable(this) + + super.onCreate() + + DaggerDaggerComponent.builder() + .syncthingModule(SyncthingModule(this)) + .build() + .inject(this) + + Languages(this).setLanguage(this) + + // The main point here is to use a VM policy without + // `detectFileUriExposure`, as that leads to exceptions when e.g. + // opening the ignores file. And it's enabled by default. + // We might want to disable `detectAll` and `penaltyLog` on release (non-RC) builds too. + val policy = VmPolicy.Builder() + .detectAll() + .penaltyLog() + .build() + StrictMode.setVmPolicy(policy) + } + + fun component(): DaggerComponent? { + return mComponent + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/SyncthingModule.java b/app/src/main/java/com/nutomic/syncthingandroid/SyncthingModule.java deleted file mode 100644 index c06c8a01..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/SyncthingModule.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.nutomic.syncthingandroid; - -import android.content.SharedPreferences; -import android.preference.PreferenceManager; - -import com.nutomic.syncthingandroid.service.NotificationHandler; - -import javax.inject.Singleton; - -import dagger.Module; -import dagger.Provides; - -@Module -public class SyncthingModule { - - private final SyncthingApp mApp; - - public SyncthingModule(SyncthingApp app) { - mApp = app; - } - - @Provides - @Singleton - public SharedPreferences getPreferences() { - return PreferenceManager.getDefaultSharedPreferences(mApp); - } - - @Provides - @Singleton - public NotificationHandler getNotificationHandler() { - return new NotificationHandler(mApp); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/SyncthingModule.kt b/app/src/main/java/com/nutomic/syncthingandroid/SyncthingModule.kt new file mode 100644 index 00000000..249b6580 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/SyncthingModule.kt @@ -0,0 +1,21 @@ +package com.nutomic.syncthingandroid + +import android.content.SharedPreferences +import androidx.preference.PreferenceManager +import com.nutomic.syncthingandroid.service.NotificationHandler +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +class SyncthingModule(private val mApp: SyncthingApp) { + @get:Singleton + @get:Provides + val preferences: SharedPreferences + get() = PreferenceManager.getDefaultSharedPreferences(mApp) + + @get:Singleton + @get:Provides + val notificationHandler: NotificationHandler + get() = NotificationHandler(mApp) +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java deleted file mode 100644 index 2aabb36c..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java +++ /dev/null @@ -1,479 +0,0 @@ -package com.nutomic.syncthingandroid.activities; - -import static android.text.TextUtils.isEmpty; -import static android.view.View.GONE; -import static android.view.View.VISIBLE; -import static android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN; -import static com.nutomic.syncthingandroid.service.SyncthingService.State.ACTIVE; -import static com.nutomic.syncthingandroid.util.Compression.METADATA; - -import android.app.Activity; -import android.app.Dialog; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.util.Log; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.CompoundButton; -import android.widget.Toast; - -import androidx.core.content.ContextCompat; - -import com.google.gson.Gson; -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.databinding.ActivityDeviceBinding; -import com.nutomic.syncthingandroid.model.Connections; -import com.nutomic.syncthingandroid.model.Device; -import com.nutomic.syncthingandroid.service.SyncthingService; -import com.nutomic.syncthingandroid.util.Compression; -import com.nutomic.syncthingandroid.util.TextWatcherAdapter; -import com.nutomic.syncthingandroid.util.Util; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -/** - * Shows device details and allows changing them. - */ -public class DeviceActivity extends SyncthingActivity implements View.OnClickListener { - - public static final String EXTRA_NOTIFICATION_ID = - "com.nutomic.syncthingandroid.activities.DeviceActivity.NOTIFICATION_ID"; - public static final String EXTRA_DEVICE_ID = - "com.nutomic.syncthingandroid.activities.DeviceActivity.DEVICE_ID"; - public static final String EXTRA_DEVICE_NAME = - "com.nutomic.syncthingandroid.activities.DeviceActivity.DEVICE_NAME"; - public static final String EXTRA_IS_CREATE = - "com.nutomic.syncthingandroid.activities.DeviceActivity.IS_CREATE"; - - private static final String TAG = "DeviceSettingsFragment"; - private static final String IS_SHOWING_DISCARD_DIALOG = "DISCARD_FOLDER_DIALOG_STATE"; - private static final String IS_SHOWING_COMPRESSION_DIALOG = "COMPRESSION_FOLDER_DIALOG_STATE"; - private static final String IS_SHOWING_DELETE_DIALOG = "DELETE_FOLDER_DIALOG_STATE"; - private static final int QR_SCAN_REQUEST_CODE = 777; - - private static final List DYNAMIC_ADDRESS = Collections.singletonList("dynamic"); - - private Device mDevice; - - private ActivityDeviceBinding binding; - - private boolean mIsCreateMode; - - private boolean mDeviceNeedsToUpdate; - - private Dialog mDeleteDialog; - private Dialog mDiscardDialog; - private Dialog mCompressionDialog; - - private final DialogInterface.OnClickListener mCompressionEntrySelectedListener = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - dialog.dismiss(); - Compression compression = Compression.fromIndex(which); - // Don't pop the restart dialog unless the value is actually different. - if (compression != Compression.fromValue(DeviceActivity.this, mDevice.compression)) { - mDeviceNeedsToUpdate = true; - - mDevice.compression = compression.getValue(DeviceActivity.this); - binding.compressionValue.setText(compression.getTitle(DeviceActivity.this)); - } - } - }; - - private final TextWatcher mIdTextWatcher = new TextWatcherAdapter() { - @Override - public void afterTextChanged(Editable s) { - if (!s.toString().equals(mDevice.deviceID)) { - mDeviceNeedsToUpdate = true; - mDevice.deviceID = s.toString(); - } - } - }; - - private final TextWatcher mNameTextWatcher = new TextWatcherAdapter() { - @Override - public void afterTextChanged(Editable s) { - if (!s.toString().equals(mDevice.name)) { - mDeviceNeedsToUpdate = true; - mDevice.name = s.toString(); - } - } - }; - - private final TextWatcher mAddressesTextWatcher = new TextWatcherAdapter() { - @Override - public void afterTextChanged(Editable s) { - if (!s.toString().equals(displayableAddresses())) { - mDeviceNeedsToUpdate = true; - mDevice.addresses = persistableAddresses(s); - } - } - }; - - private final CompoundButton.OnCheckedChangeListener mCheckedListener = - new CompoundButton.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton view, boolean isChecked) { - switch (view.getId()) { - case R.id.introducer: - mDevice.introducer = isChecked; - mDeviceNeedsToUpdate = true; - break; - case R.id.devicePause: - mDevice.paused = isChecked; - mDeviceNeedsToUpdate = true; - break; - } - } - }; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - binding = ActivityDeviceBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - mIsCreateMode = getIntent().getBooleanExtra(EXTRA_IS_CREATE, false); - registerOnServiceConnectedListener(this::onServiceConnected); - setTitle(mIsCreateMode ? R.string.add_device : R.string.edit_device); - - binding.qrButton.setOnClickListener(this); - binding.compressionContainer.setOnClickListener(this); - - if (savedInstanceState != null){ - if (mDevice == null) { - mDevice = new Gson().fromJson(savedInstanceState.getString("device"), Device.class); - } - restoreDialogStates(savedInstanceState); - } - if (mIsCreateMode) { - if (mDevice == null) { - initDevice(); - } - } - else { - prepareEditMode(); - } - } - - private void restoreDialogStates(Bundle savedInstanceState) { - if (savedInstanceState.getBoolean(IS_SHOWING_COMPRESSION_DIALOG)){ - showCompressionDialog(); - } - - if (savedInstanceState.getBoolean(IS_SHOWING_DELETE_DIALOG)){ - showDeleteDialog(); - } - - if (mIsCreateMode){ - if (savedInstanceState.getBoolean(IS_SHOWING_DISCARD_DIALOG)){ - showDiscardDialog(); - } - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - SyncthingService syncthingService = getService(); - if (syncthingService != null) { - syncthingService.getNotificationHandler().cancelConsentNotification(getIntent().getIntExtra(EXTRA_NOTIFICATION_ID, 0)); - syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange); - } - binding.id.removeTextChangedListener(mIdTextWatcher); - binding.name.removeTextChangedListener(mNameTextWatcher); - binding.addresses.removeTextChangedListener(mAddressesTextWatcher); - } - - @Override - public void onPause() { - super.onPause(); - - // We don't want to update every time a TextView's character changes, - // so we hold off until the view stops being visible to the user. - if (mDeviceNeedsToUpdate) { - updateDevice(); - } - } - - /** - * Save current settings in case we are in create mode and they aren't yet stored in the config. - */ - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putString("device", new Gson().toJson(mDevice)); - if (mIsCreateMode){ - outState.putBoolean(IS_SHOWING_DISCARD_DIALOG, mDiscardDialog != null && mDiscardDialog.isShowing()); - Util.dismissDialogSafe(mDiscardDialog, this); - } - - outState.putBoolean(IS_SHOWING_COMPRESSION_DIALOG, mCompressionDialog != null && mCompressionDialog.isShowing()); - Util.dismissDialogSafe(mCompressionDialog, this); - - outState.putBoolean(IS_SHOWING_DELETE_DIALOG, mDeleteDialog != null && mDeleteDialog.isShowing()); - Util.dismissDialogSafe(mDeleteDialog, this); - } - - private void onServiceConnected() { - Log.v(TAG, "onServiceConnected"); - SyncthingService syncthingService = (SyncthingService) getService(); - syncthingService.getNotificationHandler().cancelConsentNotification(getIntent().getIntExtra(EXTRA_NOTIFICATION_ID, 0)); - syncthingService.registerOnServiceStateChangeListener(this::onServiceStateChange); - } - - /** - * Sets version and current address of the device. - *

- * NOTE: This is only called once on startup, should be called more often to properly display - * version/address changes. - */ - private void onReceiveConnections(Connections connections) { - boolean viewsExist = binding.syncthingVersion != null && binding.currentAddress != null; - if (viewsExist && connections.connections.containsKey(mDevice.deviceID)) { - binding.currentAddress.setVisibility(VISIBLE); - binding.syncthingVersion.setVisibility(VISIBLE); - binding.currentAddress.setText(connections.connections.get(mDevice.deviceID).address); - binding.syncthingVersion.setText(connections.connections.get(mDevice.deviceID).clientVersion); - } - } - - private void onServiceStateChange(SyncthingService.State currentState) { - if (currentState != ACTIVE) { - finish(); - return; - } - - if (!mIsCreateMode) { - List devices = getApi().getDevices(false); - mDevice = null; - for (Device device : devices) { - if (device.deviceID.equals(getIntent().getStringExtra(EXTRA_DEVICE_ID))) { - mDevice = device; - break; - } - } - if (mDevice == null) { - Log.w(TAG, "Device not found in API update, maybe it was deleted?"); - finish(); - return; - } - } - - getApi().getConnections(this::onReceiveConnections); - - updateViewsAndSetListeners(); - } - - private void updateViewsAndSetListeners() { - binding.id.removeTextChangedListener(mIdTextWatcher); - binding.name.removeTextChangedListener(mNameTextWatcher); - binding.addresses.removeTextChangedListener(mAddressesTextWatcher); - binding.introducer.setOnCheckedChangeListener(null); - binding.devicePause.setOnCheckedChangeListener(null); - - // Update views - binding.id.setText(mDevice.deviceID); - binding.name.setText(mDevice.name); - binding.addresses.setText(displayableAddresses()); - binding.compressionValue.setText(Compression.fromValue(this, mDevice.compression).getTitle(this)); - binding.introducer.setChecked(mDevice.introducer); - binding.devicePause.setChecked(mDevice.paused); - - // Keep state updated - binding.id.addTextChangedListener(mIdTextWatcher); - binding.name.addTextChangedListener(mNameTextWatcher); - binding.addresses.addTextChangedListener(mAddressesTextWatcher); - binding.introducer.setOnCheckedChangeListener(mCheckedListener); - binding.devicePause.setOnCheckedChangeListener(mCheckedListener); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.device_settings, menu); - return super.onCreateOptionsMenu(menu); - } - - @Override - public boolean onPrepareOptionsMenu(Menu menu) { - menu.findItem(R.id.create).setVisible(mIsCreateMode); - menu.findItem(R.id.share_device_id).setVisible(!mIsCreateMode); - menu.findItem(R.id.remove).setVisible(!mIsCreateMode); - return true; - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.create: - if (isEmpty(mDevice.deviceID)) { - Toast.makeText(this, R.string.device_id_required, Toast.LENGTH_LONG) - .show(); - return true; - } - getApi().addDevice(mDevice, error -> - Toast.makeText(this, error, Toast.LENGTH_LONG).show()); - finish(); - return true; - case R.id.share_device_id: - shareDeviceId(this, mDevice.deviceID); - return true; - case R.id.remove: - showDeleteDialog(); - return true; - case android.R.id.home: - onBackPressed(); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - - private void showDeleteDialog(){ - mDeleteDialog = createDeleteDialog(); - mDeleteDialog.show(); - } - - private Dialog createDeleteDialog(){ - return Util.getAlertDialogBuilder(this) - .setMessage(R.string.remove_device_confirm) - .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> { - getApi().removeDevice(mDevice.deviceID); - finish(); - }) - .setNegativeButton(android.R.string.no, null) - .create(); - } - - /** - * Receives value of scanned QR code and sets it as device ID. - */ - @Override - public void onActivityResult(int requestCode, int resultCode, Intent intent) { - super.onActivityResult(requestCode, resultCode, intent); - - if (requestCode == QR_SCAN_REQUEST_CODE) { - if (resultCode == Activity.RESULT_OK) { - String scanResult = intent.getStringExtra(QRScannerActivity.QR_RESULT_ARG); - if (scanResult != null) { - mDevice.deviceID = scanResult; - binding.id.setText(mDevice.deviceID); - } - } - } - } - - private void initDevice() { - mDevice = new Device(); - mDevice.name = getIntent().getStringExtra(EXTRA_DEVICE_NAME); - mDevice.deviceID = getIntent().getStringExtra(EXTRA_DEVICE_ID); - mDevice.addresses = DYNAMIC_ADDRESS; - mDevice.compression = METADATA.getValue(this); - mDevice.introducer = false; - mDevice.paused = false; - } - - private void prepareEditMode() { - getWindow().setSoftInputMode(SOFT_INPUT_STATE_ALWAYS_HIDDEN); - - Drawable dr = ContextCompat.getDrawable(this, R.drawable.ic_content_copy_24dp); - binding.id.setCompoundDrawablesWithIntrinsicBounds(null, null, dr, null); - binding.id.setEnabled(false); - binding.qrButton.setVisibility(GONE); - - binding.idContainer.setOnClickListener(this); - } - - /** - * Sends the updated device info if in edit mode. - */ - private void updateDevice() { - if (!mIsCreateMode && mDeviceNeedsToUpdate && mDevice != null) { - getApi().editDevice(mDevice); - } - } - - private List persistableAddresses(CharSequence userInput) { - return isEmpty(userInput) - ? DYNAMIC_ADDRESS - : Arrays.asList(userInput.toString().split(" ")); - } - - private String displayableAddresses() { - List list = DYNAMIC_ADDRESS.equals(mDevice.addresses) - ? DYNAMIC_ADDRESS - : mDevice.addresses; - return TextUtils.join(" ", list); - } - - @Override - public void onClick(View v) { - if (v.equals(binding.compressionContainer)) { - showCompressionDialog(); - } else if (v.equals(binding.qrButton)){ - Intent qrIntent = QRScannerActivity.intent(this); - startActivityForResult(qrIntent, QR_SCAN_REQUEST_CODE); - } else if (v.equals(binding.idContainer)) { - Util.copyDeviceId(this, mDevice.deviceID); - } - } - - private void showCompressionDialog(){ - mCompressionDialog = createCompressionDialog(); - mCompressionDialog.show(); - } - - private Dialog createCompressionDialog(){ - return Util.getAlertDialogBuilder(this) - .setTitle(R.string.compression) - .setSingleChoiceItems(R.array.compress_entries, - Compression.fromValue(this, mDevice.compression).getIndex(), - mCompressionEntrySelectedListener) - .create(); - } - - /** - * Shares the given device ID via Intent. Must be called from an Activity. - */ - private void shareDeviceId(Context context, String id) { - Intent shareIntent = new Intent(); - shareIntent.setAction(Intent.ACTION_SEND); - shareIntent.setType("text/plain"); - shareIntent.putExtra(android.content.Intent.EXTRA_TEXT, id); - context.startActivity(Intent.createChooser( - shareIntent, context.getString(R.string.send_device_id_to))); - } - - @Override - public void onBackPressed() { - if (mIsCreateMode) { - showDiscardDialog(); - } - else { - super.onBackPressed(); - } - } - - private void showDiscardDialog(){ - mDiscardDialog = createDiscardDialog(); - mDiscardDialog.show(); - } - - private Dialog createDiscardDialog() { - return Util.getAlertDialogBuilder(this) - .setMessage(R.string.dialog_discard_changes) - .setPositiveButton(android.R.string.ok, (dialog, which) -> finish()) - .setNegativeButton(android.R.string.cancel, null) - .create(); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.kt b/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.kt new file mode 100644 index 00000000..5894d3ce --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.kt @@ -0,0 +1,539 @@ +package com.nutomic.syncthingandroid.activities + +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import android.text.Editable +import android.text.TextUtils +import android.text.TextWatcher +import android.util.Log +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.CompoundButton +import android.widget.Toast +import androidx.core.content.ContextCompat +import com.google.gson.Gson +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.databinding.ActivityDeviceBinding +import com.nutomic.syncthingandroid.model.Connections +import com.nutomic.syncthingandroid.model.Device +import com.nutomic.syncthingandroid.service.SyncthingService +import com.nutomic.syncthingandroid.service.SyncthingService.OnServiceStateChangeListener +import com.nutomic.syncthingandroid.util.Compression +import com.nutomic.syncthingandroid.util.Compression.Companion.fromIndex +import com.nutomic.syncthingandroid.util.Compression.Companion.fromValue +import com.nutomic.syncthingandroid.util.TextWatcherAdapter +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import com.nutomic.syncthingandroid.util.Util.copyDeviceId +import com.nutomic.syncthingandroid.util.Util.dismissDialogSafe +import com.nutomic.syncthingandroid.util.Util.getAlertDialogBuilder + +/** + * Shows device details and allows changing them. + */ +class DeviceActivity : SyncthingActivity(), View.OnClickListener { + private var mDevice: Device? = null + + private var binding: ActivityDeviceBinding? = null + + private lateinit var qrLauncher: ActivityResultLauncher + + private var mIsCreateMode = false + + private var mDeviceNeedsToUpdate = false + + private var mDeleteDialog: Dialog? = null + private var mDiscardDialog: Dialog? = null + private var mCompressionDialog: Dialog? = null + + private val mCompressionEntrySelectedListener: DialogInterface.OnClickListener = + DialogInterface.OnClickListener { dialog, which -> + dialog.dismiss() + val compression = fromIndex(which) + // Don't pop the restart dialog unless the value is actually different. + if (compression != fromValue(this@DeviceActivity, mDevice!!.compression)) { + mDeviceNeedsToUpdate = true + + mDevice!!.compression = compression.getValue(this@DeviceActivity) + binding!!.compressionValue.text = compression.getTitle(this@DeviceActivity) + } + } + + private val mIdTextWatcher: TextWatcher = object : TextWatcherAdapter() { + override fun afterTextChanged(s: Editable?) { + if (s.toString() != mDevice!!.deviceID) { + mDeviceNeedsToUpdate = true + mDevice!!.deviceID = s.toString() + } + } + } + + private val mNameTextWatcher: TextWatcher = object : TextWatcherAdapter() { + override fun afterTextChanged(s: Editable?) { + if (s.toString() != mDevice!!.name) { + mDeviceNeedsToUpdate = true + mDevice!!.name = s.toString() + } + } + } + + private val mAddressesTextWatcher: TextWatcher = object : TextWatcherAdapter() { + override fun afterTextChanged(s: Editable?) { + if (s.toString() != displayableAddresses()) { + mDeviceNeedsToUpdate = true + mDevice!!.addresses = persistableAddresses(s) + } + } + } + + private val mCheckedListener: CompoundButton.OnCheckedChangeListener = + CompoundButton.OnCheckedChangeListener { view, isChecked -> + when (view.id) { + R.id.introducer -> { + mDevice!!.introducer = isChecked + mDeviceNeedsToUpdate = true + } + + R.id.devicePause -> { + mDevice!!.paused = isChecked + mDeviceNeedsToUpdate = true + } + } + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityDeviceBinding.inflate(layoutInflater) + val view = binding!!.root + setContentView(view) + mIsCreateMode = intent.getBooleanExtra(EXTRA_IS_CREATE, false) + // Register Activity Result launcher for QR scanner (replaces deprecated startActivityForResult) + qrLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == RESULT_OK) { + val scanResult = result.data?.getStringExtra(QRScannerActivity.QR_RESULT_ARG) + if (scanResult != null) { + mDevice!!.deviceID = scanResult + binding!!.id.setText(mDevice!!.deviceID) + } + } + } + registerOnServiceConnectedListener { this.onServiceConnected() } + setTitle(if (mIsCreateMode) R.string.add_device else R.string.edit_device) + + binding!!.qrButton.setOnClickListener(this) + binding!!.compressionContainer.setOnClickListener(this) + + if (savedInstanceState != null) { + if (mDevice == null) { + mDevice = Gson().fromJson( + savedInstanceState.getString("device"), + Device::class.java + ) + } + restoreDialogStates(savedInstanceState) + } + if (mIsCreateMode) { + if (mDevice == null) { + initDevice() + } + } else { + prepareEditMode() + } + + onBackPressedDispatcher.addCallback(this, object : androidx.activity.OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (mIsCreateMode) { + showDiscardDialog() + } else { + finish() + } + } + }) + + // handle edge-to-edge layout by preventing the top and bottom bars from overlapping the app content + ViewCompat.setOnApplyWindowInsetsListener(view) { v, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) + v.updateLayoutParams( + block = { + leftMargin = insets.left + topMargin = insets.top + rightMargin = insets.right + bottomMargin = insets.bottom + } + ) + // Return CONSUMED if you don't want the window insets to keep passing + // down to descendant views. + WindowInsetsCompat.CONSUMED + } + } + + private fun restoreDialogStates(savedInstanceState: Bundle) { + if (savedInstanceState.getBoolean(IS_SHOWING_COMPRESSION_DIALOG)) { + showCompressionDialog() + } + + if (savedInstanceState.getBoolean(IS_SHOWING_DELETE_DIALOG)) { + showDeleteDialog() + } + + if (mIsCreateMode) { + if (savedInstanceState.getBoolean(IS_SHOWING_DISCARD_DIALOG)) { + showDiscardDialog() + } + } + } + + public override fun onDestroy() { + super.onDestroy() + val syncthingService = service + if (syncthingService != null) { + syncthingService.getNotificationHandler()!! + .cancelConsentNotification(intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)) + val unregisterOnServiceStateChangeListener = OnServiceStateChangeListener { currentState: SyncthingService.State? -> + this.onServiceStateChange( + currentState + ) + } + syncthingService.unregisterOnServiceStateChangeListener(unregisterOnServiceStateChangeListener) + } + binding!!.id.removeTextChangedListener(mIdTextWatcher) + binding!!.name.removeTextChangedListener(mNameTextWatcher) + binding!!.addresses.removeTextChangedListener(mAddressesTextWatcher) + } + + public override fun onPause() { + super.onPause() + + // We don't want to update every time a TextView's character changes, + // so we hold off until the view stops being visible to the user. + if (mDeviceNeedsToUpdate) { + updateDevice() + } + } + + /** + * Save current settings in case we are in create mode and they aren't yet + * stored in the config. + */ + public override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString("device", Gson().toJson(mDevice)) + if (mIsCreateMode) { + outState.putBoolean( + IS_SHOWING_DISCARD_DIALOG, + mDiscardDialog != null && mDiscardDialog!!.isShowing + ) + dismissDialogSafe(mDiscardDialog, this) + } + + outState.putBoolean( + IS_SHOWING_COMPRESSION_DIALOG, + mCompressionDialog != null && mCompressionDialog!!.isShowing + ) + dismissDialogSafe(mCompressionDialog, this) + + outState.putBoolean( + IS_SHOWING_DELETE_DIALOG, + mDeleteDialog != null && mDeleteDialog!!.isShowing + ) + dismissDialogSafe(mDeleteDialog, this) + } + + private fun onServiceConnected() { + Log.v(TAG, "onServiceConnected") + val syncthingService = service + syncthingService!!.getNotificationHandler()!! + .cancelConsentNotification(intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)) + syncthingService.registerOnServiceStateChangeListener { currentState: SyncthingService.State? -> + this.onServiceStateChange( + currentState + ) + } + } + + /** + * Sets version and current address of the device. + * + * + * NOTE: This is only called once on startup, should be called more often to + * properly display + * version/address changes. + */ + private fun onReceiveConnections(connections: Connections) { + val viewsExist = true + if (viewsExist && connections.connections!!.containsKey(mDevice!!.deviceID)) { + binding!!.currentAddress.visibility = View.VISIBLE + binding!!.syncthingVersion.visibility = View.VISIBLE + binding!!.currentAddress.text = connections.connections!![mDevice!!.deviceID]!!.address + binding!!.syncthingVersion.text = connections.connections!![mDevice!!.deviceID]!!.clientVersion + } + } + + private fun onServiceStateChange(currentState: SyncthingService.State?) { + if (currentState != SyncthingService.State.ACTIVE) { + finish() + return + } + + if (!mIsCreateMode) { + val devices = api?.getDevices(false) + mDevice = null + for (device in devices!!) { + if (device.deviceID == intent.getStringExtra(EXTRA_DEVICE_ID)) { + mDevice = device + break + } + } + if (mDevice == null) { + Log.w(TAG, "Device not found in API update, maybe it was deleted?") + finish() + return + } + } + + api?.getConnections { connections: Connections? -> + this.onReceiveConnections( + connections!! + ) + } + + updateViewsAndSetListeners() + } + + private fun updateViewsAndSetListeners() { + binding!!.id.removeTextChangedListener(mIdTextWatcher) + binding!!.name.removeTextChangedListener(mNameTextWatcher) + binding!!.addresses.removeTextChangedListener(mAddressesTextWatcher) + binding!!.introducer.setOnCheckedChangeListener(null) + binding!!.devicePause.setOnCheckedChangeListener(null) + + // Update views + binding!!.id.setText(mDevice!!.deviceID) + binding!!.name.setText(mDevice!!.name) + binding!!.addresses.setText(displayableAddresses()) + binding!!.compressionValue.text = fromValue(this, mDevice!!.compression).getTitle(this) + binding!!.introducer.setChecked(mDevice!!.introducer) + binding!!.devicePause.setChecked(mDevice!!.paused) + + // Keep state updated + binding!!.id.addTextChangedListener(mIdTextWatcher) + binding!!.name.addTextChangedListener(mNameTextWatcher) + binding!!.addresses.addTextChangedListener(mAddressesTextWatcher) + binding!!.introducer.setOnCheckedChangeListener(mCheckedListener) + binding!!.devicePause.setOnCheckedChangeListener(mCheckedListener) + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.device_settings, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + menu.findItem(R.id.create).isVisible = mIsCreateMode + menu.findItem(R.id.share_device_id).isVisible = !mIsCreateMode + menu.findItem(R.id.remove).isVisible = !mIsCreateMode + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.create -> { + if (TextUtils.isEmpty(mDevice!!.deviceID)) { + Toast.makeText(this, R.string.device_id_required, Toast.LENGTH_LONG) + .show() + return true + } + api?.addDevice( + mDevice!! + ) { error: String? -> + Toast.makeText( + this, + error, + Toast.LENGTH_LONG + ).show() + } + finish() + return true + } + + R.id.share_device_id -> { + shareDeviceId(this, mDevice!!.deviceID) + return true + } + + R.id.remove -> { + showDeleteDialog() + return true + } + + android.R.id.home -> { + onBackPressedDispatcher.onBackPressed() + return true + } + + else -> return super.onOptionsItemSelected(item) + } + } + + private fun showDeleteDialog() { + mDeleteDialog = createDeleteDialog() + mDeleteDialog!!.show() + } + + private fun createDeleteDialog(): Dialog { + return getAlertDialogBuilder(this) + .setMessage(R.string.remove_device_confirm) + .setPositiveButton( + android.R.string.ok + ) { _: DialogInterface?, _: Int -> + api?.removeDevice(mDevice!!.deviceID) + finish() + } + .setNegativeButton(android.R.string.cancel, null) + .create() + } + + /** + * Receives value of scanned QR code and sets it as device ID. + */ + // QR results are handled via Activity Result API registered in onCreate + + private fun initDevice() { + mDevice = Device() + // TODO - is extra device name ever provided? + mDevice!!.name = intent.getStringExtra(EXTRA_DEVICE_NAME) ?: "" + mDevice!!.deviceID = intent.getStringExtra(EXTRA_DEVICE_ID) + mDevice!!.addresses = DYNAMIC_ADDRESS + mDevice!!.compression = Compression.METADATA.getValue(this) + mDevice!!.introducer = false + mDevice!!.paused = false + } + + private fun prepareEditMode() { + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) + + val dr = ContextCompat.getDrawable(this, R.drawable.ic_content_copy_24dp) + binding!!.id.setCompoundDrawablesWithIntrinsicBounds(null, null, dr, null) + binding!!.id.setEnabled(false) + binding!!.qrButton.setVisibility(View.GONE) + + binding!!.idContainer.setOnClickListener(this) + } + + /** + * Sends the updated device info if in edit mode. + */ + private fun updateDevice() { + if (!mIsCreateMode && mDeviceNeedsToUpdate && mDevice != null) { + api?.editDevice(mDevice!!) + } + } + + private fun persistableAddresses(userInput: CharSequence?): MutableList? { + return (if (TextUtils.isEmpty(userInput)) + DYNAMIC_ADDRESS + else + listOf( + *userInput.toString().split(" ".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray())) as MutableList? + } + + private fun displayableAddresses(): String? { + val list = if (DYNAMIC_ADDRESS == mDevice!!.addresses) + DYNAMIC_ADDRESS + else + mDevice!!.addresses + return TextUtils.join(" ", list!!) + } + + override fun onClick(v: View) { + when (v) { + binding!!.compressionContainer -> { + showCompressionDialog() + } + binding!!.qrButton -> { + val qrIntent: Intent = QRScannerActivity.intent(this) + qrLauncher.launch(qrIntent) + } + binding!!.idContainer -> { + copyDeviceId(this, mDevice!!.deviceID) + } + } + } + + private fun showCompressionDialog() { + mCompressionDialog = createCompressionDialog() + mCompressionDialog!!.show() + } + + private fun createCompressionDialog(): Dialog { + return getAlertDialogBuilder(this) + .setTitle(R.string.compression) + .setSingleChoiceItems( + R.array.compress_entries, + fromValue(this, mDevice!!.compression).index, + mCompressionEntrySelectedListener + ) + .create() + } + + /** + * Shares the given device ID via Intent. Must be called from an Activity. + */ + private fun shareDeviceId(context: Context, id: String?) { + val shareIntent = Intent() + shareIntent.setAction(Intent.ACTION_SEND) + shareIntent.setType("text/plain") + shareIntent.putExtra(Intent.EXTRA_TEXT, id) + context.startActivity( + Intent.createChooser( + shareIntent, context.getString(R.string.send_device_id_to) + ) + ) + } + + private fun showDiscardDialog() { + mDiscardDialog = createDiscardDialog() + mDiscardDialog!!.show() + } + + private fun createDiscardDialog(): Dialog { + return getAlertDialogBuilder(this) + .setMessage(R.string.dialog_discard_changes) + .setPositiveButton( + android.R.string.ok + ) { _: DialogInterface?, _: Int -> finish() } + .setNegativeButton(android.R.string.cancel, null) + .create() + } + + companion object { + const val EXTRA_NOTIFICATION_ID: String = + "com.nutomic.syncthingandroid.activities.DeviceActivity.NOTIFICATION_ID" + const val EXTRA_DEVICE_ID: String = + "com.nutomic.syncthingandroid.activities.DeviceActivity.DEVICE_ID" + const val EXTRA_DEVICE_NAME: String = + "com.nutomic.syncthingandroid.activities.DeviceActivity.DEVICE_NAME" + const val EXTRA_IS_CREATE: String = + "com.nutomic.syncthingandroid.activities.DeviceActivity.IS_CREATE" + + private const val TAG = "DeviceSettingsFragment" + private const val IS_SHOWING_DISCARD_DIALOG = "DISCARD_FOLDER_DIALOG_STATE" + private const val IS_SHOWING_COMPRESSION_DIALOG = "COMPRESSION_FOLDER_DIALOG_STATE" + private const val IS_SHOWING_DELETE_DIALOG = "DELETE_FOLDER_DIALOG_STATE" +// private const val QR_SCAN_REQUEST_CODE = 777 + + private val DYNAMIC_ADDRESS = mutableListOf("dynamic") + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.java deleted file mode 100644 index 3de13678..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.java +++ /dev/null @@ -1,482 +0,0 @@ -package com.nutomic.syncthingandroid.activities; - -import android.annotation.SuppressLint; -import android.annotation.TargetApi; -import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.graphics.Color; -import android.Manifest; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; -import androidx.viewpager.widget.PagerAdapter; -import androidx.viewpager.widget.ViewPager; - -import android.provider.Settings; -import android.text.Html; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.View.OnTouchListener; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; -import android.widget.Toast; - -import com.google.android.material.color.MaterialColors; -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.SyncthingApp; -import com.nutomic.syncthingandroid.databinding.ActivityFirstStartBinding; -import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.util.PermissionUtil; -import com.nutomic.syncthingandroid.util.Util; - -import java.io.File; - -import org.apache.commons.io.FileUtils; - -import javax.inject.Inject; - -public class FirstStartActivity extends Activity { - - private enum Slide { - - INTRO(R.layout.activity_firststart_slide_intro), - - STORAGE(R.layout.activity_firststart_slide_storage), - LOCATION(R.layout.activity_firststart_slide_location), - API_LEVEL_30(R.layout.activity_firststart_slide_api_level_30), - NOTIFICATION(R.layout.activity_firststart_slide_notification); - - public final int layout; - - Slide(int layout) { - this.layout = layout; - } - }; - - private static Slide[] slides = Slide.values(); - private static String TAG = "FirstStartActivity"; - - private ViewPagerAdapter mViewPagerAdapter; - private TextView[] mDots; - - private ActivityFirstStartBinding binding; - - @Inject - SharedPreferences mPreferences; - - @SuppressLint("ClickableViewAccessibility") - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - ((SyncthingApp) getApplication()).component().inject(this); - - /** - * Recheck storage permission. If it has been revoked after the user - * completed the welcome slides, displays the slides again. - */ - if (!isFirstStart() && PermissionUtil.haveStoragePermission(this) && upgradedToApiLevel30()) { - startApp(); - return; - } - - // Show first start welcome wizard UI. - binding = ActivityFirstStartBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - binding.viewPager.setOnTouchListener(new OnTouchListener() { - @Override - public boolean onTouch(View v, MotionEvent event) { - // Consume the event to prevent swiping through the slides. - v.performClick(); - return true; - } - }); - - // Add bottom dots - addBottomDots(); - setActiveBottomDot(0); - - mViewPagerAdapter = new ViewPagerAdapter(); - binding.viewPager.setAdapter(mViewPagerAdapter); - binding.viewPager.addOnPageChangeListener(mViewPagerPageChangeListener); - - binding.btnBack.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - onBtnBackClick(); - } - }); - - binding.btnNext.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - onBtnNextClick(); - } - }); - - if (!isFirstStart()) { - // Skip intro slide - onBtnNextClick(); - } - } - - public void onBtnBackClick() { - int current = binding.viewPager.getCurrentItem() - 1; - if (current >= 0) { - // Move to previous slider. - binding.viewPager.setCurrentItem(current); - if (current == 0) { - binding.btnBack.setVisibility(View.GONE); - } - } - } - - public void onBtnNextClick() { - Slide slide = currentSlide(); - // Check if we are allowed to advance to the next slide. - switch (slide) { - case STORAGE: - // As the storage permission is a prerequisite to run syncthing, refuse to continue without it. - Boolean storagePermissionsGranted = PermissionUtil.haveStoragePermission(this); - if (!storagePermissionsGranted) { - Toast.makeText(this, R.string.toast_write_storage_permission_required, - Toast.LENGTH_LONG).show(); - return; - } - break; - case API_LEVEL_30: - if (!upgradedToApiLevel30()) { - Toast.makeText(this, R.string.toast_api_level_30_must_reset, - Toast.LENGTH_LONG).show(); - return; - } - break; - } - - int next = binding.viewPager.getCurrentItem() + 1; - while (next < slides.length) { - if (!shouldSkipSlide(slides[next])) { - binding.viewPager.setCurrentItem(next); - binding.btnBack.setVisibility(View.VISIBLE); - break; - } - next++; - } - if (next == slides.length) { - // Start the app after "mNextButton" was hit on the last slide. - Log.v(TAG, "User completed first start UI."); - mPreferences.edit().putBoolean(Constants.PREF_FIRST_START, false).apply(); - startApp(); - } - } - - private boolean isFirstStart() { - return mPreferences.getBoolean(Constants.PREF_FIRST_START, true); - } - - @TargetApi(33) - private boolean isNotificationPermissionGranted() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - return true; - } - - return ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED; - - } - - - private boolean upgradedToApiLevel30() { - if (mPreferences.getBoolean(Constants.PREF_UPGRADED_TO_API_LEVEL_30, false)) { - return true; - } - if (isFirstStart()) { - mPreferences.edit().putBoolean(Constants.PREF_UPGRADED_TO_API_LEVEL_30, true).apply(); - return true; - } - return false; - } - - private void upgradeToApiLevel30() { - File dbDir = new File(this.getFilesDir(), "index-v0.14.0.db"); - if (dbDir.exists()) { - try { - FileUtils.deleteQuietly(dbDir); - } catch (Throwable e) { - Log.w(TAG, "Deleting database with FileUtils failed", e); - Util.runShellCommand("rm -r " + dbDir.getAbsolutePath(), false); - if (dbDir.exists()) { - throw new RuntimeException("Failed to delete existing database"); - } - } - } - mPreferences.edit().putBoolean(Constants.PREF_UPGRADED_TO_API_LEVEL_30, true).apply(); - } - - private Slide currentSlide() { - return slides[binding.viewPager.getCurrentItem()]; - } - - private boolean shouldSkipSlide(Slide slide) { - switch (slide) { - case INTRO: - return !isFirstStart(); - case STORAGE: - return PermissionUtil.haveStoragePermission(this); - case LOCATION: - return hasLocationPermission(); - case API_LEVEL_30: - // Skip if running as root, as that circumvents any Android FS restrictions. - return upgradedToApiLevel30() - || mPreferences.getBoolean(Constants.PREF_USE_ROOT, false); - case NOTIFICATION: - return isNotificationPermissionGranted(); - - } - return false; - } - - private void addBottomDots() { - mDots = new TextView[slides.length]; - for (int i = 0; i < mDots.length; i++) { - mDots[i] = new TextView(this); - mDots[i].setText(Html.fromHtml("•")); - mDots[i].setTextSize(35); - binding.layoutDots.addView(mDots[i]); - } - } - - private void setActiveBottomDot(int currentPage) { - int colorInactive = MaterialColors.getColor(this, R.attr.colorPrimary, Color.BLUE); - int colorActive = MaterialColors.getColor(this, R.attr.colorSecondary, Color.BLUE); - for (TextView mDot : mDots) { - mDot.setTextColor(colorInactive); - } - mDots[currentPage].setTextColor(colorActive); - } - - // ViewPager change listener - ViewPager.OnPageChangeListener mViewPagerPageChangeListener = new ViewPager.OnPageChangeListener() { - - @Override - public void onPageSelected(int position) { - setActiveBottomDot(position); - - // Change the next button text from next to finish on last slide. - binding.btnNext.setText(getString((position == slides.length - 1) ? R.string.finish : R.string.cont)); - } - - @Override - public void onPageScrolled(int arg0, float arg1, int arg2) { - - } - - @Override - public void onPageScrollStateChanged(int arg0) { - - } - }; - - /** - * View pager adapter - */ - public class ViewPagerAdapter extends PagerAdapter { - private LayoutInflater layoutInflater; - - public ViewPagerAdapter() { - } - - @Override - public Object instantiateItem(ViewGroup container, int position) { - layoutInflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE); - - View view = layoutInflater.inflate(slides[position].layout, container, false); - - switch (slides[position]) { - case INTRO: - break; - - case STORAGE: - Button btnGrantStoragePerm = (Button) view.findViewById(R.id.btnGrantStoragePerm); - btnGrantStoragePerm.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - requestStoragePermission(); - } - }); - break; - - case LOCATION: - Button btnGrantLocationPerm = (Button) view.findViewById(R.id.btnGrantLocationPerm); - btnGrantLocationPerm.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - requestLocationPermission(); - } - }); - break; - - case API_LEVEL_30: - Button btnResetDatabase = (Button) view.findViewById(R.id.btnResetDatabase); - btnResetDatabase.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - upgradeToApiLevel30(); - onBtnNextClick(); - } - }); - break; - case NOTIFICATION: - Button notificationBtn = (Button) view.findViewById(R.id.btn_notification); - notificationBtn.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - requestNotificationPermission(); - } - }); - break; - } - - container.addView(view); - return view; - } - - @Override - public int getCount() { - return slides.length; - } - - @Override - public boolean isViewFromObject(View view, Object obj) { - return view == obj; - } - - @Override - public void destroyItem(ViewGroup container, int position, Object object) { - View view = (View) object; - container.removeView(view); - } - } - - /** - * Preconditions: - * Storage permission has been granted. - */ - private void startApp() { - Boolean doInitialKeyGeneration = !Constants.getConfigFile(this).exists(); - Intent mainIntent = new Intent(this, MainActivity.class); - mainIntent.putExtra(MainActivity.EXTRA_KEY_GENERATION_IN_PROGRESS, doInitialKeyGeneration); - /** - * In case start_into_web_gui option is enabled, start both activities - * so that back navigation works as expected. - */ - if (mPreferences.getBoolean(Constants.PREF_START_INTO_WEB_GUI, false)) { - startActivities(new Intent[]{mainIntent, new Intent(this, WebGuiActivity.class)}); - } else { - startActivity(mainIntent); - } - finish(); - } - - private boolean hasLocationPermission() { - for (String perm : PermissionUtil.getLocationPermissions()) { - if (ContextCompat.checkSelfPermission(this, perm) != PackageManager.PERMISSION_GRANTED) { - return false; - } - } - return true; - } - - /** - * Permission check and request functions - */ - private void requestLocationPermission() { - ActivityCompat.requestPermissions(this, - PermissionUtil.getLocationPermissions(), - Constants.PermissionRequestType.LOCATION.ordinal()); - } - - @TargetApi(33) - private void requestNotificationPermission() { - if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 1); - } - } - - private void requestStoragePermission() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - requestAllFilesAccessPermission(); - } else { - ActivityCompat.requestPermissions(this, - new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, - Constants.PermissionRequestType.STORAGE.ordinal()); - } - } - - @TargetApi(30) - private void requestAllFilesAccessPermission() { - Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION); - intent.setData(Uri.parse("package:" + getPackageName())); - try { - ComponentName componentName = intent.resolveActivity(getPackageManager()); - if (componentName != null) { - // Launch "Allow all files access?" dialog. - startActivity(intent); - return; - } - Log.w(TAG, "Request all files access not supported"); - } catch (ActivityNotFoundException e) { - Log.w(TAG, "Request all files access not supported", e); - } - Toast.makeText(this, R.string.dialog_all_files_access_not_supported, Toast.LENGTH_LONG).show(); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, - @NonNull int[] grantResults) { - switch (Constants.PermissionRequestType.values()[requestCode]) { - case LOCATION: - if (grantResults.length == 0 || grantResults[0] != PackageManager.PERMISSION_GRANTED) { - Log.i(TAG, "User denied foreground location permission"); - break; - } - Log.i(TAG, "User granted foreground location permission"); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ActivityCompat.requestPermissions(this, - PermissionUtil.getLocationPermissions(), - Constants.PermissionRequestType.LOCATION_BACKGROUND.ordinal()); - } - break; - case LOCATION_BACKGROUND: - if (grantResults.length == 0 || grantResults[0] != PackageManager.PERMISSION_GRANTED) { - Log.i(TAG, "User denied background location permission"); - break; - } - Log.i(TAG, "User granted background location permission"); - Toast.makeText(this, R.string.permission_granted, Toast.LENGTH_SHORT).show(); - break; - case STORAGE: - if (grantResults.length == 0 || - grantResults[0] != PackageManager.PERMISSION_GRANTED) { - Log.i(TAG, "User denied WRITE_EXTERNAL_STORAGE permission."); - } else { - Toast.makeText(this, R.string.permission_granted, Toast.LENGTH_SHORT).show(); - Log.i(TAG, "User granted WRITE_EXTERNAL_STORAGE permission."); - } - break; - default: - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - } - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.kt b/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.kt new file mode 100644 index 00000000..e2cd5533 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.kt @@ -0,0 +1,379 @@ +package com.nutomic.syncthingandroid.activities + +// Note: avoid annotating lifecycle methods with @RequiresApi; use runtime checks instead +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.graphics.Color +import android.os.Bundle +import android.provider.Settings +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.TextView +import android.widget.Toast +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.core.text.HtmlCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.color.MaterialColors +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.SyncthingApp +import com.nutomic.syncthingandroid.databinding.ActivityFirstStartBinding +import com.nutomic.syncthingandroid.service.Constants +import com.nutomic.syncthingandroid.service.Constants.PermissionRequestType +import com.nutomic.syncthingandroid.service.Constants.getConfigFile +import com.nutomic.syncthingandroid.util.PermissionUtil.haveStoragePermission +import com.nutomic.syncthingandroid.util.PermissionUtil.locationPermissions +import javax.inject.Inject + +class FirstStartActivity : Activity() { + private enum class Slide(val layout: Int) { + INTRO(R.layout.activity_firststart_slide_intro), + STORAGE(R.layout.activity_firststart_slide_storage), + LOCATION(R.layout.activity_firststart_slide_location), + NOTIFICATION(R.layout.activity_firststart_slide_notification) + } + + private var mViewPagerAdapter: ViewPagerAdapter? = null + private var mDots: Array = arrayOf() + + private var binding: ActivityFirstStartBinding? = null + + @JvmField + @Inject + var mPreferences: SharedPreferences? = null + + @SuppressLint("ClickableViewAccessibility") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + (application as SyncthingApp).component()!!.inject(this) + + /** + * Recheck storage permission. If it has been revoked after the user + * completed the welcome slides, displays the slides again. + */ + if (!this.isFirstStart && haveStoragePermission(this)) { + startApp() + return + } + + // Show first start welcome wizard UI. + binding = ActivityFirstStartBinding.inflate(layoutInflater) + val view = binding!!.getRoot() + setContentView(view) + + binding!!.viewPager.setOnTouchListener { v, _ -> // Consume the event to prevent swiping through the slides. + v.performClick() + true + } + + // Add bottom dots + addBottomDots() + setActiveBottomDot(0) + + mViewPagerAdapter = ViewPagerAdapter() + binding!!.viewPager.adapter = mViewPagerAdapter + binding!!.viewPager.registerOnPageChangeCallback(mViewPagerPageChangeCallback) + + binding!!.btnBack.setOnClickListener { onBtnBackClick() } + + binding!!.btnNext.setOnClickListener { onBtnNextClick() } + + if (!this.isFirstStart) { + // Skip intro slide + onBtnNextClick() + } + + + + // handle edge-to-edge layout by preventing the top and bottom bars from overlapping the app content + ViewCompat.setOnApplyWindowInsetsListener(view) { v, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) + v.updateLayoutParams( + block = { + leftMargin = insets.left + topMargin = insets.top + rightMargin = insets.right + bottomMargin = insets.bottom + } + ) + // Return CONSUMED if you don't want the window insets to keep passing + // down to descendant views. + WindowInsetsCompat.CONSUMED + } + } + + fun onBtnBackClick() { + val current = binding!!.viewPager.currentItem - 1 + if (current >= 0) { + // Move to previous slider. + binding!!.viewPager.currentItem = current + if (current == 0) { + binding!!.btnBack.visibility = View.GONE + } + } + } + + fun onBtnNextClick() { + val slide = currentSlide() + // Check if we are allowed to advance to the next slide. + when (slide) { + Slide.STORAGE -> { + // As the storage permission is a prerequisite to run syncthing, refuse to continue without it. + val storagePermissionsGranted = haveStoragePermission(this) + if (!storagePermissionsGranted) { + Toast.makeText( + this, R.string.toast_write_storage_permission_required, + Toast.LENGTH_LONG + ).show() + return + } + } + + else -> {} + } + + var next = binding!!.viewPager.currentItem + 1 + while (next < slides.size) { + if (!shouldSkipSlide(slides[next])) { + binding!!.viewPager.currentItem = next + binding!!.btnBack.visibility = View.VISIBLE + break + } + next++ + } + if (next == slides.size) { + // Start the app after "mNextButton" was hit on the last slide. + Log.v(TAG, "User completed first start UI.") + mPreferences!!.edit { putBoolean(Constants.PREF_FIRST_START, false) } + startApp() + } + } + + private val isFirstStart: Boolean + get() = mPreferences!!.getBoolean( + Constants.PREF_FIRST_START, + true + ) + + private val isNotificationPermissionGranted: Boolean + get() = + ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + + private fun currentSlide(): Slide { + return slides[binding!!.viewPager.currentItem] + } + + private fun shouldSkipSlide(slide: Slide): Boolean { + return when (slide) { + Slide.INTRO -> !this.isFirstStart + Slide.STORAGE -> haveStoragePermission(this) + Slide.LOCATION -> hasLocationPermission() + Slide.NOTIFICATION -> this.isNotificationPermissionGranted + } + } + + private fun addBottomDots() { + mDots = arrayOfNulls(slides.size) + for (i in mDots.indices) { + mDots[i] = TextView(this) + mDots[i]!!.text = HtmlCompat.fromHtml("•", HtmlCompat.FROM_HTML_MODE_LEGACY) + mDots[i]!!.textSize = 35f + binding!!.layoutDots.addView(mDots[i]) + } + } + + private fun setActiveBottomDot(currentPage: Int) { + val colorInactive = MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimary, Color.BLUE) + val colorActive = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSecondary, Color.BLUE) + for (mDot in mDots) { + mDot!!.setTextColor(colorInactive) + } + mDots[currentPage]!!.setTextColor(colorActive) + } + + // ViewPager2 change callback + private val mViewPagerPageChangeCallback = object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + setActiveBottomDot(position) + // Change the next button text from next to finish on last slide. + binding!!.btnNext.text = getString(if (position == slides.size - 1) R.string.finish else R.string.cont) + } + } + + /** + * View pager adapter + */ + inner class ViewPagerAdapter : RecyclerView.Adapter() { + inner class ViewHolder(val root: View) : RecyclerView.ViewHolder(root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val view = inflater.inflate(viewType, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + when (slides[position]) { + Slide.INTRO -> {} + Slide.STORAGE -> { + val btnGrantStoragePerm = holder.root.findViewById