From 58f2406f34635f212b712d0eada5efa5f39563dc Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Wed, 19 Nov 2025 19:31:51 -0800 Subject: [PATCH 01/80] Fix warnings --- app/build.gradle.kts | 14 +++++++------- gradle.properties | 3 +++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ceb571e6..4c3986e3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,3 @@ -import org.gradle.configurationcache.extensions.capitalized - plugins { id("com.android.application") id("com.github.ben-manes.versions") @@ -32,7 +30,6 @@ 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}" buildFeatures { @@ -64,7 +61,6 @@ android { applicationIdSuffix = ".debug" isDebuggable = true isJniDebuggable = true - isRenderscriptDebuggable = true isMinifyEnabled = false } getByName("release") { @@ -81,11 +77,12 @@ android { // 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" } play { @@ -113,8 +110,11 @@ tasks.register("deleteUnsupportedPlayTranslations") { } project.afterEvaluate { - android.buildTypes.forEach { - tasks.named("merge${it.name.capitalized()}JniLibFolders") { + android.buildTypes.forEach { buildType -> + tasks.named("merge${ + buildType.name.toString() + .replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + }JniLibFolders") { dependsOn(":syncthing:buildNative") } } diff --git a/gradle.properties b/gradle.properties index 5d6ceea1..04780cfa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,6 @@ +android.buildfeatures.buildconfig=true android.enableJetifier=false +android.nonFinalResIds=false +android.nonTransitiveRClass=false android.useAndroidX=true org.gradle.jvmargs=-Xmx2g -Xms2g From 8e5dc445b8fd853655ce935947275adc2cb797d6 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Wed, 19 Nov 2025 19:32:15 -0800 Subject: [PATCH 02/80] Update gradle --- build.gradle.kts | 4 ++-- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 2aa97e95..0622201b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,7 +14,7 @@ buildscript { mavenCentral() } dependencies { - classpath("com.android.tools.build:gradle:7.3.1") + classpath("com.android.tools.build:gradle:8.13.1") classpath("com.github.ben-manes:gradle-versions-plugin:0.36.0") // NOTE: Do not place your application dependencies here; they belong @@ -23,5 +23,5 @@ buildscript { } tasks.register("clean") { - delete(rootProject.buildDir) + delete(getLayout().buildDirectory) } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 559efb4c..e6045a98 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip From 1e17d16765b72de81b928bd6326d3a976a0b6939 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Wed, 19 Nov 2025 19:43:32 -0800 Subject: [PATCH 03/80] SDK upgrade from 34 -> 36, update dependencies --- app/build.gradle.kts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4c3986e3..272c072c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,36 +1,36 @@ 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" } 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("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") + annotationProcessor("com.google.dagger:dagger-compiler:2.57.2") + androidTestImplementation("androidx.test:rules:1.7.0") + androidTestImplementation("androidx.annotation:annotation:1.9.1") } android { val ndkVersionShared = rootProject.extra.get("ndkVersionShared") // Changes to these values need to be reflected in `../docker/Dockerfile` - compileSdk = 34 - ndkVersion = "${ndkVersionShared}" + compileSdk = 36 + ndkVersion = "$ndkVersionShared" buildFeatures { dataBinding = true From 743833a03616b5d173ba70d805f269a81bd96e6b Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Wed, 19 Nov 2025 19:43:59 -0800 Subject: [PATCH 04/80] SDK upgrade from 34 -> 36 in Dockerfile --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 54418f8c..7c0625cf 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -35,7 +35,7 @@ RUN yes | $SDKMANAGER --licenses > /dev/null ENV NDK_VERSION 25.2.9519653 # Install other android packages, including NDK -RUN $SDKMANAGER tools platform-tools "build-tools;34.0.0" "platforms;android-34" "extras;android;m2repository" "ndk;${NDK_VERSION}" +RUN $SDKMANAGER tools platform-tools "build-tools;36.0.0" "platforms;android-34" "extras;android;m2repository" "ndk;${NDK_VERSION}" # Accept licenses of newly installed packages RUN yes | $SDKMANAGER --licenses From bb46563150a50b395e22d7a5ee5801f361de0e4c Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Wed, 19 Nov 2025 19:44:14 -0800 Subject: [PATCH 05/80] Update gradle to 8.14.3 --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e6045a98..20014ed3 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip From b4323edd5e9da7493129d601b5f8e98483adc6d7 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Wed, 19 Nov 2025 20:27:56 -0800 Subject: [PATCH 06/80] Manifest and build warning errors fix --- app/build.gradle.kts | 4 ++-- app/src/main/AndroidManifest.xml | 11 +++++++---- syncthing/build.gradle.kts | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 272c072c..46930dc3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { implementation("com.annimon:stream:1.2.2") implementation("com.android.volley:volley:1.2.1") implementation("commons-io:commons-io:2.21.0") + implementation("androidx.documentfile:documentfile:1.1.0") implementation("com.journeyapps:zxing-android-embedded:4.3.0") { isTransitive = false @@ -112,8 +113,7 @@ tasks.register("deleteUnsupportedPlayTranslations") { project.afterEvaluate { android.buildTypes.forEach { buildType -> tasks.named("merge${ - buildType.name.toString() - .replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + 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..920bdff9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,9 @@ + @@ -23,11 +26,13 @@ - + - + @@ -45,7 +50,6 @@ android:name=".SyncthingApp"> @@ -59,7 +63,6 @@ ("buildNative") { From 4c2e041b25b24a27cf72740d350f72b001b53b71 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Wed, 19 Nov 2025 20:28:09 -0800 Subject: [PATCH 07/80] Fix warnings --- .../activities/FolderActivity.java | 3 -- .../activities/FolderPickerActivity.java | 20 +++++---- .../activities/MainActivity.java | 21 +++++---- .../syncthingandroid/model/Completion.java | 10 +++-- .../service/EventProcessor.java | 7 ++- .../service/ReceiverManager.java | 4 +- .../syncthingandroid/service/RestApi.java | 45 +++++++++---------- .../service/SyncthingService.java | 31 ++++++------- app/src/main/res/layout/dialog_qrcode.xml | 2 +- 9 files changed, 70 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java index 3210ac35..c305bd09 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java @@ -6,7 +6,6 @@ import android.content.ActivityNotFoundException; import android.content.Intent; import android.net.Uri; -import android.os.Build; import android.os.Bundle; import androidx.documentfile.provider.DocumentFile; import android.text.Editable; @@ -17,9 +16,7 @@ import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; -import android.view.ViewGroup; import android.widget.CompoundButton; -import android.widget.EditText; import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java index 33242bd0..60886a6e 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java @@ -40,6 +40,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.Iterator; +import java.util.Objects; import javax.inject.Inject; @@ -102,12 +103,12 @@ protected void onCreate(Bundle savedInstanceState) { populateRoots(); if (getIntent().hasExtra(EXTRA_INITIAL_DIRECTORY)) { - displayFolder(new File(getIntent().getStringExtra(EXTRA_INITIAL_DIRECTORY))); + displayFolder(new File(Objects.requireNonNull(getIntent().getStringExtra(EXTRA_INITIAL_DIRECTORY)))); } else { displayRoot(); } - Boolean prefUseRoot = mPreferences.getBoolean(Constants.PREF_USE_ROOT, false); + boolean prefUseRoot = mPreferences.getBoolean(Constants.PREF_USE_ROOT, false); if (!prefUseRoot) { Toast.makeText(this, R.string.kitkat_external_storage_warning, Toast.LENGTH_LONG) .show(); @@ -121,8 +122,7 @@ protected void onCreate(Bundle savedInstanceState) { */ @SuppressLint("NewApi") private void populateRoots() { - ArrayList roots = new ArrayList<>(); - roots.addAll(Arrays.asList(getExternalFilesDirs(null))); + ArrayList roots = new ArrayList<>(Arrays.asList(getExternalFilesDirs(null))); roots.remove(getExternalFilesDir(null)); String rootDir = getIntent().getStringExtra(EXTRA_ROOT_DIRECTORY); @@ -138,7 +138,7 @@ private void populateRoots() { // Add paths that might not be accessible to Syncthing. if (mPreferences.getBoolean("advanced_folder_picker", false)) { - Collections.addAll(roots, new File("/storage/").listFiles()); + Collections.addAll(roots, Objects.requireNonNull(new File("/storage/").listFiles())); roots.add(new File("/")); } } @@ -166,7 +166,7 @@ protected void onDestroy() { super.onDestroy(); SyncthingService syncthingService = getService(); if (syncthingService != null) { - syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange); + syncthingService.unregisterOnServiceStateChangeListener(this); } } @@ -253,6 +253,7 @@ public void onItemClick(AdapterView adapterView, View view, int i, long l) { @SuppressWarnings("unchecked") ArrayAdapter adapter = (ArrayAdapter) mListView.getAdapter(); File f = adapter.getItem(i); + assert f != null; if (f.isDirectory()) { displayFolder(f); invalidateOptions(); @@ -263,7 +264,7 @@ private void invalidateOptions() { invalidateOptionsMenu(); } - private class FileAdapter extends ArrayAdapter { + private static class FileAdapter extends ArrayAdapter { public FileAdapter(Context context) { super(context, R.layout.item_folder_picker); @@ -275,6 +276,7 @@ public View getView(int position, View convertView, @NonNull ViewGroup parent) { convertView = super.getView(position, convertView, parent); TextView title = convertView.findViewById(android.R.id.text1); File f = getItem(position); + assert f != null; title.setText(f.getName()); int textColor = (f.isDirectory()) ? android.R.color.primary_text_light @@ -285,7 +287,7 @@ public View getView(int position, View convertView, @NonNull ViewGroup parent) { } } - private class RootsAdapter extends ArrayAdapter { + private static class RootsAdapter extends ArrayAdapter { public RootsAdapter(Context context) { super(context, android.R.layout.simple_list_item_1); @@ -296,7 +298,7 @@ public RootsAdapter(Context context) { public View getView(int position, View convertView, @NonNull ViewGroup parent) { convertView = super.getView(position, convertView, parent); TextView title = convertView.findViewById(android.R.id.text1); - title.setText(getItem(position).getAbsolutePath()); + title.setText(Objects.requireNonNull(getItem(position)).getAbsolutePath()); return convertView; } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java index 4c7154bd..3acdd998 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java @@ -3,7 +3,7 @@ import android.annotation.SuppressLint; import android.app.Activity; -import androidx.annotation.Nullable; +import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import android.app.Dialog; import android.content.ActivityNotFoundException; @@ -20,11 +20,9 @@ import android.os.Build; import android.os.Bundle; import android.os.IBinder; -import android.os.PersistableBundle; import android.os.PowerManager; import android.provider.Settings; -import com.google.android.material.color.DynamicColors; import com.google.android.material.tabs.TabLayout; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; @@ -52,7 +50,6 @@ import com.nutomic.syncthingandroid.fragments.DeviceListFragment; import com.nutomic.syncthingandroid.fragments.DrawerFragment; import com.nutomic.syncthingandroid.fragments.FolderListFragment; -import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.service.SyncthingService; import com.nutomic.syncthingandroid.service.SyncthingServiceBinder; @@ -84,7 +81,7 @@ public class MainActivity extends StateDialogActivity /** * Time after first start when usage reporting dialog should be shown. * - * @see #showUsageReportingDialog() + * @see #showUsageReportingDialog(RestApi) */ private static final long USAGE_REPORTING_DIALOG_DELAY = TimeUnit.DAYS.toMillis(3); @@ -113,13 +110,13 @@ public void onServiceStateChange(SyncthingService.State currentState) { case STARTING: break; case ACTIVE: - getIntent().putExtra(this.EXTRA_KEY_GENERATION_IN_PROGRESS, false); + getIntent().putExtra(EXTRA_KEY_GENERATION_IN_PROGRESS, false); showBatteryOptimizationDialogIfNecessary(); mDrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED); mDrawerFragment.requestGuiUpdate(); // Check if the usage reporting minimum delay passed by. - Boolean usageReportingDelayPassed = (new Date().getTime() > getFirstStartTime() + USAGE_REPORTING_DIALOG_DELAY); + boolean usageReportingDelayPassed = (new Date().getTime() > getFirstStartTime() + USAGE_REPORTING_DIALOG_DELAY); RestApi restApi = getApi(); if (usageReportingDelayPassed && restApi != null && !restApi.isUsageReportingDecided()) { showUsageReportingDialog(restApi); @@ -312,7 +309,7 @@ public void onServiceConnected(ComponentName componentName, IBinder iBinder) { * Saves current tab index and fragment states. */ @Override - protected void onSaveInstanceState(Bundle outState) { + protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); FragmentManager fm = getSupportFragmentManager(); @@ -332,7 +329,9 @@ protected void onSaveInstanceState(Bundle outState) { outState.putBoolean(IS_QRCODE_DIALOG_DISPLAYED, true); ImageView qrCode = mQrCodeDialog.findViewById(R.id.qrcode_image_view); TextView deviceID = mQrCodeDialog.findViewById(R.id.device_id); + assert qrCode != null; outState.putParcelable(QRCODE_BITMAP_KEY, ((BitmapDrawable) qrCode.getDrawable()).getBitmap()); + assert deviceID != null; outState.putString(DEVICEID_KEY, deviceID.getText().toString()); } Util.dismissDialogSafe(mRestartDialog, this); @@ -351,7 +350,7 @@ protected void onPostCreate(Bundle savedInstanceState) { } @Override - public void onConfigurationChanged(Configuration newConfig) { + public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); mDrawerToggle.onConfigurationChanged(newConfig); } @@ -399,7 +398,7 @@ private void shareDeviceId(String deviceId) { } @Override - public boolean onOptionsItemSelected(MenuItem item) { + public boolean onOptionsItemSelected(@NonNull MenuItem item) { return mDrawerToggle.onOptionsItemSelected(item) || super.onOptionsItemSelected(item); } @@ -469,7 +468,7 @@ public void onBackPressed() { /** * Calculating width based on - * http://www.google.com/design/spec/patterns/navigation-drawer.html#navigation-drawer-specs. + * navigation-drawer-specs. */ private void setOptimalDrawerWidth(View drawerContainer) { int actionBarSize = 0; diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Completion.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Completion.java index 56e1733b..6e143098 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Completion.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Completion.java @@ -6,10 +6,11 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; /** * This class caches remote folder and device synchronization - * completion indicators defined in {@link CompletionInfo#CompletionInfo} + * completion indicators defined in {@link CompletionInfo} * according to syncthing's REST "/completion" JSON result schema. * Completion model of syncthing's web UI is completion[deviceId][folderId] */ @@ -41,7 +42,7 @@ public void updateFromConfig(List newDevices, List newFolders) { // Handle devices that were removed from the config. List removedDevices = new ArrayList<>();; - Boolean deviceFound; + boolean deviceFound; for (String deviceId : deviceFolderMap.keySet()) { deviceFound = false; for (Device device : newDevices) { @@ -69,7 +70,7 @@ public void updateFromConfig(List newDevices, List newFolders) { // Handle folders that were removed from the config. List removedFolders = new ArrayList<>();; - Boolean folderFound; + boolean folderFound; for (Map.Entry> device : deviceFolderMap.entrySet()) { for (String folderId : device.getValue().keySet()) { folderFound = false; @@ -95,6 +96,7 @@ public void updateFromConfig(List newDevices, List newFolders) { if (folder.getDevice(device.deviceID) != null) { // folder is shared with device. folderMap = deviceFolderMap.get(device.deviceID); + assert folderMap != null; if (!folderMap.containsKey(folder.id)) { Log.v(TAG, "updateFromConfig: Add folder '" + folder.id + "' shared with device '" + device.deviceID + "' to cache model."); @@ -136,6 +138,6 @@ public void setCompletionInfo(String deviceId, String folderId, deviceFolderMap.put(deviceId, new HashMap()); } // Add folder or update existing folder entry. - deviceFolderMap.get(deviceId).put(folderId, completionInfo); + Objects.requireNonNull(deviceFolderMap.get(deviceId)).put(folderId, completionInfo); } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.java b/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.java index 6b02ed40..582a1653 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.java @@ -16,7 +16,6 @@ import androidx.core.util.Consumer; import com.annimon.stream.Stream; -import com.nutomic.syncthingandroid.BuildConfig; import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.activities.DeviceActivity; @@ -177,9 +176,9 @@ public void onEvent(Event event) { case "Starting": case "StartupComplete": case "StateChanged": - if (BuildConfig.DEBUG) { - Log.v(TAG, "Ignored event " + event.type + ", data " + event.data); - } +// if (BuildConfig.DEBUG) { +// Log.v(TAG, "Ignored event " + event.type + ", data " + event.data); +// } break; default: Log.v(TAG, "Unhandled event " + event.type); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.java b/app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.java index 0ed0679b..5c1323ef 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.java @@ -6,6 +6,8 @@ import android.content.IntentFilter; import android.util.Log; +import androidx.core.content.ContextCompat; + import java.util.ArrayList; import java.util.Iterator; import java.util.List; @@ -18,7 +20,7 @@ public class ReceiverManager { public static synchronized void registerReceiver(Context context, BroadcastReceiver receiver, IntentFilter intentFilter) { mReceivers.add(receiver); - context.registerReceiver(receiver, intentFilter); + ContextCompat.registerReceiver(context, receiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED); Log.v(TAG, "Registered receiver: " + receiver + " with filter: " + intentFilter); } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java index 8a82da0f..8e056017 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java @@ -15,7 +15,6 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import com.nutomic.syncthingandroid.BuildConfig; import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.activities.ShareActivity; import com.nutomic.syncthingandroid.http.GetRequest; @@ -45,6 +44,7 @@ import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -60,9 +60,9 @@ public class RestApi { private static final SimpleDateFormat dateFormat; static { if (android.os.Build.VERSION.SDK_INT < 24) { - dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US); } else { - dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX"); + dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US); } } @@ -113,8 +113,8 @@ public interface OnResultListener2 { private long mPreviousConnectionTime = 0; /** - * In the last-finishing {@link readConfigFromRestApi} callback, we have to call - * {@link SyncthingService#onApiAvailable} to indicate that the RestApi class is fully initialized. + * In the last-finishing {@link #readConfigFromRestApi()} callback, we have to call + * {@link SyncthingService#onApiAvailable}} to indicate that the RestApi class is fully initialized. * We do this to avoid getting stuck with our main thread due to synchronous REST queries. * The correct indication of full initialisation is crucial to stability as other listeners of * {@link SettingsActivity#onServiceStateChange} needs cached config and system information available. @@ -138,12 +138,12 @@ public interface OnResultListener2 { /** * Stores the latest result of {@link #getFolderStatus} for each folder */ - private HashMap mCachedFolderStatuses = new HashMap<>(); + private final HashMap mCachedFolderStatuses = new HashMap<>(); /** * Stores the latest result of device and folder completion events. */ - private Completion mCompletion = new Completion(); + private final Completion mCompletion = new Completion(); @Inject NotificationHandler mNotificationHandler; @@ -176,7 +176,7 @@ public void readConfigFromRestApi() { asyncQuerySystemInfoComplete = false; } new GetRequest(mContext, mUrl, GetRequest.URI_VERSION, mApiKey, null, result -> { - JsonObject json = new JsonParser().parse(result).getAsJsonObject(); + JsonObject json = JsonParser.parseString(result).getAsJsonObject(); mVersion = json.get("version").getAsString(); Log.i(TAG, "Syncthing version is " + mVersion); updateDebugFacilitiesCache(); @@ -223,9 +223,9 @@ private void onReloadConfigComplete(String result) { throw new RuntimeException("config is null: " + result); } Log.v(TAG, "onReloadConfigComplete: Successfully parsed configuration."); - if (BuildConfig.DEBUG) { - Log.v(TAG, "mConfig.remoteIgnoredDevices = " + new Gson().toJson(mConfig.remoteIgnoredDevices)); - } +// if (BuildConfig.DEBUG) { +// Log.v(TAG, "mConfig.remoteIgnoredDevices = " + new Gson().toJson(mConfig.remoteIgnoredDevices)); +// } // Update cached device and folder information stored in the mCompletion model. mCompletion.updateFromConfig(getDevices(true), getFolders()); @@ -243,12 +243,9 @@ private void updateDebugFacilitiesCache() { // First binary launch or binary upgraded case. new GetRequest(mContext, mUrl, GetRequest.URI_DEBUG, mApiKey, null, result -> { try { - Set facilitiesToStore = new HashSet(); JsonObject json = new JsonParser().parse(result).getAsJsonObject(); JsonObject jsonFacilities = json.getAsJsonObject("facilities"); - for (String facilityName : jsonFacilities.keySet()) { - facilitiesToStore.add(facilityName); - } + Set facilitiesToStore = new HashSet(jsonFacilities.keySet()); PreferenceManager.getDefaultSharedPreferences(mContext).edit() .putStringSet(Constants.PREF_DEBUG_FACILITIES_AVAILABLE, facilitiesToStore) .apply(); @@ -301,7 +298,7 @@ public void ignoreFolder(String deviceId, String folderId, String folderLabel) { synchronized (mConfigLock) { for (Device device : mConfig.devices) { if (deviceId.equals(device.deviceID)) { - /** + /* * Check if the folder has already been ignored. */ for (IgnoredFolder ignoredFolder : device.ignoredFolders) { @@ -312,7 +309,7 @@ public void ignoreFolder(String deviceId, String folderId, String folderLabel) { } } - /** + /* * Ignore folder by moving its corresponding "pendingFolder" entry to * a newly created "ignoredFolder" entry. */ @@ -321,9 +318,9 @@ public void ignoreFolder(String deviceId, String folderId, String folderLabel) { ignoredFolder.label = folderLabel; ignoredFolder.time = dateFormat.format(new Date()); device.ignoredFolders.add(ignoredFolder); - if (BuildConfig.DEBUG) { - Log.v(TAG, "device.ignoredFolders = " + new Gson().toJson(device.ignoredFolders)); - } +// if (BuildConfig.DEBUG) { +// Log.v(TAG, "device.ignoredFolders = " + new Gson().toJson(device.ignoredFolders)); +// } sendConfig(); Log.d(TAG, "Ignored folder [" + folderId + "] announced by device [" + deviceId + "]"); @@ -408,7 +405,7 @@ public List getFolders() { } /** - * This is only used for new folder creation, see {@link FolderActivity}. + * This is only used for new folder creation, see {@link com.nutomic.syncthingandroid.activities.FolderActivity}. */ public void createFolder(Folder folder) { synchronized (mConfigLock) { @@ -548,7 +545,6 @@ public void editSettings(Config.Gui newGui, Options newOptions) { /** * Returns a deep copy of object. - * * This method uses Gson and only works with objects that can be converted with Gson. */ private T deepCopy(T object, Type type) { @@ -585,8 +581,8 @@ public void getSystemVersion(OnResultListener1 listener) { */ public void getConnections(final OnResultListener1 listener) { new GetRequest(mContext, mUrl, GetRequest.URI_CONNECTIONS, mApiKey, null, result -> { - Long now = System.currentTimeMillis(); - Long msElapsed = now - mPreviousConnectionTime; + long now = System.currentTimeMillis(); + long msElapsed = now - mPreviousConnectionTime; if (msElapsed < Constants.GUI_UPDATE_INTERVAL) { listener.onResult(deepCopy(mPreviousConnections.get(), Connections.class)); return; @@ -642,7 +638,6 @@ public interface OnReceiveEventListener { /** * Retrieves the events that have accumulated since the given event id. - * * The OnReceiveEventListeners onEvent method is called for each event. */ public final void getEvents(final long sinceId, final long limit, final OnReceiveEventListener listener) { diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java index 138f6e97..bc07ea5c 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java @@ -142,7 +142,7 @@ public enum State { * {@link onStartCommand}. */ private State mCurrentState = State.DISABLED; - private AtomicReference mCurrentCheckResult = new AtomicReference<>(RunConditionCheckResult.SHOULD_RUN); + private final AtomicReference mCurrentCheckResult = new AtomicReference<>(RunConditionCheckResult.SHOULD_RUN); private ConfigXml mConfig; private @Nullable PollWebGuiAvailableTask mPollWebGuiAvailableTask = null; @@ -192,7 +192,7 @@ public void onCreate() { ((SyncthingApp) getApplication()).component().inject(this); mHandler = new Handler(); - /** + /* * If runtime permissions are revoked, android kills and restarts the service. * see issue: https://github.com/syncthing/syncthing-android/issues/871 * We need to recheck if we still have the storage permission. @@ -219,7 +219,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { return START_NOT_STICKY; } - /** + /* * Send current service state to listening endpoints. * This is required that components know about the service State.DISABLED * if RunConditionMonitor does not send a "shouldRun = true" callback @@ -232,7 +232,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { } } if (mRunConditionMonitor == null) { - /** + /* * Instantiate the run condition monitor on first onStartCommand and * enable callback on run condition change affecting the final decision to * run/terminate syncthing. After initial run conditions are collected @@ -246,7 +246,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { return START_STICKY; if (ACTION_RESTART.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { - shutdown(State.INIT, () -> launchStartupTask()); + shutdown(State.INIT, this::launchStartupTask); } else if (ACTION_RESET_DATABASE.equals(intent.getAction())) { shutdown(State.INIT, () -> { new SyncthingRunnable(this, SyncthingRunnable.Command.resetdatabase).run(); @@ -261,13 +261,16 @@ public int onStartCommand(Intent intent, int flags, int startId) { mRunConditionMonitor.updateShouldRunDecision(); } else if (ACTION_IGNORE_DEVICE.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { // mApi is not null due to State.ACTIVE + assert mApi != null; mApi.ignoreDevice(intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra(EXTRA_DEVICE_NAME), intent.getStringExtra(EXTRA_DEVICE_ADDRESS)); mNotificationHandler.cancelConsentNotification(intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)); } else if (ACTION_IGNORE_FOLDER.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { // mApi is not null due to State.ACTIVE + assert mApi != null; mApi.ignoreFolder(intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra(EXTRA_FOLDER_ID), intent.getStringExtra(EXTRA_FOLDER_LABEL)); mNotificationHandler.cancelConsentNotification(intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)); } else if (ACTION_OVERRIDE_CHANGES.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { + assert mApi != null; mApi.overrideChanges(intent.getStringExtra(EXTRA_FOLDER_ID)); } return START_STICKY; @@ -298,9 +301,7 @@ private void onUpdatedShouldRunDecision(RunConditionCheckResult result) { case INIT: // HACK: Make sure there is no syncthing binary left running from an improper // shutdown (eg Play Store update). - shutdown(State.INIT, () -> { - launchStartupTask(); - }); + shutdown(State.INIT, this::launchStartupTask); break; case STARTING: case ACTIVE: @@ -351,7 +352,7 @@ private boolean startupTaskIsRunning() { * version. */ private static class StartupTask extends AsyncTask { - private WeakReference refSyncthingService; + private final WeakReference refSyncthingService; StartupTask(SyncthingService context) { refSyncthingService = new WeakReference<>(context); @@ -406,7 +407,7 @@ private void onStartupTaskCompleteListener() { mSyncthingRunnableThread = new Thread(mSyncthingRunnable); mSyncthingRunnableThread.start(); - /** + /* * Wait for the web-gui of the native syncthing binary to come online. * * In case the binary is to be stopped, also be aware that another thread could request @@ -444,7 +445,7 @@ private void onApiAvailable() { onServiceStateChange(State.ACTIVE); } - /** + /* * If the service instance got an onDestroy() event while being in * State.STARTING we'll trigger the service onDestroy() now. this * allows the syncthing binary to get gracefully stopped. @@ -474,7 +475,7 @@ public SyncthingServiceBinder onBind(Intent intent) { public void onDestroy() { Log.v(TAG, "onDestroy"); if (mRunConditionMonitor != null) { - /** + /* * Shut down the OnDeviceStateChangedListener so we won't get interrupted by run * condition events that occur during shutdown. */ @@ -486,7 +487,7 @@ public void onDestroy() { if (mStoragePermissionGranted) { synchronized (mStateLock) { if (mCurrentState == State.STARTING) { - Log.i(TAG, "Delay shutting down synchting binary until initialisation finished"); + Log.i(TAG, "Delay shutting down syncthing binary until initialisation finished"); mDestroyScheduled = true; } else { Log.i(TAG, "Shutting down syncthing binary immediately"); @@ -571,7 +572,7 @@ public void evaluateRunConditions() { /** * Register a listener for the syncthing API state changing. - * + *

* The listener is called immediately with the current state, and again whenever the state * changes. The call is always from the GUI thread. * @@ -657,7 +658,7 @@ public NotificationHandler getNotificationHandler() { * Exports the local config and keys to {@link Constants#EXPORT_PATH}. */ public void exportConfig() { - Constants.EXPORT_PATH.mkdirs(); + boolean res = Constants.EXPORT_PATH.mkdirs(); try { Files.copy(Constants.getConfigFile(this), new File(Constants.EXPORT_PATH, Constants.CONFIG_FILE)); diff --git a/app/src/main/res/layout/dialog_qrcode.xml b/app/src/main/res/layout/dialog_qrcode.xml index 4e9773bd..3eba82df 100644 --- a/app/src/main/res/layout/dialog_qrcode.xml +++ b/app/src/main/res/layout/dialog_qrcode.xml @@ -50,7 +50,7 @@ android:layout_height="200dp" android:layout_gravity="center_horizontal" tools:ignore="ContentDescription" - tools:src="@drawable/ic_launcher"/> + tools:src="@mipmap/ic_launcher"/> From 44c93b27b5e8087523249b25a5360dbb9e30ec51 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Wed, 19 Nov 2025 20:39:59 -0800 Subject: [PATCH 08/80] Fix warnings --- .../activities/FolderActivity.java | 15 +++++++++------ .../syncthingandroid/activities/MainActivity.java | 2 +- .../syncthingandroid/model/Completion.java | 10 +++++----- .../syncthingandroid/service/EventProcessor.java | 15 +++++++++------ .../syncthingandroid/service/ReceiverManager.java | 3 +-- .../nutomic/syncthingandroid/service/RestApi.java | 11 ++++++----- .../service/SyncthingService.java | 4 ++-- 7 files changed, 33 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java index c305bd09..46dea3f4 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java @@ -16,6 +16,7 @@ import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; +import android.view.ViewGroup; import android.widget.CompoundButton; import android.widget.LinearLayout; import android.widget.TextView; @@ -37,6 +38,7 @@ import java.io.File; import java.io.IOException; import java.util.List; +import java.util.Objects; import java.util.Random; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -233,7 +235,7 @@ private void showFolderTypeDialog() { return; } if (!mCanWriteToPath) { - /** + /* * Do not handle the click as the children in the folder type layout are disabled * and an explanation is already given on the UI why the only allowed folder type * is "sendonly". @@ -528,7 +530,7 @@ private void checkWriteAndUpdateUI() { return; } - /** + /* * Check if the permissions we have on that folder is readonly or readwrite. * Access level readonly: folder can only be configured "sendonly". * Access level readwrite: folder can be configured "sendonly" or "sendreceive". @@ -539,7 +541,7 @@ private void checkWriteAndUpdateUI() { binding.folderType.setEnabled(true); binding.editIgnores.setEnabled(true); if (mIsCreateMode) { - /** + /* * Suggest folder type FOLDER_TYPE_SEND_RECEIVE for folders to be created * because the user most probably intentionally chose a special folder like * "[storage]/Android/data/com.nutomic.syncthingandroid/files" @@ -580,7 +582,7 @@ private void initFolder() { mFolder.label = getIntent().getStringExtra(EXTRA_FOLDER_LABEL); mFolder.fsWatcherEnabled = true; mFolder.fsWatcherDelayS = 10; - /** + /* * Folder rescan interval defaults to 3600s as it is the default in * syncthing when the file watcher is enabled and a new folder is created. */ @@ -615,7 +617,7 @@ private void addDeviceViewAndSetListener(Device device, LayoutInflater inflater) private void updateFolder() { if (!mIsCreateMode) { - /** + /* * RestApi is guaranteed not to be null as {@link onServiceStateChange} * immediately finishes this activity if SyncthingService shuts down. */ @@ -656,6 +658,7 @@ private void updateVersioning(Bundle arguments) { String type = arguments.getString("type"); arguments.remove("type"); + assert type != null; if (type.equals("none")){ mVersioning = new Folder.Versioning(); } else { @@ -760,7 +763,7 @@ private void updateVersioningDescription() { getString(R.string.trashcan_versioning_info, mFolder.versioning.params.get("cleanoutDays"))); break; case "staggered": - int maxAge = (int) TimeUnit.SECONDS.toDays(Long.valueOf(mFolder.versioning.params.get("maxAge"))); + int maxAge = (int) TimeUnit.SECONDS.toDays(Long.parseLong(Objects.requireNonNull(mFolder.versioning.params.get("maxAge")))); setVersioningDescription(getString(R.string.type_staggered), getString(R.string.staggered_versioning_info, maxAge, mFolder.versioning.params.get("versionsPath"))); break; diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java index 3acdd998..00cd7eb4 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java @@ -457,7 +457,7 @@ public void onBackPressed() { // Close drawer on back button press. closeDrawer(); } else { - /** + /* * Leave MainActivity in its state as the home button was pressed. * This will avoid waiting for the loading spinner when getting back * and give changes to do UI updates based on EventProcessor in the future. diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Completion.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Completion.java index 6e143098..67ed94fc 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Completion.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Completion.java @@ -19,7 +19,7 @@ public class Completion { private static final String TAG = "Completion"; HashMap> deviceFolderMap = - new HashMap>(); + new HashMap<>(); /** * Removes a folder from the cache model. @@ -41,7 +41,7 @@ public void updateFromConfig(List newDevices, List newFolders) { HashMap folderMap; // Handle devices that were removed from the config. - List removedDevices = new ArrayList<>();; + List removedDevices = new ArrayList<>(); boolean deviceFound; for (String deviceId : deviceFolderMap.keySet()) { deviceFound = false; @@ -64,12 +64,12 @@ public void updateFromConfig(List newDevices, List newFolders) { for (Device device : newDevices) { if (!deviceFolderMap.containsKey(device.deviceID)) { Log.v(TAG, "updateFromConfig: Add device '" + device.deviceID + "' to cache model"); - deviceFolderMap.put(device.deviceID, new HashMap()); + deviceFolderMap.put(device.deviceID, new HashMap<>()); } } // Handle folders that were removed from the config. - List removedFolders = new ArrayList<>();; + List removedFolders = new ArrayList<>(); boolean folderFound; for (Map.Entry> device : deviceFolderMap.entrySet()) { for (String folderId : device.getValue().keySet()) { @@ -135,7 +135,7 @@ public void setCompletionInfo(String deviceId, String folderId, CompletionInfo completionInfo) { // Add device parent node if it does not exist. if (!deviceFolderMap.containsKey(deviceId)) { - deviceFolderMap.put(deviceId, new HashMap()); + deviceFolderMap.put(deviceId, new HashMap<>()); } // Add folder or update existing folder entry. Objects.requireNonNull(deviceFolderMap.get(deviceId)).put(folderId, completionInfo); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.java b/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.java index 582a1653..4b8badc8 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.java @@ -28,13 +28,14 @@ import java.io.File; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.TimeUnit; import javax.inject.Inject; /** * Run by the syncthing service to convert syncthing events into local broadcasts. - * + *

* It uses {@link RestApi#getEvents} to read the pending events and wait for new events. */ public class EventProcessor implements Runnable, RestApi.OnReceiveEventListener { @@ -101,7 +102,7 @@ public void onEvent(Event event) { Map mapData = null; try { mapData = (Map) event.data; - } catch (ClassCastException e) { } + } catch (ClassCastException ignored) { } switch (event.type) { case "ConfigSaved": if (mApi != null) { @@ -132,16 +133,16 @@ public void onEvent(Event event) { folderPath = f.path; } } - File updatedFile = new File(folderPath, (String) mapData.get("item")); + File updatedFile = new File(folderPath, (String) Objects.requireNonNull(mapData.get("item"))); if (!"delete".equals(mapData.get("action"))) { - Log.i(TAG, "Rescanned file via MediaScanner: " + updatedFile.toString()); + Log.i(TAG, "Rescanned file via MediaScanner: " + updatedFile); MediaScannerConnection.scanFile(mContext, new String[]{updatedFile.getPath()}, null, null); } else { // Starting with Android 10/Q and targeting API level 29/removing legacy storage flag, // reports of files being spuriously deleted came up. // Best guess is that Syncthing directly interacted with the filesystem before, - // and there's a virtualisation layer there now. Also there's reports this API + // and there's a virtualization layer there now. Also there's reports this API // changed behaviour with scoped storage. In any case it now does not only // update the media db, but actually delete the file on disk. Which is bad, // as it can race with the creation of the same file and thus delete it. See: @@ -151,7 +152,7 @@ public void onEvent(Event event) { break; } // https://stackoverflow.com/a/29881556/1837158 - Log.i(TAG, "Deleted file from MediaStore: " + updatedFile.toString()); + Log.i(TAG, "Deleted file from MediaStore: " + updatedFile); Uri contentUri = MediaStore.Files.getContentUri("external"); ContentResolver resolver = mContext.getContentResolver(); resolver.delete(contentUri, MediaStore.Images.ImageColumns.DATA + " = ?", @@ -231,6 +232,7 @@ private void onPendingDevicesChanged(Map added) { } Log.d(TAG, "Unknown device " + deviceName + "(" + deviceId + ") wants to connect"); + assert deviceName != null; String title = mContext.getString(R.string.device_rejected, deviceName.isEmpty() ? deviceId.substring(0, 7) : deviceName); int notificationId = mNotificationHandler.getNotificationIdFromText(title); @@ -276,6 +278,7 @@ private void onPendingFoldersChanged(Map added) { break; } } + assert folderLabel != null; String title = mContext.getString(R.string.folder_rejected, deviceName, folderLabel.isEmpty() ? folderId : folderLabel + " (" + folderId + ")"); int notificationId = mNotificationHandler.getNotificationIdFromText(title); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.java b/app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.java index 5c1323ef..06cdb487 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.java @@ -2,7 +2,6 @@ import android.content.BroadcastReceiver; import android.content.Context; -import android.content.Intent; import android.content.IntentFilter; import android.util.Log; @@ -16,7 +15,7 @@ public class ReceiverManager { private static final String TAG = "ReceiverManager"; - private static List mReceivers = new ArrayList(); + private static final List mReceivers = new ArrayList<>(); public static synchronized void registerReceiver(Context context, BroadcastReceiver receiver, IntentFilter intentFilter) { mReceivers.add(receiver); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java index 8e056017..379fd712 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java @@ -214,7 +214,7 @@ public void reloadConfig() { } private void onReloadConfigComplete(String result) { - Boolean configParseSuccess; + boolean configParseSuccess; synchronized(mConfigLock) { mConfig = new Gson().fromJson(result, Config.class); configParseSuccess = mConfig != null; @@ -243,9 +243,9 @@ private void updateDebugFacilitiesCache() { // First binary launch or binary upgraded case. new GetRequest(mContext, mUrl, GetRequest.URI_DEBUG, mApiKey, null, result -> { try { - JsonObject json = new JsonParser().parse(result).getAsJsonObject(); + JsonObject json = JsonParser.parseString(result).getAsJsonObject(); JsonObject jsonFacilities = json.getAsJsonObject("facilities"); - Set facilitiesToStore = new HashSet(jsonFacilities.keySet()); + Set facilitiesToStore = new HashSet<>(jsonFacilities.keySet()); PreferenceManager.getDefaultSharedPreferences(mContext).edit() .putStringSet(Constants.PREF_DEBUG_FACILITIES_AVAILABLE, facilitiesToStore) .apply(); @@ -597,6 +597,7 @@ public void getConnections(final OnResultListener1 listener) { (mPreviousConnections.isPresent() && mPreviousConnections.get().connections.containsKey(e.getKey())) ? mPreviousConnections.get().connections.get(e.getKey()) : new Connections.Connection(); + assert prev != null; e.getValue().setTransferRate(prev, msElapsed); } Connections.Connection prev = @@ -668,7 +669,7 @@ private void normalizeDeviceId(String id, OnResultListener1 listener, OnResultListener1 errorListener) { new GetRequest(mContext, mUrl, GetRequest.URI_DEVICEID, mApiKey, ImmutableMap.of("id", id), result -> { - JsonObject json = new JsonParser().parse(result).getAsJsonObject(); + JsonObject json = JsonParser.parseString(result).getAsJsonObject(); JsonElement normalizedId = json.get("id"); JsonElement error = json.get("error"); if (normalizedId != null) @@ -691,7 +692,7 @@ public void setCompletionInfo(String deviceId, String folderId, CompletionInfo c */ public void getUsageReport(final OnResultListener1 listener) { new GetRequest(mContext, mUrl, GetRequest.URI_REPORT, mApiKey, null, result -> { - JsonElement json = new JsonParser().parse(result); + JsonElement json = JsonParser.parseString(result); Gson gson = new GsonBuilder().setPrettyPrinting().create(); listener.onResult(gson.toJson(json)); }); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java index bc07ea5c..bc09a8a1 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java @@ -548,7 +548,7 @@ private void shutdown(State newState, SyncthingRunnable.OnSyncthingKilled onKill Log.v(TAG, "Waiting for mStartupTask to finish after cancelling ..."); try { mStartupTask.get(); - } catch (Exception e) { } + } catch (Exception ignored) { } mStartupTask = null; } onKilledListener.onKilled(); @@ -658,7 +658,7 @@ public NotificationHandler getNotificationHandler() { * Exports the local config and keys to {@link Constants#EXPORT_PATH}. */ public void exportConfig() { - boolean res = Constants.EXPORT_PATH.mkdirs(); + Constants.EXPORT_PATH.mkdirs(); try { Files.copy(Constants.getConfigFile(this), new File(Constants.EXPORT_PATH, Constants.CONFIG_FILE)); From a67aa37963f5047367ff789425f294dfaf77e14f Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Wed, 19 Nov 2025 20:46:04 -0800 Subject: [PATCH 09/80] More warning fixes --- .../nutomic/syncthingandroid/activities/FolderActivity.java | 3 +-- .../java/com/nutomic/syncthingandroid/service/RestApi.java | 2 +- .../com/nutomic/syncthingandroid/service/SyncthingService.java | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java index 46dea3f4..5fd737ba 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java @@ -16,7 +16,6 @@ import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; -import android.view.ViewGroup; import android.widget.CompoundButton; import android.widget.LinearLayout; import android.widget.TextView; @@ -427,7 +426,7 @@ public boolean onOptionsItemSelected(MenuItem item) { return true; } if (mFolderUri != null) { - /** + /* * Normally, syncthing takes care of creating the ".stfolder" marker. * This fails on newer android versions if the syncthing binary only has * readonly access on the path and the user tries to configure a diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java index 379fd712..7a3e9ef5 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java @@ -645,7 +645,7 @@ public final void getEvents(final long sinceId, final long limit, final OnReceiv Map params = ImmutableMap.of("since", String.valueOf(sinceId), "limit", String.valueOf(limit)); new GetRequest(mContext, mUrl, GetRequest.URI_EVENTS, mApiKey, params, result -> { - JsonArray jsonEvents = new JsonParser().parse(result).getAsJsonArray(); + JsonArray jsonEvents = JsonParser.parseString(result).getAsJsonArray(); long lastId = 0; for (int i = 0; i < jsonEvents.size(); i++) { diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java index bc09a8a1..324fb648 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java @@ -505,7 +505,7 @@ public void onDestroy() { /** * Stop Syncthing and all helpers like event processor and api handler. - * + *

* Sets {@link #mCurrentState} to newState, and calls onKilledListener once Syncthing is killed. */ private void shutdown(State newState, SyncthingRunnable.OnSyncthingKilled onKilledListener) { From f453ca7f8fa517cc0cf1312fe30643f62c961399 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Wed, 19 Nov 2025 20:46:32 -0800 Subject: [PATCH 10/80] Android version from 11 to 21 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 46930dc3..cbddc4c2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,8 +72,8 @@ 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 From 54aac042e1bca037bb95339f033dc87886b95935 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Thu, 20 Nov 2025 18:00:08 -0800 Subject: [PATCH 11/80] Update NDK version --- build.gradle.kts | 2 +- docker/Dockerfile | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0622201b..8b494db2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ buildscript { extra.apply { // Cannot be called "ndkVersion" as that leads to naming collision // Changes to this value must be reflected in `./docker/Dockerfile` - set("ndkVersionShared", "25.2.9519653") + set("ndkVersionShared", "29.0.14206865") } diff --git a/docker/Dockerfile b/docker/Dockerfile index 7c0625cf..de24d8c6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,6 @@ FROM eclipse-temurin:11-jdk-jammy -ENV GO_VERSION 1.22.7 +ENV GO_VERSION 1.25.4 # Can be found scrolling down on this page: # https://developer.android.com/studio/index.html#command-tools @@ -32,7 +32,7 @@ ARG SDKMANAGER="${ANDROID_HOME}/cmdline-tools/bin/sdkmanager --sdk_root=${ANDROI RUN yes | $SDKMANAGER --licenses > /dev/null # NDK version -ENV NDK_VERSION 25.2.9519653 +ENV NDK_VERSION 29.0.14206865 # Install other android packages, including NDK RUN $SDKMANAGER tools platform-tools "build-tools;36.0.0" "platforms;android-34" "extras;android;m2repository" "ndk;${NDK_VERSION}" From 2a629588f78e05ae0a0e5311cf01dd6a79674644 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Thu, 20 Nov 2025 18:05:34 -0800 Subject: [PATCH 12/80] Updated Syncthing to v2.0.9 --- syncthing/src/github.com/syncthing/syncthing | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncthing/src/github.com/syncthing/syncthing b/syncthing/src/github.com/syncthing/syncthing index 612fdff3..3382ccc3 160000 --- a/syncthing/src/github.com/syncthing/syncthing +++ b/syncthing/src/github.com/syncthing/syncthing @@ -1 +1 @@ -Subproject commit 612fdff37766ee18a1443be82635156b2f085806 +Subproject commit 3382ccc3f16536b5a7b6df7c8212951f7d4d3a9f From 71245a577c8e3adb5315df4444d770e4cb6fde57 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Thu, 20 Nov 2025 18:06:03 -0800 Subject: [PATCH 13/80] Bumped version to 2.0.9 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cbddc4c2..ef8737e4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,8 +42,8 @@ android { applicationId = "com.nutomic.syncthingandroid" minSdk = 21 targetSdk = 33 - versionCode = 4395 - versionName = "1.28.1" + versionCode = 4396 + versionName = "2.0.9" testApplicationId = "com.nutomic.syncthingandroid.test" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } From 583daa9003ce0c65588faacd4089cc5092d2b5f7 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Thu, 20 Nov 2025 19:43:28 -0800 Subject: [PATCH 14/80] Update build script to account for latest Go version --- syncthing/build-syncthing.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/syncthing/build-syncthing.py b/syncthing/build-syncthing.py index b3f99c1a..fd0b6918 100644 --- a/syncthing/build-syncthing.py +++ b/syncthing/build-syncthing.py @@ -90,9 +90,21 @@ def get_ndk_home(): print('Building syncthing for', target['arch']) environ = os.environ.copy() + # Allow injecting extra ldflags via EXTRA_LDFLAGS. Ensure the + # -checklinkname=0 flag is present to relax linkname checks when + # building with newer toolchains that may cause link failures. + # see https://github.com/wlynxg/anet/blob/main/README.md#how-to-build-with-go-1230-or-later + extra_ldflags = os.environ.get('EXTRA_LDFLAGS', '') + if extra_ldflags: + if '-checklinkname=0' not in extra_ldflags: + extra_ldflags = extra_ldflags + ' -checklinkname=0' + else: + extra_ldflags = '-checklinkname=0' + environ.update({ 'GO111MODULE': 'on', 'CGO_ENABLED': '1', + 'EXTRA_LDFLAGS': extra_ldflags, }) subprocess.check_call( From 297cc2d99224c7a76be3c714a9add759102e2b82 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Fri, 21 Nov 2025 19:05:38 -0800 Subject: [PATCH 15/80] Remove AsyncTask and fix warnings --- .../service/SyncthingService.java | 127 ++++++++++-------- .../syncthingandroid/util/ConfigXml.java | 17 +-- .../nutomic/syncthingandroid/util/Util.java | 19 ++- 3 files changed, 82 insertions(+), 81 deletions(-) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java index 324fb648..3dfc75c7 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java @@ -3,7 +3,10 @@ import android.app.Service; import android.content.Intent; import android.content.SharedPreferences; -import android.os.AsyncTask; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.CancellationException; import android.os.Handler; import android.util.Log; @@ -150,7 +153,8 @@ public enum State { private @Nullable EventProcessor mEventProcessor = null; private @Nullable RunConditionMonitor mRunConditionMonitor = null; private @Nullable SyncthingRunnable mSyncthingRunnable = null; - private StartupTask mStartupTask = null; + private ExecutorService mExecutor = null; + private Future mStartupTaskFuture = null; private Thread mSyncthingRunnableThread = null; private Handler mHandler; @@ -192,6 +196,9 @@ public void onCreate() { ((SyncthingApp) getApplication()).component().inject(this); mHandler = new Handler(); + // Executor for background tasks that previously used AsyncTask + mExecutor = Executors.newSingleThreadExecutor(); + /* * If runtime permissions are revoked, android kills and restarts the service. * see issue: https://github.com/syncthing/syncthing-android/issues/871 @@ -339,73 +346,70 @@ private void launchStartupTask () { return; } onServiceStateChange(State.STARTING); - mStartupTask = new StartupTask(this); - mStartupTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + if (mExecutor == null) { + mExecutor = Executors.newSingleThreadExecutor(); + } + mStartupTaskFuture = mExecutor.submit(new StartupTask(this)); } private boolean startupTaskIsRunning() { - return mStartupTask != null && mStartupTask.getStatus() == AsyncTask.Status.RUNNING; + return mStartupTaskFuture != null && !mStartupTaskFuture.isDone(); } /** * Sets up the initial configuration, and updates the config when coming from an old * version. */ - private static class StartupTask extends AsyncTask { - private final WeakReference refSyncthingService; + private static class StartupTask implements Runnable { + private final WeakReference refSyncthingService; - StartupTask(SyncthingService context) { - refSyncthingService = new WeakReference<>(context); - } + StartupTask(SyncthingService context) { + refSyncthingService = new WeakReference<>(context); + } - @Override - protected Void doInBackground(Void... voids) { - SyncthingService syncthingService = refSyncthingService.get(); - if (syncthingService == null) { - cancel(true); - return null; - } - try { - syncthingService.mConfig = new ConfigXml(syncthingService); - syncthingService.mConfig.updateIfNeeded(); - } catch (ConfigXml.OpenConfigException e) { - syncthingService.mNotificationHandler.showCrashedNotification(R.string.config_create_failed, true); - synchronized (syncthingService.mStateLock) { - syncthingService.onServiceStateChange(State.ERROR); - } - cancel(true); - } - return null; - } + @Override + public void run() { + SyncthingService syncthingService = refSyncthingService.get(); + if (syncthingService == null) { + return; + } + try { + syncthingService.mConfig = new ConfigXml(syncthingService); + syncthingService.mConfig.updateIfNeeded(); + } catch (ConfigXml.OpenConfigException e) { + syncthingService.mNotificationHandler.showCrashedNotification(R.string.config_create_failed, true); + synchronized (syncthingService.mStateLock) { + syncthingService.onServiceStateChange(State.ERROR); + } + return; + } - @Override - protected void onPostExecute(Void aVoid) { - // Get a reference to the service if it is still there. - SyncthingService syncthingService = refSyncthingService.get(); - if (syncthingService != null) { - syncthingService.onStartupTaskCompleteListener(); - } - } - } + // Post back to the main thread to run the completion callback + SyncthingService svc = refSyncthingService.get(); + if (svc != null && svc.mHandler != null) { + svc.mHandler.post(svc::onStartupTaskCompleteListener); + } + } + } - /** - * Callback on {@link StartupTask#onPostExecute}. - */ - private void onStartupTaskCompleteListener() { - if (mApi == null) { - mApi = new RestApi(this, mConfig.getWebGuiUrl(), mConfig.getApiKey(), - this::onApiAvailable, () -> onServiceStateChange(mCurrentState)); - Log.i(TAG, "Web GUI will be available at " + mConfig.getWebGuiUrl()); - } + /** + * Callback on {@link StartupTask#onPostExecute}. + */ + private void onStartupTaskCompleteListener() { + if (mApi == null) { + mApi = new RestApi(this, mConfig.getWebGuiUrl(), mConfig.getApiKey(), + this::onApiAvailable, () -> onServiceStateChange(mCurrentState)); + Log.i(TAG, "Web GUI will be available at " + mConfig.getWebGuiUrl()); + } - // Start the syncthing binary. - if (mSyncthingRunnable != null || mSyncthingRunnableThread != null) { - Log.e(TAG, "onStartupTaskCompleteListener: Syncthing binary lifecycle violated"); - return; - } - mSyncthingRunnable = new SyncthingRunnable(this, SyncthingRunnable.Command.main); - mSyncthingRunnableThread = new Thread(mSyncthingRunnable); - mSyncthingRunnableThread.start(); + // Start the syncthing binary. + if (mSyncthingRunnable != null || mSyncthingRunnableThread != null) { + Log.e(TAG, "onStartupTaskCompleteListener: Syncthing binary lifecycle violated"); + return; + } + mSyncthingRunnable = new SyncthingRunnable(this, SyncthingRunnable.Command.main); + mSyncthingRunnableThread = new Thread(mSyncthingRunnable); + mSyncthingRunnableThread.start(); /* * Wait for the web-gui of the native syncthing binary to come online. @@ -501,6 +505,10 @@ public void onDestroy() { shutdown(State.DISABLED, () -> {}); } super.onDestroy(); + if (mExecutor != null) { + mExecutor.shutdownNow(); + mExecutor = null; + } } /** @@ -544,12 +552,13 @@ private void shutdown(State newState, SyncthingRunnable.OnSyncthingKilled onKill mSyncthingRunnable = null; } if (startupTaskIsRunning()) { - mStartupTask.cancel(true); + mStartupTaskFuture.cancel(true); Log.v(TAG, "Waiting for mStartupTask to finish after cancelling ..."); try { - mStartupTask.get(); - } catch (Exception ignored) { } - mStartupTask = null; + mStartupTaskFuture.get(); + } catch (Exception ignored) { + } + mStartupTaskFuture = null; } onKilledListener.onKilled(); } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java index 0e2d245d..c1aaf688 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java @@ -4,7 +4,6 @@ import android.content.SharedPreferences; import android.os.Build; import android.os.Environment; -import android.preference.PreferenceManager; import android.text.TextUtils; import android.util.Log; @@ -19,16 +18,12 @@ import org.w3c.dom.NodeList; import org.xml.sax.SAXException; -import java.io.BufferedReader; import java.io.File; -import java.io.InputStreamReader; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.Locale; import java.util.Random; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import javax.inject.Inject; import javax.xml.parsers.DocumentBuilder; @@ -42,12 +37,12 @@ /** * Provides direct access to the config.xml file in the file system. - * + *

* This class should only be used if the syncthing API is not available (usually during startup). */ public class ConfigXml { - public class OpenConfigException extends RuntimeException { + public static class OpenConfigException extends RuntimeException { } private static final String TAG = "ConfigXml"; @@ -79,7 +74,7 @@ public ConfigXml(Context context) throws OpenConfigException { String localDeviceID = logOutput.replace("\n", ""); // Verify local device ID is correctly formatted. if (localDeviceID.matches("^([A-Z0-9]{7}-){7}[A-Z0-9]{7}$")) { - changed = changeLocalDeviceName(localDeviceID) || changed; + changed = changeLocalDeviceName(localDeviceID); } changed = changeDefaultFolder() || changed; @@ -124,16 +119,16 @@ public String getUserName() { /** * Updates the config file. - * + *

* Sets ignorePerms flag to true on every folder, force enables TLS, sets the * username/password, and disables weak hash checking. */ @SuppressWarnings("SdCardPath") public void updateIfNeeded() { - boolean changed = false; + boolean changed; /* Perform one-time migration tasks on syncthing's config file when coming from an older config version. */ - changed = migrateSyncthingOptions() || changed; + changed = migrateSyncthingOptions(); /* Get refs to important config objects */ NodeList folders = mConfig.getDocumentElement().getElementsByTagName("folder"); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java index 1b8c32dc..489b7043 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java @@ -7,7 +7,6 @@ import android.content.Context; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager.NameNotFoundException; -import android.os.Build; import android.preference.PreferenceManager; import androidx.appcompat.app.AlertDialog; import android.util.Log; @@ -81,7 +80,7 @@ public static String readableTransferRate(Context context, long bits) { * Normally an application's data directory is only accessible by the corresponding application. * Therefore, every file and directory is owned by an application's user and group. When running Syncthing as root, * it writes to the application's data directory. This leaves files and directories behind which are owned by root having 0600. - * Moreover, those acitons performed as root changes a file's type in terms of SELinux. + * Moreover, those actions performed as root changes a file's type in terms of SELinux. * A subsequent start of Syncthing will fail due to insufficient permissions. * Hence, this method fixes the owner, group and the files' type of the data directory. * @@ -91,7 +90,7 @@ public static boolean fixAppDataPermissions(Context context) { // We can safely assume that root magic is somehow available, because readConfig and saveChanges check for // read and write access before calling us. // Be paranoid :) and check if root is available. - // Ignore the 'use_root' preference, because we might want to fix ther permission + // Ignore the 'use_root' preference, because we might want to fix the permission // just after the root option has been disabled. if (!Shell.SU.available()) { Log.e(TAG, "Root is not available. Cannot fix permissions."); @@ -137,8 +136,8 @@ public static boolean fixAppDataPermissions(Context context) { */ public static boolean nativeBinaryCanWriteToPath(Context context, String absoluteFolderPath) { final String TOUCH_FILE_NAME = ".stwritetest"; - Boolean useRoot = false; - Boolean prefUseRoot = PreferenceManager.getDefaultSharedPreferences(context) + boolean useRoot = false; + boolean prefUseRoot = PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(Constants.PREF_USE_ROOT, false); if (prefUseRoot && Shell.SU.available()) { useRoot = true; @@ -149,12 +148,10 @@ public static boolean nativeBinaryCanWriteToPath(Context context, String absolut int exitCode = runShellCommand("echo \"\" > \"" + touchFile + "\"\n", useRoot); if (exitCode != 0) { String error; - switch (exitCode) { - case 1: - error = "Permission denied"; - break; - default: - error = "Shell execution failed"; + if (exitCode == 1) { + error = "Permission denied"; + } else { + error = "Shell execution failed"; } Log.i(TAG, "Failed to write test file '" + touchFile + "', " + error); From 1bc6680828894a499bc77195900c3c9d992409a6 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Fri, 21 Nov 2025 19:09:05 -0800 Subject: [PATCH 16/80] Readme update --- README.md | 103 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 78 insertions(+), 25 deletions(-) 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 From 7e75fca395e682b85fa7847f9c32166461d4a2f0 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Fri, 21 Nov 2025 19:15:05 -0800 Subject: [PATCH 17/80] Configure Kotlin --- app/build.gradle.kts | 9 +++++++++ build.gradle.kts | 2 ++ 2 files changed, 11 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ef8737e4..f1e373a5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,9 @@ +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.12.2" + id("org.jetbrains.kotlin.android") } dependencies { @@ -22,6 +24,7 @@ dependencies { implementation("androidx.constraintlayout:constraintlayout:2.2.1") implementation("com.google.dagger:dagger:2.57.2") + implementation("androidx.core:core-ktx:1.17.0") annotationProcessor("com.google.dagger:dagger-compiler:2.57.2") androidTestImplementation("androidx.test:rules:1.7.0") androidTestImplementation("androidx.annotation:annotation:1.9.1") @@ -86,6 +89,12 @@ android { namespace = "com.nutomic.syncthingandroid" } +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } +} + play { serviceAccountCredentials.set( file(System.getenv("SYNCTHING_RELEASE_PLAY_ACCOUNT_CONFIG_FILE") ?: "keys.json") diff --git a/build.gradle.kts b/build.gradle.kts index 8b494db2..c8716dc6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ buildscript { set("ndkVersionShared", "29.0.14206865") } + val kotlin_version by extra("2.2.0") repositories { gradlePluginPortal() @@ -16,6 +17,7 @@ buildscript { dependencies { classpath("com.android.tools.build:gradle:8.13.1") classpath("com.github.ben-manes:gradle-versions-plugin:0.36.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files From 4b56230e628ea1d8d10d4fa6c25136e58209fa3d Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Fri, 21 Nov 2025 19:24:05 -0800 Subject: [PATCH 18/80] Convert SyncthingService to kotlin --- .../service/SyncthingService.java | 720 ----------------- .../service/SyncthingService.kt | 749 ++++++++++++++++++ 2 files changed, 749 insertions(+), 720 deletions(-) delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java deleted file mode 100644 index 3dfc75c7..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java +++ /dev/null @@ -1,720 +0,0 @@ -package com.nutomic.syncthingandroid.service; - -import android.app.Service; -import android.content.Intent; -import android.content.SharedPreferences; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.CancellationException; -import android.os.Handler; -import android.util.Log; - -import androidx.annotation.Nullable; - -import com.google.common.io.Files; -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.SyncthingApp; -import com.nutomic.syncthingandroid.http.PollWebGuiAvailableTask; -import com.nutomic.syncthingandroid.model.RunConditionCheckResult; -import com.nutomic.syncthingandroid.util.ConfigXml; -import com.nutomic.syncthingandroid.util.PermissionUtil; - -import java.io.File; -import java.io.IOException; -import java.lang.ref.WeakReference; -import java.net.URL; -import java.util.HashSet; -import java.util.Iterator; -import java.util.concurrent.atomic.AtomicReference; - -import javax.inject.Inject; - -/** - * Holds the native syncthing instance and provides an API to access it. - */ -public class SyncthingService extends Service { - - private static final String TAG = "SyncthingService"; - - /** - * Intent action to perform a Syncthing restart. - */ - public static final String ACTION_RESTART = - "com.nutomic.syncthingandroid.service.SyncthingService.RESTART"; - - /** - * Intent action to reset Syncthing's database. - */ - public static final String ACTION_RESET_DATABASE = - "com.nutomic.syncthingandroid.service.SyncthingService.RESET_DATABASE"; - - /** - * Intent action to reset Syncthing's delta indexes. - */ - public static final String ACTION_RESET_DELTAS = - "com.nutomic.syncthingandroid.service.SyncthingService.RESET_DELTAS"; - - public static final String ACTION_REFRESH_NETWORK_INFO = - "com.nutomic.syncthingandroid.service.SyncthingService.REFRESH_NETWORK_INFO"; - - /** - * Intent action to permanently ignore a device connection request. - */ - public static final String ACTION_IGNORE_DEVICE = - "com.nutomic.syncthingandroid.service.SyncthingService.IGNORE_DEVICE"; - - /** - * Intent action to permanently ignore a folder share request. - */ - public static final String ACTION_IGNORE_FOLDER = - "com.nutomic.syncthingandroid.service.SyncthingService.IGNORE_FOLDER"; - - /** - * Intent action to override folder changes. - */ - public static final String ACTION_OVERRIDE_CHANGES = - "com.nutomic.syncthingandroid.service.SyncthingService.OVERRIDE_CHANGES"; - - /** - * Extra used together with ACTION_IGNORE_DEVICE, ACTION_IGNORE_FOLDER. - */ - public static final String EXTRA_NOTIFICATION_ID = - "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_NOTIFICATION_ID"; - - /** - * Extra used together with ACTION_IGNORE_DEVICE - */ - public static final String EXTRA_DEVICE_ID = - "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_DEVICE_ID"; - - /** - * Extra used together with ACTION_IGNORE_DEVICE - */ - public static final String EXTRA_DEVICE_NAME = - "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_DEVICE_NAME"; - - /** - * Extra used together with ACTION_IGNORE_DEVICE - */ - public static final String EXTRA_DEVICE_ADDRESS = - "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_DEVICE_ADDRESS"; - - /** - * Extra used together with ACTION_IGNORE_FOLDER - */ - public static final String EXTRA_FOLDER_ID = - "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_FOLDER_ID"; - - /** - * Extra used together with ACTION_IGNORE_FOLDER - */ - public static final String EXTRA_FOLDER_LABEL = - "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_FOLDER_LABEL"; - - public interface OnServiceStateChangeListener { - void onServiceStateChange(State currentState); - } - - public interface OnRunConditionCheckResultListener { - void onRunConditionCheckResultChanged(RunConditionCheckResult result); - } - - /** - * Indicates the current state of SyncthingService and of Syncthing itself. - */ - public enum State { - /** Service is initializing, Syncthing was not started yet. */ - INIT, - /** Syncthing binary is starting. */ - STARTING, - /** Syncthing binary is running, - * Rest API is available, - * RestApi class read the config and is fully initialized. - */ - ACTIVE, - /** Syncthing binary is shutting down. */ - DISABLED, - /** There is some problem that prevents Syncthing from running. */ - ERROR, - } - - /** - * Initialize the service with State.DISABLED as {@link RunConditionMonitor} will - * send an update if we should run the binary after it got instantiated in - * {@link onStartCommand}. - */ - private State mCurrentState = State.DISABLED; - private final AtomicReference mCurrentCheckResult = new AtomicReference<>(RunConditionCheckResult.SHOULD_RUN); - - private ConfigXml mConfig; - private @Nullable PollWebGuiAvailableTask mPollWebGuiAvailableTask = null; - private @Nullable RestApi mApi = null; - private @Nullable EventProcessor mEventProcessor = null; - private @Nullable RunConditionMonitor mRunConditionMonitor = null; - private @Nullable SyncthingRunnable mSyncthingRunnable = null; - private ExecutorService mExecutor = null; - private Future mStartupTaskFuture = null; - private Thread mSyncthingRunnableThread = null; - private Handler mHandler; - - private final HashSet mOnServiceStateChangeListeners = new HashSet<>(); - private final HashSet mOnRunConditionCheckResultListeners = new HashSet<>(); - private final SyncthingServiceBinder mBinder = new SyncthingServiceBinder(this); - - @Inject NotificationHandler mNotificationHandler; - @Inject SharedPreferences mPreferences; - - /** - * Object that must be locked upon accessing mCurrentState - */ - private final Object mStateLock = new Object(); - - /** - * Stores the result of the last should run decision received by OnDeviceStateChangedListener. - */ - private boolean mLastDeterminedShouldRun = false; - - /** - * True if a service {@link onDestroy} was requested while syncthing is starting, - * in that case, perform stop in {@link onApiAvailable}. - */ - private boolean mDestroyScheduled = false; - - /** - * True if the user granted the storage permission. - */ - private boolean mStoragePermissionGranted = false; - - /** - * Starts the native binary. - */ - @Override - public void onCreate() { - Log.v(TAG, "onCreate"); - super.onCreate(); - ((SyncthingApp) getApplication()).component().inject(this); - mHandler = new Handler(); - - // Executor for background tasks that previously used AsyncTask - mExecutor = Executors.newSingleThreadExecutor(); - - /* - * If runtime permissions are revoked, android kills and restarts the service. - * see issue: https://github.com/syncthing/syncthing-android/issues/871 - * We need to recheck if we still have the storage permission. - */ - mStoragePermissionGranted = PermissionUtil.haveStoragePermission(this); - - if (mNotificationHandler != null) { - mNotificationHandler.setAppShutdownInProgress(false); - } - } - - /** - * Handles intent actions, e.g. {@link #ACTION_RESTART} - */ - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - Log.v(TAG, "onStartCommand"); - if (!mStoragePermissionGranted) { - Log.e(TAG, "User revoked storage permission. Stopping service."); - if (mNotificationHandler != null) { - mNotificationHandler.showStoragePermissionRevokedNotification(); - } - stopSelf(); - return START_NOT_STICKY; - } - - /* - * Send current service state to listening endpoints. - * This is required that components know about the service State.DISABLED - * if RunConditionMonitor does not send a "shouldRun = true" callback - * to start the binary according to preferences shortly after its creation. - * See {@link mLastDeterminedShouldRun} defaulting to "false". - */ - if (mCurrentState == State.DISABLED) { - synchronized(mStateLock) { - onServiceStateChange(mCurrentState); - } - } - if (mRunConditionMonitor == null) { - /* - * Instantiate the run condition monitor on first onStartCommand and - * enable callback on run condition change affecting the final decision to - * run/terminate syncthing. After initial run conditions are collected - * the first decision is sent to {@link onUpdatedShouldRunDecision}. - */ - mRunConditionMonitor = new RunConditionMonitor(SyncthingService.this, this::onUpdatedShouldRunDecision); - } - mNotificationHandler.updatePersistentNotification(this); - - if (intent == null) - return START_STICKY; - - if (ACTION_RESTART.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { - shutdown(State.INIT, this::launchStartupTask); - } else if (ACTION_RESET_DATABASE.equals(intent.getAction())) { - shutdown(State.INIT, () -> { - new SyncthingRunnable(this, SyncthingRunnable.Command.resetdatabase).run(); - launchStartupTask(); - }); - } else if (ACTION_RESET_DELTAS.equals(intent.getAction())) { - shutdown(State.INIT, () -> { - new SyncthingRunnable(this, SyncthingRunnable.Command.resetdeltas).run(); - launchStartupTask(); - }); - } else if (ACTION_REFRESH_NETWORK_INFO.equals(intent.getAction())) { - mRunConditionMonitor.updateShouldRunDecision(); - } else if (ACTION_IGNORE_DEVICE.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { - // mApi is not null due to State.ACTIVE - assert mApi != null; - mApi.ignoreDevice(intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra(EXTRA_DEVICE_NAME), intent.getStringExtra(EXTRA_DEVICE_ADDRESS)); - mNotificationHandler.cancelConsentNotification(intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)); - } else if (ACTION_IGNORE_FOLDER.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { - // mApi is not null due to State.ACTIVE - assert mApi != null; - mApi.ignoreFolder(intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra(EXTRA_FOLDER_ID), intent.getStringExtra(EXTRA_FOLDER_LABEL)); - mNotificationHandler.cancelConsentNotification(intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)); - } else if (ACTION_OVERRIDE_CHANGES.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { - assert mApi != null; - mApi.overrideChanges(intent.getStringExtra(EXTRA_FOLDER_ID)); - } - return START_STICKY; - } - - /** - * After run conditions monitored by {@link RunConditionMonitor} changed and - * it had an influence on the decision to run/terminate syncthing, this - * function is called to notify this class to run/terminate the syncthing binary. - * {@link #onServiceStateChange} is called while applying the decision change. - */ - private void onUpdatedShouldRunDecision(RunConditionCheckResult result) { - boolean newShouldRunDecision = result.isShouldRun(); - boolean reasonsChanged = !mCurrentCheckResult.getAndSet(result).equals(result); - if (reasonsChanged) { - onRunConditionCheckResultChange(result); - } - - if (newShouldRunDecision != mLastDeterminedShouldRun) { - Log.i(TAG, "shouldRun decision changed to " + newShouldRunDecision + " according to configured run conditions."); - mLastDeterminedShouldRun = newShouldRunDecision; - - // React to the shouldRun condition change. - if (newShouldRunDecision) { - // Start syncthing. - switch (mCurrentState) { - case DISABLED: - case INIT: - // HACK: Make sure there is no syncthing binary left running from an improper - // shutdown (eg Play Store update). - shutdown(State.INIT, this::launchStartupTask); - break; - case STARTING: - case ACTIVE: - case ERROR: - break; - default: - break; - } - } else { - // Stop syncthing. - if (mCurrentState == State.DISABLED) { - return; - } - Log.v(TAG, "Stopping syncthing"); - shutdown(State.DISABLED, () -> {}); - } - } - } - - /** - * Prepares to launch the syncthing binary. - */ - private void launchStartupTask () { - Log.v(TAG, "Starting syncthing"); - synchronized(mStateLock) { - if (mCurrentState != State.INIT) { - Log.e(TAG, "launchStartupTask: Wrong state " + mCurrentState + " detected. Cancelling."); - return; - } - } - - // Safety check: Log warning if a previously launched startup task did not finish properly. - if (startupTaskIsRunning()) { - Log.w(TAG, "launchStartupTask: StartupTask is still running. Skipped starting it twice."); - return; - } - onServiceStateChange(State.STARTING); - if (mExecutor == null) { - mExecutor = Executors.newSingleThreadExecutor(); - } - mStartupTaskFuture = mExecutor.submit(new StartupTask(this)); - } - - private boolean startupTaskIsRunning() { - return mStartupTaskFuture != null && !mStartupTaskFuture.isDone(); - } - - /** - * Sets up the initial configuration, and updates the config when coming from an old - * version. - */ - private static class StartupTask implements Runnable { - private final WeakReference refSyncthingService; - - StartupTask(SyncthingService context) { - refSyncthingService = new WeakReference<>(context); - } - - @Override - public void run() { - SyncthingService syncthingService = refSyncthingService.get(); - if (syncthingService == null) { - return; - } - try { - syncthingService.mConfig = new ConfigXml(syncthingService); - syncthingService.mConfig.updateIfNeeded(); - } catch (ConfigXml.OpenConfigException e) { - syncthingService.mNotificationHandler.showCrashedNotification(R.string.config_create_failed, true); - synchronized (syncthingService.mStateLock) { - syncthingService.onServiceStateChange(State.ERROR); - } - return; - } - - // Post back to the main thread to run the completion callback - SyncthingService svc = refSyncthingService.get(); - if (svc != null && svc.mHandler != null) { - svc.mHandler.post(svc::onStartupTaskCompleteListener); - } - } - } - - /** - * Callback on {@link StartupTask#onPostExecute}. - */ - private void onStartupTaskCompleteListener() { - if (mApi == null) { - mApi = new RestApi(this, mConfig.getWebGuiUrl(), mConfig.getApiKey(), - this::onApiAvailable, () -> onServiceStateChange(mCurrentState)); - Log.i(TAG, "Web GUI will be available at " + mConfig.getWebGuiUrl()); - } - - // Start the syncthing binary. - if (mSyncthingRunnable != null || mSyncthingRunnableThread != null) { - Log.e(TAG, "onStartupTaskCompleteListener: Syncthing binary lifecycle violated"); - return; - } - mSyncthingRunnable = new SyncthingRunnable(this, SyncthingRunnable.Command.main); - mSyncthingRunnableThread = new Thread(mSyncthingRunnable); - mSyncthingRunnableThread.start(); - - /* - * Wait for the web-gui of the native syncthing binary to come online. - * - * In case the binary is to be stopped, also be aware that another thread could request - * to stop the binary in the time while waiting for the GUI to become active. See the comment - * for {@link SyncthingService#onDestroy} for details. - */ - if (mPollWebGuiAvailableTask == null) { - mPollWebGuiAvailableTask = new PollWebGuiAvailableTask( - this, getWebGuiUrl(), mConfig.getApiKey(), result -> { - Log.i(TAG, "Web GUI has come online at " + mConfig.getWebGuiUrl()); - if (mApi != null) { - mApi.readConfigFromRestApi(); - } - } - ); - } - } - - /** - * Called when {@link RestApi#checkReadConfigFromRestApiCompleted} detects - * the RestApi class has been fully initialized. - * UI stressing results in mApi getting null on simultaneous shutdown, so - * we check it for safety. - */ - private void onApiAvailable() { - if (mApi == null) { - Log.e(TAG, "onApiAvailable: Did we stop the binary during startup? mApi == null"); - return; - } - synchronized (mStateLock) { - if (mCurrentState != State.STARTING) { - Log.e(TAG, "onApiAvailable: Wrong state " + mCurrentState + " detected. Cancelling callback."); - return; - } - onServiceStateChange(State.ACTIVE); - } - - /* - * If the service instance got an onDestroy() event while being in - * State.STARTING we'll trigger the service onDestroy() now. this - * allows the syncthing binary to get gracefully stopped. - */ - if (mDestroyScheduled) { - mDestroyScheduled = false; - stopSelf(); - return; - } - - if (mEventProcessor == null) { - mEventProcessor = new EventProcessor(SyncthingService.this, mApi); - mEventProcessor.start(); - } - } - - @Override - public SyncthingServiceBinder onBind(Intent intent) { - return mBinder; - } - - /** - * Stops the native binary. - * Shuts down RunConditionMonitor instance. - */ - @Override - public void onDestroy() { - Log.v(TAG, "onDestroy"); - if (mRunConditionMonitor != null) { - /* - * Shut down the OnDeviceStateChangedListener so we won't get interrupted by run - * condition events that occur during shutdown. - */ - mRunConditionMonitor.shutdown(); - } - if (mNotificationHandler != null) { - mNotificationHandler.setAppShutdownInProgress(true); - } - if (mStoragePermissionGranted) { - synchronized (mStateLock) { - if (mCurrentState == State.STARTING) { - Log.i(TAG, "Delay shutting down syncthing binary until initialisation finished"); - mDestroyScheduled = true; - } else { - Log.i(TAG, "Shutting down syncthing binary immediately"); - shutdown(State.DISABLED, () -> {}); - } - } - } else { - // If the storage permission got revoked, we did not start the binary and - // are in State.INIT requiring an immediate shutdown of this service class. - Log.i(TAG, "Shutting down syncthing binary due to missing storage permission."); - shutdown(State.DISABLED, () -> {}); - } - super.onDestroy(); - if (mExecutor != null) { - mExecutor.shutdownNow(); - mExecutor = null; - } - } - - /** - * Stop Syncthing and all helpers like event processor and api handler. - *

- * Sets {@link #mCurrentState} to newState, and calls onKilledListener once Syncthing is killed. - */ - private void shutdown(State newState, SyncthingRunnable.OnSyncthingKilled onKilledListener) { - Log.i(TAG, "Shutting down background service"); - synchronized(mStateLock) { - onServiceStateChange(newState); - } - - if (mPollWebGuiAvailableTask != null) { - mPollWebGuiAvailableTask.cancelRequestsAndCallback(); - mPollWebGuiAvailableTask = null; - } - - if (mEventProcessor != null) { - mEventProcessor.stop(); - mEventProcessor = null; - } - - if (mApi != null) { - mApi.shutdown(); - mApi = null; - } - - if (mSyncthingRunnable != null) { - mSyncthingRunnable.killSyncthing(); - if (mSyncthingRunnableThread != null) { - Log.v(TAG, "Waiting for mSyncthingRunnableThread to finish after killSyncthing ..."); - try { - mSyncthingRunnableThread.join(); - } catch (InterruptedException e) { - Log.w(TAG, "mSyncthingRunnableThread InterruptedException"); - } - Log.v(TAG, "Finished mSyncthingRunnableThread."); - mSyncthingRunnableThread = null; - } - mSyncthingRunnable = null; - } - if (startupTaskIsRunning()) { - mStartupTaskFuture.cancel(true); - Log.v(TAG, "Waiting for mStartupTask to finish after cancelling ..."); - try { - mStartupTaskFuture.get(); - } catch (Exception ignored) { - } - mStartupTaskFuture = null; - } - onKilledListener.onKilled(); - } - - public @Nullable RestApi getApi() { - return mApi; - } - - /** - * Force re-evaluating run conditions immediately e.g. after - * preferences were modified by {@link SettingsActivity}. - */ - public void evaluateRunConditions() { - if (mRunConditionMonitor == null) { - return; - } - Log.v(TAG, "Forced re-evaluating run conditions ..."); - mRunConditionMonitor.updateShouldRunDecision(); - } - - /** - * Register a listener for the syncthing API state changing. - *

- * The listener is called immediately with the current state, and again whenever the state - * changes. The call is always from the GUI thread. - * - * @see #unregisterOnServiceStateChangeListener - */ - public void registerOnServiceStateChangeListener(OnServiceStateChangeListener listener) { - // Make sure we don't send an invalid state or syncthing might show a "disabled" message - // when it's just starting up. - listener.onServiceStateChange(mCurrentState); - mOnServiceStateChangeListeners.add(listener); - } - - /** - * Unregisters a previously registered listener. - * - * @see #registerOnServiceStateChangeListener - */ - public void unregisterOnServiceStateChangeListener(OnServiceStateChangeListener listener) { - mOnServiceStateChangeListeners.remove(listener); - } - - /** - * Called to notify listeners of an API change. - */ - private void onServiceStateChange(State newState) { - Log.v(TAG, "onServiceStateChange: from " + mCurrentState + " to " + newState); - mCurrentState = newState; - mHandler.post(() -> { - mNotificationHandler.updatePersistentNotification(this); - for (Iterator i = mOnServiceStateChangeListeners.iterator(); - i.hasNext(); ) { - OnServiceStateChangeListener listener = i.next(); - if (listener != null) { - listener.onServiceStateChange(mCurrentState); - } else { - i.remove(); - } - } - }); - } - - public void registerOnRunConditionCheckResultChange(OnRunConditionCheckResultListener listener) { - listener.onRunConditionCheckResultChanged(mCurrentCheckResult.get()); - mOnRunConditionCheckResultListeners.add(listener); - } - - public void unregisterOnRunConditionCheckResultChange(OnRunConditionCheckResultListener listener) { - mOnRunConditionCheckResultListeners.remove(listener); - } - - private void onRunConditionCheckResultChange(RunConditionCheckResult result) { - mHandler.post(() -> { - for (Iterator i = mOnRunConditionCheckResultListeners.iterator(); - i.hasNext(); ) { - OnRunConditionCheckResultListener listener = i.next(); - if (listener != null) { - listener.onRunConditionCheckResultChanged(result); - } else { - i.remove(); - } - } - }); - } - - - public URL getWebGuiUrl() { - return mConfig.getWebGuiUrl(); - } - - public State getCurrentState() { - return mCurrentState; - } - - public RunConditionCheckResult getCurrentRunConditionCheckResult() { - return mCurrentCheckResult.get(); - } - - public NotificationHandler getNotificationHandler() { - return mNotificationHandler; - } - - /** - * Exports the local config and keys to {@link Constants#EXPORT_PATH}. - */ - public void exportConfig() { - Constants.EXPORT_PATH.mkdirs(); - try { - Files.copy(Constants.getConfigFile(this), - new File(Constants.EXPORT_PATH, Constants.CONFIG_FILE)); - Files.copy(Constants.getPrivateKeyFile(this), - new File(Constants.EXPORT_PATH, Constants.PRIVATE_KEY_FILE)); - Files.copy(Constants.getPublicKeyFile(this), - new File(Constants.EXPORT_PATH, Constants.PUBLIC_KEY_FILE)); - Files.copy(Constants.getHttpsCertFile(this), - new File(Constants.EXPORT_PATH, Constants.HTTPS_CERT_FILE)); - Files.copy(Constants.getHttpsKeyFile(this), - new File(Constants.EXPORT_PATH, Constants.HTTPS_KEY_FILE)); - } catch (IOException e) { - Log.w(TAG, "Failed to export config", e); - } - } - - /** - * Imports config and keys from {@link Constants#EXPORT_PATH}. - * - * @return True if the import was successful, false otherwise (eg if files aren't found). - */ - public boolean importConfig() { - File config = new File(Constants.EXPORT_PATH, Constants.CONFIG_FILE); - File privateKey = new File(Constants.EXPORT_PATH, Constants.PRIVATE_KEY_FILE); - File publicKey = new File(Constants.EXPORT_PATH, Constants.PUBLIC_KEY_FILE); - File httpsCert = new File(Constants.EXPORT_PATH, Constants.HTTPS_CERT_FILE); - File httpsKey = new File(Constants.EXPORT_PATH, Constants.HTTPS_KEY_FILE); - if (!config.exists() || !privateKey.exists() || !publicKey.exists()) - return false; - shutdown(State.INIT, () -> { - try { - Files.copy(config, Constants.getConfigFile(this)); - Files.copy(privateKey, Constants.getPrivateKeyFile(this)); - Files.copy(publicKey, Constants.getPublicKeyFile(this)); - } catch (IOException e) { - Log.w(TAG, "Failed to import config", e); - } - if (httpsCert.exists() && httpsKey.exists()) { - try { - Files.copy(httpsCert, Constants.getHttpsCertFile(this)); - Files.copy(httpsKey, Constants.getHttpsKeyFile(this)); - } catch (IOException e) { - Log.w(TAG, "Failed to import HTTPS config files", e); - } - } - launchStartupTask(); - }); - return true; - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt new file mode 100644 index 00000000..1498cef7 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt @@ -0,0 +1,749 @@ +package com.nutomic.syncthingandroid.service + +import android.app.Service +import android.content.Intent +import android.content.SharedPreferences +import android.os.Handler +import android.util.Log +import com.google.common.io.Files +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.SyncthingApp +import com.nutomic.syncthingandroid.http.PollWebGuiAvailableTask +import com.nutomic.syncthingandroid.model.RunConditionCheckResult +import com.nutomic.syncthingandroid.service.SyncthingRunnable.OnSyncthingKilled +import com.nutomic.syncthingandroid.util.ConfigXml +import com.nutomic.syncthingandroid.util.ConfigXml.OpenConfigException +import com.nutomic.syncthingandroid.util.PermissionUtil +import java.io.File +import java.io.IOException +import java.lang.ref.WeakReference +import java.net.URL +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject + +/** + * Holds the native syncthing instance and provides an API to access it. + */ +class SyncthingService : Service() { + interface OnServiceStateChangeListener { + fun onServiceStateChange(currentState: State?) + } + + interface OnRunConditionCheckResultListener { + fun onRunConditionCheckResultChanged(result: RunConditionCheckResult?) + } + + /** + * Indicates the current state of SyncthingService and of Syncthing itself. + */ + enum class State { + /** Service is initializing, Syncthing was not started yet. */ + INIT, + + /** Syncthing binary is starting. */ + STARTING, + + /** Syncthing binary is running, + * Rest API is available, + * RestApi class read the config and is fully initialized. + */ + ACTIVE, + + /** Syncthing binary is shutting down. */ + DISABLED, + + /** There is some problem that prevents Syncthing from running. */ + ERROR, + } + + /** + * Initialize the service with State.DISABLED as [RunConditionMonitor] will + * send an update if we should run the binary after it got instantiated in + * [onStartCommand]. + */ + var currentState: State = State.DISABLED + private set + private val mCurrentCheckResult = + AtomicReference(RunConditionCheckResult.SHOULD_RUN) + + private var mConfig: ConfigXml? = null + private var mPollWebGuiAvailableTask: PollWebGuiAvailableTask? = null + var api: RestApi? = null + private set + private var mEventProcessor: EventProcessor? = null + private var mRunConditionMonitor: RunConditionMonitor? = null + private var mSyncthingRunnable: SyncthingRunnable? = null + private var mExecutor: ExecutorService? = null + private var mStartupTaskFuture: Future<*>? = null + private var mSyncthingRunnableThread: Thread? = null + private var mHandler: Handler? = null + + private val mOnServiceStateChangeListeners = HashSet() + private val mOnRunConditionCheckResultListeners = HashSet() + private val mBinder = SyncthingServiceBinder(this) + + @Inject + var notificationHandler: NotificationHandler? = null + + @JvmField + @Inject + var mPreferences: SharedPreferences? = null + + /** + * Object that must be locked upon accessing mCurrentState + */ + private val mStateLock = Any() + + /** + * Stores the result of the last should run decision received by OnDeviceStateChangedListener. + */ + private var mLastDeterminedShouldRun = false + + /** + * True if a service [onDestroy] was requested while syncthing is starting, + * in that case, perform stop in [onApiAvailable]. + */ + private var mDestroyScheduled = false + + /** + * True if the user granted the storage permission. + */ + private var mStoragePermissionGranted = false + + /** + * Starts the native binary. + */ + override fun onCreate() { + Log.v(TAG, "onCreate") + super.onCreate() + (application as SyncthingApp).component().inject(this) + mHandler = Handler() + + // Executor for background tasks that previously used AsyncTask + mExecutor = Executors.newSingleThreadExecutor() + + /* + * If runtime permissions are revoked, android kills and restarts the service. + * see issue: https://github.com/syncthing/syncthing-android/issues/871 + * We need to recheck if we still have the storage permission. + */ + mStoragePermissionGranted = PermissionUtil.haveStoragePermission(this) + + if (this.notificationHandler != null) { + notificationHandler!!.setAppShutdownInProgress(false) + } + } + + /** + * Handles intent actions, e.g. [.ACTION_RESTART] + */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.v(TAG, "onStartCommand") + if (!mStoragePermissionGranted) { + Log.e(TAG, "User revoked storage permission. Stopping service.") + if (this.notificationHandler != null) { + notificationHandler!!.showStoragePermissionRevokedNotification() + } + stopSelf() + return START_NOT_STICKY + } + + /* + * Send current service state to listening endpoints. + * This is required that components know about the service State.DISABLED + * if RunConditionMonitor does not send a "shouldRun = true" callback + * to start the binary according to preferences shortly after its creation. + * See {@link mLastDeterminedShouldRun} defaulting to "false". + */ + if (this.currentState == State.DISABLED) { + synchronized(mStateLock) { + onServiceStateChange(this.currentState) + } + } + if (mRunConditionMonitor == null) { + /* + * Instantiate the run condition monitor on first onStartCommand and + * enable callback on run condition change affecting the final decision to + * run/terminate syncthing. After initial run conditions are collected + * the first decision is sent to {@link onUpdatedShouldRunDecision}. + */ + mRunConditionMonitor = RunConditionMonitor( + this@SyncthingService + ) { result: RunConditionCheckResult? -> + this.onUpdatedShouldRunDecision( + result!! + ) + } + } + notificationHandler!!.updatePersistentNotification(this) + + if (intent == null) return START_STICKY + + if (ACTION_RESTART == intent.action && this.currentState == State.ACTIVE) { + shutdown(State.INIT) { this.launchStartupTask() } + } else if (ACTION_RESET_DATABASE == intent.action) { + shutdown(State.INIT) { + SyncthingRunnable(this, SyncthingRunnable.Command.resetdatabase).run() + launchStartupTask() + } + } else if (ACTION_RESET_DELTAS == intent.action) { + shutdown(State.INIT) { + SyncthingRunnable(this, SyncthingRunnable.Command.resetdeltas).run() + launchStartupTask() + } + } else if (ACTION_REFRESH_NETWORK_INFO == intent.action) { + mRunConditionMonitor!!.updateShouldRunDecision() + } else if (ACTION_IGNORE_DEVICE == intent.action && this.currentState == State.ACTIVE) { + // mApi is not null due to State.ACTIVE + checkNotNull(this.api) + api!!.ignoreDevice( + intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra( + EXTRA_DEVICE_NAME + ), intent.getStringExtra(EXTRA_DEVICE_ADDRESS) + ) + notificationHandler!!.cancelConsentNotification( + intent.getIntExtra( + EXTRA_NOTIFICATION_ID, + 0 + ) + ) + } else if (ACTION_IGNORE_FOLDER == intent.action && this.currentState == State.ACTIVE) { + // mApi is not null due to State.ACTIVE + checkNotNull(this.api) + api!!.ignoreFolder( + intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra( + EXTRA_FOLDER_ID + ), intent.getStringExtra(EXTRA_FOLDER_LABEL) + ) + notificationHandler!!.cancelConsentNotification( + intent.getIntExtra( + EXTRA_NOTIFICATION_ID, + 0 + ) + ) + } else if (ACTION_OVERRIDE_CHANGES == intent.action && this.currentState == State.ACTIVE) { + checkNotNull(this.api) + api!!.overrideChanges(intent.getStringExtra(EXTRA_FOLDER_ID)) + } + return START_STICKY + } + + /** + * After run conditions monitored by [RunConditionMonitor] changed and + * it had an influence on the decision to run/terminate syncthing, this + * function is called to notify this class to run/terminate the syncthing binary. + * [.onServiceStateChange] is called while applying the decision change. + */ + private fun onUpdatedShouldRunDecision(result: RunConditionCheckResult) { + val newShouldRunDecision = result.isShouldRun + val reasonsChanged = mCurrentCheckResult.getAndSet(result) != result + if (reasonsChanged) { + onRunConditionCheckResultChange(result) + } + + if (newShouldRunDecision != mLastDeterminedShouldRun) { + Log.i( + TAG, + "shouldRun decision changed to $newShouldRunDecision according to configured run conditions." + ) + mLastDeterminedShouldRun = newShouldRunDecision + + // React to the shouldRun condition change. + if (newShouldRunDecision) { + // Start syncthing. + when (this.currentState) { + State.DISABLED, State.INIT -> // HACK: Make sure there is no syncthing binary left running from an improper + // shutdown (eg Play Store update). + shutdown(State.INIT) { this.launchStartupTask() } + + State.STARTING, State.ACTIVE, State.ERROR -> {} + } + } else { + // Stop syncthing. + if (this.currentState == State.DISABLED) { + return + } + Log.v(TAG, "Stopping syncthing") + shutdown(State.DISABLED) {} + } + } + } + + /** + * Prepares to launch the syncthing binary. + */ + private fun launchStartupTask() { + Log.v(TAG, "Starting syncthing") + synchronized(mStateLock) { + if (this.currentState != State.INIT) { + Log.e( + TAG, + "launchStartupTask: Wrong state " + this.currentState + " detected. Cancelling." + ) + return + } + } + + // Safety check: Log warning if a previously launched startup task did not finish properly. + if (startupTaskIsRunning()) { + Log.w( + TAG, + "launchStartupTask: StartupTask is still running. Skipped starting it twice." + ) + return + } + onServiceStateChange(State.STARTING) + if (mExecutor == null) { + mExecutor = Executors.newSingleThreadExecutor() + } + mStartupTaskFuture = mExecutor!!.submit(StartupTask(this)) + } + + private fun startupTaskIsRunning(): Boolean { + return mStartupTaskFuture != null && !mStartupTaskFuture!!.isDone + } + + /** + * Sets up the initial configuration, and updates the config when coming from an old + * version. + */ + private class StartupTask(context: SyncthingService?) : Runnable { + private val refSyncthingService: WeakReference = WeakReference(context) + + override fun run() { + val syncthingService = refSyncthingService.get() ?: return + try { + syncthingService.mConfig = ConfigXml(syncthingService) + syncthingService.mConfig!!.updateIfNeeded() + } catch (_: OpenConfigException) { + syncthingService.notificationHandler!!.showCrashedNotification( + R.string.config_create_failed, + true + ) + synchronized(syncthingService.mStateLock) { + syncthingService.onServiceStateChange( + State.ERROR + ) + } + return + } + + // Post back to the main thread to run the completion callback + val svc = refSyncthingService.get() + if (svc != null && svc.mHandler != null) { + svc.mHandler!!.post { svc.onStartupTaskCompleteListener() } + } + } + } + + /** + * Callback on [StartupTask.onPostExecute]. + */ + private fun onStartupTaskCompleteListener() { + if (this.api == null) { + this.api = RestApi( + this, mConfig!!.getWebGuiUrl(), mConfig!!.apiKey, + { this.onApiAvailable() }, { + onServiceStateChange( + this.currentState + ) + }) + Log.i(TAG, "Web GUI will be available at " + mConfig!!.getWebGuiUrl()) + } + + // Start the syncthing binary. + if (mSyncthingRunnable != null || mSyncthingRunnableThread != null) { + Log.e(TAG, "onStartupTaskCompleteListener: Syncthing binary lifecycle violated") + return + } + mSyncthingRunnable = SyncthingRunnable(this, SyncthingRunnable.Command.main) + mSyncthingRunnableThread = Thread(mSyncthingRunnable) + mSyncthingRunnableThread!!.start() + + /* + * Wait for the web-gui of the native syncthing binary to come online. + * + * In case the binary is to be stopped, also be aware that another thread could request + * to stop the binary in the time while waiting for the GUI to become active. See the comment + * for {@link SyncthingService#onDestroy} for details. + */ + if (mPollWebGuiAvailableTask == null) { + mPollWebGuiAvailableTask = PollWebGuiAvailableTask( + this, this.webGuiUrl, mConfig!!.apiKey + ) { _: String? -> + Log.i(TAG, "Web GUI has come online at " + mConfig!!.getWebGuiUrl()) + if (this.api != null) { + api!!.readConfigFromRestApi() + } + } + } + } + + /** + * Called when [RestApi.checkReadConfigFromRestApiCompleted] detects + * the RestApi class has been fully initialized. + * UI stressing results in mApi getting null on simultaneous shutdown, so + * we check it for safety. + */ + private fun onApiAvailable() { + if (this.api == null) { + Log.e(TAG, "onApiAvailable: Did we stop the binary during startup? mApi == null") + return + } + synchronized(mStateLock) { + if (this.currentState != State.STARTING) { + Log.e( + TAG, + "onApiAvailable: Wrong state " + this.currentState + " detected. Cancelling callback." + ) + return + } + onServiceStateChange(State.ACTIVE) + } + + /* + * If the service instance got an onDestroy() event while being in + * State.STARTING we'll trigger the service onDestroy() now. this + * allows the syncthing binary to get gracefully stopped. + */ + if (mDestroyScheduled) { + mDestroyScheduled = false + stopSelf() + return + } + + if (mEventProcessor == null) { + mEventProcessor = EventProcessor(this@SyncthingService, this.api) + mEventProcessor!!.start() + } + } + + override fun onBind(intent: Intent?): SyncthingServiceBinder { + return mBinder + } + + /** + * Stops the native binary. + * Shuts down RunConditionMonitor instance. + */ + override fun onDestroy() { + Log.v(TAG, "onDestroy") + if (mRunConditionMonitor != null) { + /* + * Shut down the OnDeviceStateChangedListener so we won't get interrupted by run + * condition events that occur during shutdown. + */ + mRunConditionMonitor!!.shutdown() + } + if (this.notificationHandler != null) { + notificationHandler!!.setAppShutdownInProgress(true) + } + if (mStoragePermissionGranted) { + synchronized(mStateLock) { + if (this.currentState == State.STARTING) { + Log.i(TAG, "Delay shutting down syncthing binary until initialisation finished") + mDestroyScheduled = true + } else { + Log.i(TAG, "Shutting down syncthing binary immediately") + shutdown(State.DISABLED) {} + } + } + } else { + // If the storage permission got revoked, we did not start the binary and + // are in State.INIT requiring an immediate shutdown of this service class. + Log.i(TAG, "Shutting down syncthing binary due to missing storage permission.") + shutdown(State.DISABLED) {} + } + super.onDestroy() + if (mExecutor != null) { + mExecutor!!.shutdownNow() + mExecutor = null + } + } + + /** + * Stop Syncthing and all helpers like event processor and api handler. + * + * + * Sets [.mCurrentState] to newState, and calls onKilledListener once Syncthing is killed. + */ + private fun shutdown(newState: State, onKilledListener: OnSyncthingKilled) { + Log.i(TAG, "Shutting down background service") + synchronized(mStateLock) { + onServiceStateChange(newState) + } + + if (mPollWebGuiAvailableTask != null) { + mPollWebGuiAvailableTask!!.cancelRequestsAndCallback() + mPollWebGuiAvailableTask = null + } + + if (mEventProcessor != null) { + mEventProcessor!!.stop() + mEventProcessor = null + } + + if (this.api != null) { + api!!.shutdown() + this.api = null + } + + if (mSyncthingRunnable != null) { + mSyncthingRunnable!!.killSyncthing() + if (mSyncthingRunnableThread != null) { + Log.v(TAG, "Waiting for mSyncthingRunnableThread to finish after killSyncthing ...") + try { + mSyncthingRunnableThread!!.join() + } catch (_: InterruptedException) { + Log.w(TAG, "mSyncthingRunnableThread InterruptedException") + } + Log.v(TAG, "Finished mSyncthingRunnableThread.") + mSyncthingRunnableThread = null + } + mSyncthingRunnable = null + } + if (startupTaskIsRunning()) { + mStartupTaskFuture!!.cancel(true) + Log.v(TAG, "Waiting for mStartupTask to finish after cancelling ...") + try { + mStartupTaskFuture!!.get() + } catch (_: Exception) { + } + mStartupTaskFuture = null + } + onKilledListener.onKilled() + } + + /** + * Force re-evaluating run conditions immediately e.g. after + * preferences were modified by [SettingsActivity]. + */ + fun evaluateRunConditions() { + if (mRunConditionMonitor == null) { + return + } + Log.v(TAG, "Forced re-evaluating run conditions ...") + mRunConditionMonitor!!.updateShouldRunDecision() + } + + /** + * Register a listener for the syncthing API state changing. + * + * + * The listener is called immediately with the current state, and again whenever the state + * changes. The call is always from the GUI thread. + * + * @see .unregisterOnServiceStateChangeListener + */ + fun registerOnServiceStateChangeListener(listener: OnServiceStateChangeListener) { + // Make sure we don't send an invalid state or syncthing might show a "disabled" message + // when it's just starting up. + listener.onServiceStateChange(this.currentState) + mOnServiceStateChangeListeners.add(listener) + } + + /** + * Unregisters a previously registered listener. + * + * @see .registerOnServiceStateChangeListener + */ + fun unregisterOnServiceStateChangeListener(listener: OnServiceStateChangeListener?) { + mOnServiceStateChangeListeners.remove(listener) + } + + /** + * Called to notify listeners of an API change. + */ + private fun onServiceStateChange(newState: State) { + Log.v(TAG, "onServiceStateChange: from " + this.currentState + " to " + newState) + this.currentState = newState + mHandler!!.post { + notificationHandler!!.updatePersistentNotification(this) + val i = mOnServiceStateChangeListeners.iterator() + while (i.hasNext()) { + val listener = i.next() + if (listener != null) { + listener.onServiceStateChange(this.currentState) + } else { + i.remove() + } + } + } + } + + fun registerOnRunConditionCheckResultChange(listener: OnRunConditionCheckResultListener) { + listener.onRunConditionCheckResultChanged(mCurrentCheckResult.get()) + mOnRunConditionCheckResultListeners.add(listener) + } + + fun unregisterOnRunConditionCheckResultChange(listener: OnRunConditionCheckResultListener?) { + mOnRunConditionCheckResultListeners.remove(listener) + } + + private fun onRunConditionCheckResultChange(result: RunConditionCheckResult?) { + mHandler!!.post { + val i = mOnRunConditionCheckResultListeners.iterator() + while (i.hasNext()) { + val listener = i.next() + if (listener != null) { + listener.onRunConditionCheckResultChanged(result) + } else { + i.remove() + } + } + } + } + + + val webGuiUrl: URL? + get() = mConfig!!.getWebGuiUrl() + + val currentRunConditionCheckResult: RunConditionCheckResult? + get() = mCurrentCheckResult.get() + + /** + * Exports the local config and keys to [Constants.EXPORT_PATH]. + */ + fun exportConfig() { + Constants.EXPORT_PATH.mkdirs() + try { + Files.copy( + Constants.getConfigFile(this), + File(Constants.EXPORT_PATH, Constants.CONFIG_FILE) + ) + Files.copy( + Constants.getPrivateKeyFile(this), + File(Constants.EXPORT_PATH, Constants.PRIVATE_KEY_FILE) + ) + Files.copy( + Constants.getPublicKeyFile(this), + File(Constants.EXPORT_PATH, Constants.PUBLIC_KEY_FILE) + ) + Files.copy( + Constants.getHttpsCertFile(this), + File(Constants.EXPORT_PATH, Constants.HTTPS_CERT_FILE) + ) + Files.copy( + Constants.getHttpsKeyFile(this), + File(Constants.EXPORT_PATH, Constants.HTTPS_KEY_FILE) + ) + } catch (e: IOException) { + Log.w(TAG, "Failed to export config", e) + } + } + + /** + * Imports config and keys from [Constants.EXPORT_PATH]. + * + * @return True if the import was successful, false otherwise (eg if files aren't found). + */ + fun importConfig(): Boolean { + val config = File(Constants.EXPORT_PATH, Constants.CONFIG_FILE) + val privateKey = File(Constants.EXPORT_PATH, Constants.PRIVATE_KEY_FILE) + val publicKey = File(Constants.EXPORT_PATH, Constants.PUBLIC_KEY_FILE) + val httpsCert = File(Constants.EXPORT_PATH, Constants.HTTPS_CERT_FILE) + val httpsKey = File(Constants.EXPORT_PATH, Constants.HTTPS_KEY_FILE) + if (!config.exists() || !privateKey.exists() || !publicKey.exists()) return false + shutdown(State.INIT) { + try { + Files.copy(config, Constants.getConfigFile(this)) + Files.copy(privateKey, Constants.getPrivateKeyFile(this)) + Files.copy(publicKey, Constants.getPublicKeyFile(this)) + } catch (e: IOException) { + Log.w(TAG, "Failed to import config", e) + } + if (httpsCert.exists() && httpsKey.exists()) { + try { + Files.copy(httpsCert, Constants.getHttpsCertFile(this)) + Files.copy(httpsKey, Constants.getHttpsKeyFile(this)) + } catch (e: IOException) { + Log.w(TAG, "Failed to import HTTPS config files", e) + } + } + launchStartupTask() + } + return true + } + + companion object { + private const val TAG = "SyncthingService" + + /** + * Intent action to perform a Syncthing restart. + */ + const val ACTION_RESTART: String = + "com.nutomic.syncthingandroid.service.SyncthingService.RESTART" + + /** + * Intent action to reset Syncthing's database. + */ + const val ACTION_RESET_DATABASE: String = + "com.nutomic.syncthingandroid.service.SyncthingService.RESET_DATABASE" + + /** + * Intent action to reset Syncthing's delta indexes. + */ + const val ACTION_RESET_DELTAS: String = + "com.nutomic.syncthingandroid.service.SyncthingService.RESET_DELTAS" + + const val ACTION_REFRESH_NETWORK_INFO: String = + "com.nutomic.syncthingandroid.service.SyncthingService.REFRESH_NETWORK_INFO" + + /** + * Intent action to permanently ignore a device connection request. + */ + const val ACTION_IGNORE_DEVICE: String = + "com.nutomic.syncthingandroid.service.SyncthingService.IGNORE_DEVICE" + + /** + * Intent action to permanently ignore a folder share request. + */ + const val ACTION_IGNORE_FOLDER: String = + "com.nutomic.syncthingandroid.service.SyncthingService.IGNORE_FOLDER" + + /** + * Intent action to override folder changes. + */ + const val ACTION_OVERRIDE_CHANGES: String = + "com.nutomic.syncthingandroid.service.SyncthingService.OVERRIDE_CHANGES" + + /** + * Extra used together with ACTION_IGNORE_DEVICE, ACTION_IGNORE_FOLDER. + */ + const val EXTRA_NOTIFICATION_ID: String = + "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_NOTIFICATION_ID" + + /** + * Extra used together with ACTION_IGNORE_DEVICE + */ + const val EXTRA_DEVICE_ID: String = + "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_DEVICE_ID" + + /** + * Extra used together with ACTION_IGNORE_DEVICE + */ + const val EXTRA_DEVICE_NAME: String = + "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_DEVICE_NAME" + + /** + * Extra used together with ACTION_IGNORE_DEVICE + */ + const val EXTRA_DEVICE_ADDRESS: String = + "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_DEVICE_ADDRESS" + + /** + * Extra used together with ACTION_IGNORE_FOLDER + */ + const val EXTRA_FOLDER_ID: String = + "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_FOLDER_ID" + + /** + * Extra used together with ACTION_IGNORE_FOLDER + */ + const val EXTRA_FOLDER_LABEL: String = + "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_FOLDER_LABEL" + } +} From 46fc743a06c775fe54243ce6326a5f7f6b595f4c Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Fri, 21 Nov 2025 19:49:07 -0800 Subject: [PATCH 19/80] Kotlin conversion --- .../syncthingandroid/DaggerComponent.java | 41 -- .../syncthingandroid/DaggerComponent.kt | 38 ++ .../syncthingandroid/SyncthingApp.java | 42 -- .../nutomic/syncthingandroid/SyncthingApp.kt | 40 ++ .../syncthingandroid/SyncthingModule.java | 33 -- .../syncthingandroid/SyncthingModule.kt | 21 + .../syncthingandroid/util/Compression.java | 57 --- .../syncthingandroid/util/Compression.kt | 45 +++ .../syncthingandroid/util/ConfigXml.java | 362 ----------------- .../syncthingandroid/util/ConfigXml.kt | 374 ++++++++++++++++++ .../syncthingandroid/util/FileUtils.java | 202 ---------- .../syncthingandroid/util/FileUtils.kt | 198 ++++++++++ .../syncthingandroid/util/Languages.java | 180 --------- .../syncthingandroid/util/Languages.kt | 179 +++++++++ 14 files changed, 895 insertions(+), 917 deletions(-) delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/DaggerComponent.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/DaggerComponent.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/SyncthingApp.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/SyncthingApp.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/SyncthingModule.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/SyncthingModule.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/Compression.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/Compression.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/Languages.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/Languages.kt 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..0f33b720 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/SyncthingApp.kt @@ -0,0 +1,40 @@ +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() { + @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..2d4c1fc8 --- /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 android.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/util/Compression.java b/app/src/main/java/com/nutomic/syncthingandroid/util/Compression.java deleted file mode 100644 index 28063ecb..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Compression.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.nutomic.syncthingandroid.util; - -import android.content.Context; - -import com.nutomic.syncthingandroid.R; - -/** - * Device compression attribute helper. This unifies operations between string values as expected by - * Syncthing with string values as displayed to the user and int ordinals as expected by the dialog - * click interface. - */ -public enum Compression { - NONE(0), - METADATA(1), - ALWAYS(2); - - private final int index; - - Compression(int index) { - this.index = index; - } - - public int getIndex() { - return index; - } - - public String getValue(Context context) { - return context.getResources().getStringArray(R.array.compress_values)[index]; - } - - public String getTitle(Context context) { - return context.getResources().getStringArray(R.array.compress_entries)[index]; - } - - public static Compression fromIndex(int index) { - switch (index) { - case 0: - return NONE; - case 2: - return ALWAYS; - default: - return METADATA; - } - } - - public static Compression fromValue(Context context, String value) { - int index = 0; - String[] values = context.getResources().getStringArray(R.array.compress_values); - for (int i = 0; i < values.length; i++) { - if (values[i].equals(value)) { - index = i; - } - } - - return fromIndex(index); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/Compression.kt b/app/src/main/java/com/nutomic/syncthingandroid/util/Compression.kt new file mode 100644 index 00000000..76fa67a7 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Compression.kt @@ -0,0 +1,45 @@ +package com.nutomic.syncthingandroid.util + +import android.content.Context +import com.nutomic.syncthingandroid.R + +/** + * Device compression attribute helper. This unifies operations between string values as expected by + * Syncthing with string values as displayed to the user and int ordinals as expected by the dialog + * click interface. + */ +enum class Compression(val index: Int) { + NONE(0), + METADATA(1), + ALWAYS(2); + + fun getValue(context: Context): String? { + return context.getResources().getStringArray(R.array.compress_values)[index] + } + + fun getTitle(context: Context): String? { + return context.getResources().getStringArray(R.array.compress_entries)[index] + } + + companion object { + fun fromIndex(index: Int): Compression { + when (index) { + 0 -> return Compression.NONE + 2 -> return Compression.ALWAYS + else -> return Compression.METADATA + } + } + + fun fromValue(context: Context, value: String?): Compression { + var index = 0 + val values = context.getResources().getStringArray(R.array.compress_values) + for (i in values.indices) { + if (values[i] == value) { + index = i + } + } + + return fromIndex(index) + } + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java deleted file mode 100644 index c1aaf688..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java +++ /dev/null @@ -1,362 +0,0 @@ -package com.nutomic.syncthingandroid.util; - -import android.content.Context; -import android.content.SharedPreferences; -import android.os.Build; -import android.os.Environment; -import android.text.TextUtils; -import android.util.Log; - -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.service.SyncthingRunnable; - -import org.mindrot.jbcrypt.BCrypt; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; -import org.xml.sax.SAXException; - -import java.io.File; -import java.io.IOException; -import java.net.MalformedURLException; -import java.net.URL; -import java.util.Locale; -import java.util.Random; - -import javax.inject.Inject; -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.transform.Transformer; -import javax.xml.transform.TransformerException; -import javax.xml.transform.TransformerFactory; -import javax.xml.transform.dom.DOMSource; -import javax.xml.transform.stream.StreamResult; - -/** - * Provides direct access to the config.xml file in the file system. - *

- * This class should only be used if the syncthing API is not available (usually during startup). - */ -public class ConfigXml { - - public static class OpenConfigException extends RuntimeException { - } - - private static final String TAG = "ConfigXml"; - private static final int FOLDER_ID_APPENDIX_LENGTH = 4; - - private final Context mContext; - @Inject SharedPreferences mPreferences; - - private final File mConfigFile; - - private Document mConfig; - - public ConfigXml(Context context) throws OpenConfigException { - mContext = context; - mConfigFile = Constants.getConfigFile(mContext); - boolean isFirstStart = !mConfigFile.exists(); - if (isFirstStart) { - Log.i(TAG, "App started for the first time. Generating keys and config."); - new SyncthingRunnable(context, SyncthingRunnable.Command.generate).run(); - } - - readConfig(); - - if (isFirstStart) { - boolean changed = false; - - Log.i(TAG, "Starting syncthing to retrieve local device id."); - String logOutput = new SyncthingRunnable(context, SyncthingRunnable.Command.deviceid).run(true); - String localDeviceID = logOutput.replace("\n", ""); - // Verify local device ID is correctly formatted. - if (localDeviceID.matches("^([A-Z0-9]{7}-){7}[A-Z0-9]{7}$")) { - changed = changeLocalDeviceName(localDeviceID); - } - changed = changeDefaultFolder() || changed; - - // Save changes if we made any. - if (changed) { - saveChanges(); - } - } - } - - private void readConfig() { - if (!mConfigFile.canRead() && !Util.fixAppDataPermissions(mContext)) { - throw new OpenConfigException(); - } - try { - DocumentBuilder db = DocumentBuilderFactory.newInstance().newDocumentBuilder(); - Log.d(TAG, "Trying to read '" + mConfigFile + "'"); - mConfig = db.parse(mConfigFile); - } catch (SAXException | ParserConfigurationException | IOException e) { - Log.w(TAG, "Cannot read '" + mConfigFile + "'", e); - throw new OpenConfigException(); - } - Log.i(TAG, "Loaded Syncthing config file"); - } - - public URL getWebGuiUrl() { - String urlProtocol = Constants.osSupportsTLS12() ? "https" : "http"; - try { - return new URL(urlProtocol + "://" + getGuiElement().getElementsByTagName("address").item(0).getTextContent()); - } catch (MalformedURLException e) { - throw new RuntimeException("Failed to parse web interface URL", e); - } - } - - public String getApiKey() { - return getGuiElement().getElementsByTagName("apikey").item(0).getTextContent(); - } - - public String getUserName() { - return getGuiElement().getElementsByTagName("user").item(0).getTextContent(); - } - - /** - * Updates the config file. - *

- * Sets ignorePerms flag to true on every folder, force enables TLS, sets the - * username/password, and disables weak hash checking. - */ - @SuppressWarnings("SdCardPath") - public void updateIfNeeded() { - boolean changed; - - /* Perform one-time migration tasks on syncthing's config file when coming from an older config version. */ - changed = migrateSyncthingOptions(); - - /* Get refs to important config objects */ - NodeList folders = mConfig.getDocumentElement().getElementsByTagName("folder"); - - /* Section - folders */ - for (int i = 0; i < folders.getLength(); i++) { - Element r = (Element) folders.item(i); - // Set ignorePerms attribute. - if (!r.hasAttribute("ignorePerms") || - !Boolean.parseBoolean(r.getAttribute("ignorePerms"))) { - Log.i(TAG, "Set 'ignorePerms' on folder " + r.getAttribute("id")); - r.setAttribute("ignorePerms", Boolean.toString(true)); - changed = true; - } - - // Set 'hashers' (see https://github.com/syncthing/syncthing-android/issues/384) on the - // given folder. - changed = setConfigElement(r, "hashers", "1") || changed; - } - - /* Section - GUI */ - Element gui = getGuiElement(); - - // Platform-specific: Force REST API and Web UI access to use TLS 1.2 or not. - Boolean forceHttps = Constants.osSupportsTLS12(); - if (!gui.hasAttribute("tls") || - Boolean.parseBoolean(gui.getAttribute("tls")) != forceHttps) { - gui.setAttribute("tls", forceHttps ? "true" : "false"); - changed = true; - } - - // Set user to "syncthing" - changed = setConfigElement(gui, "user", "syncthing") || changed; - - // Set password to the API key - Node password = gui.getElementsByTagName("password").item(0); - if (password == null) { - password = mConfig.createElement("password"); - gui.appendChild(password); - } - String apikey = getApiKey(); - String pw = password.getTextContent(); - boolean passwordOk; - try { - passwordOk = !TextUtils.isEmpty(pw) && BCrypt.checkpw(apikey, pw); - } catch (IllegalArgumentException e) { - Log.w(TAG, "Malformed password", e); - passwordOk = false; - } - if (!passwordOk) { - Log.i(TAG, "Updating password"); - password.setTextContent(BCrypt.hashpw(apikey, BCrypt.gensalt(4))); - changed = true; - } - - /* Section - options */ - // Disable weak hash benchmark for faster startup. - // https://github.com/syncthing/syncthing/issues/4348 - Element options = (Element) mConfig.getDocumentElement() - .getElementsByTagName("options").item(0); - changed = setConfigElement(options, "weakHashSelectionMethod", "never") || changed; - - /* Dismiss "fsWatcherNotification" according to https://github.com/syncthing/syncthing-android/pull/1051 */ - NodeList childNodes = options.getChildNodes(); - for (int i = 0; i < childNodes.getLength(); i++) { - Node node = childNodes.item(i); - if (node.getNodeName().equals("unackedNotificationID")) { - if (node.equals("fsWatcherNotification")) { - Log.i(TAG, "Remove found unackedNotificationID 'fsWatcherNotification'."); - options.removeChild(node); - changed = true; - break; - } - } - } - - // Save changes if we made any. - if (changed) { - saveChanges(); - } - } - - /** - * Updates syncthing options to a version specific target setting in the config file. - * - * Used for one-time config migration from a lower syncthing version to the current version. - * Enables filesystem watcher. - * Returns if changes to the config have been made. - */ - private boolean migrateSyncthingOptions () { - /* Read existing config version */ - int iConfigVersion = Integer.parseInt(mConfig.getDocumentElement().getAttribute("version")); - int iOldConfigVersion = iConfigVersion; - Log.i(TAG, "Found existing config version " + Integer.toString(iConfigVersion)); - - /* Check if we have to do manual migration from version X to Y */ - if (iConfigVersion == 27) { - /* fsWatcher transition - https://github.com/syncthing/syncthing/issues/4882 */ - Log.i(TAG, "Migrating config version " + Integer.toString(iConfigVersion) + " to 28 ..."); - - /* Enable fsWatcher for all folders */ - NodeList folders = mConfig.getDocumentElement().getElementsByTagName("folder"); - for (int i = 0; i < folders.getLength(); i++) { - Element r = (Element) folders.item(i); - - // Enable "fsWatcherEnabled" attribute and set default delay. - Log.i(TAG, "Set 'fsWatcherEnabled', 'fsWatcherDelayS' on folder " + r.getAttribute("id")); - r.setAttribute("fsWatcherEnabled", "true"); - r.setAttribute("fsWatcherDelayS", "10"); - } - - /** - * Set config version to 28 after manual config migration - * This prevents "unackedNotificationID" getting populated - * with the fsWatcher GUI notification. - */ - iConfigVersion = 28; - } - - if (iConfigVersion != iOldConfigVersion) { - mConfig.getDocumentElement().setAttribute("version", Integer.toString(iConfigVersion)); - Log.i(TAG, "New config version is " + Integer.toString(iConfigVersion)); - return true; - } else { - return false; - } - } - - private boolean setConfigElement(Element parent, String tagName, String textContent) { - Node element = parent.getElementsByTagName(tagName).item(0); - if (element == null) { - element = mConfig.createElement(tagName); - parent.appendChild(element); - } - if (!textContent.equals(element.getTextContent())) { - element.setTextContent(textContent); - return true; - } - return false; - } - - private Element getGuiElement() { - return (Element) mConfig.getDocumentElement().getElementsByTagName("gui").item(0); - } - - /** - * Set device model name as device name for Syncthing. - * - * We need to iterate through XML nodes manually, as mConfig.getDocumentElement() will also - * return nested elements inside folder element. We have to check that we only rename the - * device corresponding to the local device ID. - * Returns if changes to the config have been made. - */ - private boolean changeLocalDeviceName(String localDeviceID) { - NodeList childNodes = mConfig.getDocumentElement().getChildNodes(); - for (int i = 0; i < childNodes.getLength(); i++) { - Node node = childNodes.item(i); - if (node.getNodeName().equals("device")) { - if (((Element) node).getAttribute("id").equals(localDeviceID)) { - Log.i(TAG, "changeLocalDeviceName: Rename device ID " + localDeviceID + " to " + Build.MODEL); - ((Element) node).setAttribute("name", Build.MODEL); - return true; - } - } - } - return false; - } - - /** - * Change default folder id to camera and path to camera folder path. - * Returns if changes to the config have been made. - */ - private boolean changeDefaultFolder() { - Element folder = (Element) mConfig.getDocumentElement() - .getElementsByTagName("folder").item(0); - String deviceModel = Build.MODEL - .replace(" ", "_") - .toLowerCase(Locale.US) - .replaceAll("[^a-z0-9_-]", ""); - String defaultFolderId = deviceModel + "_" + generateRandomString(FOLDER_ID_APPENDIX_LENGTH); - folder.setAttribute("label", mContext.getString(R.string.default_folder_label)); - folder.setAttribute("id", mContext.getString(R.string.default_folder_id, defaultFolderId)); - folder.setAttribute("path", Environment - .getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath()); - folder.setAttribute("type", Constants.FOLDER_TYPE_SEND_ONLY); - folder.setAttribute("fsWatcherEnabled", "true"); - folder.setAttribute("fsWatcherDelayS", "10"); - return true; - } - - /** - * Generates a random String with a given length - */ - private String generateRandomString(int length) { - char[] chars = "abcdefghjkmnpqrstuvwxyz123456789".toCharArray(); - Random random = new Random(); - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < length; ++i) { - sb.append(chars[random.nextInt(chars.length)]); - } - return sb.toString(); - } - - /** - * Writes updated mConfig back to file. - */ - private void saveChanges() { - if (!mConfigFile.canWrite() && !Util.fixAppDataPermissions(mContext)) { - Log.w(TAG, "Failed to save updated config. Cannot change the owner of the config file."); - return; - } - - Log.i(TAG, "Writing updated config file"); - File mConfigTempFile = Constants.getConfigTempFile(mContext); - try { - TransformerFactory transformerFactory = TransformerFactory.newInstance(); - Transformer transformer = transformerFactory.newTransformer(); - DOMSource domSource = new DOMSource(mConfig); - StreamResult streamResult = new StreamResult(mConfigTempFile); - transformer.transform(domSource, streamResult); - } catch (TransformerException e) { - Log.w(TAG, "Failed to save temporary config file", e); - return; - } - try { - mConfigTempFile.renameTo(mConfigFile); - } catch (Exception e) { - Log.w(TAG, "Failed to rename temporary config file to original file"); - } - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.kt b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.kt new file mode 100644 index 00000000..fb9616e1 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.kt @@ -0,0 +1,374 @@ +package com.nutomic.syncthingandroid.util + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import android.os.Environment +import android.text.TextUtils +import android.util.Log +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.service.Constants +import com.nutomic.syncthingandroid.service.SyncthingRunnable +import org.mindrot.jbcrypt.BCrypt +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.xml.sax.SAXException +import java.io.File +import java.io.IOException +import java.net.MalformedURLException +import java.net.URL +import java.util.Random +import javax.inject.Inject +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.ParserConfigurationException +import javax.xml.transform.TransformerException +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + +/** + * Provides direct access to the config.xml file in the file system. + * + * + * This class should only be used if the syncthing API is not available (usually during startup). + */ +class ConfigXml(private val mContext: Context) { + class OpenConfigException : RuntimeException() + + @Inject + var mPreferences: SharedPreferences? = null + + private val mConfigFile: File + + private var mConfig: Document? = null + + init { + mConfigFile = Constants.getConfigFile(mContext) + val isFirstStart = !mConfigFile.exists() + if (isFirstStart) { + Log.i(TAG, "App started for the first time. Generating keys and config.") + SyncthingRunnable(mContext, SyncthingRunnable.Command.generate).run() + } + + readConfig() + + if (isFirstStart) { + var changed = false + + Log.i(TAG, "Starting syncthing to retrieve local device id.") + val logOutput = + SyncthingRunnable(mContext, SyncthingRunnable.Command.deviceid).run(true) + val localDeviceID = logOutput.replace("\n", "") + // Verify local device ID is correctly formatted. + if (localDeviceID.matches("^([A-Z0-9]{7}-){7}[A-Z0-9]{7}$".toRegex())) { + changed = changeLocalDeviceName(localDeviceID) + } + changed = changeDefaultFolder() || changed + + // Save changes if we made any. + if (changed) { + saveChanges() + } + } + } + + private fun readConfig() { + if (!mConfigFile.canRead() && !Util.fixAppDataPermissions(mContext)) { + throw OpenConfigException() + } + try { + val db = DocumentBuilderFactory.newInstance().newDocumentBuilder() + Log.d(TAG, "Trying to read '" + mConfigFile + "'") + mConfig = db.parse(mConfigFile) + } catch (e: SAXException) { + Log.w(TAG, "Cannot read '" + mConfigFile + "'", e) + throw OpenConfigException() + } catch (e: ParserConfigurationException) { + Log.w(TAG, "Cannot read '" + mConfigFile + "'", e) + throw OpenConfigException() + } catch (e: IOException) { + Log.w(TAG, "Cannot read '" + mConfigFile + "'", e) + throw OpenConfigException() + } + Log.i(TAG, "Loaded Syncthing config file") + } + + val webGuiUrl: URL + get() { + val urlProtocol = + if (Constants.osSupportsTLS12()) "https" else "http" + try { + return URL( + urlProtocol + "://" + this.guiElement.getElementsByTagName("address").item(0) + .getTextContent() + ) + } catch (e: MalformedURLException) { + throw RuntimeException("Failed to parse web interface URL", e) + } + } + + val apiKey: String? + get() = this.guiElement.getElementsByTagName("apikey").item(0).getTextContent() + + val userName: String? + get() = this.guiElement.getElementsByTagName("user").item(0).getTextContent() + + /** + * Updates the config file. + * + * + * Sets ignorePerms flag to true on every folder, force enables TLS, sets the + * username/password, and disables weak hash checking. + */ + fun updateIfNeeded() { + var changed: Boolean + + /* Perform one-time migration tasks on syncthing's config file when coming from an older config version. */ + changed = migrateSyncthingOptions() + + /* Get refs to important config objects */ + val folders = mConfig!!.getDocumentElement().getElementsByTagName("folder") + + /* Section - folders */ + for (i in 0.. 0) { - if (documentPath.startsWith(File.separator)) { - return volumePath + documentPath; - } else { - return volumePath + File.separator + documentPath; - } - } else { - return volumePath; - } - } - - private static String getVolumePath(final String volumeId, Context context) { - try { - if (HOME_VOLUME_NAME.equals(volumeId)) { - Log.v(TAG, "getVolumePath: isHomeVolume"); - // Reading the environment var avoids hard coding the case of the "documents" folder. - return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS).getAbsolutePath(); - } - if (DOWNLOADS_VOLUME_NAME.equals(volumeId)) { - Log.v(TAG, "getVolumePath: isDownloadsVolume"); - // Reading the environment var avoids hard coding the case of the "downloads" folder. - return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath(); - } - - StorageManager mStorageManager = - (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); - Class storageVolumeClazz = Class.forName("android.os.storage.StorageVolume"); - Method getVolumeList = mStorageManager.getClass().getMethod("getVolumeList"); - Method getUuid = storageVolumeClazz.getMethod("getUuid"); - Method isPrimary = storageVolumeClazz.getMethod("isPrimary"); - Object result = getVolumeList.invoke(mStorageManager); - - final int length = Array.getLength(result); - for (int i = 0; i < length; i++) { - Object storageVolumeElement = Array.get(result, i); - String uuid = (String) getUuid.invoke(storageVolumeElement); - Boolean primary = (Boolean) isPrimary.invoke(storageVolumeElement); - Boolean isPrimaryVolume = (primary && PRIMARY_VOLUME_NAME.equals(volumeId)); - Boolean isExternalVolume = ((uuid != null) && uuid.equals(volumeId)); - Log.d(TAG, "Found volume with uuid='" + uuid + - "', volumeId='" + volumeId + - "', primary=" + primary + - ", isPrimaryVolume=" + isPrimaryVolume + - ", isExternalVolume=" + isExternalVolume - ); - if (isPrimaryVolume || isExternalVolume) { - Log.v(TAG, "getVolumePath: isPrimaryVolume || isExternalVolume"); - // Return path if the correct volume corresponding to volumeId was found. - return volumeToPath(storageVolumeElement, storageVolumeClazz); - } - } - } catch (Exception e) { - Log.w(TAG, "getVolumePath exception", e); - } - Log.e(TAG, "getVolumePath failed for volumeId='" + volumeId + "'"); - return null; - } - - private static String volumeToPath(Object storageVolumeElement, Class storageVolumeClazz) throws Exception { - try { - // >= API level 30 - Method getDir = storageVolumeClazz.getMethod("getDirectory"); - File file = (File) getDir.invoke(storageVolumeElement); - return file.getPath(); - } catch (NoSuchMethodException e) { - // Not present in API level 30, available at some earlier point. - Method getPath = storageVolumeClazz.getMethod("getPath"); - return (String) getPath.invoke(storageVolumeElement); - } - } - - /** - * FileProvider does not support converting the absolute path from - * getExternalFilesDir() to a "content://" Uri. As "file://" Uri - * has been blocked since Android 7+, we need to build the Uri - * manually after discovering the first external storage. - * This is crucial to assist the user finding a writeable folder - * to use syncthing's two way sync feature. - */ - public static android.net.Uri getExternalFilesDirUri(Context context) { - try { - /** - * Determine the app's private data folder on external storage if present. - * e.g. "/storage/abcd-efgh/Android/com.nutomic.syncthinandroid/files" - */ - ArrayList externalFilesDir = new ArrayList<>(); - externalFilesDir.addAll(Arrays.asList(context.getExternalFilesDirs(null))); - externalFilesDir.remove(context.getExternalFilesDir(null)); - if (externalFilesDir.size() == 0) { - Log.w(TAG, "Could not determine app's private files directory on external storage."); - return null; - } - String absPath = externalFilesDir.get(0).getAbsolutePath(); - String[] segments = absPath.split("/"); - if (segments.length < 2) { - Log.w(TAG, "Could not extract volumeId from app's private files path '" + absPath + "'"); - return null; - } - // Extract the volumeId, e.g. "abcd-efgh" - String volumeId = segments[2]; - // Build the content Uri for our private "files" folder. - return android.net.Uri.parse( - "content://com.android.externalstorage.documents/document/" + - volumeId + "%3AAndroid%2Fdata%2F" + - context.getPackageName() + "%2Ffiles"); - } catch (Exception e) { - Log.w(TAG, "getExternalFilesDirUri exception", e); - } - return null; - } - - private static String getVolumeIdFromTreeUri(final Uri treeUri) { - final String docId = DocumentsContract.getTreeDocumentId(treeUri); - final String[] split = docId.split(":"); - if (split.length > 0) { - return split[0]; - } else { - return null; - } - } - - private static String getDocumentPathFromTreeUri(final Uri treeUri) { - final String docId = DocumentsContract.getTreeDocumentId(treeUri); - final String[] split = docId.split(":"); - if ((split.length >= 2) && (split[1] != null)) return split[1]; - else return File.separator; - } - - @Nullable - public static String cutTrailingSlash(final String path) { - if (path.endsWith(File.separator)) { - return path.substring(0, path.length() - 1); - } - return path; - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.kt b/app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.kt new file mode 100644 index 00000000..310ff9e5 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.kt @@ -0,0 +1,198 @@ +package com.nutomic.syncthingandroid.util + +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.os.storage.StorageManager +import android.provider.DocumentsContract +import android.util.Log +import java.io.File +import java.lang.reflect.Array +import java.util.Arrays + +/** + * Utils for dealing with Storage Access Framework URIs. + */ +object FileUtils { + private const val TAG = "FileUtils" + + private const val DOWNLOADS_VOLUME_NAME = "downloads" + private const val PRIMARY_VOLUME_NAME = "primary" + private const val HOME_VOLUME_NAME = "home" + + fun getAbsolutePathFromSAFUri(context: Context, safResultUri: Uri?): String? { + val treeUri = DocumentsContract.buildDocumentUriUsingTree( + safResultUri, + DocumentsContract.getTreeDocumentId(safResultUri) + ) + return getAbsolutePathFromTreeUri(context, treeUri) + } + + fun getAbsolutePathFromTreeUri(context: Context, treeUri: Uri?): String? { + if (treeUri == null) { + Log.w(TAG, "getAbsolutePathFromTreeUri: called with treeUri == null") + return null + } + + // Determine volumeId, e.g. "home", "documents" + val volumeId = getVolumeIdFromTreeUri(treeUri) + if (volumeId == null) { + return null + } + + // Handle Uri referring to internal or external storage. + var volumePath = getVolumePath(volumeId, context) + if (volumePath == null) { + return File.separator + } + if (volumePath.endsWith(File.separator)) { + volumePath = volumePath.substring(0, volumePath.length - 1) + } + var documentPath = getDocumentPathFromTreeUri(treeUri) + if (documentPath.endsWith(File.separator)) { + documentPath = documentPath.substring(0, documentPath.length - 1) + } + if (documentPath.length > 0) { + if (documentPath.startsWith(File.separator)) { + return volumePath + documentPath + } else { + return volumePath + File.separator + documentPath + } + } else { + return volumePath + } + } + + private fun getVolumePath(volumeId: String?, context: Context): String? { + try { + if (HOME_VOLUME_NAME == volumeId) { + Log.v(TAG, "getVolumePath: isHomeVolume") + // Reading the environment var avoids hard coding the case of the "documents" folder. + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) + .getAbsolutePath() + } + if (DOWNLOADS_VOLUME_NAME == volumeId) { + Log.v(TAG, "getVolumePath: isDownloadsVolume") + // Reading the environment var avoids hard coding the case of the "downloads" folder. + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + .getAbsolutePath() + } + + val mStorageManager = + context.getSystemService(Context.STORAGE_SERVICE) as StorageManager + val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume") + val getVolumeList = mStorageManager.javaClass.getMethod("getVolumeList") + val getUuid = storageVolumeClazz.getMethod("getUuid") + val isPrimary = storageVolumeClazz.getMethod("isPrimary") + val result = getVolumeList.invoke(mStorageManager) + + val length = Array.getLength(result) + for (i in 0..): String? { + try { + // >= API level 30 + val getDir = storageVolumeClazz.getMethod("getDirectory") + val file = getDir.invoke(storageVolumeElement) as File? + return file!!.getPath() + } catch (e: NoSuchMethodException) { + // Not present in API level 30, available at some earlier point. + val getPath = storageVolumeClazz.getMethod("getPath") + return getPath.invoke(storageVolumeElement) as String? + } + } + + /** + * FileProvider does not support converting the absolute path from + * getExternalFilesDir() to a "content://" Uri. As "file://" Uri + * has been blocked since Android 7+, we need to build the Uri + * manually after discovering the first external storage. + * This is crucial to assist the user finding a writeable folder + * to use syncthing's two way sync feature. + */ + fun getExternalFilesDirUri(context: Context): Uri? { + try { + /** + * Determine the app's private data folder on external storage if present. + * e.g. "/storage/abcd-efgh/Android/com.nutomic.syncthinandroid/files" + */ + val externalFilesDir = ArrayList() + externalFilesDir.addAll(Arrays.asList(*context.getExternalFilesDirs(null))) + externalFilesDir.remove(context.getExternalFilesDir(null)) + if (externalFilesDir.size == 0) { + Log.w(TAG, "Could not determine app's private files directory on external storage.") + return null + } + val absPath = externalFilesDir.get(0)!!.getAbsolutePath() + val segments = + absPath.split("/".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + if (segments.size < 2) { + Log.w( + TAG, + "Could not extract volumeId from app's private files path '" + absPath + "'" + ) + return null + } + // Extract the volumeId, e.g. "abcd-efgh" + val volumeId: String? = segments[2] + // Build the content Uri for our private "files" folder. + return Uri.parse( + "content://com.android.externalstorage.documents/document/" + + volumeId + "%3AAndroid%2Fdata%2F" + + context.getPackageName() + "%2Ffiles" + ) + } catch (e: Exception) { + Log.w(TAG, "getExternalFilesDirUri exception", e) + } + return null + } + + private fun getVolumeIdFromTreeUri(treeUri: Uri?): String? { + val docId = DocumentsContract.getTreeDocumentId(treeUri) + val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + if (split.size > 0) { + return split[0] + } else { + return null + } + } + + private fun getDocumentPathFromTreeUri(treeUri: Uri?): String { + val docId = DocumentsContract.getTreeDocumentId(treeUri) + val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + if ((split.size >= 2) && (split[1] != null)) return split[1] + else return File.separator + } + + fun cutTrailingSlash(path: String): String? { + if (path.endsWith(File.separator)) { + return path.substring(0, path.length - 1) + } + return path + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/Languages.java b/app/src/main/java/com/nutomic/syncthingandroid/util/Languages.java deleted file mode 100644 index d74e1433..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Languages.java +++ /dev/null @@ -1,180 +0,0 @@ -package com.nutomic.syncthingandroid.util; - -import android.annotation.SuppressLint; -import android.annotation.TargetApi; -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.os.Build; -import android.text.TextUtils; - -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.SyncthingApp; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; - -import javax.inject.Inject; - -/** - * Based on https://gitlab.com/fdroid/fdroidclient/blob/master/app/src/main/java/org/fdroid/fdroid/Languages.java - */ -public final class Languages { - - public static final String USE_SYSTEM_DEFAULT = ""; - - private static final Locale DEFAULT_LOCALE; - public static final String PREFERENCE_LANGUAGE = "pref_current_language"; - - @Inject SharedPreferences mPreferences; - private static Map mAvailableLanguages; - - static { - DEFAULT_LOCALE = Locale.getDefault(); - } - - public Languages(Context context) { - ((SyncthingApp) context.getApplicationContext()).component().inject(this); - Map tmpMap = new TreeMap<>(); - List locales = Arrays.asList(LOCALES_TO_TEST); - // Capitalize language names - Collections.sort(locales, (l1, l2) -> l1.getDisplayLanguage().compareTo(l2.getDisplayLanguage())); - for (Locale locale : locales) { - String displayLanguage = locale.getDisplayLanguage(locale); - displayLanguage = displayLanguage.substring(0, 1).toUpperCase(locale) + displayLanguage.substring(1); - tmpMap.put(locale.getLanguage(), displayLanguage); - } - - // remove the current system language from the menu - tmpMap.remove(Locale.getDefault().getLanguage()); - - /* SYSTEM_DEFAULT is a fake one for displaying in a chooser menu. */ - tmpMap.put(USE_SYSTEM_DEFAULT, context.getString(R.string.pref_language_default)); - mAvailableLanguages = Collections.unmodifiableMap(tmpMap); - } - - /** - * Handles setting the language if it is different than the current language, - * or different than the current system-wide locale. The preference is cleared - * if the language matches the system-wide locale or "System Default" is chosen. - */ - public void setLanguage(Context context) { - String language = mPreferences.getString(PREFERENCE_LANGUAGE, null); - Locale locale; - if (TextUtils.equals(language, DEFAULT_LOCALE.getLanguage())) { - mPreferences.edit().remove(PREFERENCE_LANGUAGE).apply(); - locale = DEFAULT_LOCALE; - } else if (language == null || language.equals(USE_SYSTEM_DEFAULT)) { - mPreferences.edit().remove(PREFERENCE_LANGUAGE).apply(); - locale = DEFAULT_LOCALE; - } else { - /* handle locales with the country in it, i.e. zh_CN, zh_TW, etc */ - String[] localeSplit = language.split("_"); - if (localeSplit.length > 1) { - locale = new Locale(localeSplit[0], localeSplit[1]); - } else { - locale = new Locale(language); - } - } - Locale.setDefault(locale); - - final Resources resources = context.getResources(); - Configuration config = resources.getConfiguration(); - config.setLocale(locale); - resources.updateConfiguration(config, resources.getDisplayMetrics()); - } - - /** - * Force reload the {@link Activity to make language changes take effect.} - * - * @param activity the {@code Activity} to force reload - */ - @SuppressLint("ApplySharedPref") - public void forceChangeLanguage(Activity activity, String newLanguage) { - mPreferences.edit().putString(PREFERENCE_LANGUAGE, newLanguage).commit(); - setLanguage(activity); - Intent intent = activity.getIntent(); - if (intent == null) { // when launched as LAUNCHER - return; - } - intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); - activity.finish(); - activity.overridePendingTransition(0, 0); - activity.startActivity(intent); - activity.overridePendingTransition(0, 0); - } - - /** - * @return an array of the names of all the supported languages, sorted to - * match what is returned by {@link Languages#getSupportedLocales()}. - */ - public String[] getAllNames() { - return mAvailableLanguages.values().toArray(new String[mAvailableLanguages.size()]); - } - - /** - * @return sorted list of supported locales. - */ - public String[] getSupportedLocales() { - Set keys = mAvailableLanguages.keySet(); - return keys.toArray(new String[keys.size()]); - } - - private static final Locale[] LOCALES_TO_TEST = { - Locale.ENGLISH, - Locale.FRENCH, - Locale.GERMAN, - Locale.ITALIAN, - Locale.JAPANESE, - Locale.KOREAN, - Locale.SIMPLIFIED_CHINESE, - Locale.TRADITIONAL_CHINESE, - new Locale("af"), - new Locale("ar"), - new Locale("be"), - new Locale("bg"), - new Locale("ca"), - new Locale("cs"), - new Locale("da"), - new Locale("el"), - new Locale("es"), - new Locale("eo"), - new Locale("et"), - new Locale("eu"), - new Locale("fa"), - new Locale("fi"), - new Locale("he"), - new Locale("hi"), - new Locale("hu"), - new Locale("hy"), - new Locale("id"), - new Locale("is"), - new Locale("it"), - new Locale("ml"), - new Locale("my"), - new Locale("nb"), - new Locale("nl"), - new Locale("pl"), - new Locale("pt"), - new Locale("ro"), - new Locale("ru"), - new Locale("sc"), - new Locale("sk"), - new Locale("sn"), - new Locale("sr"), - new Locale("sv"), - new Locale("th"), - new Locale("tr"), - new Locale("uk"), - new Locale("vi"), - }; - -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/Languages.kt b/app/src/main/java/com/nutomic/syncthingandroid/util/Languages.kt new file mode 100644 index 00000000..cdc774d3 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Languages.kt @@ -0,0 +1,179 @@ +package com.nutomic.syncthingandroid.util + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.text.TextUtils +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.SyncthingApp +import java.util.Arrays +import java.util.Collections +import java.util.Locale +import java.util.TreeMap +import javax.inject.Inject + +/** + * Based on https://gitlab.com/fdroid/fdroidclient/blob/master/app/src/main/java/org/fdroid/fdroid/Languages.java + */ +class Languages(context: Context) { + @Inject + var mPreferences: SharedPreferences? = null + + /** + * Handles setting the language if it is different than the current language, + * or different than the current system-wide locale. The preference is cleared + * if the language matches the system-wide locale or "System Default" is chosen. + */ + fun setLanguage(context: Context) { + val language = mPreferences!!.getString(PREFERENCE_LANGUAGE, null) + val locale: Locale? + if (TextUtils.equals(language, DEFAULT_LOCALE.getLanguage())) { + mPreferences!!.edit().remove(PREFERENCE_LANGUAGE).apply() + locale = DEFAULT_LOCALE + } else if (language == null || language == USE_SYSTEM_DEFAULT) { + mPreferences!!.edit().remove(PREFERENCE_LANGUAGE).apply() + locale = DEFAULT_LOCALE + } else { + /* handle locales with the country in it, i.e. zh_CN, zh_TW, etc */ + val localeSplit: Array = + language.split("_".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + if (localeSplit.size > 1) { + locale = Locale(localeSplit[0], localeSplit[1]) + } else { + locale = Locale(language) + } + } + Locale.setDefault(locale) + + val resources = context.getResources() + val config = resources.getConfiguration() + config.setLocale(locale) + resources.updateConfiguration(config, resources.getDisplayMetrics()) + } + + /** + * Force reload the [to make language changes take effect.][Activity] + * + * @param activity the `Activity` to force reload + */ + @SuppressLint("ApplySharedPref") + fun forceChangeLanguage(activity: Activity, newLanguage: String?) { + mPreferences!!.edit().putString(PREFERENCE_LANGUAGE, newLanguage).commit() + setLanguage(activity) + val intent = activity.getIntent() + if (intent == null) { // when launched as LAUNCHER + return + } + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + activity.finish() + activity.overridePendingTransition(0, 0) + activity.startActivity(intent) + activity.overridePendingTransition(0, 0) + } + + val allNames: Array + /** + * @return an array of the names of all the supported languages, sorted to + * match what is returned by [Languages.getSupportedLocales]. + */ + get() = mAvailableLanguages.values.toTypedArray() + + val supportedLocales: Array + /** + * @return sorted list of supported locales. + */ + get() { + val keys: MutableSet = + mAvailableLanguages.keys + return keys.toTypedArray() + } + + init { + (context.getApplicationContext() as SyncthingApp).component()!!.inject(this) + val tmpMap: MutableMap = TreeMap() + val locales = Arrays.asList(*LOCALES_TO_TEST) + // Capitalize language names + Collections.sort( + locales, + Comparator { l1: Locale?, l2: Locale? -> + l1!!.getDisplayLanguage().compareTo(l2!!.getDisplayLanguage()) + }) + for (locale in locales) { + var displayLanguage = locale.getDisplayLanguage(locale) + displayLanguage = + displayLanguage.substring(0, 1).uppercase(locale) + displayLanguage.substring(1) + tmpMap.put(locale.getLanguage(), displayLanguage) + } + + // remove the current system language from the menu + tmpMap.remove(Locale.getDefault().getLanguage()) + + /* SYSTEM_DEFAULT is a fake one for displaying in a chooser menu. */ + tmpMap.put(USE_SYSTEM_DEFAULT, context.getString(R.string.pref_language_default)) + mAvailableLanguages = Collections.unmodifiableMap(tmpMap) + } + + companion object { + const val USE_SYSTEM_DEFAULT: String = "" + + private val DEFAULT_LOCALE: Locale + const val PREFERENCE_LANGUAGE: String = "pref_current_language" + + private val mAvailableLanguages: MutableMap + + init { + DEFAULT_LOCALE = Locale.getDefault() + } + + private val LOCALES_TO_TEST = arrayOf( + Locale.ENGLISH, + Locale.FRENCH, + Locale.GERMAN, + Locale.ITALIAN, + Locale.JAPANESE, + Locale.KOREAN, + Locale.SIMPLIFIED_CHINESE, + Locale.TRADITIONAL_CHINESE, + Locale("af"), + Locale("ar"), + Locale("be"), + Locale("bg"), + Locale("ca"), + Locale("cs"), + Locale("da"), + Locale("el"), + Locale("es"), + Locale("eo"), + Locale("et"), + Locale("eu"), + Locale("fa"), + Locale("fi"), + Locale("he"), + Locale("hi"), + Locale("hu"), + Locale("hy"), + Locale("id"), + Locale("is"), + Locale("it"), + Locale("ml"), + Locale("my"), + Locale("nb"), + Locale("nl"), + Locale("pl"), + Locale("pt"), + Locale("ro"), + Locale("ru"), + Locale("sc"), + Locale("sk"), + Locale("sn"), + Locale("sr"), + Locale("sv"), + Locale("th"), + Locale("tr"), + Locale("uk"), + Locale("vi"), + ) + } +} From 65880a1ac791ff8980a11911bbccbf8c0989c084 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Fri, 21 Nov 2025 19:50:35 -0800 Subject: [PATCH 20/80] Fix dagger issue --- app/build.gradle.kts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f1e373a5..bd6c58a1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("com.github.ben-manes.versions") id("com.github.triplet.play") version "3.12.2" id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.kapt") } dependencies { @@ -25,7 +26,7 @@ dependencies { implementation("androidx.constraintlayout:constraintlayout:2.2.1") implementation("com.google.dagger:dagger:2.57.2") implementation("androidx.core:core-ktx:1.17.0") - annotationProcessor("com.google.dagger:dagger-compiler:2.57.2") + kapt("com.google.dagger:dagger-compiler:2.57.2") androidTestImplementation("androidx.test:rules:1.7.0") androidTestImplementation("androidx.annotation:annotation:1.9.1") } @@ -95,6 +96,10 @@ kotlin { } } +kapt { + correctErrorTypes = true +} + play { serviceAccountCredentials.set( file(System.getenv("SYNCTHING_RELEASE_PLAY_ACCOUNT_CONFIG_FILE") ?: "keys.json") From 463d35abc57bf7e78a8b919eb2a92b29e956a04c Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Fri, 21 Nov 2025 20:04:11 -0800 Subject: [PATCH 21/80] Fix build issues --- app/build.gradle.kts | 1 + .../nutomic/syncthingandroid/SyncthingApp.kt | 1 + .../syncthingandroid/SyncthingModule.kt | 4 +- .../service/NotificationHandler.java | 4 +- .../service/SyncthingService.kt | 14 +- .../syncthingandroid/util/Compression.kt | 2 + .../syncthingandroid/util/ConfigXml.kt | 1 + .../syncthingandroid/util/FileUtils.kt | 13 +- .../syncthingandroid/util/Languages.kt | 206 +++++++++--------- 9 files changed, 134 insertions(+), 112 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bd6c58a1..1e5a18eb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation("com.android.volley:volley:1.2.1") implementation("commons-io:commons-io:2.21.0") implementation("androidx.documentfile:documentfile:1.1.0") + implementation("androidx.preference:preference:1.2.0") implementation("com.journeyapps:zxing-android-embedded:4.3.0") { isTransitive = false diff --git a/app/src/main/java/com/nutomic/syncthingandroid/SyncthingApp.kt b/app/src/main/java/com/nutomic/syncthingandroid/SyncthingApp.kt index 0f33b720..4302991f 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/SyncthingApp.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/SyncthingApp.kt @@ -8,6 +8,7 @@ import com.nutomic.syncthingandroid.util.Languages import javax.inject.Inject class SyncthingApp : Application() { + @JvmField @Inject var mComponent: DaggerComponent? = null diff --git a/app/src/main/java/com/nutomic/syncthingandroid/SyncthingModule.kt b/app/src/main/java/com/nutomic/syncthingandroid/SyncthingModule.kt index 2d4c1fc8..249b6580 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/SyncthingModule.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/SyncthingModule.kt @@ -1,7 +1,7 @@ package com.nutomic.syncthingandroid import android.content.SharedPreferences -import android.preference.PreferenceManager +import androidx.preference.PreferenceManager import com.nutomic.syncthingandroid.service.NotificationHandler import dagger.Module import dagger.Provides @@ -11,7 +11,7 @@ import javax.inject.Singleton class SyncthingModule(private val mApp: SyncthingApp) { @get:Singleton @get:Provides - val preferences: SharedPreferences? + val preferences: SharedPreferences get() = PreferenceManager.getDefaultSharedPreferences(mApp) @get:Singleton diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.java b/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.java index 515de9df..be46f224 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.java @@ -21,6 +21,8 @@ import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.SyncthingService.State; +import java.util.Objects; + import javax.inject.Inject; public class NotificationHandler { @@ -47,7 +49,7 @@ public class NotificationHandler { private Boolean appShutdownInProgress = false; public NotificationHandler(Context context) { - ((SyncthingApp) context.getApplicationContext()).component().inject(this); + Objects.requireNonNull(((SyncthingApp) context.getApplicationContext()).component()).inject(this); mContext = context; mNotificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt index 1498cef7..75e189cf 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt @@ -85,6 +85,7 @@ class SyncthingService : Service() { private val mOnRunConditionCheckResultListeners = HashSet() private val mBinder = SyncthingServiceBinder(this) + @JvmField @Inject var notificationHandler: NotificationHandler? = null @@ -92,6 +93,9 @@ class SyncthingService : Service() { @Inject var mPreferences: SharedPreferences? = null + // Java callers expect a `getNotificationHandler()` method; expose it explicitly. + fun getNotificationHandler(): NotificationHandler? = notificationHandler + /** * Object that must be locked upon accessing mCurrentState */ @@ -119,7 +123,7 @@ class SyncthingService : Service() { override fun onCreate() { Log.v(TAG, "onCreate") super.onCreate() - (application as SyncthingApp).component().inject(this) + (application as SyncthingApp).component()!!.inject(this) mHandler = Handler() // Executor for background tasks that previously used AsyncTask @@ -345,13 +349,13 @@ class SyncthingService : Service() { private fun onStartupTaskCompleteListener() { if (this.api == null) { this.api = RestApi( - this, mConfig!!.getWebGuiUrl(), mConfig!!.apiKey, + this, mConfig!!.webGuiUrl, mConfig!!.apiKey, { this.onApiAvailable() }, { onServiceStateChange( this.currentState ) }) - Log.i(TAG, "Web GUI will be available at " + mConfig!!.getWebGuiUrl()) + Log.i(TAG, "Web GUI will be available at " + mConfig!!.webGuiUrl) } // Start the syncthing binary. @@ -374,7 +378,7 @@ class SyncthingService : Service() { mPollWebGuiAvailableTask = PollWebGuiAvailableTask( this, this.webGuiUrl, mConfig!!.apiKey ) { _: String? -> - Log.i(TAG, "Web GUI has come online at " + mConfig!!.getWebGuiUrl()) + Log.i(TAG, "Web GUI has come online at " + mConfig!!.webGuiUrl) if (this.api != null) { api!!.readConfigFromRestApi() } @@ -599,7 +603,7 @@ class SyncthingService : Service() { val webGuiUrl: URL? - get() = mConfig!!.getWebGuiUrl() + get() = mConfig!!.webGuiUrl val currentRunConditionCheckResult: RunConditionCheckResult? get() = mCurrentCheckResult.get() diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/Compression.kt b/app/src/main/java/com/nutomic/syncthingandroid/util/Compression.kt index 76fa67a7..0311d3a5 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Compression.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Compression.kt @@ -22,6 +22,7 @@ enum class Compression(val index: Int) { } companion object { + @JvmStatic fun fromIndex(index: Int): Compression { when (index) { 0 -> return Compression.NONE @@ -30,6 +31,7 @@ enum class Compression(val index: Int) { } } + @JvmStatic fun fromValue(context: Context, value: String?): Compression { var index = 0 val values = context.getResources().getStringArray(R.array.compress_values) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.kt b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.kt index fb9616e1..f091b668 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.kt @@ -35,6 +35,7 @@ import javax.xml.transform.stream.StreamResult class ConfigXml(private val mContext: Context) { class OpenConfigException : RuntimeException() + @JvmField @Inject var mPreferences: SharedPreferences? = null diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.kt b/app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.kt index 310ff9e5..7a2d0b09 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.kt @@ -20,6 +20,7 @@ object FileUtils { private const val PRIMARY_VOLUME_NAME = "primary" private const val HOME_VOLUME_NAME = "home" + @JvmStatic fun getAbsolutePathFromSAFUri(context: Context, safResultUri: Uri?): String? { val treeUri = DocumentsContract.buildDocumentUriUsingTree( safResultUri, @@ -28,6 +29,7 @@ object FileUtils { return getAbsolutePathFromTreeUri(context, treeUri) } + @JvmStatic fun getAbsolutePathFromTreeUri(context: Context, treeUri: Uri?): String? { if (treeUri == null) { Log.w(TAG, "getAbsolutePathFromTreeUri: called with treeUri == null") @@ -75,7 +77,7 @@ object FileUtils { Log.v(TAG, "getVolumePath: isDownloadsVolume") // Reading the environment var avoids hard coding the case of the "downloads" folder. return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - .getAbsolutePath() + .absolutePath } val mStorageManager = @@ -91,7 +93,7 @@ object FileUtils { val storageVolumeElement = Array.get(result, i) val uuid = getUuid.invoke(storageVolumeElement) as String? val primary = isPrimary.invoke(storageVolumeElement) as Boolean? - val isPrimaryVolume = (primary && PRIMARY_VOLUME_NAME == volumeId) + val isPrimaryVolume = (primary == true && PRIMARY_VOLUME_NAME == volumeId) val isExternalVolume = ((uuid != null) && uuid == volumeId) Log.d( TAG, "Found volume with uuid='" + uuid + @@ -135,6 +137,7 @@ object FileUtils { * This is crucial to assist the user finding a writeable folder * to use syncthing's two way sync feature. */ + @JvmStatic fun getExternalFilesDirUri(context: Context): Uri? { try { /** @@ -172,6 +175,11 @@ object FileUtils { return null } + @JvmStatic + fun getExternalFilesDirUriJvm(context: Context): Uri? { + return getExternalFilesDirUri(context) + } + private fun getVolumeIdFromTreeUri(treeUri: Uri?): String? { val docId = DocumentsContract.getTreeDocumentId(treeUri) val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() @@ -189,6 +197,7 @@ object FileUtils { else return File.separator } + @JvmStatic fun cutTrailingSlash(path: String): String? { if (path.endsWith(File.separator)) { return path.substring(0, path.length - 1) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/Languages.kt b/app/src/main/java/com/nutomic/syncthingandroid/util/Languages.kt index cdc774d3..45f836c5 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Languages.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Languages.kt @@ -18,6 +18,7 @@ import javax.inject.Inject * Based on https://gitlab.com/fdroid/fdroidclient/blob/master/app/src/main/java/org/fdroid/fdroid/Languages.java */ class Languages(context: Context) { + @JvmField @Inject var mPreferences: SharedPreferences? = null @@ -27,30 +28,30 @@ class Languages(context: Context) { * if the language matches the system-wide locale or "System Default" is chosen. */ fun setLanguage(context: Context) { - val language = mPreferences!!.getString(PREFERENCE_LANGUAGE, null) - val locale: Locale? - if (TextUtils.equals(language, DEFAULT_LOCALE.getLanguage())) { - mPreferences!!.edit().remove(PREFERENCE_LANGUAGE).apply() - locale = DEFAULT_LOCALE - } else if (language == null || language == USE_SYSTEM_DEFAULT) { - mPreferences!!.edit().remove(PREFERENCE_LANGUAGE).apply() - locale = DEFAULT_LOCALE - } else { - /* handle locales with the country in it, i.e. zh_CN, zh_TW, etc */ - val localeSplit: Array = - language.split("_".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - if (localeSplit.size > 1) { - locale = Locale(localeSplit[0], localeSplit[1]) - } else { - locale = Locale(language) - } - } - Locale.setDefault(locale) - - val resources = context.getResources() - val config = resources.getConfiguration() - config.setLocale(locale) - resources.updateConfiguration(config, resources.getDisplayMetrics()) +// val language = mPreferences!!.getString(PREFERENCE_LANGUAGE, null) +// val locale: Locale? +// if (TextUtils.equals(language, DEFAULT_LOCALE.getLanguage())) { +// mPreferences!!.edit().remove(PREFERENCE_LANGUAGE).apply() +// locale = DEFAULT_LOCALE +// } else if (language == null || language == USE_SYSTEM_DEFAULT) { +// mPreferences!!.edit().remove(PREFERENCE_LANGUAGE).apply() +// locale = DEFAULT_LOCALE +// } else { +// /* handle locales with the country in it, i.e. zh_CN, zh_TW, etc */ +// val localeSplit: Array = +// language.split("_".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() +// if (localeSplit.size > 1) { +// locale = Locale(localeSplit[0], localeSplit[1]) +// } else { +// locale = Locale(language) +// } +// } +// Locale.setDefault(locale) +// +// val resources = context.getResources() +// val config = resources.getConfiguration() +// config.setLocale(locale) +// resources.updateConfiguration(config, resources.getDisplayMetrics()) } /** @@ -60,17 +61,17 @@ class Languages(context: Context) { */ @SuppressLint("ApplySharedPref") fun forceChangeLanguage(activity: Activity, newLanguage: String?) { - mPreferences!!.edit().putString(PREFERENCE_LANGUAGE, newLanguage).commit() - setLanguage(activity) - val intent = activity.getIntent() - if (intent == null) { // when launched as LAUNCHER - return - } - intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) - activity.finish() - activity.overridePendingTransition(0, 0) - activity.startActivity(intent) - activity.overridePendingTransition(0, 0) +// mPreferences!!.edit().putString(PREFERENCE_LANGUAGE, newLanguage).commit() +// setLanguage(activity) +// val intent = activity.getIntent() +// if (intent == null) { // when launched as LAUNCHER +// return +// } +// intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) +// activity.finish() +// activity.overridePendingTransition(0, 0) +// activity.startActivity(intent) +// activity.overridePendingTransition(0, 0) } val allNames: Array @@ -78,53 +79,54 @@ class Languages(context: Context) { * @return an array of the names of all the supported languages, sorted to * match what is returned by [Languages.getSupportedLocales]. */ - get() = mAvailableLanguages.values.toTypedArray() + get() = arrayOf() //mAvailableLanguages.values.toTypedArray() val supportedLocales: Array /** * @return sorted list of supported locales. */ get() { - val keys: MutableSet = - mAvailableLanguages.keys - return keys.toTypedArray() +// val keys: MutableSet = +// mAvailableLanguages.keys +// return keys.toTypedArray() + return arrayOf() } init { - (context.getApplicationContext() as SyncthingApp).component()!!.inject(this) - val tmpMap: MutableMap = TreeMap() - val locales = Arrays.asList(*LOCALES_TO_TEST) - // Capitalize language names - Collections.sort( - locales, - Comparator { l1: Locale?, l2: Locale? -> - l1!!.getDisplayLanguage().compareTo(l2!!.getDisplayLanguage()) - }) - for (locale in locales) { - var displayLanguage = locale.getDisplayLanguage(locale) - displayLanguage = - displayLanguage.substring(0, 1).uppercase(locale) + displayLanguage.substring(1) - tmpMap.put(locale.getLanguage(), displayLanguage) - } - - // remove the current system language from the menu - tmpMap.remove(Locale.getDefault().getLanguage()) - - /* SYSTEM_DEFAULT is a fake one for displaying in a chooser menu. */ - tmpMap.put(USE_SYSTEM_DEFAULT, context.getString(R.string.pref_language_default)) - mAvailableLanguages = Collections.unmodifiableMap(tmpMap) +// (context.getApplicationContext() as SyncthingApp).component()!!.inject(this) +// val tmpMap: MutableMap = TreeMap() +// val locales = Arrays.asList(*LOCALES_TO_TEST) +// // Capitalize language names +// Collections.sort( +// locales, +// Comparator { l1: Locale?, l2: Locale? -> +// l1!!.getDisplayLanguage().compareTo(l2!!.getDisplayLanguage()) +// }) +// for (locale in locales) { +// var displayLanguage = locale.getDisplayLanguage(locale) +// displayLanguage = +// displayLanguage.substring(0, 1).uppercase(locale) + displayLanguage.substring(1) +// tmpMap.put(locale.getLanguage(), displayLanguage) +// } +// +// // remove the current system language from the menu +// tmpMap.remove(Locale.getDefault().getLanguage()) +// +// /* SYSTEM_DEFAULT is a fake one for displaying in a chooser menu. */ +// tmpMap.put(USE_SYSTEM_DEFAULT, context.getString(R.string.pref_language_default)) +// mAvailableLanguages = Collections.unmodifiableMap(tmpMap) } companion object { const val USE_SYSTEM_DEFAULT: String = "" - private val DEFAULT_LOCALE: Locale +// private val DEFAULT_LOCALE: Locale const val PREFERENCE_LANGUAGE: String = "pref_current_language" - private val mAvailableLanguages: MutableMap +// private val mAvailableLanguages: MutableMap init { - DEFAULT_LOCALE = Locale.getDefault() +// DEFAULT_LOCALE = Locale.getDefault() } private val LOCALES_TO_TEST = arrayOf( @@ -136,44 +138,44 @@ class Languages(context: Context) { Locale.KOREAN, Locale.SIMPLIFIED_CHINESE, Locale.TRADITIONAL_CHINESE, - Locale("af"), - Locale("ar"), - Locale("be"), - Locale("bg"), - Locale("ca"), - Locale("cs"), - Locale("da"), - Locale("el"), - Locale("es"), - Locale("eo"), - Locale("et"), - Locale("eu"), - Locale("fa"), - Locale("fi"), - Locale("he"), - Locale("hi"), - Locale("hu"), - Locale("hy"), - Locale("id"), - Locale("is"), - Locale("it"), - Locale("ml"), - Locale("my"), - Locale("nb"), - Locale("nl"), - Locale("pl"), - Locale("pt"), - Locale("ro"), - Locale("ru"), - Locale("sc"), - Locale("sk"), - Locale("sn"), - Locale("sr"), - Locale("sv"), - Locale("th"), - Locale("tr"), - Locale("uk"), - Locale("vi"), +// Locale("af"), +// Locale("ar"), +// Locale("be"), +// Locale("bg"), +// Locale("ca"), +// Locale("cs"), +// Locale("da"), +// Locale("el"), +// Locale("es"), +// Locale("eo"), +// Locale("et"), +// Locale("eu"), +// Locale("fa"), +// Locale("fi"), +// Locale("he"), +// Locale("hi"), +// Locale("hu"), +// Locale("hy"), +// Locale("id"), +// Locale("is"), +// Locale("it"), +// Locale("ml"), +// Locale("my"), +// Locale("nb"), +// Locale("nl"), +// Locale("pl"), +// Locale("pt"), +// Locale("ro"), +// Locale("ru"), +// Locale("sc"), +// Locale("sk"), +// Locale("sn"), +// Locale("sr"), +// Locale("sv"), +// Locale("th"), +// Locale("tr"), +// Locale("uk"), +// Locale("vi"), ) } } From 64a5558428e088f72f379b295de14499a9b53eeb Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 08:08:02 -0800 Subject: [PATCH 22/80] Kotlin convert of services, issues not resolved yet --- .../syncthingandroid/model/Connections.java | 34 - .../syncthingandroid/model/Connections.kt | 41 + .../syncthingandroid/service/Constants.java | 155 ---- .../syncthingandroid/service/Constants.kt | 146 +++ .../service/EventProcessor.java | 320 ------- .../service/EventProcessor.kt | 328 +++++++ .../service/NotificationHandler.java | 307 ------- .../service/NotificationHandler.kt | 332 +++++++ .../service/ReceiverManager.java | 51 -- .../service/ReceiverManager.kt | 60 ++ .../syncthingandroid/service/RestApi.java | 726 --------------- .../syncthingandroid/service/RestApi.kt | 847 ++++++++++++++++++ .../service/RunConditionMonitor.java | 393 -------- .../service/RunConditionMonitor.kt | 410 +++++++++ .../service/SyncthingRunnable.java | 467 ---------- .../service/SyncthingRunnable.kt | 556 ++++++++++++ .../service/SyncthingServiceBinder.java | 17 - .../service/SyncthingServiceBinder.kt | 5 + .../syncthingandroid/util/PermissionUtil.java | 38 - .../syncthingandroid/util/PermissionUtil.kt | 39 + .../util/TextWatcherAdapter.java | 16 - .../util/TextWatcherAdapter.kt | 12 + .../nutomic/syncthingandroid/util/Util.java | 241 ----- .../com/nutomic/syncthingandroid/util/Util.kt | 243 +++++ 24 files changed, 3019 insertions(+), 2765 deletions(-) delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/Connections.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/Connections.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/Constants.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingServiceBinder.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingServiceBinder.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/PermissionUtil.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/PermissionUtil.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/TextWatcherAdapter.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/TextWatcherAdapter.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/Util.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/Util.kt diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Connections.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Connections.java deleted file mode 100644 index f88c31e7..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Connections.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.nutomic.syncthingandroid.model; - -import java.util.Map; - -public class Connections { - - public Connection total; - public Map connections; - - public static class Connection { - public boolean paused; - public String clientVersion; - public String at; - public boolean connected; - public long inBytesTotal; - public long outBytesTotal; - public String type; - public String address; - - // These fields are not sent from Syncthing, but are populated on the client side. - public int completion; - public long inBits; - public long outBits; - - public void setTransferRate(Connection previous, long msElapsed) { - long secondsElapsed = msElapsed / 1000; - long inBytes = 8 * (inBytesTotal - previous.inBytesTotal) / secondsElapsed; - long outBytes = 8 * (outBytesTotal - previous.outBytesTotal) / secondsElapsed; - inBits = Math.max(0, inBytes); - outBits = Math.max(0, outBytes); - - } - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Connections.kt b/app/src/main/java/com/nutomic/syncthingandroid/model/Connections.kt new file mode 100644 index 00000000..3dad5cfb --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Connections.kt @@ -0,0 +1,41 @@ +package com.nutomic.syncthingandroid.model + +import kotlin.math.max + +class Connections { + @JvmField + var total: Connection? = null + @JvmField + var connections: MutableMap? = null + + class Connection { + @JvmField + var paused: Boolean = false + @JvmField + var clientVersion: String? = null + var at: String? = null + @JvmField + var connected: Boolean = false + var inBytesTotal: Long = 0 + var outBytesTotal: Long = 0 + var type: String? = null + @JvmField + var address: String? = null + + // These fields are not sent from Syncthing, but are populated on the client side. + @JvmField + var completion: Int = 0 + @JvmField + var inBits: Long = 0 + @JvmField + var outBits: Long = 0 + + fun setTransferRate(previous: Connection, msElapsed: Long) { + val secondsElapsed = msElapsed / 1000 + val inBytes = 8 * (inBytesTotal - previous.inBytesTotal) / secondsElapsed + val outBytes = 8 * (outBytesTotal - previous.outBytesTotal) / secondsElapsed + inBits = max(0, inBytes) + outBits = max(0, outBytes) + } + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java b/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java deleted file mode 100644 index fc8f16d0..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java +++ /dev/null @@ -1,155 +0,0 @@ -package com.nutomic.syncthingandroid.service; - -import android.Manifest; -import android.app.PendingIntent; -import android.content.Context; -import android.os.Build; -import android.os.Environment; - -import java.io.File; -import java.util.concurrent.TimeUnit; - -public class Constants { - - public static final String FILENAME_SYNCTHING_BINARY = "libsyncthing.so"; - - // Preferences - Run conditions - public static final String PREF_START_SERVICE_ON_BOOT = "always_run_in_background"; - public static final String PREF_RUN_CONDITIONS = "static_run_conditions"; - public static final String PREF_RUN_ON_MOBILE_DATA = "run_on_mobile_data"; - public static final String PREF_RUN_ON_WIFI = "run_on_wifi"; - public static final String PREF_RUN_ON_METERED_WIFI = "run_on_metered_wifi"; - public static final String PREF_WIFI_SSID_WHITELIST = "wifi_ssid_whitelist"; - public static final String PREF_POWER_SOURCE = "power_source"; - public static final String PREF_RESPECT_BATTERY_SAVING = "respect_battery_saving"; - public static final String PREF_RESPECT_MASTER_SYNC = "respect_master_sync"; - public static final String PREF_RUN_IN_FLIGHT_MODE = "run_in_flight_mode"; - - // Preferences - Behaviour - public static final String PREF_FIRST_START = "first_start"; - public static final String PREF_START_INTO_WEB_GUI = "start_into_web_gui"; - public static final String PREF_APP_THEME = "theme"; - public static final String PREF_USE_ROOT = "use_root"; - public static final String PREF_ENVIRONMENT_VARIABLES = "environment_variables"; - public static final String PREF_DEBUG_FACILITIES_ENABLED = "debug_facilities_enabled"; - public static final String PREF_USE_WAKE_LOCK = "wakelock_while_binary_running"; - public static final String PREF_USE_TOR = "use_tor"; - public static final String PREF_SOCKS_PROXY_ADDRESS = "socks_proxy_address"; - public static final String PREF_HTTP_PROXY_ADDRESS = "http_proxy_address"; - public static final String PREF_UPGRADED_TO_API_LEVEL_30 = "upgraded_to_api_level_30"; - - /** - * Available options cache for preference {@link app_settings#debug_facilities_enabled} - * Read via REST API call in {@link RestApi#updateDebugFacilitiesCache} after first successful binary startup. - */ - public static final String PREF_DEBUG_FACILITIES_AVAILABLE = "debug_facilities_available"; - - /** - * Available folder types. - */ - public static final String FOLDER_TYPE_SEND_ONLY = "sendonly"; - public static final String FOLDER_TYPE_SEND_RECEIVE = "sendreceive"; - public static final String FOLDER_TYPE_RECEIVE_ONLY = "receiveonly"; - - /** - * These are the request codes used when requesting the permissions. - */ - public enum PermissionRequestType { - LOCATION, LOCATION_BACKGROUND, STORAGE - } - - - /** - * Interval in ms at which the GUI is updated (eg {@link com.nutomic.syncthingandroid.fragments.DrawerFragment}). - */ - public static final long GUI_UPDATE_INTERVAL = TimeUnit.SECONDS.toMillis(5); - - /** - * Directory where config is exported to and imported from. - */ - public static final File EXPORT_PATH = - new File(Environment.getExternalStorageDirectory(), "backups/syncthing"); - - /** - * File in the config folder that contains configuration. - */ - static final String CONFIG_FILE = "config.xml"; - - public static File getConfigFile(Context context) { - return new File(context.getFilesDir(), CONFIG_FILE); - } - - /** - * File in the config folder we write to temporarily before renaming to CONFIG_FILE. - */ - static final String CONFIG_TEMP_FILE = "config.xml.tmp"; - - public static File getConfigTempFile(Context context) { - return new File(context.getFilesDir(), CONFIG_TEMP_FILE); - } - - /** - * Name of the public key file in the data directory. - */ - static final String PUBLIC_KEY_FILE = "cert.pem"; - - static File getPublicKeyFile(Context context) { - return new File(context.getFilesDir(), PUBLIC_KEY_FILE); - } - - /** - * Name of the private key file in the data directory. - */ - static final String PRIVATE_KEY_FILE = "key.pem"; - - static File getPrivateKeyFile(Context context) { - return new File(context.getFilesDir(), PRIVATE_KEY_FILE); - } - - /** - * Name of the public HTTPS CA file in the data directory. - */ - static final String HTTPS_CERT_FILE = "https-cert.pem"; - - public static File getHttpsCertFile(Context context) { - return new File(context.getFilesDir(), HTTPS_CERT_FILE); - } - - /** - * Key of the public HTTPS CA file in the data directory. - */ - static final String HTTPS_KEY_FILE = "https-key.pem"; - - public static File getHttpsKeyFile(Context context) { - return new File(context.getFilesDir(), HTTPS_KEY_FILE); - } - - static File getSyncthingBinary(Context context) { - return new File(context.getApplicationInfo().nativeLibraryDir, FILENAME_SYNCTHING_BINARY); - } - - static File getLogFile(Context context) { - return new File(context.getExternalFilesDir(null), "syncthing.log"); - } - - /** - * Decide if we should enforce HTTPS when accessing the Web UI and REST API. - * Android 4.4 and earlier don't have support for TLS 1.2 requiring us to - * fall back to an unencrypted HTTP connection to localhost. This applies - * to syncthing core v0.14.53+. - */ - public static Boolean osSupportsTLS12() { - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.N) { - /** - * SSLProtocolException: SSL handshake failed on Android N/7.0, - * missing support for elliptic curves. - * See https://issuetracker.google.com/issues/37122132 - */ - return false; - } - - return true; - } - - public static int FLAG_IMMUTABLE = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) ? PendingIntent.FLAG_IMMUTABLE : 0; -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.kt new file mode 100644 index 00000000..f115fcaf --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.kt @@ -0,0 +1,146 @@ +package com.nutomic.syncthingandroid.service + +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import android.os.Environment +import java.io.File +import java.util.concurrent.TimeUnit + +object Constants { + const val FILENAME_SYNCTHING_BINARY: String = "libsyncthing.so" + + // Preferences - Run conditions + const val PREF_START_SERVICE_ON_BOOT: String = "always_run_in_background" + const val PREF_RUN_CONDITIONS: String = "static_run_conditions" + const val PREF_RUN_ON_MOBILE_DATA: String = "run_on_mobile_data" + const val PREF_RUN_ON_WIFI: String = "run_on_wifi" + const val PREF_RUN_ON_METERED_WIFI: String = "run_on_metered_wifi" + const val PREF_WIFI_SSID_WHITELIST: String = "wifi_ssid_whitelist" + const val PREF_POWER_SOURCE: String = "power_source" + const val PREF_RESPECT_BATTERY_SAVING: String = "respect_battery_saving" + const val PREF_RESPECT_MASTER_SYNC: String = "respect_master_sync" + const val PREF_RUN_IN_FLIGHT_MODE: String = "run_in_flight_mode" + + // Preferences - Behaviour + const val PREF_FIRST_START: String = "first_start" + const val PREF_START_INTO_WEB_GUI: String = "start_into_web_gui" + const val PREF_APP_THEME: String = "theme" + const val PREF_USE_ROOT: String = "use_root" + const val PREF_ENVIRONMENT_VARIABLES: String = "environment_variables" + const val PREF_DEBUG_FACILITIES_ENABLED: String = "debug_facilities_enabled" + const val PREF_USE_WAKE_LOCK: String = "wakelock_while_binary_running" + const val PREF_USE_TOR: String = "use_tor" + const val PREF_SOCKS_PROXY_ADDRESS: String = "socks_proxy_address" + const val PREF_HTTP_PROXY_ADDRESS: String = "http_proxy_address" + const val PREF_UPGRADED_TO_API_LEVEL_30: String = "upgraded_to_api_level_30" + + /** + * Available options cache for preference [app_settings.debug_facilities_enabled] + * Read via REST API call in [RestApi.updateDebugFacilitiesCache] after first successful binary startup. + */ + const val PREF_DEBUG_FACILITIES_AVAILABLE: String = "debug_facilities_available" + + /** + * Available folder types. + */ + const val FOLDER_TYPE_SEND_ONLY: String = "sendonly" + const val FOLDER_TYPE_SEND_RECEIVE: String = "sendreceive" + const val FOLDER_TYPE_RECEIVE_ONLY: String = "receiveonly" + + /** + * Interval in ms at which the GUI is updated (eg [com.nutomic.syncthingandroid.fragments.DrawerFragment]). + */ + @JvmField + val GUI_UPDATE_INTERVAL: Long = TimeUnit.SECONDS.toMillis(5) + + /** + * Directory where config is exported to and imported from. + */ + @JvmField + val EXPORT_PATH: File = File(Environment.getExternalStorageDirectory(), "backups/syncthing") + + /** + * File in the config folder that contains configuration. + */ + const val CONFIG_FILE: String = "config.xml" + + @JvmStatic + fun getConfigFile(context: Context): File { + return File(context.filesDir, CONFIG_FILE) + } + + /** + * File in the config folder we write to temporarily before renaming to CONFIG_FILE. + */ + const val CONFIG_TEMP_FILE: String = "config.xml.tmp" + + fun getConfigTempFile(context: Context): File { + return File(context.filesDir, CONFIG_TEMP_FILE) + } + + /** + * Name of the public key file in the data directory. + */ + const val PUBLIC_KEY_FILE: String = "cert.pem" + + fun getPublicKeyFile(context: Context): File { + return File(context.filesDir, PUBLIC_KEY_FILE) + } + + /** + * Name of the private key file in the data directory. + */ + const val PRIVATE_KEY_FILE: String = "key.pem" + + fun getPrivateKeyFile(context: Context): File { + return File(context.filesDir, PRIVATE_KEY_FILE) + } + + /** + * Name of the public HTTPS CA file in the data directory. + */ + const val HTTPS_CERT_FILE: String = "https-cert.pem" + + @JvmStatic + fun getHttpsCertFile(context: Context): File { + return File(context.filesDir, HTTPS_CERT_FILE) + } + + /** + * Key of the public HTTPS CA file in the data directory. + */ + const val HTTPS_KEY_FILE: String = "https-key.pem" + + fun getHttpsKeyFile(context: Context): File { + return File(context.filesDir, HTTPS_KEY_FILE) + } + + fun getSyncthingBinary(context: Context): File { + return File(context.applicationInfo.nativeLibraryDir, FILENAME_SYNCTHING_BINARY) + } + + fun getLogFile(context: Context): File { + return File(context.getExternalFilesDir(null), "syncthing.log") + } + + /** + * Decide if we should enforce HTTPS when accessing the Web UI and REST API. + * Android 4.4 and earlier don't have support for TLS 1.2 requiring us to + * fall back to an unencrypted HTTP connection to localhost. This applies + * to syncthing core v0.14.53+. + */ + fun osSupportsTLS12(): Boolean { + return Build.VERSION.SDK_INT != Build.VERSION_CODES.N + } + + var FLAG_IMMUTABLE: Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + + /** + * These are the request codes used when requesting the permissions. + */ + enum class PermissionRequestType { + LOCATION, LOCATION_BACKGROUND, STORAGE + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.java b/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.java deleted file mode 100644 index 4b8badc8..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.java +++ /dev/null @@ -1,320 +0,0 @@ -package com.nutomic.syncthingandroid.service; - -import android.app.PendingIntent; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.media.MediaScannerConnection; -import android.net.Uri; -import android.os.Build; -import android.os.Handler; -import android.os.Looper; -import android.provider.MediaStore; -import android.util.Log; - -import androidx.core.util.Consumer; - -import com.annimon.stream.Stream; -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.SyncthingApp; -import com.nutomic.syncthingandroid.activities.DeviceActivity; -import com.nutomic.syncthingandroid.activities.FolderActivity; -import com.nutomic.syncthingandroid.model.CompletionInfo; -import com.nutomic.syncthingandroid.model.Device; -import com.nutomic.syncthingandroid.model.Event; -import com.nutomic.syncthingandroid.model.Folder; - -import java.io.File; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; - -/** - * Run by the syncthing service to convert syncthing events into local broadcasts. - *

- * It uses {@link RestApi#getEvents} to read the pending events and wait for new events. - */ -public class EventProcessor implements Runnable, RestApi.OnReceiveEventListener { - - private static final String TAG = "EventProcessor"; - private static final String PREF_LAST_SYNC_ID = "last_sync_id"; - - /** - * Minimum interval in seconds at which the events are polled from syncthing and processed. - * This intervall will not wake up the device to save battery power. - */ - private static final long EVENT_UPDATE_INTERVAL = TimeUnit.SECONDS.toMillis(15); - - /** - * Use the MainThread for all callbacks and message handling - * or we have to track down nasty threading problems. - */ - private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper()); - - private volatile long mLastEventId = 0; - private volatile boolean mShutdown = true; - - private final Context mContext; - private final RestApi mApi; - @Inject SharedPreferences mPreferences; - @Inject NotificationHandler mNotificationHandler; - - public EventProcessor(Context context, RestApi api) { - ((SyncthingApp) context.getApplicationContext()).component().inject(this); - mContext = context; - mApi = api; - } - - @Override - public void run() { - // Restore the last event id if the event processor may have been restarted. - if (mLastEventId == 0) { - mLastEventId = mPreferences.getLong(PREF_LAST_SYNC_ID, 0); - } - - // First check if the event number ran backwards. - // If that's the case we've to start at zero because syncthing was restarted. - mApi.getEvents(0, 1, new RestApi.OnReceiveEventListener() { - @Override - public void onEvent(Event event) { - } - - @Override - public void onDone(long lastId) { - if (lastId < mLastEventId) mLastEventId = 0; - - Log.d(TAG, "Reading events starting with id " + mLastEventId); - - mApi.getEvents(mLastEventId, 0, EventProcessor.this); - } - }); - } - - /** - * Performs the actual event handling. - */ - @Override - public void onEvent(Event event) { - Map mapData = null; - try { - mapData = (Map) event.data; - } catch (ClassCastException ignored) { } - switch (event.type) { - case "ConfigSaved": - if (mApi != null) { - Log.v(TAG, "Forwarding ConfigSaved event to RestApi to get the updated config."); - mApi.reloadConfig(); - } - break; - case "PendingDevicesChanged": - mapNullable((List>) mapData.get("added"), this::onPendingDevicesChanged); - break; - case "FolderCompletion": - CompletionInfo completionInfo = new CompletionInfo(); - completionInfo.completion = (Double) mapData.get("completion"); - mApi.setCompletionInfo( - (String) mapData.get("device"), // deviceId - (String) mapData.get("folder"), // folderId - completionInfo - ); - break; - case "PendingFoldersChanged": - mapNullable((List>) mapData.get("added"), this::onPendingFoldersChanged); - break; - case "ItemFinished": - String folder = (String) mapData.get("folder"); - String folderPath = null; - for (Folder f : mApi.getFolders()) { - if (f.id.equals(folder)) { - folderPath = f.path; - } - } - File updatedFile = new File(folderPath, (String) Objects.requireNonNull(mapData.get("item"))); - if (!"delete".equals(mapData.get("action"))) { - Log.i(TAG, "Rescanned file via MediaScanner: " + updatedFile); - MediaScannerConnection.scanFile(mContext, new String[]{updatedFile.getPath()}, - null, null); - } else { - // Starting with Android 10/Q and targeting API level 29/removing legacy storage flag, - // reports of files being spuriously deleted came up. - // Best guess is that Syncthing directly interacted with the filesystem before, - // and there's a virtualization layer there now. Also there's reports this API - // changed behaviour with scoped storage. In any case it now does not only - // update the media db, but actually delete the file on disk. Which is bad, - // as it can race with the creation of the same file and thus delete it. See: - // https://github.com/syncthing/syncthing-android/issues/1801 - // https://github.com/syncthing/syncthing/issues/7974 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - break; - } - // https://stackoverflow.com/a/29881556/1837158 - Log.i(TAG, "Deleted file from MediaStore: " + updatedFile); - Uri contentUri = MediaStore.Files.getContentUri("external"); - ContentResolver resolver = mContext.getContentResolver(); - resolver.delete(contentUri, MediaStore.Images.ImageColumns.DATA + " = ?", - new String[]{updatedFile.getPath()}); - } - break; - case "Ping": - // Ignored. - break; - case "DeviceConnected": - case "DeviceDisconnected": - case "DeviceDiscovered": - case "DownloadProgress": - case "FolderPaused": - case "FolderScanProgress": - case "FolderSummary": - case "ItemStarted": - case "LocalIndexUpdated": - case "LoginAttempt": - case "RemoteDownloadProgress": - case "RemoteIndexUpdated": - case "Starting": - case "StartupComplete": - case "StateChanged": -// if (BuildConfig.DEBUG) { -// Log.v(TAG, "Ignored event " + event.type + ", data " + event.data); -// } - break; - default: - Log.v(TAG, "Unhandled event " + event.type); - } - } - - @Override - public void onDone(long id) { - if (mLastEventId < id) { - mLastEventId = id; - - // Store the last EventId in case we get killed - mPreferences.edit().putLong(PREF_LAST_SYNC_ID, mLastEventId).apply(); - } - - synchronized (mMainThreadHandler) { - if (!mShutdown) { - mMainThreadHandler.removeCallbacks(this); - mMainThreadHandler.postDelayed(this, EVENT_UPDATE_INTERVAL); - } - } - } - - public void start() { - Log.d(TAG, "Starting event processor."); - - // Remove all pending callbacks and add a new one. This makes sure that only one - // event poller is running at any given time. - synchronized (mMainThreadHandler) { - mShutdown = false; - mMainThreadHandler.removeCallbacks(this); - mMainThreadHandler.postDelayed(this, EVENT_UPDATE_INTERVAL); - } - } - - public void stop() { - Log.d(TAG, "Stopping event processor."); - synchronized (mMainThreadHandler) { - mShutdown = true; - mMainThreadHandler.removeCallbacks(this); - } - } - - private void onPendingDevicesChanged(Map added) { - String deviceId = added.get("deviceID"); - String deviceName = added.get("name"); - String deviceAddress = added.get("address"); - if (deviceId == null) { - return; - } - Log.d(TAG, "Unknown device " + deviceName + "(" + deviceId + ") wants to connect"); - - assert deviceName != null; - String title = mContext.getString(R.string.device_rejected, - deviceName.isEmpty() ? deviceId.substring(0, 7) : deviceName); - int notificationId = mNotificationHandler.getNotificationIdFromText(title); - - // Prepare "accept" action. - Intent intentAccept = new Intent(mContext, DeviceActivity.class) - .putExtra(DeviceActivity.EXTRA_NOTIFICATION_ID, notificationId) - .putExtra(DeviceActivity.EXTRA_IS_CREATE, true) - .putExtra(DeviceActivity.EXTRA_DEVICE_ID, deviceId) - .putExtra(DeviceActivity.EXTRA_DEVICE_NAME, deviceName); - PendingIntent piAccept = PendingIntent.getActivity(mContext, notificationId, - intentAccept, Constants.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); - - // Prepare "ignore" action. - Intent intentIgnore = new Intent(mContext, SyncthingService.class) - .putExtra(SyncthingService.EXTRA_NOTIFICATION_ID, notificationId) - .putExtra(SyncthingService.EXTRA_DEVICE_ID, deviceId) - .putExtra(SyncthingService.EXTRA_DEVICE_NAME, deviceName) - .putExtra(SyncthingService.EXTRA_DEVICE_ADDRESS, deviceAddress); - intentIgnore.setAction(SyncthingService.ACTION_IGNORE_DEVICE); - PendingIntent piIgnore = PendingIntent.getService(mContext, 0, - intentIgnore, Constants.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); - - // Show notification. - mNotificationHandler.showConsentNotification(notificationId, title, piAccept, piIgnore); - } - - private void onPendingFoldersChanged(Map added) { - String deviceId = added.get("deviceID"); - String folderId = added.get("folderID"); - String folderLabel = added.get("folderLabel"); - if (deviceId == null || folderId == null) { - return; - } - Log.d(TAG, "Device " + deviceId + " wants to share folder " + - folderLabel + " (" + folderId + ")"); - - // Find the deviceName corresponding to the deviceId - String deviceName = null; - for (Device d : mApi.getDevices(false)) { - if (d.deviceID.equals(deviceId)) { - deviceName = d.getDisplayName(); - break; - } - } - assert folderLabel != null; - String title = mContext.getString(R.string.folder_rejected, deviceName, - folderLabel.isEmpty() ? folderId : folderLabel + " (" + folderId + ")"); - int notificationId = mNotificationHandler.getNotificationIdFromText(title); - - // Prepare "accept" action. - boolean isNewFolder = Stream.of(mApi.getFolders()) - .noneMatch(f -> f.id.equals(folderId)); - Intent intentAccept = new Intent(mContext, FolderActivity.class) - .putExtra(FolderActivity.EXTRA_NOTIFICATION_ID, notificationId) - .putExtra(FolderActivity.EXTRA_IS_CREATE, isNewFolder) - .putExtra(FolderActivity.EXTRA_DEVICE_ID, deviceId) - .putExtra(FolderActivity.EXTRA_FOLDER_ID, folderId) - .putExtra(FolderActivity.EXTRA_FOLDER_LABEL, folderLabel); - PendingIntent piAccept = PendingIntent.getActivity(mContext, notificationId, - intentAccept, Constants.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); - - // Prepare "ignore" action. - Intent intentIgnore = new Intent(mContext, SyncthingService.class) - .putExtra(SyncthingService.EXTRA_NOTIFICATION_ID, notificationId) - .putExtra(SyncthingService.EXTRA_DEVICE_ID, deviceId) - .putExtra(SyncthingService.EXTRA_FOLDER_ID, folderId) - .putExtra(SyncthingService.EXTRA_FOLDER_LABEL, folderLabel); - intentIgnore.setAction(SyncthingService.ACTION_IGNORE_FOLDER); - PendingIntent piIgnore = PendingIntent.getService(mContext, 0, - intentIgnore, Constants.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); - - // Show notification. - mNotificationHandler.showConsentNotification(notificationId, title, piAccept, piIgnore); - } - - private void mapNullable(List l, Consumer c) { - if (l != null) { - for (T m : l) { - c.accept(m); - } - } - } - -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.kt new file mode 100644 index 00000000..b34edb17 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.kt @@ -0,0 +1,328 @@ +package com.nutomic.syncthingandroid.service + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.media.MediaScannerConnection +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import android.util.Log +import androidx.core.util.Consumer +import com.annimon.stream.Stream +import com.annimon.stream.function.Predicate +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.SyncthingApp +import com.nutomic.syncthingandroid.activities.DeviceActivity +import com.nutomic.syncthingandroid.activities.FolderActivity +import com.nutomic.syncthingandroid.model.CompletionInfo +import com.nutomic.syncthingandroid.model.Event +import com.nutomic.syncthingandroid.model.Folder +import com.nutomic.syncthingandroid.service.RestApi.OnReceiveEventListener +import java.io.File +import java.util.Objects +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.concurrent.Volatile +import androidx.core.content.edit + +/** + * Run by the syncthing service to convert syncthing events into local broadcasts. + * + * + * It uses [RestApi.getEvents] to read the pending events and wait for new events. + */ +class EventProcessor(context: Context, api: RestApi?) : Runnable, OnReceiveEventListener { + /** + * Use the MainThread for all callbacks and message handling + * or we have to track down nasty threading problems. + */ + private val mMainThreadHandler = Handler(Looper.getMainLooper()) + + @Volatile + private var mLastEventId: Long = 0 + + @Volatile + private var mShutdown = true + + private val mContext: Context + private val mApi: RestApi? + + @Inject + var mPreferences: SharedPreferences? = null + + @Inject + var mNotificationHandler: NotificationHandler? = null + + init { + (context.applicationContext as SyncthingApp).component()!!.inject(this) + mContext = context + mApi = api + } + + override fun run() { + // Restore the last event id if the event processor may have been restarted. + if (mLastEventId == 0L) { + mLastEventId = mPreferences!!.getLong(PREF_LAST_SYNC_ID, 0) + } + + // First check if the event number ran backwards. + // If that's the case we've to start at zero because syncthing was restarted. + mApi!!.getEvents(0, 1, object : OnReceiveEventListener { + override fun onEvent(event: Event?) { + } + + override fun onDone(lastId: Long) { + if (lastId < mLastEventId) mLastEventId = 0 + + Log.d(TAG, "Reading events starting with id $mLastEventId") + + mApi.getEvents(mLastEventId, 0, this@EventProcessor) + } + }) + } + + /** + * Performs the actual event handling. + */ + override fun onEvent(event: Event?) { + var mapData: MutableMap? = null + try { + mapData = event?.data as MutableMap? + } catch (_: ClassCastException) { + } + when (event?.type) { + "ConfigSaved" -> if (mApi != null) { + Log.v(TAG, "Forwarding ConfigSaved event to RestApi to get the updated config.") + mApi.reloadConfig() + } + + "PendingDevicesChanged" -> { + mapNullable?>( + mapData!!["added"] as MutableList?>? + ) { added: MutableMap? -> + this.onPendingDevicesChanged(added!!) + } + } + + "FolderCompletion" -> { + val completionInfo = CompletionInfo() + completionInfo.completion = (mapData.get("completion") as Double?)!! + mApi!!.setCompletionInfo( + mapData!!["device"] as String?, // deviceId + mapData["folder"] as String?, // folderId + completionInfo + ) + } + + "PendingFoldersChanged" -> { + mapNullable?>( + mapData!!["added"] as MutableList?>? + ) { added: MutableMap? -> + this.onPendingFoldersChanged(added!!) + } + } + + "ItemFinished" -> { + val folder = mapData!!["folder"] as String? + var folderPath: String? = null + for (f in mApi!!.folders) { + if (f?.id == folder) { + folderPath = f?.path + } + } + val updatedFile = + File(folderPath, Objects.requireNonNull(mapData["item"]) as String) + if ("delete" != mapData["action"]) { + Log.i(TAG, "Rescanned file via MediaScanner: $updatedFile") + MediaScannerConnection.scanFile( + mContext, arrayOf(updatedFile.path), + null, null + ) + } else { + // Starting with Android 10/Q and targeting API level 29/removing legacy storage flag, + // reports of files being spuriously deleted came up. + // Best guess is that Syncthing directly interacted with the filesystem before, + // and there's a virtualization layer there now. Also there's reports this API + // changed behaviour with scoped storage. In any case it now does not only + // update the media db, but actually delete the file on disk. Which is bad, + // as it can race with the creation of the same file and thus delete it. See: + // https://github.com/syncthing/syncthing-android/issues/1801 + // https://github.com/syncthing/syncthing/issues/7974 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return + } + // https://stackoverflow.com/a/29881556/1837158 + Log.i(TAG, "Deleted file from MediaStore: $updatedFile") + val contentUri = MediaStore.Files.getContentUri("external") + val resolver = mContext.contentResolver + resolver.delete( + contentUri, MediaStore.Images.ImageColumns.DATA + " = ?", + arrayOf(updatedFile.path) + ) + } + } + + "Ping" -> {} + "DeviceConnected", "DeviceDisconnected", "DeviceDiscovered", "DownloadProgress", "FolderPaused", "FolderScanProgress", "FolderSummary", "ItemStarted", "LocalIndexUpdated", "LoginAttempt", "RemoteDownloadProgress", "RemoteIndexUpdated", "Starting", "StartupComplete", "StateChanged" -> {} + else -> Log.v(TAG, "Unhandled event " + event?.type) + } + } + + override fun onDone(lastId: Long) { + if (mLastEventId < lastId) { + mLastEventId = lastId + + // Store the last EventId in case we get killed + mPreferences!!.edit { putLong(PREF_LAST_SYNC_ID, mLastEventId) } + } + + synchronized(mMainThreadHandler) { + if (!mShutdown) { + mMainThreadHandler.removeCallbacks(this) + mMainThreadHandler.postDelayed(this, EVENT_UPDATE_INTERVAL) + } + } + } + + fun start() { + Log.d(TAG, "Starting event processor.") + + // Remove all pending callbacks and add a new one. This makes sure that only one + // event poller is running at any given time. + synchronized(mMainThreadHandler) { + mShutdown = false + mMainThreadHandler.removeCallbacks(this) + mMainThreadHandler.postDelayed(this, EVENT_UPDATE_INTERVAL) + } + } + + fun stop() { + Log.d(TAG, "Stopping event processor.") + synchronized(mMainThreadHandler) { + mShutdown = true + mMainThreadHandler.removeCallbacks(this) + } + } + + private fun onPendingDevicesChanged(added: MutableMap) { + val deviceId = added["deviceID"] + val deviceName = added["name"] + val deviceAddress = added["address"] + if (deviceId == null) { + return + } + Log.d(TAG, "Unknown device $deviceName($deviceId) wants to connect") + + checkNotNull(deviceName) + val title = mContext.getString( + R.string.device_rejected, + deviceName.ifEmpty { deviceId.take(7) } + ) + val notificationId = mNotificationHandler!!.getNotificationIdFromText(title) + + // Prepare "accept" action. + val intentAccept = Intent(mContext, DeviceActivity::class.java) + .putExtra(DeviceActivity.EXTRA_NOTIFICATION_ID, notificationId) + .putExtra(DeviceActivity.EXTRA_IS_CREATE, true) + .putExtra(DeviceActivity.EXTRA_DEVICE_ID, deviceId) + .putExtra(DeviceActivity.EXTRA_DEVICE_NAME, deviceName) + val piAccept = PendingIntent.getActivity( + mContext, notificationId, + intentAccept, Constants.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + // Prepare "ignore" action. + val intentIgnore = Intent(mContext, SyncthingService::class.java) + .putExtra(SyncthingService.EXTRA_NOTIFICATION_ID, notificationId) + .putExtra(SyncthingService.EXTRA_DEVICE_ID, deviceId) + .putExtra(SyncthingService.EXTRA_DEVICE_NAME, deviceName) + .putExtra(SyncthingService.EXTRA_DEVICE_ADDRESS, deviceAddress) + intentIgnore.setAction(SyncthingService.ACTION_IGNORE_DEVICE) + val piIgnore = PendingIntent.getService( + mContext, 0, + intentIgnore, Constants.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + // Show notification. + mNotificationHandler!!.showConsentNotification(notificationId, title, piAccept, piIgnore) + } + + private fun onPendingFoldersChanged(added: MutableMap) { + val deviceId = added["deviceID"] + val folderId = added["folderID"] + val folderLabel = added["folderLabel"] + if (deviceId == null || folderId == null) { + return + } + Log.d( + TAG, "Device " + deviceId + " wants to share folder " + + folderLabel + " (" + folderId + ")" + ) + + // Find the deviceName corresponding to the deviceId + var deviceName: String? = null + for (d in mApi!!.getDevices(false)) { + if (d.deviceID == deviceId) { + deviceName = d.displayName + break + } + } + checkNotNull(folderLabel) + val title = mContext.getString( + R.string.folder_rejected, deviceName, + if (folderLabel.isEmpty()) folderId else "$folderLabel ($folderId)" + ) + val notificationId = mNotificationHandler!!.getNotificationIdFromText(title) + + // Prepare "accept" action. + val isNewFolder = Stream.of(mApi.folders) + .noneMatch(Predicate { f: Folder? -> f!!.id == folderId }) + val intentAccept = Intent(mContext, FolderActivity::class.java) + .putExtra(FolderActivity.EXTRA_NOTIFICATION_ID, notificationId) + .putExtra(FolderActivity.EXTRA_IS_CREATE, isNewFolder) + .putExtra(FolderActivity.EXTRA_DEVICE_ID, deviceId) + .putExtra(FolderActivity.EXTRA_FOLDER_ID, folderId) + .putExtra(FolderActivity.EXTRA_FOLDER_LABEL, folderLabel) + val piAccept = PendingIntent.getActivity( + mContext, notificationId, + intentAccept, Constants.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + // Prepare "ignore" action. + val intentIgnore = Intent(mContext, SyncthingService::class.java) + .putExtra(SyncthingService.EXTRA_NOTIFICATION_ID, notificationId) + .putExtra(SyncthingService.EXTRA_DEVICE_ID, deviceId) + .putExtra(SyncthingService.EXTRA_FOLDER_ID, folderId) + .putExtra(SyncthingService.EXTRA_FOLDER_LABEL, folderLabel) + intentIgnore.setAction(SyncthingService.ACTION_IGNORE_FOLDER) + val piIgnore = PendingIntent.getService( + mContext, 0, + intentIgnore, Constants.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + // Show notification. + mNotificationHandler!!.showConsentNotification(notificationId, title, piAccept, piIgnore) + } + + private fun mapNullable(l: MutableList?, c: Consumer) { + if (l != null) { + for (m in l) { + c.accept(m) + } + } + } + + companion object { + private const val TAG = "EventProcessor" + private const val PREF_LAST_SYNC_ID = "last_sync_id" + + /** + * Minimum interval in seconds at which the events are polled from syncthing and processed. + * This intervall will not wake up the device to save battery power. + */ + private val EVENT_UPDATE_INTERVAL = TimeUnit.SECONDS.toMillis(15) + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.java b/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.java deleted file mode 100644 index be46f224..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.java +++ /dev/null @@ -1,307 +0,0 @@ -package com.nutomic.syncthingandroid.service; - -import android.annotation.TargetApi; -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Build; -import androidx.annotation.StringRes; -import androidx.core.app.NotificationCompat; -import android.util.Log; - -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.SyncthingApp; -import com.nutomic.syncthingandroid.activities.FirstStartActivity; -import com.nutomic.syncthingandroid.activities.LogActivity; -import com.nutomic.syncthingandroid.activities.MainActivity; -import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.service.SyncthingService.State; - -import java.util.Objects; - -import javax.inject.Inject; - -public class NotificationHandler { - - private static final String TAG = "NotificationHandler"; - private static final int ID_PERSISTENT = 1; - private static final int ID_PERSISTENT_WAITING = 4; - private static final int ID_RESTART = 2; - private static final int ID_STOP_BACKGROUND_WARNING = 3; - private static final int ID_CRASH = 9; - private static final int ID_MISSING_PERM = 10; - private static final String CHANNEL_PERSISTENT = "01_syncthing_persistent"; - private static final String CHANNEL_INFO = "02_syncthing_notifications"; - private static final String CHANNEL_PERSISTENT_WAITING = "03_syncthing_persistent_waiting"; - - private final Context mContext; - @Inject SharedPreferences mPreferences; - private final NotificationManager mNotificationManager; - private final NotificationChannel mPersistentChannel; - private final NotificationChannel mPersistentChannelWaiting; - private final NotificationChannel mInfoChannel; - - private Boolean lastStartForegroundService = false; - private Boolean appShutdownInProgress = false; - - public NotificationHandler(Context context) { - Objects.requireNonNull(((SyncthingApp) context.getApplicationContext()).component()).inject(this); - mContext = context; - mNotificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - mPersistentChannel = new NotificationChannel( - CHANNEL_PERSISTENT, mContext.getString(R.string.notifications_persistent_channel), - NotificationManager.IMPORTANCE_MIN); - mPersistentChannel.enableLights(false); - mPersistentChannel.enableVibration(false); - mPersistentChannel.setSound(null, null); - mPersistentChannel.setShowBadge(false); - mNotificationManager.createNotificationChannel(mPersistentChannel); - - mPersistentChannelWaiting = new NotificationChannel( - CHANNEL_PERSISTENT_WAITING, mContext.getString(R.string.notification_persistent_waiting_channel), - NotificationManager.IMPORTANCE_MIN); - mPersistentChannelWaiting.enableLights(false); - mPersistentChannelWaiting.enableVibration(false); - mPersistentChannelWaiting.setSound(null, null); - mPersistentChannelWaiting.setShowBadge(false); - mNotificationManager.createNotificationChannel(mPersistentChannelWaiting); - - mInfoChannel = new NotificationChannel( - CHANNEL_INFO, mContext.getString(R.string.notifications_other_channel), - NotificationManager.IMPORTANCE_LOW); - mInfoChannel.enableVibration(false); - mInfoChannel.setSound(null, null); - mInfoChannel.setShowBadge(true); - mNotificationManager.createNotificationChannel(mInfoChannel); - } else { - mPersistentChannel = null; - mPersistentChannelWaiting = null; - mInfoChannel = null; - } - } - - private NotificationCompat.Builder getNotificationBuilder(NotificationChannel channel) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return new NotificationCompat.Builder(mContext, channel.getId()); - } else { - //noinspection deprecation - return new NotificationCompat.Builder(mContext); - } - } - - /** - * Shows, updates or hides the notification. - */ - public void updatePersistentNotification(SyncthingService service) { - boolean startServiceOnBoot = mPreferences.getBoolean(Constants.PREF_START_SERVICE_ON_BOOT, false); - State currentServiceState = service.getCurrentState(); - boolean syncthingRunning = currentServiceState == SyncthingService.State.ACTIVE || - currentServiceState == SyncthingService.State.STARTING; - boolean startForegroundService = false; - if (!appShutdownInProgress) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - /** - * Android 7 and lower: - * The app may run in background and monitor run conditions even if it is not - * running as a foreground service. For that reason, we can use a normal - * notification if syncthing is DISABLED. - */ - startForegroundService = startServiceOnBoot || syncthingRunning; - } else { - /** - * Android 8+: - * Always use startForeground. - * This makes sure the app is not killed, and we don't miss run condition events. - * On Android 8+, this behaviour is mandatory to receive broadcasts. - * https://stackoverflow.com/a/44505719/1837158 - * Foreground priority requires a notification so this ensures that we either have a - * "default" or "low_priority" notification, but not "none". - */ - startForegroundService = true; - } - } - - // Check if we have to stopForeground. - if (startForegroundService != lastStartForegroundService) { - if (!startForegroundService) { - Log.v(TAG, "Stopping foreground service"); - service.stopForeground(false); - } - } - - // Prepare notification builder. - int title = R.string.syncthing_terminated; - switch (currentServiceState) { - case ERROR: - case INIT: - break; - case DISABLED: - title = R.string.syncthing_disabled; - break; - case STARTING: - title = R.string.syncthing_starting; - break; - case ACTIVE: - title = R.string.syncthing_active; - break; - default: - break; - } - - /** - * Reason for two separate IDs: if one of the notification channels is hidden then - * the startForeground() below won't update the notification but use the old one. - */ - int idToShow = syncthingRunning ? ID_PERSISTENT : ID_PERSISTENT_WAITING; - int idToCancel = syncthingRunning ? ID_PERSISTENT_WAITING : ID_PERSISTENT; - Intent intent = new Intent(mContext, MainActivity.class); - NotificationChannel channel = syncthingRunning ? mPersistentChannel : mPersistentChannelWaiting; - NotificationCompat.Builder builder = getNotificationBuilder(channel) - .setContentTitle(mContext.getString(title)) - .setSmallIcon(R.drawable.ic_stat_notify) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setContentIntent(PendingIntent.getActivity(mContext, 0, intent, Constants.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT)); - if (!appShutdownInProgress) { - if (startForegroundService) { - Log.v(TAG, "Starting foreground service or updating notification"); - service.startForeground(idToShow, builder.build()); - } else { - Log.v(TAG, "Updating notification"); - mNotificationManager.notify(idToShow, builder.build()); - } - } else { - mNotificationManager.cancel(idToShow); - } - mNotificationManager.cancel(idToCancel); - - // Remember last notification visibility. - lastStartForegroundService = startForegroundService; - } - - /** - * Called by {@link SyncthingService#onStart} {@link SyncthingService#onDestroy} - * to indicate app startup and shutdown. - */ - public void setAppShutdownInProgress(Boolean newValue) { - appShutdownInProgress = newValue; - } - - public void showCrashedNotification(@StringRes int title, boolean force) { - if (force || mPreferences.getBoolean("notify_crashes", false)) { - Intent intent = new Intent(mContext, LogActivity.class); - Notification n = getNotificationBuilder(mInfoChannel) - .setContentTitle(mContext.getString(title)) - .setContentText(mContext.getString(R.string.notification_crash_text)) - .setSmallIcon(R.drawable.ic_stat_notify) - .setContentIntent(PendingIntent.getActivity(mContext, 0, intent, Constants.FLAG_IMMUTABLE)) - .setAutoCancel(true) - .build(); - mNotificationManager.notify(ID_CRASH, n); - } - } - - /** - * Calculate a deterministic ID between 1000 and 2000 to avoid duplicate - * notification ids for different device, folder consent popups triggered - * by {@link EventProcessor}. - */ - public int getNotificationIdFromText(String text) { - return 1000 + text.hashCode() % 1000; - } - - /** - * Closes a notification. Required after the user hit an action button. - */ - public void cancelConsentNotification(int notificationId) { - if (notificationId == 0) { - return; - } - Log.v(TAG, "Cancelling notification with id " + notificationId); - mNotificationManager.cancel(notificationId); - } - - /** - * Used by {@link EventProcessor} - */ - public void showConsentNotification(int notificationId, - String text, - PendingIntent piAccept, - PendingIntent piIgnore) { - /** - * As we know the id for a specific notification text, - * we'll dismiss this notification as it may be outdated. - * This is also valid if the notification does not exist. - */ - mNotificationManager.cancel(notificationId); - Notification n = getNotificationBuilder(mInfoChannel) - .setContentTitle(mContext.getString(R.string.app_name)) - .setContentText(text) - .setStyle(new NotificationCompat.BigTextStyle() - .bigText(text)) - .setContentIntent(piAccept) - .addAction(R.drawable.ic_stat_notify, mContext.getString(R.string.accept), piAccept) - .addAction(R.drawable.ic_stat_notify, mContext.getString(R.string.ignore), piIgnore) - .setSmallIcon(R.drawable.ic_stat_notify) - .setAutoCancel(true) - .build(); - mNotificationManager.notify(notificationId, n); - } - - public void showStoragePermissionRevokedNotification() { - Intent intent = new Intent(mContext, FirstStartActivity.class); - Notification n = getNotificationBuilder(mInfoChannel) - .setContentTitle(mContext.getString(R.string.syncthing_terminated)) - .setContentText(mContext.getString(R.string.toast_write_storage_permission_required)) - .setSmallIcon(R.drawable.ic_stat_notify) - .setContentIntent(PendingIntent.getActivity(mContext, 0, intent, Constants.FLAG_IMMUTABLE)) - .setAutoCancel(true) - .setOnlyAlertOnce(true) - .build(); - mNotificationManager.notify(ID_MISSING_PERM, n); - } - - public void showRestartNotification() { - Intent intent = new Intent(mContext, SyncthingService.class) - .setAction(SyncthingService.ACTION_RESTART); - PendingIntent pi = PendingIntent.getService(mContext, 0, intent, Constants.FLAG_IMMUTABLE); - - Notification n = getNotificationBuilder(mInfoChannel) - .setContentTitle(mContext.getString(R.string.restart_title)) - .setContentText(mContext.getString(R.string.restart_notification_text)) - .setSmallIcon(R.drawable.ic_stat_notify) - .setContentIntent(pi) - .build(); - n.flags |= Notification.FLAG_ONLY_ALERT_ONCE | Notification.FLAG_AUTO_CANCEL; - mNotificationManager.notify(ID_RESTART, n); - } - - public void cancelRestartNotification() { - mNotificationManager.cancel(ID_RESTART); - } - - public void showStopSyncthingWarningNotification() { - final String msg = mContext.getString(R.string.appconfig_receiver_background_enabled); - NotificationCompat.Builder nb = getNotificationBuilder(mInfoChannel) - .setContentText(msg) - .setTicker(msg) - .setStyle(new NotificationCompat.BigTextStyle().bigText(msg)) - .setContentTitle(mContext.getText(mContext.getApplicationInfo().labelRes)) - .setSmallIcon(R.drawable.ic_stat_notify) - .setAutoCancel(true) - .setContentIntent(PendingIntent.getActivity(mContext, 0, - new Intent(mContext, MainActivity.class), - Constants.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT)); - - - nb.setCategory(Notification.CATEGORY_ERROR); - mNotificationManager.notify(ID_STOP_BACKGROUND_WARNING, nb.build()); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.kt new file mode 100644 index 00000000..13a42301 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.kt @@ -0,0 +1,332 @@ +package com.nutomic.syncthingandroid.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Build +import android.util.Log +import androidx.annotation.StringRes +import androidx.core.app.NotificationCompat +import com.nutomic.syncthingandroid.DaggerComponent +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.SyncthingApp +import com.nutomic.syncthingandroid.activities.FirstStartActivity +import com.nutomic.syncthingandroid.activities.LogActivity +import com.nutomic.syncthingandroid.activities.MainActivity +import java.util.Objects +import javax.inject.Inject + +class NotificationHandler(context: Context) { + private val mContext: Context + + @JvmField + @Inject + var mPreferences: SharedPreferences? = null + private val mNotificationManager: NotificationManager + private val mPersistentChannel: NotificationChannel? + private val mPersistentChannelWaiting: NotificationChannel? + private val mInfoChannel: NotificationChannel? + + private var lastStartForegroundService = false + private var appShutdownInProgress = false + + init { + Objects.requireNonNull((context.getApplicationContext() as SyncthingApp).component()) + .inject(this) + mContext = context + mNotificationManager = + mContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + mPersistentChannel = NotificationChannel( + CHANNEL_PERSISTENT, mContext.getString(R.string.notifications_persistent_channel), + NotificationManager.IMPORTANCE_MIN + ) + mPersistentChannel.enableLights(false) + mPersistentChannel.enableVibration(false) + mPersistentChannel.setSound(null, null) + mPersistentChannel.setShowBadge(false) + mNotificationManager.createNotificationChannel(mPersistentChannel) + + mPersistentChannelWaiting = NotificationChannel( + CHANNEL_PERSISTENT_WAITING, + mContext.getString(R.string.notification_persistent_waiting_channel), + NotificationManager.IMPORTANCE_MIN + ) + mPersistentChannelWaiting.enableLights(false) + mPersistentChannelWaiting.enableVibration(false) + mPersistentChannelWaiting.setSound(null, null) + mPersistentChannelWaiting.setShowBadge(false) + mNotificationManager.createNotificationChannel(mPersistentChannelWaiting) + + mInfoChannel = NotificationChannel( + CHANNEL_INFO, mContext.getString(R.string.notifications_other_channel), + NotificationManager.IMPORTANCE_LOW + ) + mInfoChannel.enableVibration(false) + mInfoChannel.setSound(null, null) + mInfoChannel.setShowBadge(true) + mNotificationManager.createNotificationChannel(mInfoChannel) + } else { + mPersistentChannel = null + mPersistentChannelWaiting = null + mInfoChannel = null + } + } + + private fun getNotificationBuilder(channel: NotificationChannel): NotificationCompat.Builder { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return NotificationCompat.Builder(mContext, channel.getId()) + } else { + return NotificationCompat.Builder(mContext) + } + } + + /** + * Shows, updates or hides the notification. + */ + fun updatePersistentNotification(service: SyncthingService) { + val startServiceOnBoot = + mPreferences!!.getBoolean(Constants.PREF_START_SERVICE_ON_BOOT, false) + val currentServiceState = service.currentState + val syncthingRunning = currentServiceState == SyncthingService.State.ACTIVE || + currentServiceState == SyncthingService.State.STARTING + var startForegroundService = false + if (!appShutdownInProgress) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + /** + * Android 7 and lower: + * The app may run in background and monitor run conditions even if it is not + * running as a foreground service. For that reason, we can use a normal + * notification if syncthing is DISABLED. + */ + startForegroundService = startServiceOnBoot || syncthingRunning + } else { + /** + * Android 8+: + * Always use startForeground. + * This makes sure the app is not killed, and we don't miss run condition events. + * On Android 8+, this behaviour is mandatory to receive broadcasts. + * https://stackoverflow.com/a/44505719/1837158 + * Foreground priority requires a notification so this ensures that we either have a + * "default" or "low_priority" notification, but not "none". + */ + startForegroundService = true + } + } + + // Check if we have to stopForeground. + if (startForegroundService != lastStartForegroundService) { + if (!startForegroundService) { + Log.v(TAG, "Stopping foreground service") + service.stopForeground(false) + } + } + + // Prepare notification builder. + var title = R.string.syncthing_terminated + when (currentServiceState) { + SyncthingService.State.ERROR, SyncthingService.State.INIT -> {} + SyncthingService.State.DISABLED -> title = R.string.syncthing_disabled + SyncthingService.State.STARTING -> title = R.string.syncthing_starting + SyncthingService.State.ACTIVE -> title = R.string.syncthing_active + else -> {} + } + + /** + * Reason for two separate IDs: if one of the notification channels is hidden then + * the startForeground() below won't update the notification but use the old one. + */ + val idToShow: Int = if (syncthingRunning) ID_PERSISTENT else ID_PERSISTENT_WAITING + val idToCancel: Int = if (syncthingRunning) ID_PERSISTENT_WAITING else ID_PERSISTENT + val intent = Intent(mContext, MainActivity::class.java) + val channel = (if (syncthingRunning) mPersistentChannel else mPersistentChannelWaiting)!! + val builder = getNotificationBuilder(channel) + .setContentTitle(mContext.getString(title)) + .setSmallIcon(R.drawable.ic_stat_notify) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setContentIntent( + PendingIntent.getActivity( + mContext, + 0, + intent, + Constants.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + ) + if (!appShutdownInProgress) { + if (startForegroundService) { + Log.v(TAG, "Starting foreground service or updating notification") + service.startForeground(idToShow, builder.build()) + } else { + Log.v(TAG, "Updating notification") + mNotificationManager.notify(idToShow, builder.build()) + } + } else { + mNotificationManager.cancel(idToShow) + } + mNotificationManager.cancel(idToCancel) + + // Remember last notification visibility. + lastStartForegroundService = startForegroundService + } + + /** + * Called by [SyncthingService.onStart] [SyncthingService.onDestroy] + * to indicate app startup and shutdown. + */ + fun setAppShutdownInProgress(newValue: Boolean) { + appShutdownInProgress = newValue + } + + fun showCrashedNotification(@StringRes title: Int, force: Boolean) { + if (force || mPreferences!!.getBoolean("notify_crashes", false)) { + val intent = Intent(mContext, LogActivity::class.java) + val n = getNotificationBuilder(mInfoChannel!!) + .setContentTitle(mContext.getString(title)) + .setContentText(mContext.getString(R.string.notification_crash_text)) + .setSmallIcon(R.drawable.ic_stat_notify) + .setContentIntent( + PendingIntent.getActivity( + mContext, + 0, + intent, + Constants.FLAG_IMMUTABLE + ) + ) + .setAutoCancel(true) + .build() + mNotificationManager.notify(ID_CRASH, n) + } + } + + /** + * Calculate a deterministic ID between 1000 and 2000 to avoid duplicate + * notification ids for different device, folder consent popups triggered + * by [EventProcessor]. + */ + fun getNotificationIdFromText(text: String): Int { + return 1000 + text.hashCode() % 1000 + } + + /** + * Closes a notification. Required after the user hit an action button. + */ + fun cancelConsentNotification(notificationId: Int) { + if (notificationId == 0) { + return + } + Log.v(TAG, "Cancelling notification with id " + notificationId) + mNotificationManager.cancel(notificationId) + } + + /** + * Used by [EventProcessor] + */ + fun showConsentNotification( + notificationId: Int, + text: String?, + piAccept: PendingIntent?, + piIgnore: PendingIntent? + ) { + /** + * As we know the id for a specific notification text, + * we'll dismiss this notification as it may be outdated. + * This is also valid if the notification does not exist. + */ + mNotificationManager.cancel(notificationId) + val n = getNotificationBuilder(mInfoChannel!!) + .setContentTitle(mContext.getString(R.string.app_name)) + .setContentText(text) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(text) + ) + .setContentIntent(piAccept) + .addAction(R.drawable.ic_stat_notify, mContext.getString(R.string.accept), piAccept) + .addAction(R.drawable.ic_stat_notify, mContext.getString(R.string.ignore), piIgnore) + .setSmallIcon(R.drawable.ic_stat_notify) + .setAutoCancel(true) + .build() + mNotificationManager.notify(notificationId, n) + } + + fun showStoragePermissionRevokedNotification() { + val intent = Intent(mContext, FirstStartActivity::class.java) + val n = getNotificationBuilder(mInfoChannel!!) + .setContentTitle(mContext.getString(R.string.syncthing_terminated)) + .setContentText(mContext.getString(R.string.toast_write_storage_permission_required)) + .setSmallIcon(R.drawable.ic_stat_notify) + .setContentIntent( + PendingIntent.getActivity( + mContext, + 0, + intent, + Constants.FLAG_IMMUTABLE + ) + ) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .build() + mNotificationManager.notify(ID_MISSING_PERM, n) + } + + fun showRestartNotification() { + val intent = Intent(mContext, SyncthingService::class.java) + .setAction(SyncthingService.ACTION_RESTART) + val pi = PendingIntent.getService(mContext, 0, intent, Constants.FLAG_IMMUTABLE) + + val n = getNotificationBuilder(mInfoChannel!!) + .setContentTitle(mContext.getString(R.string.restart_title)) + .setContentText(mContext.getString(R.string.restart_notification_text)) + .setSmallIcon(R.drawable.ic_stat_notify) + .setContentIntent(pi) + .build() + n.flags = n.flags or (Notification.FLAG_ONLY_ALERT_ONCE or Notification.FLAG_AUTO_CANCEL) + mNotificationManager.notify(ID_RESTART, n) + } + + fun cancelRestartNotification() { + mNotificationManager.cancel(ID_RESTART) + } + + fun showStopSyncthingWarningNotification() { + val msg = mContext.getString(R.string.appconfig_receiver_background_enabled) + val nb = getNotificationBuilder(mInfoChannel!!) + .setContentText(msg) + .setTicker(msg) + .setStyle(NotificationCompat.BigTextStyle().bigText(msg)) + .setContentTitle(mContext.getText(mContext.getApplicationInfo().labelRes)) + .setSmallIcon(R.drawable.ic_stat_notify) + .setAutoCancel(true) + .setContentIntent( + PendingIntent.getActivity( + mContext, 0, + Intent(mContext, MainActivity::class.java), + Constants.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + ) + + + nb.setCategory(Notification.CATEGORY_ERROR) + mNotificationManager.notify(ID_STOP_BACKGROUND_WARNING, nb.build()) + } + + companion object { + private const val TAG = "NotificationHandler" + private const val ID_PERSISTENT = 1 + private const val ID_PERSISTENT_WAITING = 4 + private const val ID_RESTART = 2 + private const val ID_STOP_BACKGROUND_WARNING = 3 + private const val ID_CRASH = 9 + private const val ID_MISSING_PERM = 10 + private const val CHANNEL_PERSISTENT = "01_syncthing_persistent" + private const val CHANNEL_INFO = "02_syncthing_notifications" + private const val CHANNEL_PERSISTENT_WAITING = "03_syncthing_persistent_waiting" + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.java b/app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.java deleted file mode 100644 index 06cdb487..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.nutomic.syncthingandroid.service; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.IntentFilter; -import android.util.Log; - -import androidx.core.content.ContextCompat; - -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; - -public class ReceiverManager { - - private static final String TAG = "ReceiverManager"; - - private static final List mReceivers = new ArrayList<>(); - - public static synchronized void registerReceiver(Context context, BroadcastReceiver receiver, IntentFilter intentFilter) { - mReceivers.add(receiver); - ContextCompat.registerReceiver(context, receiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED); - Log.v(TAG, "Registered receiver: " + receiver + " with filter: " + intentFilter); - } - - public static synchronized boolean isReceiverRegistered(BroadcastReceiver receiver) { - return mReceivers.contains(receiver); - } - - public static synchronized void unregisterAllReceivers(Context context) { - if (context == null) { - Log.e(TAG, "unregisterReceiver: context is null"); - return; - } - Iterator iter = mReceivers.iterator(); - while (iter.hasNext()) { - BroadcastReceiver receiver = iter.next(); - if (isReceiverRegistered(receiver)) { - try { - context.unregisterReceiver(receiver); - Log.v(TAG, "Unregistered receiver: " + receiver); - } catch(IllegalArgumentException e) { - // We have to catch the race condition a registration is still pending in android - // according to https://stackoverflow.com/a/3568906 - Log.w(TAG, "unregisterReceiver(" + receiver + ") threw IllegalArgumentException"); - } - iter.remove(); - } - } - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.kt new file mode 100644 index 00000000..7cd07eb1 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/ReceiverManager.kt @@ -0,0 +1,60 @@ +package com.nutomic.syncthingandroid.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.IntentFilter +import android.util.Log +import androidx.core.content.ContextCompat + +object ReceiverManager { + private const val TAG = "ReceiverManager" + + private val mReceivers: MutableList = ArrayList() + + @Synchronized + fun registerReceiver( + context: Context, + receiver: BroadcastReceiver?, + intentFilter: IntentFilter + ) { + mReceivers.add(receiver) + ContextCompat.registerReceiver( + context, + receiver, + intentFilter, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + Log.v(TAG, "Registered receiver: " + receiver + " with filter: " + intentFilter) + } + + @Synchronized + fun isReceiverRegistered(receiver: BroadcastReceiver?): Boolean { + return mReceivers.contains(receiver) + } + + @Synchronized + fun unregisterAllReceivers(context: Context?) { + if (context == null) { + Log.e(TAG, "unregisterReceiver: context is null") + return + } + val iter = mReceivers.iterator() + while (iter.hasNext()) { + val receiver = iter.next() + if (isReceiverRegistered(receiver)) { + try { + context.unregisterReceiver(receiver) + Log.v(TAG, "Unregistered receiver: " + receiver) + } catch (e: IllegalArgumentException) { + // We have to catch the race condition a registration is still pending in android + // according to https://stackoverflow.com/a/3568906 + Log.w( + TAG, + "unregisterReceiver(" + receiver + ") threw IllegalArgumentException" + ) + } + iter.remove() + } + } + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java deleted file mode 100644 index 7a3e9ef5..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java +++ /dev/null @@ -1,726 +0,0 @@ -package com.nutomic.syncthingandroid.service; - -import android.content.Context; -import android.content.Intent; -import android.preference.PreferenceManager; -import android.util.Log; - -import com.google.common.base.Objects; -import com.google.common.base.Optional; -import com.google.common.collect.ImmutableMap; -import com.google.common.reflect.TypeToken; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.nutomic.syncthingandroid.SyncthingApp; -import com.nutomic.syncthingandroid.activities.ShareActivity; -import com.nutomic.syncthingandroid.http.GetRequest; -import com.nutomic.syncthingandroid.http.PostRequest; -import com.nutomic.syncthingandroid.http.PostConfigRequest; -import com.nutomic.syncthingandroid.model.Config; -import com.nutomic.syncthingandroid.model.Completion; -import com.nutomic.syncthingandroid.model.CompletionInfo; -import com.nutomic.syncthingandroid.model.Connections; -import com.nutomic.syncthingandroid.model.Device; -import com.nutomic.syncthingandroid.model.Event; -import com.nutomic.syncthingandroid.model.Folder; -import com.nutomic.syncthingandroid.model.FolderStatus; -import com.nutomic.syncthingandroid.model.IgnoredFolder; -import com.nutomic.syncthingandroid.model.Options; -import com.nutomic.syncthingandroid.model.RemoteIgnoredDevice; -import com.nutomic.syncthingandroid.model.SystemInfo; -import com.nutomic.syncthingandroid.model.SystemVersion; - -import java.lang.reflect.Type; -import java.net.URL; -import java.text.SimpleDateFormat; -import java.util.Collections; -import java.util.Comparator; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; - -import javax.inject.Inject; - -/** - * Provides functions to interact with the syncthing REST API. - */ -public class RestApi { - - private static final String TAG = "RestApi"; - - private static final SimpleDateFormat dateFormat; - static { - if (android.os.Build.VERSION.SDK_INT < 24) { - dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US); - } else { - dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US); - } - } - - /** - * Compares folders by labels, uses the folder ID as fallback if the label is empty - */ - private final static Comparator FOLDERS_COMPARATOR = (lhs, rhs) -> { - String lhsLabel = lhs.label != null && !lhs.label.isEmpty() ? lhs.label : lhs.id; - String rhsLabel = rhs.label != null && !rhs.label.isEmpty() ? rhs.label : rhs.id; - - return lhsLabel.compareTo(rhsLabel); - }; - - public interface OnConfigChangedListener { - void onConfigChanged(); - } - - public interface OnResultListener1 { - void onResult(T t); - } - - public interface OnResultListener2 { - void onResult(T t, R r); - } - - private final Context mContext; - private final URL mUrl; - private final String mApiKey; - - private String mVersion; - private Config mConfig; - - /** - * Results cached from systemInfo - */ - private String mLocalDeviceId; - private Integer mUrVersionMax; - - /** - * Stores the result of the last successful request to {@link GetRequest#URI_CONNECTIONS}, - * or an empty Map. - */ - private Optional mPreviousConnections = Optional.absent(); - - /** - * Stores the timestamp of the last successful request to {@link GetRequest#URI_CONNECTIONS}. - */ - private long mPreviousConnectionTime = 0; - - /** - * In the last-finishing {@link #readConfigFromRestApi()} callback, we have to call - * {@link SyncthingService#onApiAvailable}} to indicate that the RestApi class is fully initialized. - * We do this to avoid getting stuck with our main thread due to synchronous REST queries. - * The correct indication of full initialisation is crucial to stability as other listeners of - * {@link SettingsActivity#onServiceStateChange} needs cached config and system information available. - * e.g. SettingsFragment need "mLocalDeviceId" - */ - private Boolean asyncQueryConfigComplete = false; - private Boolean asyncQueryVersionComplete = false; - private Boolean asyncQuerySystemInfoComplete = false; - - /** - * Object that must be locked upon accessing the following variables: - * asyncQueryConfigComplete, asyncQueryVersionComplete, asyncQuerySystemInfoComplete - */ - private final Object mAsyncQueryCompleteLock = new Object(); - - /** - * Object that must be locked upon accessing mConfig - */ - private final Object mConfigLock = new Object(); - - /** - * Stores the latest result of {@link #getFolderStatus} for each folder - */ - private final HashMap mCachedFolderStatuses = new HashMap<>(); - - /** - * Stores the latest result of device and folder completion events. - */ - private final Completion mCompletion = new Completion(); - - @Inject NotificationHandler mNotificationHandler; - - public RestApi(Context context, URL url, String apiKey, OnApiAvailableListener apiListener, - OnConfigChangedListener configListener) { - ((SyncthingApp) context.getApplicationContext()).component().inject(this); - mContext = context; - mUrl = url; - mApiKey = apiKey; - mOnApiAvailableListener = apiListener; - mOnConfigChangedListener = configListener; - } - - public interface OnApiAvailableListener { - void onApiAvailable(); - } - - private final OnApiAvailableListener mOnApiAvailableListener; - - private final OnConfigChangedListener mOnConfigChangedListener; - - /** - * Gets local device ID, syncthing version and config, then calls all OnApiAvailableListeners. - */ - public void readConfigFromRestApi() { - Log.v(TAG, "Reading config from REST ..."); - synchronized (mAsyncQueryCompleteLock) { - asyncQueryVersionComplete = false; - asyncQueryConfigComplete = false; - asyncQuerySystemInfoComplete = false; - } - new GetRequest(mContext, mUrl, GetRequest.URI_VERSION, mApiKey, null, result -> { - JsonObject json = JsonParser.parseString(result).getAsJsonObject(); - mVersion = json.get("version").getAsString(); - Log.i(TAG, "Syncthing version is " + mVersion); - updateDebugFacilitiesCache(); - synchronized (mAsyncQueryCompleteLock) { - asyncQueryVersionComplete = true; - checkReadConfigFromRestApiCompleted(); - } - }); - new GetRequest(mContext, mUrl, GetRequest.URI_CONFIG, mApiKey, null, result -> { - onReloadConfigComplete(result); - synchronized (mAsyncQueryCompleteLock) { - asyncQueryConfigComplete = true; - checkReadConfigFromRestApiCompleted(); - } - }); - getSystemInfo(info -> { - mLocalDeviceId = info.myID; - mUrVersionMax = info.urVersionMax; - synchronized (mAsyncQueryCompleteLock) { - asyncQuerySystemInfoComplete = true; - checkReadConfigFromRestApiCompleted(); - } - }); - } - - private void checkReadConfigFromRestApiCompleted() { - if (asyncQueryVersionComplete && asyncQueryConfigComplete && asyncQuerySystemInfoComplete) { - Log.v(TAG, "Reading config from REST completed."); - mOnApiAvailableListener.onApiAvailable(); - } - } - - public void reloadConfig() { - new GetRequest(mContext, mUrl, GetRequest.URI_CONFIG, mApiKey, null, this::onReloadConfigComplete); - } - - private void onReloadConfigComplete(String result) { - boolean configParseSuccess; - synchronized(mConfigLock) { - mConfig = new Gson().fromJson(result, Config.class); - configParseSuccess = mConfig != null; - } - if (!configParseSuccess) { - throw new RuntimeException("config is null: " + result); - } - Log.v(TAG, "onReloadConfigComplete: Successfully parsed configuration."); -// if (BuildConfig.DEBUG) { -// Log.v(TAG, "mConfig.remoteIgnoredDevices = " + new Gson().toJson(mConfig.remoteIgnoredDevices)); -// } - - // Update cached device and folder information stored in the mCompletion model. - mCompletion.updateFromConfig(getDevices(true), getFolders()); - } - - /** - * Queries debug facilities available from the currently running syncthing binary - * if the syncthing binary version changed. First launch of the binary is also - * considered as a version change. - * Precondition: {@link #mVersion} read from REST - */ - private void updateDebugFacilitiesCache() { - final String PREF_LAST_BINARY_VERSION = "lastBinaryVersion"; - if (!mVersion.equals(PreferenceManager.getDefaultSharedPreferences(mContext).getString(PREF_LAST_BINARY_VERSION, ""))) { - // First binary launch or binary upgraded case. - new GetRequest(mContext, mUrl, GetRequest.URI_DEBUG, mApiKey, null, result -> { - try { - JsonObject json = JsonParser.parseString(result).getAsJsonObject(); - JsonObject jsonFacilities = json.getAsJsonObject("facilities"); - Set facilitiesToStore = new HashSet<>(jsonFacilities.keySet()); - PreferenceManager.getDefaultSharedPreferences(mContext).edit() - .putStringSet(Constants.PREF_DEBUG_FACILITIES_AVAILABLE, facilitiesToStore) - .apply(); - - // Store current binary version so we will only store this information again - // after a binary update. - PreferenceManager.getDefaultSharedPreferences(mContext).edit() - .putString(PREF_LAST_BINARY_VERSION, mVersion) - .apply(); - } catch (Exception e) { - Log.w(TAG, "updateDebugFacilitiesCache: Failed to get debug facilities. result=" + result); - } - }); - } - } - - /** - * Permanently ignore a device when it tries to connect. - * Ignored devices will not trigger the "DeviceRejected" event - * in {@link EventProcessor#onEvent}. - */ - public void ignoreDevice(String deviceId, String deviceName, String deviceAddress) { - synchronized (mConfigLock) { - // Check if the device has already been ignored. - for (RemoteIgnoredDevice remoteIgnoredDevice : mConfig.remoteIgnoredDevices) { - if (deviceId.equals(remoteIgnoredDevice.deviceID)) { - // Device already ignored. - Log.d(TAG, "Device already ignored [" + deviceId + "]"); - return; - } - } - - RemoteIgnoredDevice remoteIgnoredDevice = new RemoteIgnoredDevice(); - remoteIgnoredDevice.deviceID = deviceId; - remoteIgnoredDevice.address = deviceAddress; - remoteIgnoredDevice.name = deviceName; - remoteIgnoredDevice.time = dateFormat.format(new Date()); - mConfig.remoteIgnoredDevices.add(remoteIgnoredDevice); - sendConfig(); - Log.d(TAG, "Ignored device [" + deviceId + "]"); - } - } - - /** - * Permanently ignore a folder share request. - * Ignored folders will not trigger the "FolderRejected" event - * in {@link EventProcessor#onEvent}. - */ - public void ignoreFolder(String deviceId, String folderId, String folderLabel) { - synchronized (mConfigLock) { - for (Device device : mConfig.devices) { - if (deviceId.equals(device.deviceID)) { - /* - * Check if the folder has already been ignored. - */ - for (IgnoredFolder ignoredFolder : device.ignoredFolders) { - if (folderId.equals(ignoredFolder.id)) { - // Folder already ignored. - Log.d(TAG, "Folder [" + folderId + "] already ignored on device [" + deviceId + "]"); - return; - } - } - - /* - * Ignore folder by moving its corresponding "pendingFolder" entry to - * a newly created "ignoredFolder" entry. - */ - IgnoredFolder ignoredFolder = new IgnoredFolder(); - ignoredFolder.id = folderId; - ignoredFolder.label = folderLabel; - ignoredFolder.time = dateFormat.format(new Date()); - device.ignoredFolders.add(ignoredFolder); -// if (BuildConfig.DEBUG) { -// Log.v(TAG, "device.ignoredFolders = " + new Gson().toJson(device.ignoredFolders)); -// } - sendConfig(); - Log.d(TAG, "Ignored folder [" + folderId + "] announced by device [" + deviceId + "]"); - - // Given deviceId handled. - break; - } - } - } - } - - /** - * Undo ignoring devices and folders. - */ - public void undoIgnoredDevicesAndFolders() { - Log.d(TAG, "Undo ignoring devices and folders ..."); - synchronized (mConfigLock) { - mConfig.remoteIgnoredDevices.clear(); - for (Device device : mConfig.devices) { - device.ignoredFolders.clear(); - } - } - } - - /** - * Override folder changes. This is the same as hitting - * the "override changes" button from the web UI. - */ - public void overrideChanges(String folderId) { - Log.d(TAG, "overrideChanges '" + folderId + "'"); - new PostRequest(mContext, mUrl, PostRequest.URI_DB_OVERRIDE, mApiKey, - ImmutableMap.of("folder", folderId), null); - } - - /** - * Sends current config to Syncthing. - * Will result in a "ConfigSaved" event. - * EventProcessor will trigger this.reloadConfig(). - */ - private void sendConfig() { - String jsonConfig; - synchronized (mConfigLock) { - jsonConfig = new Gson().toJson(mConfig); - } - new PostConfigRequest(mContext, mUrl, mApiKey, jsonConfig, null); - mOnConfigChangedListener.onConfigChanged(); - } - - /** - * Sends current config and restarts Syncthing. - */ - public void saveConfigAndRestart() { - String jsonConfig; - synchronized (mConfigLock) { - jsonConfig = new Gson().toJson(mConfig); - } - new PostConfigRequest(mContext, mUrl, mApiKey, jsonConfig, result -> { - Intent intent = new Intent(mContext, SyncthingService.class) - .setAction(SyncthingService.ACTION_RESTART); - mContext.startService(intent); - }); - mOnConfigChangedListener.onConfigChanged(); - } - - public void shutdown() { - mNotificationHandler.cancelRestartNotification(); - } - - /** - * Returns the version name, or a (text) error message on failure. - */ - public String getVersion() { - return mVersion; - } - - public List getFolders() { - List folders; - synchronized (mConfigLock) { - folders = deepCopy(mConfig.folders, new TypeToken>(){}.getType()); - } - Collections.sort(folders, FOLDERS_COMPARATOR); - return folders; - } - - /** - * This is only used for new folder creation, see {@link com.nutomic.syncthingandroid.activities.FolderActivity}. - */ - public void createFolder(Folder folder) { - synchronized (mConfigLock) { - // Add the new folder to the model. - mConfig.folders.add(folder); - // Send model changes to syncthing, does not require a restart. - sendConfig(); - } - } - - public void updateFolder(Folder newFolder) { - synchronized (mConfigLock) { - removeFolderInternal(newFolder.id); - mConfig.folders.add(newFolder); - sendConfig(); - } - } - - public void removeFolder(String id) { - synchronized (mConfigLock) { - removeFolderInternal(id); - // mCompletion will be updated after the ConfigSaved event. - sendConfig(); - // Remove saved data from share activity for this folder. - } - PreferenceManager.getDefaultSharedPreferences(mContext).edit() - .remove(ShareActivity.PREF_FOLDER_SAVED_SUBDIRECTORY+id) - .apply(); - } - - private void removeFolderInternal(String id) { - synchronized (mConfigLock) { - Iterator it = mConfig.folders.iterator(); - while (it.hasNext()) { - Folder f = it.next(); - if (f.id.equals(id)) { - it.remove(); - break; - } - } - } - } - - /** - * Returns a list of all existing devices. - * - * @param includeLocal True if the local device should be included in the result. - */ - public List getDevices(boolean includeLocal) { - List devices; - synchronized (mConfigLock) { - devices = deepCopy(mConfig.devices, new TypeToken>(){}.getType()); - } - - Iterator it = devices.iterator(); - while (it.hasNext()) { - Device device = it.next(); - boolean isLocalDevice = Objects.equal(mLocalDeviceId, device.deviceID); - if (!includeLocal && isLocalDevice) { - it.remove(); - break; - } - } - return devices; - } - - public Device getLocalDevice() { - List devices = getDevices(true); - if (devices.isEmpty()) { - throw new RuntimeException("RestApi.getLocalDevice: devices is empty."); - } - Log.v(TAG, "getLocalDevice: Looking for local device ID " + mLocalDeviceId); - for (Device d : devices) { - if (d.deviceID.equals(mLocalDeviceId)) { - return deepCopy(d, Device.class); - } - } - throw new RuntimeException("RestApi.getLocalDevice: Failed to get the local device crucial to continuing execution."); - } - - public void addDevice(Device device, OnResultListener1 errorListener) { - normalizeDeviceId(device.deviceID, normalizedId -> { - synchronized (mConfigLock) { - mConfig.devices.add(device); - sendConfig(); - } - }, errorListener); - } - - public void editDevice(Device newDevice) { - synchronized (mConfigLock) { - removeDeviceInternal(newDevice.deviceID); - mConfig.devices.add(newDevice); - sendConfig(); - } - } - - public void removeDevice(String deviceId) { - synchronized (mConfigLock) { - removeDeviceInternal(deviceId); - // mCompletion will be updated after the ConfigSaved event. - sendConfig(); - } - } - - private void removeDeviceInternal(String deviceId) { - synchronized (mConfigLock) { - Iterator it = mConfig.devices.iterator(); - while (it.hasNext()) { - Device d = it.next(); - if (d.deviceID.equals(deviceId)) { - it.remove(); - break; - } - } - } - } - - public Options getOptions() { - synchronized (mConfigLock) { - return deepCopy(mConfig.options, Options.class); - } - } - - public Config.Gui getGui() { - synchronized (mConfigLock) { - return deepCopy(mConfig.gui, Config.Gui.class); - } - } - - public void editSettings(Config.Gui newGui, Options newOptions) { - synchronized (mConfigLock) { - mConfig.gui = newGui; - mConfig.options = newOptions; - } - } - - /** - * Returns a deep copy of object. - * This method uses Gson and only works with objects that can be converted with Gson. - */ - private T deepCopy(T object, Type type) { - Gson gson = new Gson(); - return gson.fromJson(gson.toJson(object, type), type); - } - - /** - * Requests and parses information about current system status and resource usage. - */ - public void getSystemInfo(OnResultListener1 listener) { - new GetRequest(mContext, mUrl, GetRequest.URI_SYSTEM, mApiKey, null, result -> - listener.onResult(new Gson().fromJson(result, SystemInfo.class))); - } - - public boolean isConfigLoaded() { - synchronized(mConfigLock) { - return mConfig != null; - } - } - - /** - * Requests and parses system version information. - */ - public void getSystemVersion(OnResultListener1 listener) { - new GetRequest(mContext, mUrl, GetRequest.URI_VERSION, mApiKey, null, result -> { - SystemVersion systemVersion = new Gson().fromJson(result, SystemVersion.class); - listener.onResult(systemVersion); - }); - } - - /** - * Returns connection info for the local device and all connected devices. - */ - public void getConnections(final OnResultListener1 listener) { - new GetRequest(mContext, mUrl, GetRequest.URI_CONNECTIONS, mApiKey, null, result -> { - long now = System.currentTimeMillis(); - long msElapsed = now - mPreviousConnectionTime; - if (msElapsed < Constants.GUI_UPDATE_INTERVAL) { - listener.onResult(deepCopy(mPreviousConnections.get(), Connections.class)); - return; - } - - mPreviousConnectionTime = now; - Connections connections = new Gson().fromJson(result, Connections.class); - for (Map.Entry e : connections.connections.entrySet()) { - e.getValue().completion = mCompletion.getDeviceCompletion(e.getKey()); - - Connections.Connection prev = - (mPreviousConnections.isPresent() && mPreviousConnections.get().connections.containsKey(e.getKey())) - ? mPreviousConnections.get().connections.get(e.getKey()) - : new Connections.Connection(); - assert prev != null; - e.getValue().setTransferRate(prev, msElapsed); - } - Connections.Connection prev = - mPreviousConnections.transform(c -> c.total).or(new Connections.Connection()); - connections.total.setTransferRate(prev, msElapsed); - mPreviousConnections = Optional.of(connections); - listener.onResult(deepCopy(connections, Connections.class)); - }); - } - - /** - * Returns status information about the folder with the given id. - */ - public void getFolderStatus(final String folderId, final OnResultListener2 listener) { - new GetRequest(mContext, mUrl, GetRequest.URI_STATUS, mApiKey, - ImmutableMap.of("folder", folderId), result -> { - FolderStatus m = new Gson().fromJson(result, FolderStatus.class); - mCachedFolderStatuses.put(folderId, m); - listener.onResult(folderId, m); - }); - } - - /** - * Listener for {@link #getEvents}. - */ - public interface OnReceiveEventListener { - /** - * Called for each event. - */ - void onEvent(Event event); - - /** - * Called after all available events have been processed. - * @param lastId The id of the last event processed. Should be used as a starting point for - * the next round of event processing. - */ - void onDone(long lastId); - } - - /** - * Retrieves the events that have accumulated since the given event id. - * The OnReceiveEventListeners onEvent method is called for each event. - */ - public final void getEvents(final long sinceId, final long limit, final OnReceiveEventListener listener) { - Map params = - ImmutableMap.of("since", String.valueOf(sinceId), "limit", String.valueOf(limit)); - new GetRequest(mContext, mUrl, GetRequest.URI_EVENTS, mApiKey, params, result -> { - JsonArray jsonEvents = JsonParser.parseString(result).getAsJsonArray(); - long lastId = 0; - - for (int i = 0; i < jsonEvents.size(); i++) { - JsonElement json = jsonEvents.get(i); - Event event = new Gson().fromJson(json, Event.class); - - if (lastId < event.id) - lastId = event.id; - - listener.onEvent(event); - } - - listener.onDone(lastId); - }); - } - - /** - * Normalizes a given device ID. - */ - private void normalizeDeviceId(String id, OnResultListener1 listener, - OnResultListener1 errorListener) { - new GetRequest(mContext, mUrl, GetRequest.URI_DEVICEID, mApiKey, - ImmutableMap.of("id", id), result -> { - JsonObject json = JsonParser.parseString(result).getAsJsonObject(); - JsonElement normalizedId = json.get("id"); - JsonElement error = json.get("error"); - if (normalizedId != null) - listener.onResult(normalizedId.getAsString()); - if (error != null) - errorListener.onResult(error.getAsString()); - }); - } - - - /** - * Updates cached folder and device completion info according to event data. - */ - public void setCompletionInfo(String deviceId, String folderId, CompletionInfo completionInfo) { - mCompletion.setCompletionInfo(deviceId, folderId, completionInfo); - } - - /** - * Returns prettyfied usage report. - */ - public void getUsageReport(final OnResultListener1 listener) { - new GetRequest(mContext, mUrl, GetRequest.URI_REPORT, mApiKey, null, result -> { - JsonElement json = JsonParser.parseString(result); - Gson gson = new GsonBuilder().setPrettyPrinting().create(); - listener.onResult(gson.toJson(json)); - }); - } - - public URL getUrl() { - return mUrl; - } - - public Boolean isUsageReportingDecided() { - Options options = getOptions(); - if (options == null) { - Log.e(TAG, "isUsageReportingDecided called while options == null"); - return true; - } - return options.isUsageReportingDecided(mUrVersionMax); - } - - public void setUsageReporting(Boolean acceptUsageReporting) { - Options options = getOptions(); - if (options == null) { - Log.e(TAG, "setUsageReporting called while options == null"); - return; - } - options.urAccepted = acceptUsageReporting ? mUrVersionMax : Options.USAGE_REPORTING_DENIED; - synchronized (mConfigLock) { - mConfig.options = options; - } - } - -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt new file mode 100644 index 00000000..772d5b1d --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt @@ -0,0 +1,847 @@ +package com.nutomic.syncthingandroid.service + +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.preference.PreferenceManager +import android.util.Log +import com.google.common.base.Function +import com.google.common.base.Objects +import com.google.common.base.Optional +import com.google.common.collect.ImmutableMap +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonParser +import com.nutomic.syncthingandroid.SyncthingApp +import com.nutomic.syncthingandroid.activities.FolderActivity +import com.nutomic.syncthingandroid.activities.ShareActivity +import com.nutomic.syncthingandroid.http.ApiRequest.OnSuccessListener +import com.nutomic.syncthingandroid.http.GetRequest +import com.nutomic.syncthingandroid.http.PostConfigRequest +import com.nutomic.syncthingandroid.http.PostRequest +import com.nutomic.syncthingandroid.model.Completion +import com.nutomic.syncthingandroid.model.CompletionInfo +import com.nutomic.syncthingandroid.model.Config +import com.nutomic.syncthingandroid.model.Config.Gui +import com.nutomic.syncthingandroid.model.Connections +import com.nutomic.syncthingandroid.model.Device +import com.nutomic.syncthingandroid.model.Event +import com.nutomic.syncthingandroid.model.Folder +import com.nutomic.syncthingandroid.model.FolderStatus +import com.nutomic.syncthingandroid.model.IgnoredFolder +import com.nutomic.syncthingandroid.model.Options +import com.nutomic.syncthingandroid.model.RemoteIgnoredDevice +import com.nutomic.syncthingandroid.model.SystemInfo +import com.nutomic.syncthingandroid.model.SystemVersion +import java.lang.reflect.Type +import java.net.URL +import java.text.SimpleDateFormat +import java.util.Collections +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import androidx.core.content.edit + +/** + * Provides functions to interact with the syncthing REST API. + */ +class RestApi( + context: Context, url: URL?, apiKey: String?, apiListener: OnApiAvailableListener, + configListener: OnConfigChangedListener +) { + interface OnConfigChangedListener { + fun onConfigChanged() + } + + fun interface OnResultListener1 { + fun onResult(t: T?) + } + + interface OnResultListener2 { + fun onResult(t: T?, r: R?) + } + + private val mContext: Context + val url: URL? + private val mApiKey: String? + + private var mVersion: String? = null + private var mConfig: Config? = null + + /** + * Results cached from systemInfo + */ + private var mLocalDeviceId: String? = null + private var mUrVersionMax: Int? = null + + /** + * Stores the result of the last successful request to [GetRequest.URI_CONNECTIONS], + * or an empty Map. + */ + private var mPreviousConnections = Optional.absent() + + /** + * Stores the timestamp of the last successful request to [GetRequest.URI_CONNECTIONS]. + */ + private var mPreviousConnectionTime: Long = 0 + + /** + * In the last-finishing [.readConfigFromRestApi] callback, we have to call + * [SyncthingService.onApiAvailable]} to indicate that the RestApi class is fully initialized. + * We do this to avoid getting stuck with our main thread due to synchronous REST queries. + * The correct indication of full initialisation is crucial to stability as other listeners of + * [SettingsActivity.onServiceStateChange] needs cached config and system information available. + * e.g. SettingsFragment need "mLocalDeviceId" + */ + private var asyncQueryConfigComplete = false + private var asyncQueryVersionComplete = false + private var asyncQuerySystemInfoComplete = false + + /** + * Object that must be locked upon accessing the following variables: + * asyncQueryConfigComplete, asyncQueryVersionComplete, asyncQuerySystemInfoComplete + */ + private val mAsyncQueryCompleteLock = Any() + + /** + * Object that must be locked upon accessing mConfig + */ + private val mConfigLock = Any() + + /** + * Stores the latest result of [.getFolderStatus] for each folder + */ + private val mCachedFolderStatuses = HashMap() + + /** + * Stores the latest result of device and folder completion events. + */ + private val mCompletion = Completion() + + @Inject + var mNotificationHandler: NotificationHandler? = null + + interface OnApiAvailableListener { + fun onApiAvailable() + } + + private val mOnApiAvailableListener: OnApiAvailableListener + + private val mOnConfigChangedListener: OnConfigChangedListener + + init { + (context.applicationContext as SyncthingApp).component()!!.inject(this) + mContext = context + this.url = url + mApiKey = apiKey + mOnApiAvailableListener = apiListener + mOnConfigChangedListener = configListener + } + + /** + * Gets local device ID, syncthing version and config, then calls all OnApiAvailableListeners. + */ + fun readConfigFromRestApi() { + Log.v(TAG, "Reading config from REST ...") + synchronized(mAsyncQueryCompleteLock) { + asyncQueryVersionComplete = false + asyncQueryConfigComplete = false + asyncQuerySystemInfoComplete = false + } + GetRequest( + mContext, + this.url, + GetRequest.URI_VERSION, + mApiKey, + null + ) { result: String? -> + val json = JsonParser.parseString(result).getAsJsonObject() + mVersion = json.get("version").asString + Log.i(TAG, "Syncthing version is $mVersion") + updateDebugFacilitiesCache() + synchronized(mAsyncQueryCompleteLock) { + asyncQueryVersionComplete = true + checkReadConfigFromRestApiCompleted() + } + } + GetRequest( + mContext, + this.url, + GetRequest.URI_CONFIG, + mApiKey, + null + ) { result: String? -> + onReloadConfigComplete(result) + synchronized(mAsyncQueryCompleteLock) { + asyncQueryConfigComplete = true + checkReadConfigFromRestApiCompleted() + } + } + getSystemInfo { info: SystemInfo? -> + mLocalDeviceId = info!!.myID + mUrVersionMax = info.urVersionMax + synchronized(mAsyncQueryCompleteLock) { + asyncQuerySystemInfoComplete = true + checkReadConfigFromRestApiCompleted() + } + } + } + + private fun checkReadConfigFromRestApiCompleted() { + if (asyncQueryVersionComplete && asyncQueryConfigComplete && asyncQuerySystemInfoComplete) { + Log.v(TAG, "Reading config from REST completed.") + mOnApiAvailableListener.onApiAvailable() + } + } + + fun reloadConfig() { + GetRequest( + mContext, + this.url, + GetRequest.URI_CONFIG, + mApiKey, + null + ) { result: String? -> this.onReloadConfigComplete(result) } + } + + private fun onReloadConfigComplete(result: String?) { + val configParseSuccess: Boolean + synchronized(mConfigLock) { + mConfig = Gson().fromJson(result, Config::class.java) + configParseSuccess = mConfig != null + } + if (!configParseSuccess) { + throw RuntimeException("config is null: $result") + } + Log.v(TAG, "onReloadConfigComplete: Successfully parsed configuration.") + + // if (BuildConfig.DEBUG) { +// Log.v(TAG, "mConfig.remoteIgnoredDevices = " + new Gson().toJson(mConfig.remoteIgnoredDevices)); +// } + + // Update cached device and folder information stored in the mCompletion model. + mCompletion.updateFromConfig(getDevices(true), this.folders) + } + + /** + * Queries debug facilities available from the currently running syncthing binary + * if the syncthing binary version changed. First launch of the binary is also + * considered as a version change. + * Precondition: [.mVersion] read from REST + */ + private fun updateDebugFacilitiesCache() { + val PREF_LAST_BINARY_VERSION = "lastBinaryVersion" + if (mVersion != PreferenceManager.getDefaultSharedPreferences(mContext) + .getString(PREF_LAST_BINARY_VERSION, "") + ) { + // First binary launch or binary upgraded case. + GetRequest( + mContext, + this.url, + GetRequest.URI_DEBUG, + mApiKey, + null + ) { result: String? -> + try { + val json = JsonParser.parseString(result).getAsJsonObject() + val jsonFacilities = json.getAsJsonObject("facilities") + val facilitiesToStore: MutableSet = + HashSet(jsonFacilities.keySet()) + PreferenceManager.getDefaultSharedPreferences(mContext).edit { + putStringSet( + Constants.PREF_DEBUG_FACILITIES_AVAILABLE, + facilitiesToStore + ) + } + + // Store current binary version so we will only store this information again + // after a binary update. + PreferenceManager.getDefaultSharedPreferences(mContext).edit { + putString(PREF_LAST_BINARY_VERSION, mVersion) + } + } catch (_: Exception) { + Log.w( + TAG, + "updateDebugFacilitiesCache: Failed to get debug facilities. result=$result" + ) + } + } + } + } + + /** + * Permanently ignore a device when it tries to connect. + * Ignored devices will not trigger the "DeviceRejected" event + * in [EventProcessor.onEvent]. + */ + fun ignoreDevice(deviceId: String, deviceName: String?, deviceAddress: String?) { + synchronized(mConfigLock) { + // Check if the device has already been ignored. + for (remoteIgnoredDevice in mConfig!!.remoteIgnoredDevices) { + if (deviceId == remoteIgnoredDevice.deviceID) { + // Device already ignored. + Log.d(TAG, "Device already ignored [$deviceId]") + return + } + } + + val remoteIgnoredDevice = RemoteIgnoredDevice() + remoteIgnoredDevice.deviceID = deviceId + remoteIgnoredDevice.address = deviceAddress + remoteIgnoredDevice.name = deviceName + remoteIgnoredDevice.time = dateFormat.format(Date()) + mConfig!!.remoteIgnoredDevices.add(remoteIgnoredDevice) + sendConfig() + Log.d(TAG, "Ignored device [$deviceId]") + } + } + + /** + * Permanently ignore a folder share request. + * Ignored folders will not trigger the "FolderRejected" event + * in [EventProcessor.onEvent]. + */ + fun ignoreFolder(deviceId: String, folderId: String, folderLabel: String?) { + synchronized(mConfigLock) { + for (device in mConfig!!.devices) { + if (deviceId == device.deviceID) { + /* + * Check if the folder has already been ignored. + */ + for (ignoredFolder in device.ignoredFolders) { + if (folderId == ignoredFolder.id) { + // Folder already ignored. + Log.d( + TAG, + "Folder [$folderId] already ignored on device [$deviceId]" + ) + return + } + } + + /* + * Ignore folder by moving its corresponding "pendingFolder" entry to + * a newly created "ignoredFolder" entry. + */ + val ignoredFolder = IgnoredFolder() + ignoredFolder.id = folderId + ignoredFolder.label = folderLabel + ignoredFolder.time = dateFormat.format(Date()) + device.ignoredFolders.add(ignoredFolder) + // if (BuildConfig.DEBUG) { +// Log.v(TAG, "device.ignoredFolders = " + new Gson().toJson(device.ignoredFolders)); +// } + sendConfig() + Log.d( + TAG, + "Ignored folder [$folderId] announced by device [$deviceId]" + ) + + // Given deviceId handled. + break + } + } + } + } + + /** + * Undo ignoring devices and folders. + */ + fun undoIgnoredDevicesAndFolders() { + Log.d(TAG, "Undo ignoring devices and folders ...") + synchronized(mConfigLock) { + mConfig!!.remoteIgnoredDevices.clear() + for (device in mConfig!!.devices) { + device.ignoredFolders.clear() + } + } + } + + /** + * Override folder changes. This is the same as hitting + * the "override changes" button from the web UI. + */ + fun overrideChanges(folderId: String) { + Log.d(TAG, "overrideChanges '$folderId'") + PostRequest( + mContext, + this.url, PostRequest.URI_DB_OVERRIDE, mApiKey, + ImmutableMap.of("folder", folderId), null + ) + } + + /** + * Sends current config to Syncthing. + * Will result in a "ConfigSaved" event. + * EventProcessor will trigger this.reloadConfig(). + */ + private fun sendConfig() { + val jsonConfig: String? + synchronized(mConfigLock) { + jsonConfig = Gson().toJson(mConfig) + } + PostConfigRequest(mContext, this.url, mApiKey, jsonConfig, null) + mOnConfigChangedListener.onConfigChanged() + } + + /** + * Sends current config and restarts Syncthing. + */ + fun saveConfigAndRestart() { + val jsonConfig: String? + synchronized(mConfigLock) { + jsonConfig = Gson().toJson(mConfig) + } + PostConfigRequest( + mContext, + this.url, + mApiKey, + jsonConfig + ) { _: String? -> + val intent = Intent(mContext, SyncthingService::class.java) + .setAction(SyncthingService.ACTION_RESTART) + mContext.startService(intent) + } + mOnConfigChangedListener.onConfigChanged() + } + + fun shutdown() { + mNotificationHandler!!.cancelRestartNotification() + } + + val version: String + /** + * Returns the version name, or a (text) error message on failure. + */ + get() = mVersion!! + + val folders: MutableList + get() { + val folders: MutableList + synchronized(mConfigLock) { + folders = + deepCopy( + mConfig.folders, + object : + com.google.common.reflect.TypeToken?>() {}.type + )!! + } + Collections.sort( + folders, + FOLDERS_COMPARATOR + ) + return folders + } + + /** + * This is only used for new folder creation, see [FolderActivity]. + */ + fun createFolder(folder: Folder?) { + synchronized(mConfigLock) { + // Add the new folder to the model. + mConfig!!.folders.add(folder) + // Send model changes to syncthing, does not require a restart. + sendConfig() + } + } + + fun updateFolder(newFolder: Folder) { + synchronized(mConfigLock) { + removeFolderInternal(newFolder.id) + mConfig!!.folders.add(newFolder) + sendConfig() + } + } + + fun removeFolder(id: String?) { + synchronized(mConfigLock) { + removeFolderInternal(id) + // mCompletion will be updated after the ConfigSaved event. + sendConfig() + } + PreferenceManager.getDefaultSharedPreferences(mContext).edit { + remove(ShareActivity.PREF_FOLDER_SAVED_SUBDIRECTORY + id) + } + } + + private fun removeFolderInternal(id: String?) { + synchronized(mConfigLock) { + val it = mConfig!!.folders.iterator() + while (it.hasNext()) { + val f = it.next() + if (f.id == id) { + it.remove() + break + } + } + } + } + + /** + * Returns a list of all existing devices. + * + * @param includeLocal True if the local device should be included in the result. + */ + fun getDevices(includeLocal: Boolean): MutableList { + val devices: MutableList + synchronized(mConfigLock) { + devices = deepCopy( + mConfig!!.devices, + object : + com.google.common.reflect.TypeToken?>() {}.type + )!! + } + + val it = devices.iterator() + while (it.hasNext()) { + val device = it.next() + val isLocalDevice = Objects.equal(mLocalDeviceId, device.deviceID) + if (!includeLocal && isLocalDevice) { + it.remove() + break + } + } + return devices + } + + val localDevice: Device? + get() { + val devices = + getDevices(true) + if (devices.isEmpty()) { + throw RuntimeException("RestApi.getLocalDevice: devices is empty.") + } + Log.v( + TAG, + "getLocalDevice: Looking for local device ID $mLocalDeviceId" + ) + for (d in devices) { + if (d.deviceID == mLocalDeviceId) { + return deepCopy( + d, + Device::class.java + ) + } + } + throw RuntimeException("RestApi.getLocalDevice: Failed to get the local device crucial to continuing execution.") + } + + fun addDevice(device: Device, errorListener: OnResultListener1) { + normalizeDeviceId(device.deviceID, { _: String? -> + synchronized(mConfigLock) { + mConfig!!.devices.add(device) + sendConfig() + } + }, errorListener) + } + + fun editDevice(newDevice: Device) { + synchronized(mConfigLock) { + removeDeviceInternal(newDevice.deviceID) + mConfig!!.devices.add(newDevice) + sendConfig() + } + } + + fun removeDevice(deviceId: String?) { + synchronized(mConfigLock) { + removeDeviceInternal(deviceId) + // mCompletion will be updated after the ConfigSaved event. + sendConfig() + } + } + + private fun removeDeviceInternal(deviceId: String?) { + synchronized(mConfigLock) { + val it = mConfig!!.devices.iterator() + while (it.hasNext()) { + val d = it.next() + if (d.deviceID == deviceId) { + it.remove() + break + } + } + } + } + + val options: Options? + get() { + synchronized(mConfigLock) { + return deepCopy( + mConfig!!.options, + Options::class.java + ) + } + } + + val gui: Gui? + get() { + synchronized(mConfigLock) { + return deepCopy(mConfig!!.gui, Gui::class.java) + } + } + + fun editSettings(newGui: Gui?, newOptions: Options?) { + synchronized(mConfigLock) { + mConfig!!.gui = newGui + mConfig!!.options = newOptions + } + } + + /** + * Returns a deep copy of object. + * This method uses Gson and only works with objects that can be converted with Gson. + */ + private fun deepCopy(`object`: T?, type: Type): T? { + val gson = Gson() + return gson.fromJson(gson.toJson(`object`, type), type) + } + + /** + * Requests and parses information about current system status and resource usage. + */ + fun getSystemInfo(listener: OnResultListener1) { + GetRequest( + mContext, + this.url, GetRequest.URI_SYSTEM, mApiKey, null + ) { result: String? -> + listener.onResult( + Gson().fromJson(result, SystemInfo::class.java) + ) + } + } + + val isConfigLoaded: Boolean + get() { + synchronized(mConfigLock) { + return mConfig != null + } + } + + /** + * Requests and parses system version information. + */ + fun getSystemVersion(listener: OnResultListener1) { + GetRequest( + mContext, + this.url, + GetRequest.URI_VERSION, + mApiKey, + null + ) { result: String? -> + val systemVersion = + Gson().fromJson(result, SystemVersion::class.java) + listener.onResult(systemVersion) + } + } + + /** + * Returns connection info for the local device and all connected devices. + */ + fun getConnections(listener: OnResultListener1) { + GetRequest( + mContext, + this.url, + GetRequest.URI_CONNECTIONS, + mApiKey, + null, + OnSuccessListener { result: String? -> + val now = System.currentTimeMillis() + val msElapsed = now - mPreviousConnectionTime + if (msElapsed < Constants.GUI_UPDATE_INTERVAL) { + listener.onResult( + deepCopy( + mPreviousConnections.get(), + Connections::class.java + ) + ) + return@OnSuccessListener + } + + mPreviousConnectionTime = now + val connections = Gson().fromJson(result, Connections::class.java) + for (e in connections.connections.entries) { + e.value.completion = mCompletion.getDeviceCompletion(e.key) + + val prev: Connections.Connection = checkNotNull( + if (mPreviousConnections.isPresent && mPreviousConnections.get()!!.connections.containsKey( + e.key + ) + ) + mPreviousConnections.get()!!.connections.get(e.key) + else + Connections.Connection() + ) + e.value.setTransferRate(prev, msElapsed) + } + val prev = + mPreviousConnections.transform(Function { c: Connections? -> c!!.total }) + .or( + Connections.Connection() + ) + connections.total.setTransferRate(prev, msElapsed) + mPreviousConnections = Optional.of(connections) + listener.onResult(deepCopy(connections, Connections::class.java)) + }) + } + + /** + * Returns status information about the folder with the given id. + */ + fun getFolderStatus(folderId: String, listener: OnResultListener2) { + GetRequest( + mContext, + this.url, + GetRequest.URI_STATUS, + mApiKey, + ImmutableMap.of("folder", folderId) + ) { result: String? -> + val m = Gson().fromJson(result, FolderStatus::class.java) + mCachedFolderStatuses[folderId] = m + listener.onResult(folderId, m) + } + } + + /** + * Listener for [.getEvents]. + */ + interface OnReceiveEventListener { + /** + * Called for each event. + */ + fun onEvent(event: Event?) + + /** + * Called after all available events have been processed. + * @param lastId The id of the last event processed. Should be used as a starting point for + * the next round of event processing. + */ + fun onDone(lastId: Long) + } + + /** + * Retrieves the events that have accumulated since the given event id. + * The OnReceiveEventListeners onEvent method is called for each event. + */ + fun getEvents(sinceId: Long, limit: Long, listener: OnReceiveEventListener) { + val params: MutableMap = + ImmutableMap.of( + "since", + sinceId.toString(), + "limit", + limit.toString() + ) + GetRequest( + mContext, + this.url, + GetRequest.URI_EVENTS, + mApiKey, + params + ) { result: String? -> + val jsonEvents = JsonParser.parseString(result).getAsJsonArray() + var lastId: Long = 0 + + for (i in 0.., + errorListener: OnResultListener1 + ) { + GetRequest( + mContext, + this.url, GetRequest.URI_DEVICEID, mApiKey, + ImmutableMap.of("id", id) + ) { result: String? -> + val json = JsonParser.parseString(result).getAsJsonObject() + val normalizedId = json.get("id") + val error = json.get("error") + if (normalizedId != null) listener.onResult(normalizedId.asString) + if (error != null) errorListener.onResult(error.asString) + } + } + + + /** + * Updates cached folder and device completion info according to event data. + */ + fun setCompletionInfo(deviceId: String?, folderId: String?, completionInfo: CompletionInfo?) { + mCompletion.setCompletionInfo(deviceId, folderId, completionInfo) + } + + /** + * Returns prettyfied usage report. + */ + fun getUsageReport(listener: OnResultListener1) { + GetRequest( + mContext, + this.url, + GetRequest.URI_REPORT, + mApiKey, + null + ) { result: String? -> + val json = JsonParser.parseString(result) + val gson = GsonBuilder().setPrettyPrinting().create() + listener.onResult(gson.toJson(json)) + } + } + + val isUsageReportingDecided: Boolean + get() { + val options = this.options + if (options == null) { + Log.e( + TAG, + "isUsageReportingDecided called while options == null" + ) + return true + } + return options.isUsageReportingDecided(mUrVersionMax!!) + } + + fun setUsageReporting(acceptUsageReporting: Boolean) { + val options = this.options + if (options == null) { + Log.e(TAG, "setUsageReporting called while options == null") + return + } + options.urAccepted = + (if (acceptUsageReporting) mUrVersionMax else Options.USAGE_REPORTING_DENIED)!! + synchronized(mConfigLock) { + mConfig!!.options = options + } + } + + companion object { + private const val TAG = "RestApi" + + private val dateFormat: SimpleDateFormat = if (Build.VERSION.SDK_INT < 24) { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) + } else { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US) + } + + /** + * Compares folders by labels, uses the folder ID as fallback if the label is empty + */ + private val FOLDERS_COMPARATOR = Comparator { lhs: Folder?, rhs: Folder? -> + val lhsLabel = + if (lhs!!.label != null && !lhs.label.isEmpty()) lhs.label else lhs.id + val rhsLabel = + if (rhs!!.label != null && !rhs.label.isEmpty()) rhs.label else rhs.id + lhsLabel.compareTo(rhsLabel) + } + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.java b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.java deleted file mode 100644 index 997aedc6..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.java +++ /dev/null @@ -1,393 +0,0 @@ -package com.nutomic.syncthingandroid.service; - -import android.annotation.TargetApi; -import android.content.BroadcastReceiver; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.content.SyncStatusObserver; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.net.wifi.WifiInfo; -import android.net.wifi.WifiManager; -import android.os.BatteryManager; -import android.os.Build; -import android.os.Handler; -import android.os.PowerManager; -import androidx.annotation.Nullable; -import android.util.Log; - -import com.nutomic.syncthingandroid.SyncthingApp; -import com.nutomic.syncthingandroid.model.RunConditionCheckResult; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -import javax.inject.Inject; - -import static com.nutomic.syncthingandroid.model.RunConditionCheckResult.*; -import static com.nutomic.syncthingandroid.model.RunConditionCheckResult.BlockerReason.*; - -/** - * Holds information about the current wifi and charging state of the device. - * - * This information is actively read on instance creation, and then updated from intents - * that are passed with {@link #ACTION_DEVICE_STATE_CHANGED}. - */ -public class RunConditionMonitor { - - private static final String TAG = "RunConditionMonitor"; - - private static final String POWER_SOURCE_CHARGER_BATTERY = "ac_and_battery_power"; - private static final String POWER_SOURCE_CHARGER = "ac_power"; - private static final String POWER_SOURCE_BATTERY = "battery_power"; - - private @Nullable Object mSyncStatusObserverHandle = null; - private final SyncStatusObserver mSyncStatusObserver = new SyncStatusObserver() { - @Override - public void onStatusChanged(int which) { - updateShouldRunDecision(); - } - }; - - public interface OnRunConditionChangedListener { - void onRunConditionChanged(RunConditionCheckResult result); - } - - private final Context mContext; - @Inject SharedPreferences mPreferences; - private ReceiverManager mReceiverManager; - - /** - * Sending callback notifications through {@link OnRunConditionChangedListener} is enabled if not null. - */ - private @Nullable OnRunConditionChangedListener mOnRunConditionChangedListener = null; - - /** - * Stores the result of the last call to {@link #decideShouldRun()}. - */ - private RunConditionCheckResult lastRunConditionCheckResult; - - public RunConditionMonitor(Context context, OnRunConditionChangedListener listener) { - Log.v(TAG, "Created new instance"); - ((SyncthingApp) context.getApplicationContext()).component().inject(this); - mContext = context; - mOnRunConditionChangedListener = listener; - - /** - * Register broadcast receivers. - */ - // NetworkReceiver - ReceiverManager.registerReceiver(mContext, new NetworkReceiver(), new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); - - // BatteryReceiver - IntentFilter filter = new IntentFilter(); - filter.addAction(Intent.ACTION_POWER_CONNECTED); - filter.addAction(Intent.ACTION_POWER_DISCONNECTED); - ReceiverManager.registerReceiver(mContext, new BatteryReceiver(), filter); - - // PowerSaveModeChangedReceiver - ReceiverManager.registerReceiver(mContext, - new PowerSaveModeChangedReceiver(), - new IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)); - - // SyncStatusObserver to monitor android's "AutoSync" quick toggle. - mSyncStatusObserverHandle = ContentResolver.addStatusChangeListener( - ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, mSyncStatusObserver); - - // Initially determine if syncthing should run under current circumstances. - updateShouldRunDecision(); - } - - public void shutdown() { - Log.v(TAG, "Shutting down"); - if (mSyncStatusObserverHandle != null) { - ContentResolver.removeStatusChangeListener(mSyncStatusObserverHandle); - mSyncStatusObserverHandle = null; - } - mReceiverManager.unregisterAllReceivers(mContext); - } - - private class BatteryReceiver extends BroadcastReceiver { - @Override - public void onReceive(Context context, Intent intent) { - if (Intent.ACTION_POWER_CONNECTED.equals(intent.getAction()) - || Intent.ACTION_POWER_DISCONNECTED.equals(intent.getAction())) { - Handler handler = new Handler(); - handler.postDelayed(new Runnable() { - @Override - public void run() { - updateShouldRunDecision(); - } - }, 5000); - } - } - } - - private class NetworkReceiver extends BroadcastReceiver { - @Override - public void onReceive(Context context, Intent intent) { - if (ConnectivityManager.CONNECTIVITY_ACTION.equals(intent.getAction())) { - updateShouldRunDecision(); - } - } - } - - private class PowerSaveModeChangedReceiver extends BroadcastReceiver { - @Override - public void onReceive(Context context, Intent intent) { - if (PowerManager.ACTION_POWER_SAVE_MODE_CHANGED.equals(intent.getAction())) { - updateShouldRunDecision(); - } - } - } - - public void updateShouldRunDecision() { - // Reason if the current conditions changed the result of decideShouldRun() - // compared to the last determined result. - RunConditionCheckResult result = decideShouldRun(); - boolean change; - synchronized (this) { - change = lastRunConditionCheckResult == null || !lastRunConditionCheckResult.equals(result); - lastRunConditionCheckResult = result; - } - if (change) { - if (mOnRunConditionChangedListener != null) { - mOnRunConditionChangedListener.onRunConditionChanged(result); - } - } - } - - /** - * Determines if Syncthing should currently run. - */ - private RunConditionCheckResult decideShouldRun() { - // Get run conditions preferences. - boolean prefRunConditions= mPreferences.getBoolean(Constants.PREF_RUN_CONDITIONS, true); - boolean prefRunOnMobileData= mPreferences.getBoolean(Constants.PREF_RUN_ON_MOBILE_DATA, false); - boolean prefRunOnWifi= mPreferences.getBoolean(Constants.PREF_RUN_ON_WIFI, true); - boolean prefRunOnMeteredWifi= mPreferences.getBoolean(Constants.PREF_RUN_ON_METERED_WIFI, false); - Set whitelistedWifiSsids = mPreferences.getStringSet(Constants.PREF_WIFI_SSID_WHITELIST, new HashSet<>()); - boolean prefWifiWhitelistEnabled = !whitelistedWifiSsids.isEmpty(); - boolean prefRunInFlightMode = mPreferences.getBoolean(Constants.PREF_RUN_IN_FLIGHT_MODE, false); - String prefPowerSource = mPreferences.getString(Constants.PREF_POWER_SOURCE, POWER_SOURCE_CHARGER_BATTERY); - boolean prefRespectPowerSaving = mPreferences.getBoolean(Constants.PREF_RESPECT_BATTERY_SAVING, true); - boolean prefRespectMasterSync = mPreferences.getBoolean(Constants.PREF_RESPECT_MASTER_SYNC, false); - - if (!prefRunConditions) { - Log.v(TAG, "decideShouldRun: !runConditions"); - return SHOULD_RUN; - } - - List blockerReasons = new ArrayList<>(); - - // PREF_POWER_SOURCE - switch (prefPowerSource) { - case POWER_SOURCE_CHARGER: - if (!isCharging()) { - Log.v(TAG, "decideShouldRun: POWER_SOURCE_AC && !isCharging"); - blockerReasons.add(ON_BATTERY); - } - break; - case POWER_SOURCE_BATTERY: - if (isCharging()) { - Log.v(TAG, "decideShouldRun: POWER_SOURCE_BATTERY && isCharging"); - blockerReasons.add(ON_CHARGER); - } - break; - case POWER_SOURCE_CHARGER_BATTERY: - default: - break; - } - - // Power saving - if (prefRespectPowerSaving && isPowerSaving()) { - Log.v(TAG, "decideShouldRun: prefRespectPowerSaving && isPowerSaving"); - blockerReasons.add(POWERSAVING_ENABLED); - } - - // Android global AutoSync setting. - if (prefRespectMasterSync && !ContentResolver.getMasterSyncAutomatically()) { - Log.v(TAG, "decideShouldRun: prefRespectMasterSync && !getMasterSyncAutomatically"); - blockerReasons.add(GLOBAL_SYNC_DISABLED); - } - - // Run on mobile data. - if (blockerReasons.isEmpty() && prefRunOnMobileData && isMobileDataConnection()) { - Log.v(TAG, "decideShouldRun: prefRunOnMobileData && isMobileDataConnection"); - return SHOULD_RUN; - } - - // Run on wifi. - if (prefRunOnWifi && isWifiOrEthernetConnection()) { - if (prefRunOnMeteredWifi) { - // We are on non-metered or metered wifi. Reason if wifi whitelist run condition is met. - if (wifiWhitelistConditionMet(prefWifiWhitelistEnabled, whitelistedWifiSsids)) { - Log.v(TAG, "decideShouldRun: prefRunOnWifi && isWifiOrEthernetConnection && prefRunOnMeteredWifi && wifiWhitelistConditionMet"); - if (blockerReasons.isEmpty()) return SHOULD_RUN; - } else { - blockerReasons.add(WIFI_SSID_NOT_WHITELISTED); - } - } else { - // Reason if we are on a non-metered wifi and if wifi whitelist run condition is met. - if (!isMeteredNetworkConnection()) { - if (wifiWhitelistConditionMet(prefWifiWhitelistEnabled, whitelistedWifiSsids)) { - Log.v(TAG, "decideShouldRun: prefRunOnWifi && isWifiOrEthernetConnection && !prefRunOnMeteredWifi && !isMeteredNetworkConnection && wifiWhitelistConditionMet"); - if (blockerReasons.isEmpty()) return SHOULD_RUN; - } else { - blockerReasons.add(WIFI_SSID_NOT_WHITELISTED); - } - } else { - blockerReasons.add(WIFI_WIFI_IS_METERED); - } - } - } - - // Run in flight mode. - if (prefRunInFlightMode && isFlightMode()) { - Log.v(TAG, "decideShouldRun: prefRunInFlightMode && isFlightMode"); - if (blockerReasons.isEmpty()) return SHOULD_RUN; - } - - /** - * If none of the above run conditions matched, don't run. - */ - Log.v(TAG, "decideShouldRun: return false"); - if (blockerReasons.isEmpty()) { - if (isFlightMode()) { - blockerReasons.add(NO_NETWORK_OR_FLIGHTMODE); - } else if (!prefRunOnWifi && !prefRunOnMobileData) { - blockerReasons.add(NO_ALLOWED_NETWORK); - } else if (prefRunOnMobileData) { - blockerReasons.add(NO_MOBILE_CONNECTION); - } else if (prefRunOnWifi) { - blockerReasons.add(NO_WIFI_CONNECTION); - } else { - blockerReasons.add(NO_NETWORK_OR_FLIGHTMODE); - } - } - return new RunConditionCheckResult(blockerReasons); - } - - /** - * Return whether the wifi whitelist run condition is met. - * Precondition: An active wifi connection has been detected. - */ - private boolean wifiWhitelistConditionMet(boolean prefWifiWhitelistEnabled, - Set whitelistedWifiSsids) { - if (!prefWifiWhitelistEnabled) { - Log.v(TAG, "handleWifiWhitelist: !prefWifiWhitelistEnabled"); - return true; - } - if (isWifiConnectionWhitelisted(whitelistedWifiSsids)) { - Log.v(TAG, "handleWifiWhitelist: isWifiConnectionWhitelisted"); - return true; - } - return false; - } - - /** - * Functions for run condition information retrieval. - */ - private boolean isCharging() { - Intent intent = mContext.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); - int plugged = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1); - return plugged == BatteryManager.BATTERY_PLUGGED_AC || - plugged == BatteryManager.BATTERY_PLUGGED_USB || - plugged == BatteryManager.BATTERY_PLUGGED_WIRELESS; - } - - private boolean isPowerSaving() { - PowerManager powerManager = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE); - if (powerManager == null) { - Log.e(TAG, "getSystemService(POWER_SERVICE) unexpectedly returned NULL."); - return false; - } - return powerManager.isPowerSaveMode(); - } - - private boolean isFlightMode() { - ConnectivityManager cm = (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE); - NetworkInfo ni = cm.getActiveNetworkInfo(); - return ni == null; - } - - private boolean isMeteredNetworkConnection() { - ConnectivityManager cm = (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE); - NetworkInfo ni = cm.getActiveNetworkInfo(); - if (ni == null) { - // In flight mode. - return false; - } - if (!ni.isConnected()) { - // No network connection. - return false; - } - return cm.isActiveNetworkMetered(); - } - - private boolean isMobileDataConnection() { - ConnectivityManager cm = (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE); - NetworkInfo ni = cm.getActiveNetworkInfo(); - if (ni == null) { - // In flight mode. - return false; - } - if (!ni.isConnected()) { - // No network connection. - return false; - } - switch (ni.getType()) { - case ConnectivityManager.TYPE_BLUETOOTH: - case ConnectivityManager.TYPE_MOBILE: - case ConnectivityManager.TYPE_MOBILE_DUN: - case ConnectivityManager.TYPE_MOBILE_HIPRI: - return true; - default: - return false; - } - } - - private boolean isWifiOrEthernetConnection() { - ConnectivityManager cm = (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE); - NetworkInfo ni = cm.getActiveNetworkInfo(); - if (ni == null) { - // In flight mode. - return false; - } - if (!ni.isConnected()) { - // No network connection. - return false; - } - switch (ni.getType()) { - case ConnectivityManager.TYPE_WIFI: - case ConnectivityManager.TYPE_WIMAX: - case ConnectivityManager.TYPE_ETHERNET: - return true; - default: - return false; - } - } - - private boolean isWifiConnectionWhitelisted(Set whitelistedSsids) { - WifiManager wifiManager = (WifiManager) mContext.getApplicationContext() - .getSystemService(Context.WIFI_SERVICE); - WifiInfo wifiInfo = wifiManager.getConnectionInfo(); - if (wifiInfo == null) { - // May be null, if wifi has been turned off in the meantime. - Log.d(TAG, "isWifiConnectionWhitelisted: SSID unknown due to wifiInfo == null"); - return false; - } - String wifiSsid = wifiInfo.getSSID(); - if (wifiSsid == null) { - Log.w(TAG, "isWifiConnectionWhitelisted: Got null SSID. Try to enable android location service."); - return false; - } - return whitelistedSsids.contains(wifiSsid); - } - -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt new file mode 100644 index 00000000..36e46a6a --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt @@ -0,0 +1,410 @@ +package com.nutomic.syncthingandroid.service + +import android.content.BroadcastReceiver +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.SharedPreferences +import android.content.SyncStatusObserver +import android.net.ConnectivityManager +import android.net.wifi.WifiManager +import android.os.BatteryManager +import android.os.Handler +import android.os.PowerManager +import android.util.Log +import com.nutomic.syncthingandroid.SyncthingApp +import com.nutomic.syncthingandroid.model.RunConditionCheckResult +import com.nutomic.syncthingandroid.model.RunConditionCheckResult.BlockerReason +import javax.inject.Inject + +/** + * Holds information about the current wifi and charging state of the device. + * + * This information is actively read on instance creation, and then updated from intents + * that are passed with [.ACTION_DEVICE_STATE_CHANGED]. + */ +class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListener?) { + private var mSyncStatusObserverHandle: Any? = null + private val mSyncStatusObserver: SyncStatusObserver = object : SyncStatusObserver { + override fun onStatusChanged(which: Int) { + updateShouldRunDecision() + } + } + + interface OnRunConditionChangedListener { + fun onRunConditionChanged(result: RunConditionCheckResult?) + } + + private val mContext: Context + + @JvmField + @Inject + var mPreferences: SharedPreferences? = null + private val mReceiverManager: ReceiverManager? = null + + /** + * Sending callback notifications through [OnRunConditionChangedListener] is enabled if not null. + */ + private var mOnRunConditionChangedListener: OnRunConditionChangedListener? = null + + /** + * Stores the result of the last call to [.decideShouldRun]. + */ + private var lastRunConditionCheckResult: RunConditionCheckResult? = null + + init { + Log.v(TAG, "Created new instance") + (context.getApplicationContext() as SyncthingApp).component()!!.inject(this) + mContext = context + mOnRunConditionChangedListener = listener + + /** + * Register broadcast receivers. + */ + // NetworkReceiver + ReceiverManager.registerReceiver( + mContext, + NetworkReceiver(), + IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) + ) + + // BatteryReceiver + val filter = IntentFilter() + filter.addAction(Intent.ACTION_POWER_CONNECTED) + filter.addAction(Intent.ACTION_POWER_DISCONNECTED) + ReceiverManager.registerReceiver(mContext, RunConditionMonitor.BatteryReceiver(), filter) + + // PowerSaveModeChangedReceiver + ReceiverManager.registerReceiver( + mContext, + PowerSaveModeChangedReceiver(), + IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) + ) + + // SyncStatusObserver to monitor android's "AutoSync" quick toggle. + mSyncStatusObserverHandle = ContentResolver.addStatusChangeListener( + ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, mSyncStatusObserver + ) + + // Initially determine if syncthing should run under current circumstances. + updateShouldRunDecision() + } + + fun shutdown() { + Log.v(TAG, "Shutting down") + if (mSyncStatusObserverHandle != null) { + ContentResolver.removeStatusChangeListener(mSyncStatusObserverHandle) + mSyncStatusObserverHandle = null + } + ReceiverManager.unregisterAllReceivers(mContext) + } + + private inner class BatteryReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent) { + if (Intent.ACTION_POWER_CONNECTED == intent.getAction() + || Intent.ACTION_POWER_DISCONNECTED == intent.getAction() + ) { + val handler = Handler() + handler.postDelayed(object : Runnable { + override fun run() { + updateShouldRunDecision() + } + }, 5000) + } + } + } + + private inner class NetworkReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent) { + if (ConnectivityManager.CONNECTIVITY_ACTION == intent.getAction()) { + updateShouldRunDecision() + } + } + } + + private inner class PowerSaveModeChangedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent) { + if (PowerManager.ACTION_POWER_SAVE_MODE_CHANGED == intent.getAction()) { + updateShouldRunDecision() + } + } + } + + fun updateShouldRunDecision() { + // Reason if the current conditions changed the result of decideShouldRun() + // compared to the last determined result. + val result = decideShouldRun() + val change: Boolean + synchronized(this) { + change = lastRunConditionCheckResult == null || lastRunConditionCheckResult != result + lastRunConditionCheckResult = result + } + if (change) { + if (mOnRunConditionChangedListener != null) { + mOnRunConditionChangedListener!!.onRunConditionChanged(result) + } + } + } + + /** + * Determines if Syncthing should currently run. + */ + private fun decideShouldRun(): RunConditionCheckResult { + // Get run conditions preferences. + val prefRunConditions = mPreferences!!.getBoolean(Constants.PREF_RUN_CONDITIONS, true) + val prefRunOnMobileData = + mPreferences!!.getBoolean(Constants.PREF_RUN_ON_MOBILE_DATA, false) + val prefRunOnWifi = mPreferences!!.getBoolean(Constants.PREF_RUN_ON_WIFI, true) + val prefRunOnMeteredWifi = + mPreferences!!.getBoolean(Constants.PREF_RUN_ON_METERED_WIFI, false) + val whitelistedWifiSsids: MutableSet = mPreferences.getStringSet( + com.nutomic.syncthingandroid.service.Constants.PREF_WIFI_SSID_WHITELIST, + java.util.HashSet() + )!! + val prefWifiWhitelistEnabled = !whitelistedWifiSsids.isEmpty() + val prefRunInFlightMode = + mPreferences!!.getBoolean(Constants.PREF_RUN_IN_FLIGHT_MODE, false) + val prefPowerSource: String = mPreferences.getString( + com.nutomic.syncthingandroid.service.Constants.PREF_POWER_SOURCE, + RunConditionMonitor.Companion.POWER_SOURCE_CHARGER_BATTERY + )!! + val prefRespectPowerSaving = + mPreferences!!.getBoolean(Constants.PREF_RESPECT_BATTERY_SAVING, true) + val prefRespectMasterSync = + mPreferences!!.getBoolean(Constants.PREF_RESPECT_MASTER_SYNC, false) + + if (!prefRunConditions) { + Log.v(TAG, "decideShouldRun: !runConditions") + return RunConditionCheckResult.SHOULD_RUN + } + + val blockerReasons: MutableList = ArrayList() + + // PREF_POWER_SOURCE + when (prefPowerSource) { + POWER_SOURCE_CHARGER -> if (!this.isCharging) { + Log.v(TAG, "decideShouldRun: POWER_SOURCE_AC && !isCharging") + blockerReasons.add(BlockerReason.ON_BATTERY) + } + + POWER_SOURCE_BATTERY -> if (this.isCharging) { + Log.v(TAG, "decideShouldRun: POWER_SOURCE_BATTERY && isCharging") + blockerReasons.add(BlockerReason.ON_CHARGER) + } + + POWER_SOURCE_CHARGER_BATTERY -> {} + else -> {} + } + + // Power saving + if (prefRespectPowerSaving && this.isPowerSaving) { + Log.v(TAG, "decideShouldRun: prefRespectPowerSaving && isPowerSaving") + blockerReasons.add(BlockerReason.POWERSAVING_ENABLED) + } + + // Android global AutoSync setting. + if (prefRespectMasterSync && !ContentResolver.getMasterSyncAutomatically()) { + Log.v(TAG, "decideShouldRun: prefRespectMasterSync && !getMasterSyncAutomatically") + blockerReasons.add(BlockerReason.GLOBAL_SYNC_DISABLED) + } + + // Run on mobile data. + if (blockerReasons.isEmpty() && prefRunOnMobileData && this.isMobileDataConnection) { + Log.v(TAG, "decideShouldRun: prefRunOnMobileData && isMobileDataConnection") + return RunConditionCheckResult.SHOULD_RUN + } + + // Run on wifi. + if (prefRunOnWifi && this.isWifiOrEthernetConnection) { + if (prefRunOnMeteredWifi) { + // We are on non-metered or metered wifi. Reason if wifi whitelist run condition is met. + if (wifiWhitelistConditionMet(prefWifiWhitelistEnabled, whitelistedWifiSsids)) { + Log.v( + TAG, + "decideShouldRun: prefRunOnWifi && isWifiOrEthernetConnection && prefRunOnMeteredWifi && wifiWhitelistConditionMet" + ) + if (blockerReasons.isEmpty()) return RunConditionCheckResult.SHOULD_RUN + } else { + blockerReasons.add(BlockerReason.WIFI_SSID_NOT_WHITELISTED) + } + } else { + // Reason if we are on a non-metered wifi and if wifi whitelist run condition is met. + if (!this.isMeteredNetworkConnection) { + if (wifiWhitelistConditionMet(prefWifiWhitelistEnabled, whitelistedWifiSsids)) { + Log.v( + TAG, + "decideShouldRun: prefRunOnWifi && isWifiOrEthernetConnection && !prefRunOnMeteredWifi && !isMeteredNetworkConnection && wifiWhitelistConditionMet" + ) + if (blockerReasons.isEmpty()) return RunConditionCheckResult.SHOULD_RUN + } else { + blockerReasons.add(BlockerReason.WIFI_SSID_NOT_WHITELISTED) + } + } else { + blockerReasons.add(BlockerReason.WIFI_WIFI_IS_METERED) + } + } + } + + // Run in flight mode. + if (prefRunInFlightMode && this.isFlightMode) { + Log.v(TAG, "decideShouldRun: prefRunInFlightMode && isFlightMode") + if (blockerReasons.isEmpty()) return RunConditionCheckResult.SHOULD_RUN + } + + /** + * If none of the above run conditions matched, don't run. + */ + Log.v(TAG, "decideShouldRun: return false") + if (blockerReasons.isEmpty()) { + if (this.isFlightMode) { + blockerReasons.add(BlockerReason.NO_NETWORK_OR_FLIGHTMODE) + } else if (!prefRunOnWifi && !prefRunOnMobileData) { + blockerReasons.add(BlockerReason.NO_ALLOWED_NETWORK) + } else if (prefRunOnMobileData) { + blockerReasons.add(BlockerReason.NO_MOBILE_CONNECTION) + } else if (prefRunOnWifi) { + blockerReasons.add(BlockerReason.NO_WIFI_CONNECTION) + } else { + blockerReasons.add(BlockerReason.NO_NETWORK_OR_FLIGHTMODE) + } + } + return RunConditionCheckResult(blockerReasons) + } + + /** + * Return whether the wifi whitelist run condition is met. + * Precondition: An active wifi connection has been detected. + */ + private fun wifiWhitelistConditionMet( + prefWifiWhitelistEnabled: Boolean, + whitelistedWifiSsids: MutableSet + ): Boolean { + if (!prefWifiWhitelistEnabled) { + Log.v(TAG, "handleWifiWhitelist: !prefWifiWhitelistEnabled") + return true + } + if (isWifiConnectionWhitelisted(whitelistedWifiSsids)) { + Log.v(TAG, "handleWifiWhitelist: isWifiConnectionWhitelisted") + return true + } + return false + } + + private val isCharging: Boolean + /** + * Functions for run condition information retrieval. + */ + get() { + val intent = mContext.registerReceiver( + null, + IntentFilter(Intent.ACTION_BATTERY_CHANGED) + ) + val plugged = intent!!.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) + return plugged == BatteryManager.BATTERY_PLUGGED_AC || plugged == BatteryManager.BATTERY_PLUGGED_USB || plugged == BatteryManager.BATTERY_PLUGGED_WIRELESS + } + + private val isPowerSaving: Boolean + get() { + val powerManager = + mContext.getSystemService(Context.POWER_SERVICE) as PowerManager? + if (powerManager == null) { + Log.e( + TAG, + "getSystemService(POWER_SERVICE) unexpectedly returned NULL." + ) + return false + } + return powerManager.isPowerSaveMode() + } + + private val isFlightMode: Boolean + get() { + val cm = + mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val ni = cm.getActiveNetworkInfo() + return ni == null + } + + private val isMeteredNetworkConnection: Boolean + get() { + val cm = + mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val ni = cm.getActiveNetworkInfo() + if (ni == null) { + // In flight mode. + return false + } + if (!ni.isConnected()) { + // No network connection. + return false + } + return cm.isActiveNetworkMetered() + } + + private val isMobileDataConnection: Boolean + get() { + val cm = + mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val ni = cm.getActiveNetworkInfo() + if (ni == null) { + // In flight mode. + return false + } + if (!ni.isConnected()) { + // No network connection. + return false + } + when (ni.getType()) { + ConnectivityManager.TYPE_BLUETOOTH, ConnectivityManager.TYPE_MOBILE, ConnectivityManager.TYPE_MOBILE_DUN, ConnectivityManager.TYPE_MOBILE_HIPRI -> return true + else -> return false + } + } + + private val isWifiOrEthernetConnection: Boolean + get() { + val cm = + mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val ni = cm.getActiveNetworkInfo() + if (ni == null) { + // In flight mode. + return false + } + if (!ni.isConnected()) { + // No network connection. + return false + } + when (ni.getType()) { + ConnectivityManager.TYPE_WIFI, ConnectivityManager.TYPE_WIMAX, ConnectivityManager.TYPE_ETHERNET -> return true + else -> return false + } + } + + private fun isWifiConnectionWhitelisted(whitelistedSsids: MutableSet): Boolean { + val wifiManager = mContext.getApplicationContext() + .getSystemService(Context.WIFI_SERVICE) as WifiManager + val wifiInfo = wifiManager.getConnectionInfo() + if (wifiInfo == null) { + // May be null, if wifi has been turned off in the meantime. + Log.d(TAG, "isWifiConnectionWhitelisted: SSID unknown due to wifiInfo == null") + return false + } + val wifiSsid = wifiInfo.getSSID() + if (wifiSsid == null) { + Log.w( + TAG, + "isWifiConnectionWhitelisted: Got null SSID. Try to enable android location service." + ) + return false + } + return whitelistedSsids.contains(wifiSsid) + } + + companion object { + private const val TAG = "RunConditionMonitor" + + private const val POWER_SOURCE_CHARGER_BATTERY = "ac_and_battery_power" + private const val POWER_SOURCE_CHARGER = "ac_power" + private const val POWER_SOURCE_BATTERY = "battery_power" + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java deleted file mode 100644 index 6be374ae..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java +++ /dev/null @@ -1,467 +0,0 @@ -package com.nutomic.syncthingandroid.service; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Environment; -import android.os.PowerManager; -import android.os.SystemClock; -import android.text.TextUtils; -import android.util.Log; - -import com.google.common.base.Charsets; -import com.google.common.io.Files; -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.SyncthingApp; -import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.util.Util; - -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.DataOutputStream; -import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.LineNumberReader; -import java.security.InvalidParameterException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; - -import javax.inject.Inject; - -import eu.chainfire.libsuperuser.Shell; - -/** - * Runs the syncthing binary from command line, and prints its output to logcat. - * - * @see Command Line Docs - */ -public class SyncthingRunnable implements Runnable { - - private static final String TAG = "SyncthingRunnable"; - private static final String TAG_NATIVE = "SyncthingNativeCode"; - private static final String TAG_NICE = "SyncthingRunnableIoNice"; - private static final int LOG_FILE_MAX_LINES = 10; - - private static final AtomicReference mSyncthing = new AtomicReference<>(); - private final Context mContext; - private final File mSyncthingBinary; - private String[] mCommand; - private final File mLogFile; - @Inject SharedPreferences mPreferences; - private final boolean mUseRoot; - @Inject NotificationHandler mNotificationHandler; - - public enum Command { - deviceid, // Output the device ID to the command line. - generate, // Generate keys, a config file and immediately exit. - main, // Run the main Syncthing application. - resetdatabase, // Reset Syncthing's database - resetdeltas, // Reset Syncthing's delta indexes - } - - /** - * Constructs instance. - * - * @param command Which type of Syncthing command to execute. - */ - public SyncthingRunnable(Context context, Command command) { - ((SyncthingApp) context.getApplicationContext()).component().inject(this); - mContext = context; - mSyncthingBinary = Constants.getSyncthingBinary(mContext); - mLogFile = Constants.getLogFile(mContext); - - // Get preferences relevant to starting syncthing core. - mUseRoot = mPreferences.getBoolean(Constants.PREF_USE_ROOT, false) && Shell.SU.available(); - switch (command) { - case deviceid: - mCommand = new String[]{ mSyncthingBinary.getPath(), "-home", mContext.getFilesDir().toString(), "--device-id" }; - break; - case generate: - mCommand = new String[]{ mSyncthingBinary.getPath(), "-generate", mContext.getFilesDir().toString(), "-logflags=0" }; - break; - case main: - mCommand = new String[]{ mSyncthingBinary.getPath(), "-home", mContext.getFilesDir().toString(), "-no-browser", "-logflags=0" }; - break; - case resetdatabase: - mCommand = new String[]{ mSyncthingBinary.getPath(), "-home", mContext.getFilesDir().toString(), "-reset-database", "-logflags=0" }; - break; - case resetdeltas: - mCommand = new String[]{ mSyncthingBinary.getPath(), "-home", mContext.getFilesDir().toString(), "-reset-deltas", "-logflags=0" }; - break; - default: - throw new InvalidParameterException("Unknown command option"); - } - } - - @Override - public void run() { - run(false); - } - - @SuppressLint("WakelockTimeout") - public String run(boolean returnStdOut) { - trimLogFile(); - int ret; - String capturedStdOut = ""; - // Make sure Syncthing is executable - try { - ProcessBuilder pb = new ProcessBuilder("chmod", "500", mSyncthingBinary.getPath()); - Process p = pb.start(); - p.waitFor(); - } catch (IOException|InterruptedException e) { - Log.w(TAG, "Failed to chmod Syncthing", e); - } - // Loop Syncthing - Process process = null; - // Potential fix for #498, keep the CPU running while native binary is running - PowerManager pm = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE); - PowerManager.WakeLock wakeLock = useWakeLock() - ? pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, mContext.getString(R.string.app_name) + ":" + TAG) - : null; - try { - if (wakeLock != null) - wakeLock.acquire(); - increaseInotifyWatches(); - - HashMap targetEnv = buildEnvironment(); - process = setupAndLaunch(targetEnv); - - mSyncthing.set(process); - - Thread lInfo = null; - Thread lWarn = null; - if (returnStdOut) { - BufferedReader br = null; - try { - br = new BufferedReader(new InputStreamReader(process.getInputStream(), Charsets.UTF_8)); - String line; - while ((line = br.readLine()) != null) { - Log.println(Log.INFO, TAG_NATIVE, line); - capturedStdOut = capturedStdOut + line + "\n"; - } - } catch (IOException e) { - Log.w(TAG, "Failed to read Syncthing's command line output", e); - } finally { - if (br != null) - br.close(); - } - } else { - lInfo = log(process.getInputStream(), Log.INFO, true); - lWarn = log(process.getErrorStream(), Log.WARN, true); - } - - niceSyncthing(); - - ret = process.waitFor(); - Log.i(TAG, "Syncthing exited with code " + ret); - mSyncthing.set(null); - if (lInfo != null) - lInfo.join(); - if (lWarn != null) - lWarn.join(); - - switch (ret) { - case 0: - case 137: - // Syncthing was shut down (via API or SIGKILL), do nothing. - break; - case 1: - Log.w(TAG, "Another Syncthing instance is already running, requesting restart via SyncthingService intent"); - //fallthrough - case 3: - // Restart was requested via Rest API call. - Log.i(TAG, "Restarting syncthing"); - mContext.startService(new Intent(mContext, SyncthingService.class) - .setAction(SyncthingService.ACTION_RESTART)); - break; - default: - Log.w(TAG, "Syncthing has crashed (exit code " + ret + ")"); - mNotificationHandler.showCrashedNotification(R.string.notification_crash_title, false); - } - } catch (IOException | InterruptedException e) { - Log.e(TAG, "Failed to execute syncthing binary or read output", e); - } finally { - if (wakeLock != null) - wakeLock.release(); - if (process != null) - process.destroy(); - } - return capturedStdOut; - } - - private void putCustomEnvironmentVariables(Map environment, SharedPreferences sp) { - String customEnvironment = sp.getString("environment_variables", null); - if (TextUtils.isEmpty(customEnvironment)) - return; - - for (String e : customEnvironment.split(" ")) { - String[] e2 = e.split("="); - environment.put(e2[0], e2[1]); - } - } - - /** - * Returns true if the experimental setting for using wake locks has been enabled in settings. - */ - private boolean useWakeLock() { - return mPreferences.getBoolean(Constants.PREF_USE_WAKE_LOCK, false); - } - - /** - * Look for running libsyncthing.so processes and return an array - * containing the PIDs of found instances. - */ - private List getSyncthingPIDs() { - List syncthingPIDs = new ArrayList(); - Process ps = null; - DataOutputStream psOut = null; - BufferedReader br = null; - try { - ps = Runtime.getRuntime().exec((mUseRoot) ? "su" : "sh"); - psOut = new DataOutputStream(ps.getOutputStream()); - psOut.writeBytes("ps\n"); - psOut.writeBytes("exit\n"); - psOut.flush(); - ps.waitFor(); - br = new BufferedReader(new InputStreamReader(ps.getInputStream(), "UTF-8")); - String line; - while ((line = br.readLine()) != null) { - if (line.contains(Constants.FILENAME_SYNCTHING_BINARY)) { - String syncthingPID = line.trim().split("\\s+")[1]; - Log.v(TAG, "getSyncthingPIDs: Found process PID [" + syncthingPID + "]"); - syncthingPIDs.add(syncthingPID); - } - } - } catch (IOException | InterruptedException e) { - Log.w(TAG, "Failed to list Syncthing processes", e); - } finally { - try { - if (br != null) { - br.close(); - } - if (psOut != null) { - psOut.close(); - } - } catch (IOException e) { - Log.w(TAG, "Failed to close psOut stream", e); - } - if (ps != null) { - ps.destroy(); - } - } - return syncthingPIDs; - } - - /** - * Root-only: Temporarily increase "fs.inotify.max_user_watches" - * as Android has a default limit of 8192 watches. - * Manually run "sysctl fs.inotify" in a root shell terminal to check current limit. - */ - private void increaseInotifyWatches() { - if (!mUseRoot || !Shell.SU.available()) { - Log.i(TAG, "increaseInotifyWatches: Root is not available. Cannot increase inotify limit."); - return; - } - int exitCode = Util.runShellCommand("sysctl -n -w fs.inotify.max_user_watches=131072\n", true); - Log.i(TAG, "increaseInotifyWatches: sysctl returned " + Integer.toString(exitCode)); - } - - /** - * Look for a running libsyncthing.so process and nice its IO. - */ - private void niceSyncthing() { - if (!mUseRoot || !Shell.SU.available()) { - Log.i(TAG_NICE, "Root is not available. Cannot nice syncthing."); - return; - } - - List syncthingPIDs = getSyncthingPIDs(); - if (syncthingPIDs.isEmpty()) { - Log.i(TAG_NICE, "Found no running instances of " + Constants.FILENAME_SYNCTHING_BINARY); - return; - } - - // Ionice all running syncthing processes. - for (String syncthingPID : syncthingPIDs) { - // Set best-effort, low priority using ionice. - int exitCode = Util.runShellCommand("/system/bin/ionice " + syncthingPID + " be 7\n", true); - Log.i(TAG_NICE, "ionice returned " + Integer.toString(exitCode) + - " on " + Constants.FILENAME_SYNCTHING_BINARY); - } - } - - public interface OnSyncthingKilled { - void onKilled(); - } - /** - * Look for running libsyncthing.so processes and kill them. - * Try a SIGINT first, then try again with SIGKILL. - */ - public void killSyncthing() { - for (int i = 0; i < 2; i++) { - List syncthingPIDs = getSyncthingPIDs(); - if (syncthingPIDs.isEmpty()) { - Log.d(TAG, "killSyncthing: Found no more running instances of " + Constants.FILENAME_SYNCTHING_BINARY); - break; - } - - int exitCode; - for (String syncthingPID : syncthingPIDs) { - if (i > 0) { - // Force termination of the process by sending SIGKILL. - SystemClock.sleep(3000); - exitCode = Util.runShellCommand("kill -SIGKILL " + syncthingPID + "\n", mUseRoot); - } else { - exitCode = Util.runShellCommand("kill -SIGINT " + syncthingPID + "\n", mUseRoot); - SystemClock.sleep(1000); - } - if (exitCode == 0) { - Log.d(TAG, "Killed Syncthing process " + syncthingPID); - } else { - Log.w(TAG, "Failed to kill Syncthing process " + syncthingPID + - " exit code " + Integer.toString(exitCode)); - } - } - } - } - - /** - * Logs the outputs of a stream to logcat and mNativeLog. - * - * @param is The stream to log. - * @param priority The priority level. - * @param saveLog True if the log should be stored to {@link #mLogFile}. - */ - private Thread log(final InputStream is, final int priority, final boolean saveLog) { - Thread t = new Thread(() -> { - BufferedReader br = null; - try { - br = new BufferedReader(new InputStreamReader(is, Charsets.UTF_8)); - String line; - while ((line = br.readLine()) != null) { - Log.println(priority, TAG_NATIVE, line); - - if (saveLog) { - Files.append(line + "\n", mLogFile, Charsets.UTF_8); - } - } - } catch (IOException e) { - Log.w(TAG, "Failed to read Syncthing's command line output", e); - } - if (br != null) { - try { - br.close(); - } catch (IOException e) { - Log.w(TAG, "log: Failed to close bufferedReader", e); - } - } - }); - t.start(); - return t; - } - - /** - * Only keep last {@link #LOG_FILE_MAX_LINES} lines in log file, to avoid bloat. - */ - private void trimLogFile() { - if (!mLogFile.exists()) - return; - - try { - LineNumberReader lnr = new LineNumberReader(new FileReader(mLogFile)); - lnr.skip(Long.MAX_VALUE); - - int lineCount = lnr.getLineNumber(); - lnr.close(); - - File tempFile = new File(mContext.getExternalFilesDir(null), "syncthing.log.tmp"); - - BufferedReader reader = new BufferedReader(new FileReader(mLogFile)); - BufferedWriter writer = new BufferedWriter(new FileWriter(tempFile)); - - String currentLine; - int startFrom = lineCount - LOG_FILE_MAX_LINES; - for (int i = 0; (currentLine = reader.readLine()) != null; i++) { - if (i > startFrom) { - writer.write(currentLine + "\n"); - } - } - writer.close(); - reader.close(); - tempFile.renameTo(mLogFile); - } catch (IOException e) { - Log.w(TAG, "Failed to trim log file", e); - } - } - - private HashMap buildEnvironment() { - HashMap targetEnv = new HashMap<>(); - // Set home directory to data folder for web GUI folder picker. - targetEnv.put("HOME", Environment.getExternalStorageDirectory().getAbsolutePath()); - targetEnv.put("STTRACE", TextUtils.join(" ", - mPreferences.getStringSet(Constants.PREF_DEBUG_FACILITIES_ENABLED, new HashSet<>()))); - File externalFilesDir = mContext.getExternalFilesDir(null); - if (externalFilesDir != null) - targetEnv.put("STGUIASSETS", externalFilesDir.getAbsolutePath() + "/gui"); - targetEnv.put("STMONITORED", "1"); - targetEnv.put("STNOUPGRADE", "1"); - // Disable hash benchmark for faster startup. - // https://github.com/syncthing/syncthing/issues/4348 - targetEnv.put("STHASHING", "minio"); - if (mPreferences.getBoolean(Constants.PREF_USE_TOR, false)) { - targetEnv.put("all_proxy", "socks5://localhost:9050"); - targetEnv.put("ALL_PROXY_NO_FALLBACK", "1"); - } else { - String socksProxyAddress = mPreferences.getString(Constants.PREF_SOCKS_PROXY_ADDRESS, ""); - if (!socksProxyAddress.equals("")) { - targetEnv.put("all_proxy", socksProxyAddress); - } - - String httpProxyAddress = mPreferences.getString(Constants.PREF_HTTP_PROXY_ADDRESS, ""); - if (!httpProxyAddress.equals("")) { - targetEnv.put("http_proxy", httpProxyAddress); - targetEnv.put("https_proxy", httpProxyAddress); - } - } - if (mPreferences.getBoolean("use_legacy_hashing", false)) - targetEnv.put("STHASHING", "standard"); - putCustomEnvironmentVariables(targetEnv, mPreferences); - return targetEnv; - } - - private Process setupAndLaunch(HashMap env) throws IOException { - if (mUseRoot) { - ProcessBuilder pb = new ProcessBuilder("su"); - Process process = pb.start(); - // The su binary prohibits the inheritance of environment variables. - // Even with --preserve-environment the environment gets messed up. - // We therefore start a root shell, and set all the environment variables manually. - DataOutputStream suOut = new DataOutputStream(process.getOutputStream()); - for (Map.Entry entry : env.entrySet()) { - suOut.writeBytes(String.format("export %s=\"%s\"\n", entry.getKey(), entry.getValue())); - } - suOut.flush(); - // Exec will replace the su process image by Syncthing as execlp in C does. - // Without using exec, the process will drop to the root shell as soon as Syncthing terminates like a normal shell does. - // If we did not use exec, we would wait infinitely for the process to terminate (ret = process.waitFor(); in run()). - // With exec the whole process terminates when Syncthing exits. - suOut.writeBytes("exec " + TextUtils.join(" ", mCommand) + "\n"); - // suOut.flush has to be called to fix issue - #1005 Endless loader after enabling "Superuser mode" - suOut.flush(); - return process; - } else { - ProcessBuilder pb = new ProcessBuilder(mCommand); - pb.environment().putAll(env); - return pb.start(); - } - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt new file mode 100644 index 00000000..e27306de --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt @@ -0,0 +1,556 @@ +package com.nutomic.syncthingandroid.service + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Environment +import android.os.PowerManager +import android.os.SystemClock +import android.text.TextUtils +import android.util.Log +import com.google.common.base.Charsets +import com.google.common.io.Files +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.SyncthingApp +import com.nutomic.syncthingandroid.util.Util.runShellCommand +import eu.chainfire.libsuperuser.Shell +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.DataOutputStream +import java.io.File +import java.io.FileReader +import java.io.FileWriter +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.io.LineNumberReader +import java.security.InvalidParameterException +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject + +/** + * Runs the syncthing binary from command line, and prints its output to logcat. + * + * @see [Command Line Docs](http://docs.syncthing.net/users/syncthing.html) + */ +class SyncthingRunnable(context: Context, command: Command) : Runnable { + private val mContext: Context + private val mSyncthingBinary: File + private val mCommand: Array + private val mLogFile: File + + @JvmField + @Inject + var mPreferences: SharedPreferences? = null + private val mUseRoot: Boolean + + @JvmField + @Inject + var mNotificationHandler: NotificationHandler? = null + + enum class Command { + deviceid, // Output the device ID to the command line. + generate, // Generate keys, a config file and immediately exit. + main, // Run the main Syncthing application. + resetdatabase, // Reset Syncthing's database + resetdeltas, // Reset Syncthing's delta indexes + } + + /** + * Constructs instance. + * + * @param command Which type of Syncthing command to execute. + */ + init { + (context.getApplicationContext() as SyncthingApp).component()!!.inject(this) + mContext = context + mSyncthingBinary = Constants.getSyncthingBinary(mContext) + mLogFile = Constants.getLogFile(mContext) + + // Get preferences relevant to starting syncthing core. + mUseRoot = mPreferences!!.getBoolean(Constants.PREF_USE_ROOT, false) && Shell.SU.available() + when (command) { + Command.deviceid -> mCommand = arrayOf( + mSyncthingBinary.getPath(), + "-home", + mContext.getFilesDir().toString(), + "--device-id" + ) + + Command.generate -> mCommand = arrayOf( + mSyncthingBinary.getPath(), + "-generate", + mContext.getFilesDir().toString(), + "-logflags=0" + ) + + Command.main -> mCommand = arrayOf( + mSyncthingBinary.getPath(), + "-home", + mContext.getFilesDir().toString(), + "-no-browser", + "-logflags=0" + ) + + Command.resetdatabase -> mCommand = arrayOf( + mSyncthingBinary.getPath(), + "-home", + mContext.getFilesDir().toString(), + "-reset-database", + "-logflags=0" + ) + + Command.resetdeltas -> mCommand = arrayOf( + mSyncthingBinary.getPath(), + "-home", + mContext.getFilesDir().toString(), + "-reset-deltas", + "-logflags=0" + ) + + else -> throw InvalidParameterException("Unknown command option") + } + } + + override fun run() { + run(false) + } + + @SuppressLint("WakelockTimeout") + fun run(returnStdOut: Boolean): String { + trimLogFile() + val ret: Int + var capturedStdOut = "" + // Make sure Syncthing is executable + try { + val pb = ProcessBuilder("chmod", "500", mSyncthingBinary.getPath()) + val p = pb.start() + p.waitFor() + } catch (e: IOException) { + Log.w(TAG, "Failed to chmod Syncthing", e) + } catch (e: InterruptedException) { + Log.w(TAG, "Failed to chmod Syncthing", e) + } + // Loop Syncthing + var process: Process? = null + // Potential fix for #498, keep the CPU running while native binary is running + val pm = mContext.getSystemService(Context.POWER_SERVICE) as PowerManager + val wakeLock = if (useWakeLock()) + pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + mContext.getString(R.string.app_name) + ":" + TAG + ) + else + null + try { + if (wakeLock != null) wakeLock.acquire() + increaseInotifyWatches() + + val targetEnv = buildEnvironment() + process = setupAndLaunch(targetEnv) + + mSyncthing.set(process) + + var lInfo: Thread? = null + var lWarn: Thread? = null + if (returnStdOut) { + var br: BufferedReader? = null + try { + br = BufferedReader(InputStreamReader(process.getInputStream(), Charsets.UTF_8)) + var line: String + while ((br.readLine().also { line = it }) != null) { + Log.println(Log.INFO, TAG_NATIVE, line) + capturedStdOut = capturedStdOut + line + "\n" + } + } catch (e: IOException) { + Log.w(TAG, "Failed to read Syncthing's command line output", e) + } finally { + if (br != null) br.close() + } + } else { + lInfo = log(process.getInputStream(), Log.INFO, true) + lWarn = log(process.getErrorStream(), Log.WARN, true) + } + + niceSyncthing() + + ret = process.waitFor() + Log.i(TAG, "Syncthing exited with code " + ret) + mSyncthing.set(null) + if (lInfo != null) lInfo.join() + if (lWarn != null) lWarn.join() + + when (ret) { + 0, 137 -> {} + 1 -> { + Log.w( + TAG, + "Another Syncthing instance is already running, requesting restart via SyncthingService intent" + ) + // Restart was requested via Rest API call. + Log.i(TAG, "Restarting syncthing") + mContext.startService( + Intent(mContext, SyncthingService::class.java) + .setAction(SyncthingService.ACTION_RESTART) + ) + } + + 3 -> { + Log.i(TAG, "Restarting syncthing") + mContext.startService( + Intent(mContext, SyncthingService::class.java) + .setAction(SyncthingService.ACTION_RESTART) + ) + } + + else -> { + Log.w(TAG, "Syncthing has crashed (exit code " + ret + ")") + mNotificationHandler!!.showCrashedNotification( + R.string.notification_crash_title, + false + ) + } + } + } catch (e: IOException) { + Log.e(TAG, "Failed to execute syncthing binary or read output", e) + } catch (e: InterruptedException) { + Log.e(TAG, "Failed to execute syncthing binary or read output", e) + } finally { + if (wakeLock != null) wakeLock.release() + if (process != null) process.destroy() + } + return capturedStdOut + } + + private fun putCustomEnvironmentVariables( + environment: MutableMap, + sp: SharedPreferences + ) { + val customEnvironment = sp.getString("environment_variables", null) + if (TextUtils.isEmpty(customEnvironment)) return + + for (e in customEnvironment!!.split(" ".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray()) { + val e2 = e.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + environment.put(e2[0], e2[1]) + } + } + + /** + * Returns true if the experimental setting for using wake locks has been enabled in settings. + */ + private fun useWakeLock(): Boolean { + return mPreferences!!.getBoolean(Constants.PREF_USE_WAKE_LOCK, false) + } + + private val syncthingPIDs: MutableList + /** + * Look for running libsyncthing.so processes and return an array + * containing the PIDs of found instances. + */ + get() { + val syncthingPIDs: MutableList = + ArrayList() + var ps: Process? = null + var psOut: DataOutputStream? = null + var br: BufferedReader? = null + try { + ps = Runtime.getRuntime().exec(if (mUseRoot) "su" else "sh") + psOut = DataOutputStream(ps.getOutputStream()) + psOut.writeBytes("ps\n") + psOut.writeBytes("exit\n") + psOut.flush() + ps.waitFor() + br = + BufferedReader(InputStreamReader(ps.getInputStream(), "UTF-8")) + var line: String? + while ((br.readLine().also { line = it }) != null) { + if (line!!.contains(Constants.FILENAME_SYNCTHING_BINARY)) { + val syncthingPID: String? = + line.trim { it <= ' ' }.split("\\s+".toRegex()) + .dropLastWhile { it.isEmpty() }.toTypedArray()[1] + Log.v( + TAG, + "getSyncthingPIDs: Found process PID [" + syncthingPID + "]" + ) + syncthingPIDs.add(syncthingPID) + } + } + } catch (e: IOException) { + Log.w( + TAG, + "Failed to list Syncthing processes", + e + ) + } catch (e: InterruptedException) { + Log.w( + TAG, + "Failed to list Syncthing processes", + e + ) + } finally { + try { + if (br != null) { + br.close() + } + if (psOut != null) { + psOut.close() + } + } catch (e: IOException) { + Log.w( + TAG, + "Failed to close psOut stream", + e + ) + } + if (ps != null) { + ps.destroy() + } + } + return syncthingPIDs + } + + /** + * Root-only: Temporarily increase "fs.inotify.max_user_watches" + * as Android has a default limit of 8192 watches. + * Manually run "sysctl fs.inotify" in a root shell terminal to check current limit. + */ + private fun increaseInotifyWatches() { + if (!mUseRoot || !Shell.SU.available()) { + Log.i( + TAG, + "increaseInotifyWatches: Root is not available. Cannot increase inotify limit." + ) + return + } + val exitCode = runShellCommand("sysctl -n -w fs.inotify.max_user_watches=131072\n", true) + Log.i(TAG, "increaseInotifyWatches: sysctl returned " + exitCode.toString()) + } + + /** + * Look for a running libsyncthing.so process and nice its IO. + */ + private fun niceSyncthing() { + if (!mUseRoot || !Shell.SU.available()) { + Log.i(TAG_NICE, "Root is not available. Cannot nice syncthing.") + return + } + + val syncthingPIDs = this.syncthingPIDs + if (syncthingPIDs.isEmpty()) { + Log.i(TAG_NICE, "Found no running instances of " + Constants.FILENAME_SYNCTHING_BINARY) + return + } + + // Ionice all running syncthing processes. + for (syncthingPID in syncthingPIDs) { + // Set best-effort, low priority using ionice. + val exitCode = runShellCommand("/system/bin/ionice " + syncthingPID + " be 7\n", true) + Log.i( + TAG_NICE, "ionice returned " + exitCode.toString() + + " on " + Constants.FILENAME_SYNCTHING_BINARY + ) + } + } + + interface OnSyncthingKilled { + fun onKilled() + } + + /** + * Look for running libsyncthing.so processes and kill them. + * Try a SIGINT first, then try again with SIGKILL. + */ + fun killSyncthing() { + for (i in 0..1) { + val syncthingPIDs = this.syncthingPIDs + if (syncthingPIDs.isEmpty()) { + Log.d( + TAG, + "killSyncthing: Found no more running instances of " + Constants.FILENAME_SYNCTHING_BINARY + ) + break + } + + var exitCode: Int + for (syncthingPID in syncthingPIDs) { + if (i > 0) { + // Force termination of the process by sending SIGKILL. + SystemClock.sleep(3000) + exitCode = runShellCommand("kill -SIGKILL " + syncthingPID + "\n", mUseRoot) + } else { + exitCode = runShellCommand("kill -SIGINT " + syncthingPID + "\n", mUseRoot) + SystemClock.sleep(1000) + } + if (exitCode == 0) { + Log.d(TAG, "Killed Syncthing process " + syncthingPID) + } else { + Log.w( + TAG, "Failed to kill Syncthing process " + syncthingPID + + " exit code " + exitCode.toString() + ) + } + } + } + } + + /** + * Logs the outputs of a stream to logcat and mNativeLog. + * + * @param is The stream to log. + * @param priority The priority level. + * @param saveLog True if the log should be stored to [.mLogFile]. + */ + private fun log(`is`: InputStream?, priority: Int, saveLog: Boolean): Thread { + val t = Thread(Runnable { + var br: BufferedReader? = null + try { + br = BufferedReader(InputStreamReader(`is`, Charsets.UTF_8)) + var line: String? + while ((br.readLine().also { line = it }) != null) { + Log.println(priority, TAG_NATIVE, line!!) + + if (saveLog) { + Files.append(line + "\n", mLogFile, Charsets.UTF_8) + } + } + } catch (e: IOException) { + Log.w(TAG, "Failed to read Syncthing's command line output", e) + } + if (br != null) { + try { + br.close() + } catch (e: IOException) { + Log.w(TAG, "log: Failed to close bufferedReader", e) + } + } + }) + t.start() + return t + } + + /** + * Only keep last [.LOG_FILE_MAX_LINES] lines in log file, to avoid bloat. + */ + private fun trimLogFile() { + if (!mLogFile.exists()) return + + try { + val lnr = LineNumberReader(FileReader(mLogFile)) + lnr.skip(Long.Companion.MAX_VALUE) + + val lineCount = lnr.getLineNumber() + lnr.close() + + val tempFile = File(mContext.getExternalFilesDir(null), "syncthing.log.tmp") + + val reader = BufferedReader(FileReader(mLogFile)) + val writer = BufferedWriter(FileWriter(tempFile)) + + var currentLine: String? + val startFrom: Int = lineCount - LOG_FILE_MAX_LINES + var i = 0 + while ((reader.readLine().also { currentLine = it }) != null) { + if (i > startFrom) { + writer.write(currentLine + "\n") + } + i++ + } + writer.close() + reader.close() + tempFile.renameTo(mLogFile) + } catch (e: IOException) { + Log.w(TAG, "Failed to trim log file", e) + } + } + + private fun buildEnvironment(): HashMap { + val targetEnv = HashMap() + // Set home directory to data folder for web GUI folder picker. + targetEnv.put("HOME", Environment.getExternalStorageDirectory().getAbsolutePath()) + targetEnv.put( + "STTRACE", TextUtils.join( + " ", + mPreferences!!.getStringSet( + com.nutomic.syncthingandroid.service.Constants.PREF_DEBUG_FACILITIES_ENABLED, + java.util.HashSet() + )!! + ) + ) + val externalFilesDir = mContext.getExternalFilesDir(null) + if (externalFilesDir != null) targetEnv.put( + "STGUIASSETS", + externalFilesDir.getAbsolutePath() + "/gui" + ) + targetEnv.put("STMONITORED", "1") + targetEnv.put("STNOUPGRADE", "1") + // Disable hash benchmark for faster startup. + // https://github.com/syncthing/syncthing/issues/4348 + targetEnv.put("STHASHING", "minio") + if (mPreferences!!.getBoolean(Constants.PREF_USE_TOR, false)) { + targetEnv.put("all_proxy", "socks5://localhost:9050") + targetEnv.put("ALL_PROXY_NO_FALLBACK", "1") + } else { + val socksProxyAddress: String = mPreferences.getString( + com.nutomic.syncthingandroid.service.Constants.PREF_SOCKS_PROXY_ADDRESS, + "" + )!! + if (socksProxyAddress != "") { + targetEnv.put("all_proxy", socksProxyAddress) + } + + val httpProxyAddress: String = mPreferences.getString( + com.nutomic.syncthingandroid.service.Constants.PREF_HTTP_PROXY_ADDRESS, + "" + )!! + if (httpProxyAddress != "") { + targetEnv.put("http_proxy", httpProxyAddress) + targetEnv.put("https_proxy", httpProxyAddress) + } + } + if (mPreferences!!.getBoolean("use_legacy_hashing", false)) targetEnv.put( + "STHASHING", + "standard" + ) + putCustomEnvironmentVariables(targetEnv, mPreferences!!) + return targetEnv + } + + @Throws(IOException::class) + private fun setupAndLaunch(env: HashMap): Process { + if (mUseRoot) { + val pb = ProcessBuilder("su") + val process = pb.start() + // The su binary prohibits the inheritance of environment variables. + // Even with --preserve-environment the environment gets messed up. + // We therefore start a root shell, and set all the environment variables manually. + val suOut = DataOutputStream(process.getOutputStream()) + for (entry in env.entries) { + suOut.writeBytes(String.format("export %s=\"%s\"\n", entry.key, entry.value)) + } + suOut.flush() + // Exec will replace the su process image by Syncthing as execlp in C does. + // Without using exec, the process will drop to the root shell as soon as Syncthing terminates like a normal shell does. + // If we did not use exec, we would wait infinitely for the process to terminate (ret = process.waitFor(); in run()). + // With exec the whole process terminates when Syncthing exits. + suOut.writeBytes("exec " + TextUtils.join(" ", mCommand) + "\n") + // suOut.flush has to be called to fix issue - #1005 Endless loader after enabling "Superuser mode" + suOut.flush() + return process + } else { + val pb = ProcessBuilder(*mCommand) + pb.environment().putAll(env) + return pb.start() + } + } + + companion object { + private const val TAG = "SyncthingRunnable" + private const val TAG_NATIVE = "SyncthingNativeCode" + private const val TAG_NICE = "SyncthingRunnableIoNice" + private const val LOG_FILE_MAX_LINES = 10 + + private val mSyncthing = AtomicReference() + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingServiceBinder.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingServiceBinder.java deleted file mode 100644 index 67b0400c..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingServiceBinder.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.nutomic.syncthingandroid.service; - -import android.os.Binder; - -public class SyncthingServiceBinder extends Binder { - - private final SyncthingService mService; - - public SyncthingServiceBinder(SyncthingService service) { - mService = service; - } - - public SyncthingService getService() { - return mService; - } - -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingServiceBinder.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingServiceBinder.kt new file mode 100644 index 00000000..eae5048e --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingServiceBinder.kt @@ -0,0 +1,5 @@ +package com.nutomic.syncthingandroid.service + +import android.os.Binder + +class SyncthingServiceBinder(val service: SyncthingService?) : Binder() diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/PermissionUtil.java b/app/src/main/java/com/nutomic/syncthingandroid/util/PermissionUtil.java deleted file mode 100644 index a793dd50..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/PermissionUtil.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.nutomic.syncthingandroid.util; - -import android.Manifest; -import android.content.Context; -import android.content.pm.PackageManager; -import android.os.Build; -import android.os.Environment; - -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; - -public class PermissionUtil { - private PermissionUtil() {} - - /** - * Returns the location permissions required to access wifi SSIDs depending - * on the respective Android version. - */ - public static String[] getLocationPermissions() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { // before android 9 - return new String[]{ - Manifest.permission.ACCESS_COARSE_LOCATION, - }; - } - return new String[]{ - Manifest.permission.ACCESS_FINE_LOCATION, - }; - } - - public static boolean haveStoragePermission(@NonNull Context context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - return Environment.isExternalStorageManager(); - } - int permissionState = ContextCompat.checkSelfPermission(context, - Manifest.permission.WRITE_EXTERNAL_STORAGE); - return permissionState == PackageManager.PERMISSION_GRANTED; - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/PermissionUtil.kt b/app/src/main/java/com/nutomic/syncthingandroid/util/PermissionUtil.kt new file mode 100644 index 00000000..fe6b6490 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/PermissionUtil.kt @@ -0,0 +1,39 @@ +package com.nutomic.syncthingandroid.util + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Environment +import androidx.core.content.ContextCompat + +object PermissionUtil { + @JvmStatic + val locationPermissions: Array + /** + * Returns the location permissions required to access wifi SSIDs depending + * on the respective Android version. + */ + get() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { // before android 9 + return arrayOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + ) + } + return arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + ) + } + + @JvmStatic + fun haveStoragePermission(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + return Environment.isExternalStorageManager() + } + val permissionState = ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + return permissionState == PackageManager.PERMISSION_GRANTED + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/TextWatcherAdapter.java b/app/src/main/java/com/nutomic/syncthingandroid/util/TextWatcherAdapter.java deleted file mode 100644 index ee9f2888..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/TextWatcherAdapter.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.nutomic.syncthingandroid.util; - -import android.text.Editable; -import android.text.TextWatcher; - -public class TextWatcherAdapter implements TextWatcher { - - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) {} - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) {} - - @Override - public void afterTextChanged(Editable s) {} -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/TextWatcherAdapter.kt b/app/src/main/java/com/nutomic/syncthingandroid/util/TextWatcherAdapter.kt new file mode 100644 index 00000000..53f5e826 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/TextWatcherAdapter.kt @@ -0,0 +1,12 @@ +package com.nutomic.syncthingandroid.util + +import android.text.Editable +import android.text.TextWatcher + +open class TextWatcherAdapter : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(s: Editable?) {} +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java deleted file mode 100644 index 489b7043..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java +++ /dev/null @@ -1,241 +0,0 @@ -package com.nutomic.syncthingandroid.util; - -import android.app.Activity; -import android.app.Dialog; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager.NameNotFoundException; -import android.preference.PreferenceManager; -import androidx.appcompat.app.AlertDialog; -import android.util.Log; -import android.widget.Toast; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.service.Constants; - -import java.io.BufferedWriter; -import java.io.DataOutputStream; -import java.io.File; -import java.io.IOException; -import java.io.OutputStreamWriter; -import java.text.DecimalFormat; - -import eu.chainfire.libsuperuser.Shell; - -public class Util { - - private static final String TAG = "SyncthingUtil"; - - private Util() { - } - - /** - * Copies the given device ID to the clipboard (and shows a Toast telling about it). - * - * @param id The device ID to copy. - */ - public static void copyDeviceId(Context context, String id) { - ClipboardManager clipboard = (ClipboardManager) - context.getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText(context.getString(R.string.device_id), id); - clipboard.setPrimaryClip(clip); - if (android.os.Build.VERSION.SDK_INT < 33) { - Toast.makeText(context, R.string.device_id_copied_to_clipboard, Toast.LENGTH_SHORT) - .show(); - } - } - - /** - * Converts a number of bytes to a human readable file size (eg 3.5 GiB). - *

- * Based on http://stackoverflow.com/a/5599842 - */ - public static String readableFileSize(Context context, long bytes) { - final String[] units = context.getResources().getStringArray(R.array.file_size_units); - if (bytes <= 0) return "0 " + units[0]; - int digitGroups = (int) (Math.log10(bytes) / Math.log10(1024)); - return new DecimalFormat("#,##0.#") - .format(bytes / Math.pow(1024, digitGroups)) + " " + units[digitGroups]; - } - - /** - * Converts a number of bytes to a human readable transfer rate in bytes per second - * (eg 100 KiB/s). - *

- * Based on http://stackoverflow.com/a/5599842 - */ - public static String readableTransferRate(Context context, long bits) { - final String[] units = context.getResources().getStringArray(R.array.transfer_rate_units); - long bytes = bits / 8; - if (bytes <= 0) return "0 " + units[0]; - int digitGroups = (int) (Math.log10(bytes) / Math.log10(1024)); - return new DecimalFormat("#,##0.#") - .format(bytes / Math.pow(1024, digitGroups)) + " " + units[digitGroups]; - } - - /** - * Normally an application's data directory is only accessible by the corresponding application. - * Therefore, every file and directory is owned by an application's user and group. When running Syncthing as root, - * it writes to the application's data directory. This leaves files and directories behind which are owned by root having 0600. - * Moreover, those actions performed as root changes a file's type in terms of SELinux. - * A subsequent start of Syncthing will fail due to insufficient permissions. - * Hence, this method fixes the owner, group and the files' type of the data directory. - * - * @return true if the operation was successfully performed. False otherwise. - */ - public static boolean fixAppDataPermissions(Context context) { - // We can safely assume that root magic is somehow available, because readConfig and saveChanges check for - // read and write access before calling us. - // Be paranoid :) and check if root is available. - // Ignore the 'use_root' preference, because we might want to fix the permission - // just after the root option has been disabled. - if (!Shell.SU.available()) { - Log.e(TAG, "Root is not available. Cannot fix permissions."); - return false; - } - - String packageName; - ApplicationInfo appInfo; - try { - packageName = context.getPackageName(); - appInfo = context.getPackageManager().getApplicationInfo(packageName, 0); - - } catch (NameNotFoundException e) { - // This should not happen! - // One should always be able to retrieve the application info for its own package. - Log.w(TAG, "Error getting current package name", e); - return false; - } - Log.d(TAG, "Uid of '" + packageName + "' is " + appInfo.uid); - - // Get private app's "files" dir residing in "/data/data/[packageName]". - String dir = context.getFilesDir().getAbsolutePath(); - String cmd = "chown -R " + appInfo.uid + ":" + appInfo.uid + " " + dir + "; "; - // Running Syncthing as root might change a file's or directories type in terms of SELinux. - // Leaving them as they are, the Android service won't be able to access them. - // At least for those files residing in an application's data folder. - // Simply reverting the type to its default should do the trick. - cmd += "restorecon -R " + dir + "\n"; - Log.d(TAG, "Running: '" + cmd); - int exitCode = runShellCommand(cmd, true); - if (exitCode == 0) { - Log.i(TAG, "Fixed app data permissions on '" + dir + "'."); - } else { - Log.w(TAG, "Failed to fix app data permissions on '" + dir + "'. Result: " + - Integer.toString(exitCode)); - } - return exitCode == 0; - } - - /** - * Returns if the syncthing binary would be able to write a file into - * the given folder given the configured access level. - */ - public static boolean nativeBinaryCanWriteToPath(Context context, String absoluteFolderPath) { - final String TOUCH_FILE_NAME = ".stwritetest"; - boolean useRoot = false; - boolean prefUseRoot = PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(Constants.PREF_USE_ROOT, false); - if (prefUseRoot && Shell.SU.available()) { - useRoot = true; - } - - // Write permission test file. - String touchFile = absoluteFolderPath + "/" + TOUCH_FILE_NAME; - int exitCode = runShellCommand("echo \"\" > \"" + touchFile + "\"\n", useRoot); - if (exitCode != 0) { - String error; - if (exitCode == 1) { - error = "Permission denied"; - } else { - error = "Shell execution failed"; - } - Log.i(TAG, "Failed to write test file '" + touchFile + - "', " + error); - return false; - } - - // Detected we have write permission. - Log.i(TAG, "Successfully wrote test file '" + touchFile + "'"); - - // Remove test file. - if (runShellCommand("rm \"" + touchFile + "\"\n", useRoot) != 0) { - // This is very unlikely to happen, so we have less error handling. - Log.i(TAG, "Failed to remove test file"); - } - return true; - } - - /** - * Run command in a shell and return the exit code. - */ - public static int runShellCommand(String cmd, Boolean useRoot) { - // Assume "failure" exit code if an error is caught. - int exitCode = 255; - Process shellProc = null; - DataOutputStream shellOut = null; - try { - shellProc = Runtime.getRuntime().exec((useRoot) ? "su" : "sh"); - shellOut = new DataOutputStream(shellProc.getOutputStream()); - BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(shellOut)); - Log.d(TAG, "runShellCommand: " + cmd); - bufferedWriter.write(cmd); - bufferedWriter.flush(); - shellOut.close(); - shellOut = null; - exitCode = shellProc.waitFor(); - } catch (IOException | InterruptedException e) { - Log.w(TAG, "runShellCommand: Exception", e); - } finally { - try { - if (shellOut != null) { - shellOut.close(); - } - } catch (IOException e) { - Log.w(TAG, "Failed to close shell stream", e); - } - if (shellProc != null) { - shellProc.destroy(); - } - } - return exitCode; - } - - /** - * Make sure that dialog is showing and activity is valid before dismissing dialog, to prevent - * various crashes. - */ - public static void dismissDialogSafe(Dialog dialog, Activity activity) { - if (dialog == null || !dialog.isShowing()) - return; - - if (activity.isFinishing()) - return; - - if (activity.isDestroyed()) - return; - - dialog.dismiss(); - } - - /** - * Format a path properly. - * - * @param path String containing the path that needs formatting. - * @return formatted file path as a string. - */ - public static String formatPath(String path) { - return new File(path).toURI().normalize().getPath(); - } - - /** - * @return a themed AlertDialog builder. - */ - public static AlertDialog.Builder getAlertDialogBuilder(Context context) - { - return new MaterialAlertDialogBuilder(context); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.kt b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.kt new file mode 100644 index 00000000..62cb73a3 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.kt @@ -0,0 +1,243 @@ +package com.nutomic.syncthingandroid.util + +import android.app.Activity +import android.app.Dialog +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.os.Build +import androidx.preference.PreferenceManager +import android.util.Log +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.service.Constants +import eu.chainfire.libsuperuser.Shell +import java.io.BufferedWriter +import java.io.DataOutputStream +import java.io.File +import java.io.IOException +import java.io.OutputStreamWriter +import java.text.DecimalFormat +import kotlin.math.log10 +import kotlin.math.pow + +object Util { + private const val TAG = "SyncthingUtil" + + /** + * Copies the given device ID to the clipboard (and shows a Toast telling about it). + * + * @param id The device ID to copy. + */ + @JvmStatic + fun copyDeviceId(context: Context, id: String?) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(context.getString(R.string.device_id), id) + clipboard.setPrimaryClip(clip) + if (Build.VERSION.SDK_INT < 33) { + Toast.makeText(context, R.string.device_id_copied_to_clipboard, Toast.LENGTH_SHORT) + .show() + } + } + + /** + * Converts a number of bytes to a human readable file size (eg 3.5 GiB). + * + * + * Based on http://stackoverflow.com/a/5599842 + */ + @JvmStatic + fun readableFileSize(context: Context, bytes: Long): String { + val units = context.resources.getStringArray(R.array.file_size_units) + if (bytes <= 0) return "0 " + units[0] + val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt() + return DecimalFormat("#,##0.#") + .format(bytes / 1024.0.pow(digitGroups.toDouble())) + " " + units[digitGroups] + } + + /** + * Converts a number of bytes to a human readable transfer rate in bytes per second + * (eg 100 KiB/s). + * + * + * Based on http://stackoverflow.com/a/5599842 + */ + @JvmStatic + fun readableTransferRate(context: Context, bits: Long): String { + val units = context.resources.getStringArray(R.array.transfer_rate_units) + val bytes = bits / 8 + if (bytes <= 0) return "0 " + units[0] + val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt() + return DecimalFormat("#,##0.#") + .format(bytes / 1024.0.pow(digitGroups.toDouble())) + " " + units[digitGroups] + } + + /** + * Normally an application's data directory is only accessible by the corresponding application. + * Therefore, every file and directory is owned by an application's user and group. When running Syncthing as root, + * it writes to the application's data directory. This leaves files and directories behind which are owned by root having 0600. + * Moreover, those actions performed as root changes a file's type in terms of SELinux. + * A subsequent start of Syncthing will fail due to insufficient permissions. + * Hence, this method fixes the owner, group and the files' type of the data directory. + * + * @return true if the operation was successfully performed. False otherwise. + */ + @JvmStatic + fun fixAppDataPermissions(context: Context): Boolean { + // We can safely assume that root magic is somehow available, because readConfig and saveChanges check for + // read and write access before calling us. + // Be paranoid :) and check if root is available. + // Ignore the 'use_root' preference, because we might want to fix the permission + // just after the root option has been disabled. + if (!Shell.SU.available()) { + Log.e(TAG, "Root is not available. Cannot fix permissions.") + return false + } + + val packageName: String + val appInfo: ApplicationInfo? + try { + packageName = context.packageName + appInfo = context.packageManager.getApplicationInfo(packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + // This should not happen! + // One should always be able to retrieve the application info for its own package. + Log.w(TAG, "Error getting current package name", e) + return false + } + Log.d(TAG, "Uid of '" + packageName + "' is " + appInfo.uid) + + // Get private app's "files" dir residing in "/data/data/[packageName]". + val dir = context.filesDir.absolutePath + var cmd = "chown -R " + appInfo.uid + ":" + appInfo.uid + " " + dir + "; " + // Running Syncthing as root might change a file's or directories type in terms of SELinux. + // Leaving them as they are, the Android service won't be able to access them. + // At least for those files residing in an application's data folder. + // Simply reverting the type to its default should do the trick. + cmd += "restorecon -R $dir\n" + Log.d(TAG, "Running: '$cmd") + val exitCode = runShellCommand(cmd, true) + if (exitCode == 0) { + Log.i(TAG, "Fixed app data permissions on '$dir'.") + } else { + Log.w( + TAG, + "Failed to fix app data permissions on '$dir'. Result: $exitCode" + ) + } + return exitCode == 0 + } + + /** + * Returns if the syncthing binary would be able to write a file into + * the given folder given the configured access level. + */ + @JvmStatic + fun nativeBinaryCanWriteToPath(context: Context?, absoluteFolderPath: String?): Boolean { + val TOUCH_FILE_NAME = ".stwritetest" + var useRoot = false + val prefUseRoot = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.PREF_USE_ROOT, false) + if (prefUseRoot && Shell.SU.available()) { + useRoot = true + } + + // Write permission test file. + val touchFile = "$absoluteFolderPath/$TOUCH_FILE_NAME" + val exitCode = runShellCommand("echo \"\" > \"$touchFile\"\n", useRoot) + if (exitCode != 0) { + val error = if (exitCode == 1) { + "Permission denied" + } else { + "Shell execution failed" + } + Log.i( + TAG, "Failed to write test file '" + touchFile + + "', " + error + ) + return false + } + + // Detected we have write permission. + Log.i(TAG, "Successfully wrote test file '$touchFile'") + + // Remove test file. + if (runShellCommand("rm \"$touchFile\"\n", useRoot) != 0) { + // This is very unlikely to happen, so we have less error handling. + Log.i(TAG, "Failed to remove test file") + } + return true + } + + /** + * Run command in a shell and return the exit code. + */ + @JvmStatic + fun runShellCommand(cmd: String?, useRoot: Boolean): Int { + // Assume "failure" exit code if an error is caught. + var exitCode = 255 + var shellProc: Process? = null + var shellOut: DataOutputStream? = null + try { + shellProc = Runtime.getRuntime().exec(if (useRoot) "su" else "sh") + shellOut = DataOutputStream(shellProc.outputStream) + val bufferedWriter = BufferedWriter(OutputStreamWriter(shellOut)) + Log.d(TAG, "runShellCommand: $cmd") + bufferedWriter.write(cmd) + bufferedWriter.flush() + shellOut.close() + shellOut = null + exitCode = shellProc.waitFor() + } catch (e: IOException) { + Log.w(TAG, "runShellCommand: Exception", e) + } catch (e: InterruptedException) { + Log.w(TAG, "runShellCommand: Exception", e) + } finally { + try { + shellOut?.close() + } catch (e: IOException) { + Log.w(TAG, "Failed to close shell stream", e) + } + shellProc?.destroy() + } + return exitCode + } + + /** + * Make sure that dialog is showing and activity is valid before dismissing dialog, to prevent + * various crashes. + */ + @JvmStatic + fun dismissDialogSafe(dialog: Dialog?, activity: Activity) { + if (dialog == null || !dialog.isShowing) return + + if (activity.isFinishing) return + + if (activity.isDestroyed) return + + dialog.dismiss() + } + + /** + * Format a path properly. + * + * @param path String containing the path that needs formatting. + * @return formatted file path as a string. + */ + @JvmStatic + fun formatPath(path: String): String? { + return File(path).toURI().normalize().getPath() + } + + /** + * @return a themed AlertDialog builder. + */ + @JvmStatic + fun getAlertDialogBuilder(context: Context): AlertDialog.Builder { + return MaterialAlertDialogBuilder(context) + } +} From 5bdce1a2ba11dbd6c4e68566eb2c9fd518392441 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 08:22:34 -0800 Subject: [PATCH 23/80] Fixes --- .../service/EventProcessor.kt | 6 ++- .../syncthingandroid/service/RestApi.kt | 41 ++++++++++--------- .../service/SyncthingRunnable.kt | 4 +- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.kt index b34edb17..bcc37385 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.kt @@ -50,9 +50,11 @@ class EventProcessor(context: Context, api: RestApi?) : Runnable, OnReceiveEvent private val mContext: Context private val mApi: RestApi? + @JvmField @Inject var mPreferences: SharedPreferences? = null + @JvmField @Inject var mNotificationHandler: NotificationHandler? = null @@ -108,8 +110,8 @@ class EventProcessor(context: Context, api: RestApi?) : Runnable, OnReceiveEvent } "FolderCompletion" -> { - val completionInfo = CompletionInfo() - completionInfo.completion = (mapData.get("completion") as Double?)!! + val completionInfo = CompletionInfo() + completionInfo.completion = (mapData!!["completion"] as Double?)!! mApi!!.setCompletionInfo( mapData!!["device"] as String?, // deviceId mapData["folder"] as String?, // folderId diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt index 772d5b1d..e556502e 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt @@ -49,7 +49,7 @@ class RestApi( context: Context, url: URL?, apiKey: String?, apiListener: OnApiAvailableListener, configListener: OnConfigChangedListener ) { - interface OnConfigChangedListener { + fun interface OnConfigChangedListener { fun onConfigChanged() } @@ -57,7 +57,7 @@ class RestApi( fun onResult(t: T?) } - interface OnResultListener2 { + fun interface OnResultListener2 { fun onResult(t: T?, r: R?) } @@ -78,7 +78,7 @@ class RestApi( * Stores the result of the last successful request to [GetRequest.URI_CONNECTIONS], * or an empty Map. */ - private var mPreviousConnections = Optional.absent() + private var mPreviousConnections = Optional.absent() /** * Stores the timestamp of the last successful request to [GetRequest.URI_CONNECTIONS]. @@ -118,6 +118,7 @@ class RestApi( */ private val mCompletion = Completion() + @JvmField @Inject var mNotificationHandler: NotificationHandler? = null @@ -366,7 +367,7 @@ class RestApi( PostRequest( mContext, this.url, PostRequest.URI_DB_OVERRIDE, mApiKey, - ImmutableMap.of("folder", folderId), null + ImmutableMap.of("folder", folderId), null ) } @@ -421,9 +422,9 @@ class RestApi( synchronized(mConfigLock) { folders = deepCopy( - mConfig.folders, + mConfig?.folders, object : - com.google.common.reflect.TypeToken?>() {}.type + com.google.common.reflect.TypeToken>() {}.type )!! } Collections.sort( @@ -488,7 +489,7 @@ class RestApi( devices = deepCopy( mConfig!!.devices, object : - com.google.common.reflect.TypeToken?>() {}.type + com.google.common.reflect.TypeToken>() {}.type )!! } @@ -660,27 +661,27 @@ class RestApi( mPreviousConnectionTime = now val connections = Gson().fromJson(result, Connections::class.java) - for (e in connections.connections.entries) { - e.value.completion = mCompletion.getDeviceCompletion(e.key) + for (e in connections.connections?.entries!!) { + e.value?.completion = mCompletion.getDeviceCompletion(e.key) val prev: Connections.Connection = checkNotNull( - if (mPreviousConnections.isPresent && mPreviousConnections.get()!!.connections.containsKey( + if (mPreviousConnections.isPresent && mPreviousConnections.get().connections?.containsKey( e.key - ) + ) == true ) - mPreviousConnections.get()!!.connections.get(e.key) + mPreviousConnections.get().connections?.get(e.key) else Connections.Connection() ) - e.value.setTransferRate(prev, msElapsed) + e.value?.setTransferRate(prev, msElapsed) } val prev = - mPreviousConnections.transform(Function { c: Connections? -> c!!.total }) + mPreviousConnections.transform(Function { c: Connections -> c.total!! }) .or( Connections.Connection() ) - connections.total.setTransferRate(prev, msElapsed) - mPreviousConnections = Optional.of(connections) + connections.total?.setTransferRate(prev, msElapsed) + mPreviousConnections = Optional.of(connections) listener.onResult(deepCopy(connections, Connections::class.java)) }) } @@ -694,7 +695,7 @@ class RestApi( this.url, GetRequest.URI_STATUS, mApiKey, - ImmutableMap.of("folder", folderId) + ImmutableMap.of("folder", folderId) ) { result: String? -> val m = Gson().fromJson(result, FolderStatus::class.java) mCachedFolderStatuses[folderId] = m @@ -724,8 +725,8 @@ class RestApi( * The OnReceiveEventListeners onEvent method is called for each event. */ fun getEvents(sinceId: Long, limit: Long, listener: OnReceiveEventListener) { - val params: MutableMap = - ImmutableMap.of( + val params: MutableMap = + ImmutableMap.of( "since", sinceId.toString(), "limit", @@ -763,7 +764,7 @@ class RestApi( GetRequest( mContext, this.url, GetRequest.URI_DEVICEID, mApiKey, - ImmutableMap.of("id", id) + ImmutableMap.of("id", id) ) { result: String? -> val json = JsonParser.parseString(result).getAsJsonObject() val normalizedId = json.get("id") diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt index e27306de..329d5a6d 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt @@ -37,7 +37,7 @@ import javax.inject.Inject class SyncthingRunnable(context: Context, command: Command) : Runnable { private val mContext: Context private val mSyncthingBinary: File - private val mCommand: Array + private val mCommand: Array private val mLogFile: File @JvmField @@ -354,7 +354,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { } } - interface OnSyncthingKilled { + fun interface OnSyncthingKilled { fun onKilled() } From 1622337351596496c373e53123278322e1cf0a7b Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 08:27:52 -0800 Subject: [PATCH 24/80] Fixes --- .../activities/DeviceActivity.java | 74 +++++++++---------- .../service/RunConditionMonitor.kt | 4 +- .../service/SyncthingRunnable.kt | 4 +- .../service/SyncthingService.kt | 8 +- .../com/nutomic/syncthingandroid/util/Util.kt | 2 +- 5 files changed, 43 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java index 2aabb36c..bf5951e7 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java @@ -4,8 +4,6 @@ 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; @@ -45,14 +43,10 @@ */ 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"; + 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"; @@ -119,8 +113,7 @@ public void afterTextChanged(Editable s) { } }; - private final CompoundButton.OnCheckedChangeListener mCheckedListener = - new CompoundButton.OnCheckedChangeListener() { + private final CompoundButton.OnCheckedChangeListener mCheckedListener = new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton view, boolean isChecked) { switch (view.getId()) { @@ -149,33 +142,32 @@ public void onCreate(Bundle savedInstanceState) { binding.qrButton.setOnClickListener(this); binding.compressionContainer.setOnClickListener(this); - if (savedInstanceState != null){ + if (savedInstanceState != null) { if (mDevice == null) { mDevice = new Gson().fromJson(savedInstanceState.getString("device"), Device.class); } restoreDialogStates(savedInstanceState); } if (mIsCreateMode) { - if (mDevice == null) { + if (mDevice == null) { initDevice(); } - } - else { + } else { prepareEditMode(); } } private void restoreDialogStates(Bundle savedInstanceState) { - if (savedInstanceState.getBoolean(IS_SHOWING_COMPRESSION_DIALOG)){ + if (savedInstanceState.getBoolean(IS_SHOWING_COMPRESSION_DIALOG)) { showCompressionDialog(); } - if (savedInstanceState.getBoolean(IS_SHOWING_DELETE_DIALOG)){ + if (savedInstanceState.getBoolean(IS_SHOWING_DELETE_DIALOG)) { showDeleteDialog(); } - if (mIsCreateMode){ - if (savedInstanceState.getBoolean(IS_SHOWING_DISCARD_DIALOG)){ + if (mIsCreateMode) { + if (savedInstanceState.getBoolean(IS_SHOWING_DISCARD_DIALOG)) { showDiscardDialog(); } } @@ -186,7 +178,8 @@ public void onDestroy() { super.onDestroy(); SyncthingService syncthingService = getService(); if (syncthingService != null) { - syncthingService.getNotificationHandler().cancelConsentNotification(getIntent().getIntExtra(EXTRA_NOTIFICATION_ID, 0)); + syncthingService.getNotificationHandler() + .cancelConsentNotification(getIntent().getIntExtra(EXTRA_NOTIFICATION_ID, 0)); syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange); } binding.id.removeTextChangedListener(mIdTextWatcher); @@ -206,18 +199,20 @@ public void onPause() { } /** - * Save current settings in case we are in create mode and they aren't yet stored in the config. + * 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){ + 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()); + outState.putBoolean(IS_SHOWING_COMPRESSION_DIALOG, + mCompressionDialog != null && mCompressionDialog.isShowing()); Util.dismissDialogSafe(mCompressionDialog, this); outState.putBoolean(IS_SHOWING_DELETE_DIALOG, mDeleteDialog != null && mDeleteDialog.isShowing()); @@ -227,14 +222,16 @@ public void onSaveInstanceState(Bundle outState) { private void onServiceConnected() { Log.v(TAG, "onServiceConnected"); SyncthingService syncthingService = (SyncthingService) getService(); - syncthingService.getNotificationHandler().cancelConsentNotification(getIntent().getIntExtra(EXTRA_NOTIFICATION_ID, 0)); + 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 + * NOTE: This is only called once on startup, should be called more often to + * properly display * version/address changes. */ private void onReceiveConnections(Connections connections) { @@ -248,7 +245,7 @@ private void onReceiveConnections(Connections connections) { } private void onServiceStateChange(SyncthingService.State currentState) { - if (currentState != ACTIVE) { + if (currentState != SyncthingService.State.ACTIVE) { finish(); return; } @@ -320,15 +317,14 @@ public boolean onOptionsItemSelected(MenuItem item) { .show(); return true; } - getApi().addDevice(mDevice, error -> - Toast.makeText(this, error, Toast.LENGTH_LONG).show()); + 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(); + showDeleteDialog(); return true; case android.R.id.home: onBackPressed(); @@ -338,13 +334,12 @@ public boolean onOptionsItemSelected(MenuItem item) { } } - - private void showDeleteDialog(){ + private void showDeleteDialog() { mDeleteDialog = createDeleteDialog(); mDeleteDialog.show(); } - private Dialog createDeleteDialog(){ + private Dialog createDeleteDialog() { return Util.getAlertDialogBuilder(this) .setMessage(R.string.remove_device_confirm) .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> { @@ -378,7 +373,7 @@ private void initDevice() { mDevice.name = getIntent().getStringExtra(EXTRA_DEVICE_NAME); mDevice.deviceID = getIntent().getStringExtra(EXTRA_DEVICE_ID); mDevice.addresses = DYNAMIC_ADDRESS; - mDevice.compression = METADATA.getValue(this); + mDevice.compression = Compression.METADATA.getValue(this); mDevice.introducer = false; mDevice.paused = false; } @@ -420,7 +415,7 @@ private String displayableAddresses() { public void onClick(View v) { if (v.equals(binding.compressionContainer)) { showCompressionDialog(); - } else if (v.equals(binding.qrButton)){ + } else if (v.equals(binding.qrButton)) { Intent qrIntent = QRScannerActivity.intent(this); startActivityForResult(qrIntent, QR_SCAN_REQUEST_CODE); } else if (v.equals(binding.idContainer)) { @@ -428,12 +423,12 @@ public void onClick(View v) { } } - private void showCompressionDialog(){ + private void showCompressionDialog() { mCompressionDialog = createCompressionDialog(); mCompressionDialog.show(); } - private Dialog createCompressionDialog(){ + private Dialog createCompressionDialog() { return Util.getAlertDialogBuilder(this) .setTitle(R.string.compression) .setSingleChoiceItems(R.array.compress_entries, @@ -458,13 +453,12 @@ private void shareDeviceId(Context context, String id) { public void onBackPressed() { if (mIsCreateMode) { showDiscardDialog(); - } - else { + } else { super.onBackPressed(); } } - private void showDiscardDialog(){ + private void showDiscardDialog() { mDiscardDialog = createDiscardDialog(); mDiscardDialog.show(); } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt index 36e46a6a..8d06b875 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt @@ -158,14 +158,14 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe val prefRunOnWifi = mPreferences!!.getBoolean(Constants.PREF_RUN_ON_WIFI, true) val prefRunOnMeteredWifi = mPreferences!!.getBoolean(Constants.PREF_RUN_ON_METERED_WIFI, false) - val whitelistedWifiSsids: MutableSet = mPreferences.getStringSet( + val whitelistedWifiSsids: MutableSet = mPreferences!!.getStringSet( com.nutomic.syncthingandroid.service.Constants.PREF_WIFI_SSID_WHITELIST, java.util.HashSet() )!! val prefWifiWhitelistEnabled = !whitelistedWifiSsids.isEmpty() val prefRunInFlightMode = mPreferences!!.getBoolean(Constants.PREF_RUN_IN_FLIGHT_MODE, false) - val prefPowerSource: String = mPreferences.getString( + val prefPowerSource: String = mPreferences!!.getString( com.nutomic.syncthingandroid.service.Constants.PREF_POWER_SOURCE, RunConditionMonitor.Companion.POWER_SOURCE_CHARGER_BATTERY )!! diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt index 329d5a6d..85786b77 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt @@ -492,7 +492,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { targetEnv.put("all_proxy", "socks5://localhost:9050") targetEnv.put("ALL_PROXY_NO_FALLBACK", "1") } else { - val socksProxyAddress: String = mPreferences.getString( + val socksProxyAddress: String = mPreferences!!.getString( com.nutomic.syncthingandroid.service.Constants.PREF_SOCKS_PROXY_ADDRESS, "" )!! @@ -500,7 +500,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { targetEnv.put("all_proxy", socksProxyAddress) } - val httpProxyAddress: String = mPreferences.getString( + val httpProxyAddress: String = mPreferences!!.getString( com.nutomic.syncthingandroid.service.Constants.PREF_HTTP_PROXY_ADDRESS, "" )!! diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt index 75e189cf..221bb470 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt @@ -204,7 +204,7 @@ class SyncthingService : Service() { // mApi is not null due to State.ACTIVE checkNotNull(this.api) api!!.ignoreDevice( - intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra( + intent.getStringExtra(EXTRA_DEVICE_ID)!!, intent.getStringExtra( EXTRA_DEVICE_NAME ), intent.getStringExtra(EXTRA_DEVICE_ADDRESS) ) @@ -218,9 +218,9 @@ class SyncthingService : Service() { // mApi is not null due to State.ACTIVE checkNotNull(this.api) api!!.ignoreFolder( - intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra( + intent.getStringExtra(EXTRA_DEVICE_ID)!!, intent.getStringExtra( EXTRA_FOLDER_ID - ), intent.getStringExtra(EXTRA_FOLDER_LABEL) + )!!, intent.getStringExtra(EXTRA_FOLDER_LABEL) ) notificationHandler!!.cancelConsentNotification( intent.getIntExtra( @@ -230,7 +230,7 @@ class SyncthingService : Service() { ) } else if (ACTION_OVERRIDE_CHANGES == intent.action && this.currentState == State.ACTIVE) { checkNotNull(this.api) - api!!.overrideChanges(intent.getStringExtra(EXTRA_FOLDER_ID)) + api!!.overrideChanges(intent.getStringExtra(EXTRA_FOLDER_ID)!!) } return START_STICKY } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.kt b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.kt index 62cb73a3..f9b54034 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.kt @@ -140,7 +140,7 @@ object Util { fun nativeBinaryCanWriteToPath(context: Context?, absoluteFolderPath: String?): Boolean { val TOUCH_FILE_NAME = ".stwritetest" var useRoot = false - val prefUseRoot = PreferenceManager.getDefaultSharedPreferences(context) + val prefUseRoot = PreferenceManager.getDefaultSharedPreferences(context!!) .getBoolean(Constants.PREF_USE_ROOT, false) if (prefUseRoot && Shell.SU.available()) { useRoot = true From 8b87efa5967ba9955b9f7d9f486f9b988c336374 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 08:36:49 -0800 Subject: [PATCH 25/80] Build success --- .../main/java/com/nutomic/syncthingandroid/service/RestApi.kt | 2 +- .../nutomic/syncthingandroid/service/RunConditionMonitor.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt index e556502e..d5d40610 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt @@ -122,7 +122,7 @@ class RestApi( @Inject var mNotificationHandler: NotificationHandler? = null - interface OnApiAvailableListener { + fun interface OnApiAvailableListener { fun onApiAvailable() } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt index 8d06b875..8481919a 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt @@ -32,7 +32,7 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe } } - interface OnRunConditionChangedListener { + fun interface OnRunConditionChangedListener { fun onRunConditionChanged(result: RunConditionCheckResult?) } @@ -73,7 +73,7 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe val filter = IntentFilter() filter.addAction(Intent.ACTION_POWER_CONNECTED) filter.addAction(Intent.ACTION_POWER_DISCONNECTED) - ReceiverManager.registerReceiver(mContext, RunConditionMonitor.BatteryReceiver(), filter) + ReceiverManager.registerReceiver(mContext, BatteryReceiver(), filter) // PowerSaveModeChangedReceiver ReceiverManager.registerReceiver( From 1edf2388949a980640255ef4dd660fdb4bac3efb Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 09:23:00 -0800 Subject: [PATCH 26/80] Fix warnings --- .../service/NotificationHandler.kt | 3 +- .../service/SyncthingRunnable.kt | 129 ++++++++---------- .../syncthingandroid/util/FileUtils.kt | 27 ++-- 3 files changed, 70 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.kt index 13a42301..cdaba4d9 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.kt @@ -35,7 +35,7 @@ class NotificationHandler(context: Context) { private var appShutdownInProgress = false init { - Objects.requireNonNull((context.getApplicationContext() as SyncthingApp).component()) + Objects.requireNonNull((context.applicationContext as SyncthingApp).component()) .inject(this) mContext = context mNotificationManager = @@ -134,7 +134,6 @@ class NotificationHandler(context: Context) { SyncthingService.State.DISABLED -> title = R.string.syncthing_disabled SyncthingService.State.STARTING -> title = R.string.syncthing_starting SyncthingService.State.ACTIVE -> title = R.string.syncthing_active - else -> {} } /** diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt index 85786b77..5ad9427f 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt @@ -25,7 +25,6 @@ import java.io.IOException import java.io.InputStream import java.io.InputStreamReader import java.io.LineNumberReader -import java.security.InvalidParameterException import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject @@ -63,7 +62,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { * @param command Which type of Syncthing command to execute. */ init { - (context.getApplicationContext() as SyncthingApp).component()!!.inject(this) + (context.applicationContext as SyncthingApp).component()!!.inject(this) mContext = context mSyncthingBinary = Constants.getSyncthingBinary(mContext) mLogFile = Constants.getLogFile(mContext) @@ -72,44 +71,42 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { mUseRoot = mPreferences!!.getBoolean(Constants.PREF_USE_ROOT, false) && Shell.SU.available() when (command) { Command.deviceid -> mCommand = arrayOf( - mSyncthingBinary.getPath(), + mSyncthingBinary.path, "-home", - mContext.getFilesDir().toString(), + mContext.filesDir.toString(), "--device-id" ) Command.generate -> mCommand = arrayOf( - mSyncthingBinary.getPath(), + mSyncthingBinary.path, "-generate", - mContext.getFilesDir().toString(), + mContext.filesDir.toString(), "-logflags=0" ) Command.main -> mCommand = arrayOf( - mSyncthingBinary.getPath(), + mSyncthingBinary.path, "-home", - mContext.getFilesDir().toString(), + mContext.filesDir.toString(), "-no-browser", "-logflags=0" ) Command.resetdatabase -> mCommand = arrayOf( - mSyncthingBinary.getPath(), + mSyncthingBinary.path, "-home", - mContext.getFilesDir().toString(), + mContext.filesDir.toString(), "-reset-database", "-logflags=0" ) Command.resetdeltas -> mCommand = arrayOf( - mSyncthingBinary.getPath(), + mSyncthingBinary.path, "-home", - mContext.getFilesDir().toString(), + mContext.filesDir.toString(), "-reset-deltas", "-logflags=0" ) - - else -> throw InvalidParameterException("Unknown command option") } } @@ -124,7 +121,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { var capturedStdOut = "" // Make sure Syncthing is executable try { - val pb = ProcessBuilder("chmod", "500", mSyncthingBinary.getPath()) + val pb = ProcessBuilder("chmod", "500", mSyncthingBinary.path) val p = pb.start() p.waitFor() } catch (e: IOException) { @@ -144,7 +141,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { else null try { - if (wakeLock != null) wakeLock.acquire() + wakeLock?.acquire() increaseInotifyWatches() val targetEnv = buildEnvironment() @@ -157,7 +154,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { if (returnStdOut) { var br: BufferedReader? = null try { - br = BufferedReader(InputStreamReader(process.getInputStream(), Charsets.UTF_8)) + br = BufferedReader(InputStreamReader(process.inputStream, Charsets.UTF_8)) var line: String while ((br.readLine().also { line = it }) != null) { Log.println(Log.INFO, TAG_NATIVE, line) @@ -166,20 +163,20 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { } catch (e: IOException) { Log.w(TAG, "Failed to read Syncthing's command line output", e) } finally { - if (br != null) br.close() + br?.close() } } else { - lInfo = log(process.getInputStream(), Log.INFO, true) - lWarn = log(process.getErrorStream(), Log.WARN, true) + lInfo = log(process.inputStream, Log.INFO, true) + lWarn = log(process.errorStream, Log.WARN, true) } niceSyncthing() ret = process.waitFor() - Log.i(TAG, "Syncthing exited with code " + ret) + Log.i(TAG, "Syncthing exited with code $ret") mSyncthing.set(null) - if (lInfo != null) lInfo.join() - if (lWarn != null) lWarn.join() + lInfo?.join() + lWarn?.join() when (ret) { 0, 137 -> {} @@ -205,7 +202,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { } else -> { - Log.w(TAG, "Syncthing has crashed (exit code " + ret + ")") + Log.w(TAG, "Syncthing has crashed (exit code $ret)") mNotificationHandler!!.showCrashedNotification( R.string.notification_crash_title, false @@ -217,8 +214,8 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { } catch (e: InterruptedException) { Log.e(TAG, "Failed to execute syncthing binary or read output", e) } finally { - if (wakeLock != null) wakeLock.release() - if (process != null) process.destroy() + wakeLock?.release() + process?.destroy() } return capturedStdOut } @@ -233,7 +230,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { for (e in customEnvironment!!.split(" ".toRegex()).dropLastWhile { it.isEmpty() } .toTypedArray()) { val e2 = e.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - environment.put(e2[0], e2[1]) + environment[e2[0]] = e2[1] } } @@ -257,13 +254,13 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { var br: BufferedReader? = null try { ps = Runtime.getRuntime().exec(if (mUseRoot) "su" else "sh") - psOut = DataOutputStream(ps.getOutputStream()) + psOut = DataOutputStream(ps.outputStream) psOut.writeBytes("ps\n") psOut.writeBytes("exit\n") psOut.flush() ps.waitFor() br = - BufferedReader(InputStreamReader(ps.getInputStream(), "UTF-8")) + BufferedReader(InputStreamReader(ps.inputStream, "UTF-8")) var line: String? while ((br.readLine().also { line = it }) != null) { if (line!!.contains(Constants.FILENAME_SYNCTHING_BINARY)) { @@ -272,7 +269,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { .dropLastWhile { it.isEmpty() }.toTypedArray()[1] Log.v( TAG, - "getSyncthingPIDs: Found process PID [" + syncthingPID + "]" + "getSyncthingPIDs: Found process PID [$syncthingPID]" ) syncthingPIDs.add(syncthingPID) } @@ -291,12 +288,8 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { ) } finally { try { - if (br != null) { - br.close() - } - if (psOut != null) { - psOut.close() - } + br?.close() + psOut?.close() } catch (e: IOException) { Log.w( TAG, @@ -304,9 +297,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { e ) } - if (ps != null) { - ps.destroy() - } + ps?.destroy() } return syncthingPIDs } @@ -325,7 +316,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { return } val exitCode = runShellCommand("sysctl -n -w fs.inotify.max_user_watches=131072\n", true) - Log.i(TAG, "increaseInotifyWatches: sysctl returned " + exitCode.toString()) + Log.i(TAG, "increaseInotifyWatches: sysctl returned $exitCode") } /** @@ -346,7 +337,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { // Ionice all running syncthing processes. for (syncthingPID in syncthingPIDs) { // Set best-effort, low priority using ionice. - val exitCode = runShellCommand("/system/bin/ionice " + syncthingPID + " be 7\n", true) + val exitCode = runShellCommand("/system/bin/ionice $syncthingPID be 7\n", true) Log.i( TAG_NICE, "ionice returned " + exitCode.toString() + " on " + Constants.FILENAME_SYNCTHING_BINARY @@ -378,13 +369,13 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { if (i > 0) { // Force termination of the process by sending SIGKILL. SystemClock.sleep(3000) - exitCode = runShellCommand("kill -SIGKILL " + syncthingPID + "\n", mUseRoot) + exitCode = runShellCommand("kill -SIGKILL $syncthingPID\n", mUseRoot) } else { - exitCode = runShellCommand("kill -SIGINT " + syncthingPID + "\n", mUseRoot) + exitCode = runShellCommand("kill -SIGINT $syncthingPID\n", mUseRoot) SystemClock.sleep(1000) } if (exitCode == 0) { - Log.d(TAG, "Killed Syncthing process " + syncthingPID) + Log.d(TAG, "Killed Syncthing process $syncthingPID") } else { Log.w( TAG, "Failed to kill Syncthing process " + syncthingPID + @@ -440,7 +431,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { val lnr = LineNumberReader(FileReader(mLogFile)) lnr.skip(Long.Companion.MAX_VALUE) - val lineCount = lnr.getLineNumber() + val lineCount = lnr.lineNumber lnr.close() val tempFile = File(mContext.getExternalFilesDir(null), "syncthing.log.tmp") @@ -468,51 +459,43 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { private fun buildEnvironment(): HashMap { val targetEnv = HashMap() // Set home directory to data folder for web GUI folder picker. - targetEnv.put("HOME", Environment.getExternalStorageDirectory().getAbsolutePath()) - targetEnv.put( - "STTRACE", TextUtils.join( - " ", - mPreferences!!.getStringSet( - com.nutomic.syncthingandroid.service.Constants.PREF_DEBUG_FACILITIES_ENABLED, - java.util.HashSet() - )!! - ) + targetEnv["HOME"] = Environment.getExternalStorageDirectory().absolutePath + targetEnv["STTRACE"] = TextUtils.join( + " ", + mPreferences!!.getStringSet( + Constants.PREF_DEBUG_FACILITIES_ENABLED, + java.util.HashSet() + )!! ) val externalFilesDir = mContext.getExternalFilesDir(null) - if (externalFilesDir != null) targetEnv.put( - "STGUIASSETS", - externalFilesDir.getAbsolutePath() + "/gui" - ) - targetEnv.put("STMONITORED", "1") - targetEnv.put("STNOUPGRADE", "1") + if (externalFilesDir != null) targetEnv["STGUIASSETS"] = externalFilesDir.absolutePath + "/gui" + targetEnv["STMONITORED"] = "1" + targetEnv["STNOUPGRADE"] = "1" // Disable hash benchmark for faster startup. // https://github.com/syncthing/syncthing/issues/4348 - targetEnv.put("STHASHING", "minio") + targetEnv["STHASHING"] = "minio" if (mPreferences!!.getBoolean(Constants.PREF_USE_TOR, false)) { - targetEnv.put("all_proxy", "socks5://localhost:9050") - targetEnv.put("ALL_PROXY_NO_FALLBACK", "1") + targetEnv["all_proxy"] = "socks5://localhost:9050" + targetEnv["ALL_PROXY_NO_FALLBACK"] = "1" } else { val socksProxyAddress: String = mPreferences!!.getString( - com.nutomic.syncthingandroid.service.Constants.PREF_SOCKS_PROXY_ADDRESS, + Constants.PREF_SOCKS_PROXY_ADDRESS, "" )!! if (socksProxyAddress != "") { - targetEnv.put("all_proxy", socksProxyAddress) + targetEnv["all_proxy"] = socksProxyAddress } val httpProxyAddress: String = mPreferences!!.getString( - com.nutomic.syncthingandroid.service.Constants.PREF_HTTP_PROXY_ADDRESS, + Constants.PREF_HTTP_PROXY_ADDRESS, "" )!! if (httpProxyAddress != "") { - targetEnv.put("http_proxy", httpProxyAddress) - targetEnv.put("https_proxy", httpProxyAddress) + targetEnv["http_proxy"] = httpProxyAddress + targetEnv["https_proxy"] = httpProxyAddress } } - if (mPreferences!!.getBoolean("use_legacy_hashing", false)) targetEnv.put( - "STHASHING", - "standard" - ) + if (mPreferences!!.getBoolean("use_legacy_hashing", false)) targetEnv["STHASHING"] = "standard" putCustomEnvironmentVariables(targetEnv, mPreferences!!) return targetEnv } @@ -525,7 +508,7 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { // The su binary prohibits the inheritance of environment variables. // Even with --preserve-environment the environment gets messed up. // We therefore start a root shell, and set all the environment variables manually. - val suOut = DataOutputStream(process.getOutputStream()) + val suOut = DataOutputStream(process.outputStream) for (entry in env.entries) { suOut.writeBytes(String.format("export %s=\"%s\"\n", entry.key, entry.value)) } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.kt b/app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.kt index 7a2d0b09..ef22620e 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.kt @@ -9,6 +9,7 @@ import android.util.Log import java.io.File import java.lang.reflect.Array import java.util.Arrays +import androidx.core.net.toUri /** * Utils for dealing with Storage Access Framework URIs. @@ -88,7 +89,7 @@ object FileUtils { val isPrimary = storageVolumeClazz.getMethod("isPrimary") val result = getVolumeList.invoke(mStorageManager) - val length = Array.getLength(result) + val length = Array.getLength(result!!) for (i in 0..= API level 30 val getDir = storageVolumeClazz.getMethod("getDirectory") val file = getDir.invoke(storageVolumeElement) as File? - return file!!.getPath() + return file!!.path } catch (e: NoSuchMethodException) { // Not present in API level 30, available at some earlier point. val getPath = storageVolumeClazz.getMethod("getPath") @@ -162,13 +163,11 @@ object FileUtils { return null } // Extract the volumeId, e.g. "abcd-efgh" - val volumeId: String? = segments[2] + val volumeId: String = segments[2] // Build the content Uri for our private "files" folder. - return Uri.parse( - "content://com.android.externalstorage.documents/document/" + - volumeId + "%3AAndroid%2Fdata%2F" + - context.getPackageName() + "%2Ffiles" - ) + return ("content://com.android.externalstorage.documents/document/" + + volumeId + "%3AAndroid%2Fdata%2F" + + context.packageName + "%2Ffiles").toUri() } catch (e: Exception) { Log.w(TAG, "getExternalFilesDirUri exception", e) } @@ -183,24 +182,24 @@ object FileUtils { private fun getVolumeIdFromTreeUri(treeUri: Uri?): String? { val docId = DocumentsContract.getTreeDocumentId(treeUri) val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - if (split.size > 0) { - return split[0] + return if (split.isNotEmpty()) { + split[0] } else { - return null + null } } private fun getDocumentPathFromTreeUri(treeUri: Uri?): String { val docId = DocumentsContract.getTreeDocumentId(treeUri) val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - if ((split.size >= 2) && (split[1] != null)) return split[1] + if (split.size >= 2) return split[1] else return File.separator } @JvmStatic - fun cutTrailingSlash(path: String): String? { + fun cutTrailingSlash(path: String): String { if (path.endsWith(File.separator)) { - return path.substring(0, path.length - 1) + return path.dropLast(1) } return path } From e9641bd19887b3929293a8ede895ac4b9484e4e4 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 09:37:51 -0800 Subject: [PATCH 27/80] Fix compiler warnings --- .../service/NotificationHandler.kt | 12 ++- .../service/RunConditionMonitor.kt | 75 ++++++------------- .../service/SyncthingRunnable.kt | 9 ++- .../service/SyncthingService.kt | 51 +++++-------- 4 files changed, 52 insertions(+), 95 deletions(-) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.kt index cdaba4d9..bd646146 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/NotificationHandler.kt @@ -4,6 +4,7 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent +import android.app.Service import android.content.Context import android.content.Intent import android.content.SharedPreferences @@ -78,12 +79,9 @@ class NotificationHandler(context: Context) { } } - private fun getNotificationBuilder(channel: NotificationChannel): NotificationCompat.Builder { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return NotificationCompat.Builder(mContext, channel.getId()) - } else { - return NotificationCompat.Builder(mContext) - } + private fun getNotificationBuilder(channel: NotificationChannel?): NotificationCompat.Builder { + val channelId = channel?.id ?: CHANNEL_INFO + return NotificationCompat.Builder(mContext, channelId) } /** @@ -123,7 +121,7 @@ class NotificationHandler(context: Context) { if (startForegroundService != lastStartForegroundService) { if (!startForegroundService) { Log.v(TAG, "Stopping foreground service") - service.stopForeground(false) + service.stopForeground(Service.STOP_FOREGROUND_DETACH) } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt index 8481919a..0c6376d9 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt @@ -9,8 +9,10 @@ import android.content.SharedPreferences import android.content.SyncStatusObserver import android.net.ConnectivityManager import android.net.wifi.WifiManager +import android.net.NetworkCapabilities import android.os.BatteryManager import android.os.Handler +import android.os.Looper import android.os.PowerManager import android.util.Log import com.nutomic.syncthingandroid.SyncthingApp @@ -62,7 +64,7 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe /** * Register broadcast receivers. */ - // NetworkReceiver + // NetworkReceiver (legacy broadcast used for older platforms) ReceiverManager.registerReceiver( mContext, NetworkReceiver(), @@ -105,12 +107,8 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe if (Intent.ACTION_POWER_CONNECTED == intent.getAction() || Intent.ACTION_POWER_DISCONNECTED == intent.getAction() ) { - val handler = Handler() - handler.postDelayed(object : Runnable { - override fun run() { - updateShouldRunDecision() - } - }, 5000) + val handler = Handler(Looper.getMainLooper()) + handler.postDelayed({ updateShouldRunDecision() }, 5000) } } } @@ -320,76 +318,39 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe private val isFlightMode: Boolean get() { - val cm = - mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val ni = cm.getActiveNetworkInfo() - return ni == null + return networkCapabilities() == null } private val isMeteredNetworkConnection: Boolean get() { - val cm = - mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val ni = cm.getActiveNetworkInfo() - if (ni == null) { - // In flight mode. - return false - } - if (!ni.isConnected()) { - // No network connection. - return false - } + val cm = mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val active = cm.activeNetwork ?: return false return cm.isActiveNetworkMetered() } private val isMobileDataConnection: Boolean get() { - val cm = - mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val ni = cm.getActiveNetworkInfo() - if (ni == null) { - // In flight mode. - return false - } - if (!ni.isConnected()) { - // No network connection. - return false - } - when (ni.getType()) { - ConnectivityManager.TYPE_BLUETOOTH, ConnectivityManager.TYPE_MOBILE, ConnectivityManager.TYPE_MOBILE_DUN, ConnectivityManager.TYPE_MOBILE_HIPRI -> return true - else -> return false - } + val nc = networkCapabilities() ?: return false + return nc.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) } private val isWifiOrEthernetConnection: Boolean get() { - val cm = - mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val ni = cm.getActiveNetworkInfo() - if (ni == null) { - // In flight mode. - return false - } - if (!ni.isConnected()) { - // No network connection. - return false - } - when (ni.getType()) { - ConnectivityManager.TYPE_WIFI, ConnectivityManager.TYPE_WIMAX, ConnectivityManager.TYPE_ETHERNET -> return true - else -> return false - } + val nc = networkCapabilities() ?: return false + return nc.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || nc.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) || nc.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) } private fun isWifiConnectionWhitelisted(whitelistedSsids: MutableSet): Boolean { val wifiManager = mContext.getApplicationContext() .getSystemService(Context.WIFI_SERVICE) as WifiManager - val wifiInfo = wifiManager.getConnectionInfo() + @Suppress("DEPRECATION") + val wifiInfo = wifiManager.connectionInfo if (wifiInfo == null) { // May be null, if wifi has been turned off in the meantime. Log.d(TAG, "isWifiConnectionWhitelisted: SSID unknown due to wifiInfo == null") return false } - val wifiSsid = wifiInfo.getSSID() + val wifiSsid = wifiInfo.ssid if (wifiSsid == null) { Log.w( TAG, @@ -400,6 +361,12 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe return whitelistedSsids.contains(wifiSsid) } + private fun networkCapabilities(): NetworkCapabilities? { + val cm = mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val active = cm.activeNetwork ?: return null + return cm.getNetworkCapabilities(active) + } + companion object { private const val TAG = "RunConditionMonitor" diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt index 5ad9427f..a238077b 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.kt @@ -9,8 +9,7 @@ import android.os.PowerManager import android.os.SystemClock import android.text.TextUtils import android.util.Log -import com.google.common.base.Charsets -import com.google.common.io.Files +import kotlin.text.Charsets import com.nutomic.syncthingandroid.R import com.nutomic.syncthingandroid.SyncthingApp import com.nutomic.syncthingandroid.util.Util.runShellCommand @@ -403,7 +402,11 @@ class SyncthingRunnable(context: Context, command: Command) : Runnable { Log.println(priority, TAG_NATIVE, line!!) if (saveLog) { - Files.append(line + "\n", mLogFile, Charsets.UTF_8) + try { + mLogFile.appendText(line + "\n", Charsets.UTF_8) + } catch (e: IOException) { + Log.w(TAG, "log: Failed to append to log file", e) + } } } } catch (e: IOException) { diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt index 221bb470..078af6c2 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.kt @@ -4,8 +4,8 @@ import android.app.Service import android.content.Intent import android.content.SharedPreferences import android.os.Handler +import android.os.Looper import android.util.Log -import com.google.common.io.Files import com.nutomic.syncthingandroid.R import com.nutomic.syncthingandroid.SyncthingApp import com.nutomic.syncthingandroid.http.PollWebGuiAvailableTask @@ -124,7 +124,7 @@ class SyncthingService : Service() { Log.v(TAG, "onCreate") super.onCreate() (application as SyncthingApp).component()!!.inject(this) - mHandler = Handler() + mHandler = Handler(Looper.getMainLooper()) // Executor for background tasks that previously used AsyncTask mExecutor = Executors.newSingleThreadExecutor() @@ -614,26 +614,15 @@ class SyncthingService : Service() { fun exportConfig() { Constants.EXPORT_PATH.mkdirs() try { - Files.copy( - Constants.getConfigFile(this), - File(Constants.EXPORT_PATH, Constants.CONFIG_FILE) - ) - Files.copy( - Constants.getPrivateKeyFile(this), - File(Constants.EXPORT_PATH, Constants.PRIVATE_KEY_FILE) - ) - Files.copy( - Constants.getPublicKeyFile(this), - File(Constants.EXPORT_PATH, Constants.PUBLIC_KEY_FILE) - ) - Files.copy( - Constants.getHttpsCertFile(this), - File(Constants.EXPORT_PATH, Constants.HTTPS_CERT_FILE) - ) - Files.copy( - Constants.getHttpsKeyFile(this), - File(Constants.EXPORT_PATH, Constants.HTTPS_KEY_FILE) - ) + try { + Constants.getConfigFile(this).copyTo(File(Constants.EXPORT_PATH, Constants.CONFIG_FILE), overwrite = true) + Constants.getPrivateKeyFile(this).copyTo(File(Constants.EXPORT_PATH, Constants.PRIVATE_KEY_FILE), overwrite = true) + Constants.getPublicKeyFile(this).copyTo(File(Constants.EXPORT_PATH, Constants.PUBLIC_KEY_FILE), overwrite = true) + Constants.getHttpsCertFile(this).copyTo(File(Constants.EXPORT_PATH, Constants.HTTPS_CERT_FILE), overwrite = true) + Constants.getHttpsKeyFile(this).copyTo(File(Constants.EXPORT_PATH, Constants.HTTPS_KEY_FILE), overwrite = true) + } catch (e: IOException) { + Log.w(TAG, "Failed to export config", e) + } } catch (e: IOException) { Log.w(TAG, "Failed to export config", e) } @@ -652,17 +641,17 @@ class SyncthingService : Service() { val httpsKey = File(Constants.EXPORT_PATH, Constants.HTTPS_KEY_FILE) if (!config.exists() || !privateKey.exists() || !publicKey.exists()) return false shutdown(State.INIT) { - try { - Files.copy(config, Constants.getConfigFile(this)) - Files.copy(privateKey, Constants.getPrivateKeyFile(this)) - Files.copy(publicKey, Constants.getPublicKeyFile(this)) - } catch (e: IOException) { - Log.w(TAG, "Failed to import config", e) - } + try { + config.copyTo(Constants.getConfigFile(this), overwrite = true) + privateKey.copyTo(Constants.getPrivateKeyFile(this), overwrite = true) + publicKey.copyTo(Constants.getPublicKeyFile(this), overwrite = true) + } catch (e: IOException) { + Log.w(TAG, "Failed to import config", e) + } if (httpsCert.exists() && httpsKey.exists()) { try { - Files.copy(httpsCert, Constants.getHttpsCertFile(this)) - Files.copy(httpsKey, Constants.getHttpsKeyFile(this)) + httpsCert.copyTo(Constants.getHttpsCertFile(this), overwrite = true) + httpsKey.copyTo(Constants.getHttpsKeyFile(this), overwrite = true) } catch (e: IOException) { Log.w(TAG, "Failed to import HTTPS config files", e) } From 15754103b27cdf1a7771d40e90ef8af5b372bac2 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 09:39:08 -0800 Subject: [PATCH 28/80] Kotlin upgrade --- .../receiver/AppConfigReceiver.java | 56 ------------------- .../receiver/AppConfigReceiver.kt | 51 +++++++++++++++++ .../receiver/BootReceiver.java | 46 --------------- .../syncthingandroid/receiver/BootReceiver.kt | 40 +++++++++++++ 4 files changed, 91 insertions(+), 102 deletions(-) delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/receiver/AppConfigReceiver.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/receiver/AppConfigReceiver.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.kt diff --git a/app/src/main/java/com/nutomic/syncthingandroid/receiver/AppConfigReceiver.java b/app/src/main/java/com/nutomic/syncthingandroid/receiver/AppConfigReceiver.java deleted file mode 100644 index 3e2265e5..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/receiver/AppConfigReceiver.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.nutomic.syncthingandroid.receiver; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.preference.PreferenceManager; - -import com.nutomic.syncthingandroid.SyncthingApp; -import com.nutomic.syncthingandroid.service.NotificationHandler; -import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.service.SyncthingService; - -import javax.inject.Inject; - -/** - * Broadcast-receiver to control and configure Syncthing remotely. - */ -public class AppConfigReceiver extends BroadcastReceiver { - - /** - * Start the Syncthing-Service - */ - private static final String ACTION_START = "com.nutomic.syncthingandroid.action.START"; - - /** - * Stop the Syncthing-Service - * If startServiceOnBoot is enabled the service must not be stopped. Instead a - * notification is presented to the user. - */ - private static final String ACTION_STOP = "com.nutomic.syncthingandroid.action.STOP"; - - @Inject NotificationHandler mNotificationHandler; - - @Override - public void onReceive(Context context, Intent intent) { - ((SyncthingApp) context.getApplicationContext()).component().inject(this); - switch (intent.getAction()) { - case ACTION_START: - BootReceiver.startServiceCompat(context); - break; - case ACTION_STOP: - if (startServiceOnBoot(context)) { - mNotificationHandler.showStopSyncthingWarningNotification(); - } else { - context.stopService(new Intent(context, SyncthingService.class)); - } - break; - } - } - - private static boolean startServiceOnBoot(Context context) { - SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - return sp.getBoolean(Constants.PREF_START_SERVICE_ON_BOOT, false); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/receiver/AppConfigReceiver.kt b/app/src/main/java/com/nutomic/syncthingandroid/receiver/AppConfigReceiver.kt new file mode 100644 index 00000000..feb8f311 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/receiver/AppConfigReceiver.kt @@ -0,0 +1,51 @@ +package com.nutomic.syncthingandroid.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.preference.PreferenceManager +import com.nutomic.syncthingandroid.SyncthingApp +import com.nutomic.syncthingandroid.service.Constants +import com.nutomic.syncthingandroid.service.NotificationHandler +import com.nutomic.syncthingandroid.service.SyncthingService +import javax.inject.Inject + +/** + * Broadcast-receiver to control and configure Syncthing remotely. + */ +class AppConfigReceiver : BroadcastReceiver() { + @JvmField + @Inject + var mNotificationHandler: NotificationHandler? = null + + override fun onReceive(context: Context, intent: Intent) { + (context.applicationContext as SyncthingApp).component()!!.inject(this) + when (intent.action) { + ACTION_START -> BootReceiver.Companion.startServiceCompat(context) + ACTION_STOP -> if (startServiceOnBoot(context)) { + mNotificationHandler!!.showStopSyncthingWarningNotification() + } else { + context.stopService(Intent(context, SyncthingService::class.java)) + } + } + } + + companion object { + /** + * Start the Syncthing-Service + */ + private const val ACTION_START = "com.nutomic.syncthingandroid.action.START" + + /** + * Stop the Syncthing-Service + * If startServiceOnBoot is enabled the service must not be stopped. Instead a + * notification is presented to the user. + */ + private const val ACTION_STOP = "com.nutomic.syncthingandroid.action.STOP" + + private fun startServiceOnBoot(context: Context?): Boolean { + val sp = PreferenceManager.getDefaultSharedPreferences(context!!) + return sp.getBoolean(Constants.PREF_START_SERVICE_ON_BOOT, false) + } + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java b/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java deleted file mode 100644 index 1b0d7e50..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.nutomic.syncthingandroid.receiver; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Build; -import android.preference.PreferenceManager; - -import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.service.SyncthingService; - -public class BootReceiver extends BroadcastReceiver { - - @Override - public void onReceive(Context context, Intent intent) { - if (!intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED) && - !intent.getAction().equals(Intent.ACTION_MY_PACKAGE_REPLACED)) - return; - - if (!startServiceOnBoot(context)) - return; - - startServiceCompat(context); - } - - /** - * Workaround for starting service from background on Android 8+. - * - * https://stackoverflow.com/a/44505719/1837158 - */ - static void startServiceCompat(Context context) { - Intent intent = new Intent(context, SyncthingService.class); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent); - } - else { - context.startService(intent); - } - } - - private static boolean startServiceOnBoot(Context context) { - SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - return sp.getBoolean(Constants.PREF_START_SERVICE_ON_BOOT, false); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.kt b/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.kt new file mode 100644 index 00000000..df0f671b --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.kt @@ -0,0 +1,40 @@ +package com.nutomic.syncthingandroid.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.preference.PreferenceManager +import com.nutomic.syncthingandroid.service.Constants +import com.nutomic.syncthingandroid.service.SyncthingService + +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED && intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return + + if (!startServiceOnBoot(context)) return + + startServiceCompat(context) + } + + companion object { + /** + * Workaround for starting service from background on Android 8+. + * + * https://stackoverflow.com/a/44505719/1837158 + */ + fun startServiceCompat(context: Context) { + val intent = Intent(context, SyncthingService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + private fun startServiceOnBoot(context: Context?): Boolean { + val sp = PreferenceManager.getDefaultSharedPreferences(context!!) + return sp.getBoolean(Constants.PREF_START_SERVICE_ON_BOOT, false) + } + } +} From 77bad6736bb8430259cb628b9d0202f77ce9533e Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 09:44:35 -0800 Subject: [PATCH 29/80] Update min SDK to 23 --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1e5a18eb..ff82da0c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,7 +45,7 @@ android { defaultConfig { applicationId = "com.nutomic.syncthingandroid" - minSdk = 21 + minSdk = 23 targetSdk = 33 versionCode = 4396 versionName = "2.0.9" From f193b4c4fea4217d2972ccbc274aa828df6073a4 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 09:45:36 -0800 Subject: [PATCH 30/80] Fix warnings --- .../receiver/AppConfigReceiver.kt | 2 +- .../service/RunConditionMonitor.kt | 39 ++++++++----------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/receiver/AppConfigReceiver.kt b/app/src/main/java/com/nutomic/syncthingandroid/receiver/AppConfigReceiver.kt index feb8f311..4456a17e 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/receiver/AppConfigReceiver.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/receiver/AppConfigReceiver.kt @@ -21,7 +21,7 @@ class AppConfigReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { (context.applicationContext as SyncthingApp).component()!!.inject(this) when (intent.action) { - ACTION_START -> BootReceiver.Companion.startServiceCompat(context) + ACTION_START -> BootReceiver.startServiceCompat(context) ACTION_STOP -> if (startServiceOnBoot(context)) { mNotificationHandler!!.showStopSyncthingWarningNotification() } else { diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt index 0c6376d9..79f10ff3 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt @@ -8,8 +8,8 @@ import android.content.IntentFilter import android.content.SharedPreferences import android.content.SyncStatusObserver import android.net.ConnectivityManager -import android.net.wifi.WifiManager import android.net.NetworkCapabilities +import android.net.wifi.WifiManager import android.os.BatteryManager import android.os.Handler import android.os.Looper @@ -28,11 +28,7 @@ import javax.inject.Inject */ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListener?) { private var mSyncStatusObserverHandle: Any? = null - private val mSyncStatusObserver: SyncStatusObserver = object : SyncStatusObserver { - override fun onStatusChanged(which: Int) { - updateShouldRunDecision() - } - } + private val mSyncStatusObserver: SyncStatusObserver = SyncStatusObserver { updateShouldRunDecision() } fun interface OnRunConditionChangedListener { fun onRunConditionChanged(result: RunConditionCheckResult?) @@ -43,7 +39,6 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe @JvmField @Inject var mPreferences: SharedPreferences? = null - private val mReceiverManager: ReceiverManager? = null /** * Sending callback notifications through [OnRunConditionChangedListener] is enabled if not null. @@ -57,7 +52,7 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe init { Log.v(TAG, "Created new instance") - (context.getApplicationContext() as SyncthingApp).component()!!.inject(this) + (context.applicationContext as SyncthingApp).component()!!.inject(this) mContext = context mOnRunConditionChangedListener = listener @@ -104,8 +99,8 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe private inner class BatteryReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent) { - if (Intent.ACTION_POWER_CONNECTED == intent.getAction() - || Intent.ACTION_POWER_DISCONNECTED == intent.getAction() + if (Intent.ACTION_POWER_CONNECTED == intent.action + || Intent.ACTION_POWER_DISCONNECTED == intent.action ) { val handler = Handler(Looper.getMainLooper()) handler.postDelayed({ updateShouldRunDecision() }, 5000) @@ -115,7 +110,7 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe private inner class NetworkReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent) { - if (ConnectivityManager.CONNECTIVITY_ACTION == intent.getAction()) { + if (ConnectivityManager.CONNECTIVITY_ACTION == intent.action) { updateShouldRunDecision() } } @@ -123,7 +118,7 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe private inner class PowerSaveModeChangedReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent) { - if (PowerManager.ACTION_POWER_SAVE_MODE_CHANGED == intent.getAction()) { + if (PowerManager.ACTION_POWER_SAVE_MODE_CHANGED == intent.action) { updateShouldRunDecision() } } @@ -157,15 +152,15 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe val prefRunOnMeteredWifi = mPreferences!!.getBoolean(Constants.PREF_RUN_ON_METERED_WIFI, false) val whitelistedWifiSsids: MutableSet = mPreferences!!.getStringSet( - com.nutomic.syncthingandroid.service.Constants.PREF_WIFI_SSID_WHITELIST, - java.util.HashSet() + Constants.PREF_WIFI_SSID_WHITELIST, + java.util.HashSet() )!! val prefWifiWhitelistEnabled = !whitelistedWifiSsids.isEmpty() val prefRunInFlightMode = mPreferences!!.getBoolean(Constants.PREF_RUN_IN_FLIGHT_MODE, false) val prefPowerSource: String = mPreferences!!.getString( - com.nutomic.syncthingandroid.service.Constants.PREF_POWER_SOURCE, - RunConditionMonitor.Companion.POWER_SOURCE_CHARGER_BATTERY + Constants.PREF_POWER_SOURCE, + POWER_SOURCE_CHARGER_BATTERY )!! val prefRespectPowerSaving = mPreferences!!.getBoolean(Constants.PREF_RESPECT_BATTERY_SAVING, true) @@ -177,7 +172,7 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe return RunConditionCheckResult.SHOULD_RUN } - val blockerReasons: MutableList = ArrayList() + val blockerReasons: MutableList = ArrayList() // PREF_POWER_SOURCE when (prefPowerSource) { @@ -261,10 +256,8 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe blockerReasons.add(BlockerReason.NO_ALLOWED_NETWORK) } else if (prefRunOnMobileData) { blockerReasons.add(BlockerReason.NO_MOBILE_CONNECTION) - } else if (prefRunOnWifi) { - blockerReasons.add(BlockerReason.NO_WIFI_CONNECTION) } else { - blockerReasons.add(BlockerReason.NO_NETWORK_OR_FLIGHTMODE) + blockerReasons.add(BlockerReason.NO_WIFI_CONNECTION) } } return RunConditionCheckResult(blockerReasons) @@ -313,7 +306,7 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe ) return false } - return powerManager.isPowerSaveMode() + return powerManager.isPowerSaveMode } private val isFlightMode: Boolean @@ -325,7 +318,7 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe get() { val cm = mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val active = cm.activeNetwork ?: return false - return cm.isActiveNetworkMetered() + return cm.isActiveNetworkMetered } private val isMobileDataConnection: Boolean @@ -341,7 +334,7 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe } private fun isWifiConnectionWhitelisted(whitelistedSsids: MutableSet): Boolean { - val wifiManager = mContext.getApplicationContext() + val wifiManager = mContext.applicationContext .getSystemService(Context.WIFI_SERVICE) as WifiManager @Suppress("DEPRECATION") val wifiInfo = wifiManager.connectionInfo From 5004356c022acfc3b1eb5dcd0c5ea6eb4ab60a29 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 09:48:54 -0800 Subject: [PATCH 31/80] Update min sdk to 24 --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ff82da0c..906be238 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,7 +45,7 @@ android { defaultConfig { applicationId = "com.nutomic.syncthingandroid" - minSdk = 23 + minSdk = 24 targetSdk = 33 versionCode = 4396 versionName = "2.0.9" From f13704647b27e059133e45fc46edf6dedc6b93ce Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 09:57:08 -0800 Subject: [PATCH 32/80] Fix deprecations --- .../service/RunConditionMonitor.kt | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt index 79f10ff3..ed60c623 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt @@ -50,6 +50,16 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe */ private var lastRunConditionCheckResult: RunConditionCheckResult? = null + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: android.net.Network) { + updateShouldRunDecision() + } + + override fun onLost(network: android.net.Network) { + updateShouldRunDecision() + } + } + init { Log.v(TAG, "Created new instance") (context.applicationContext as SyncthingApp).component()!!.inject(this) @@ -59,12 +69,10 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe /** * Register broadcast receivers. */ - // NetworkReceiver (legacy broadcast used for older platforms) - ReceiverManager.registerReceiver( - mContext, - NetworkReceiver(), - IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) - ) + + // Register network callback for connectivity changes (API 24+) + val cm = mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + cm.registerDefaultNetworkCallback(networkCallback) // BatteryReceiver val filter = IntentFilter() @@ -88,12 +96,15 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe updateShouldRunDecision() } + fun shutdown() { Log.v(TAG, "Shutting down") if (mSyncStatusObserverHandle != null) { ContentResolver.removeStatusChangeListener(mSyncStatusObserverHandle) mSyncStatusObserverHandle = null } + val cm = mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + cm.unregisterNetworkCallback(networkCallback) ReceiverManager.unregisterAllReceivers(mContext) } @@ -108,13 +119,6 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe } } - private inner class NetworkReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent) { - if (ConnectivityManager.CONNECTIVITY_ACTION == intent.action) { - updateShouldRunDecision() - } - } - } private inner class PowerSaveModeChangedReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent) { From d125d846e8a913faf54b3bf7ddeebb296fda538e Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 10:12:39 -0800 Subject: [PATCH 33/80] Kotlin upgrade --- .../syncthingandroid/model/Completion.java | 143 ----------------- .../syncthingandroid/model/Completion.kt | 145 ++++++++++++++++++ ...{CompletionInfo.java => CompletionInfo.kt} | 45 +++--- .../syncthingandroid/model/Config.java | 23 --- .../nutomic/syncthingandroid/model/Config.kt | 23 +++ .../syncthingandroid/service/RestApi.kt | 34 ++-- 6 files changed, 207 insertions(+), 206 deletions(-) delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/Completion.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/Completion.kt rename app/src/main/java/com/nutomic/syncthingandroid/model/{CompletionInfo.java => CompletionInfo.kt} (80%) delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/Config.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/Config.kt diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Completion.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Completion.java deleted file mode 100644 index 67ed94fc..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Completion.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.nutomic.syncthingandroid.model; - -import android.util.Log; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -/** - * This class caches remote folder and device synchronization - * completion indicators defined in {@link CompletionInfo} - * according to syncthing's REST "/completion" JSON result schema. - * Completion model of syncthing's web UI is completion[deviceId][folderId] - */ -public class Completion { - - private static final String TAG = "Completion"; - - HashMap> deviceFolderMap = - new HashMap<>(); - - /** - * Removes a folder from the cache model. - */ - private void removeFolder(String folderId) { - for (HashMap folderMap : deviceFolderMap.values()) { - if (folderMap.containsKey(folderId)) { - folderMap.remove(folderId); - break; - } - } - } - - /** - * Updates device and folder information in the cache model - * after a config update. - */ - public void updateFromConfig(List newDevices, List newFolders) { - HashMap folderMap; - - // Handle devices that were removed from the config. - List removedDevices = new ArrayList<>(); - boolean deviceFound; - for (String deviceId : deviceFolderMap.keySet()) { - deviceFound = false; - for (Device device : newDevices) { - if (device.deviceID.equals(deviceId)) { - deviceFound = true; - break; - } - } - if (!deviceFound) { - removedDevices.add(deviceId); - } - } - for (String deviceId : removedDevices) { - Log.v(TAG, "updateFromConfig: Remove device '" + deviceId + "' from cache model"); - deviceFolderMap.remove(deviceId); - } - - // Handle devices that were added to the config. - for (Device device : newDevices) { - if (!deviceFolderMap.containsKey(device.deviceID)) { - Log.v(TAG, "updateFromConfig: Add device '" + device.deviceID + "' to cache model"); - deviceFolderMap.put(device.deviceID, new HashMap<>()); - } - } - - // Handle folders that were removed from the config. - List removedFolders = new ArrayList<>(); - boolean folderFound; - for (Map.Entry> device : deviceFolderMap.entrySet()) { - for (String folderId : device.getValue().keySet()) { - folderFound = false; - for (Folder folder : newFolders) { - if (folder.id.equals(folderId)) { - folderFound = true; - break; - } - } - if (!folderFound) { - removedFolders.add(folderId); - } - } - } - for (String folderId : removedFolders) { - Log.v(TAG, "updateFromConfig: Remove folder '" + folderId + "' from cache model"); - removeFolder(folderId); - } - - // Handle folders that were added to the config. - for (Folder folder : newFolders) { - for (Device device : newDevices) { - if (folder.getDevice(device.deviceID) != null) { - // folder is shared with device. - folderMap = deviceFolderMap.get(device.deviceID); - assert folderMap != null; - if (!folderMap.containsKey(folder.id)) { - Log.v(TAG, "updateFromConfig: Add folder '" + folder.id + - "' shared with device '" + device.deviceID + "' to cache model."); - folderMap.put(folder.id, new CompletionInfo()); - } - } - } - } - } - - /** - * Calculates remote device sync completion percentage across all folders - * shared with the device. - */ - public int getDeviceCompletion(String deviceId) { - int folderCount = 0; - double sumCompletion = 0; - HashMap folderMap = deviceFolderMap.get(deviceId); - if (folderMap != null) { - for (Map.Entry folder : folderMap.entrySet()) { - sumCompletion += folder.getValue().completion; - folderCount++; - } - } - if (folderCount == 0) { - return 100; - } else { - return (int) Math.floor(sumCompletion / folderCount); - } - } - - /** - * Set completionInfo within the completion[deviceId][folderId] model. - */ - public void setCompletionInfo(String deviceId, String folderId, - CompletionInfo completionInfo) { - // Add device parent node if it does not exist. - if (!deviceFolderMap.containsKey(deviceId)) { - deviceFolderMap.put(deviceId, new HashMap<>()); - } - // Add folder or update existing folder entry. - Objects.requireNonNull(deviceFolderMap.get(deviceId)).put(folderId, completionInfo); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Completion.kt b/app/src/main/java/com/nutomic/syncthingandroid/model/Completion.kt new file mode 100644 index 00000000..2fce7a45 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Completion.kt @@ -0,0 +1,145 @@ +package com.nutomic.syncthingandroid.model + +import android.util.Log +import java.util.Objects +import kotlin.math.floor + +/** + * This class caches remote folder and device synchronization + * completion indicators defined in [CompletionInfo] + * according to syncthing's REST "/completion" JSON result schema. + * Completion model of syncthing's web UI is completion[deviceId][folderId] + */ +class Completion { + var deviceFolderMap: HashMap> = + HashMap() + + /** + * Removes a folder from the cache model. + */ + private fun removeFolder(folderId: String?) { + for (folderMap in deviceFolderMap.values) { + if (folderMap.containsKey(folderId)) { + folderMap.remove(folderId) + break + } + } + } + + /** + * Updates device and folder information in the cache model + * after a config update. + */ + fun updateFromConfig(newDevices: MutableList, newFolders: MutableList) { + var folderMap: HashMap? + + // Handle devices that were removed from the config. + val removedDevices: MutableList = ArrayList() + var deviceFound: Boolean + for (deviceId in deviceFolderMap.keys) { + deviceFound = false + for (device in newDevices) { + if (device.deviceID == deviceId) { + deviceFound = true + break + } + } + if (!deviceFound) { + removedDevices.add(deviceId) + } + } + for (deviceId in removedDevices) { + Log.v(TAG, "updateFromConfig: Remove device '$deviceId' from cache model") + deviceFolderMap.remove(deviceId) + } + + // Handle devices that were added to the config. + for (device in newDevices) { + if (!deviceFolderMap.containsKey(device.deviceID)) { + Log.v(TAG, "updateFromConfig: Add device '" + device.deviceID + "' to cache model") + deviceFolderMap[device.deviceID] = HashMap() + } + } + + // Handle folders that were removed from the config. + val removedFolders: MutableList = ArrayList() + var folderFound: Boolean + for (device in deviceFolderMap.entries) { + for (folderId in device.value.keys) { + folderFound = false + for (folder in newFolders) { + if (folder?.id == folderId) { + folderFound = true + break + } + } + if (!folderFound) { + removedFolders.add(folderId) + } + } + } + for (folderId in removedFolders) { + Log.v(TAG, "updateFromConfig: Remove folder '$folderId' from cache model") + removeFolder(folderId) + } + + // Handle folders that were added to the config. + for (folder in newFolders) { + for (device in newDevices) { + if (folder?.getDevice(device.deviceID) != null) { + // folder is shared with device. + folderMap = deviceFolderMap.get(device.deviceID) + checkNotNull(folderMap) + if (!folderMap.containsKey(folder.id)) { + Log.v( + TAG, "updateFromConfig: Add folder '" + folder.id + + "' shared with device '" + device.deviceID + "' to cache model." + ) + folderMap[folder.id] = CompletionInfo() + } + } + } + } + } + + /** + * Calculates remote device sync completion percentage across all folders + * shared with the device. + */ + fun getDeviceCompletion(deviceId: String?): Int { + var folderCount = 0 + var sumCompletion = 0.0 + val folderMap = deviceFolderMap.get(deviceId) + if (folderMap != null) { + for (folder in folderMap.entries) { + sumCompletion += folder.value!!.completion + folderCount++ + } + } + return if (folderCount == 0) { + 100 + } else { + floor(sumCompletion / folderCount).toInt() + } + } + + /** + * Set completionInfo within the completion[deviceId][folderId] model. + */ + fun setCompletionInfo( + deviceId: String?, folderId: String?, + completionInfo: CompletionInfo? + ) { + // Add device parent node if it does not exist. + if (!deviceFolderMap.containsKey(deviceId)) { + deviceFolderMap[deviceId] = HashMap() + } + // Add folder or update existing folder entry. + Objects.requireNonNull>(deviceFolderMap.get(deviceId))[folderId] = + completionInfo + } + + companion object { + private const val TAG = "Completion" + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/CompletionInfo.java b/app/src/main/java/com/nutomic/syncthingandroid/model/CompletionInfo.kt similarity index 80% rename from app/src/main/java/com/nutomic/syncthingandroid/model/CompletionInfo.java rename to app/src/main/java/com/nutomic/syncthingandroid/model/CompletionInfo.kt index 796bc12c..f638cf1b 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/CompletionInfo.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/CompletionInfo.kt @@ -1,23 +1,22 @@ -package com.nutomic.syncthingandroid.model; - -/** - * According to syncthing REST API - * https://docs.syncthing.net/rest/db-completion-get.html - * - * completion is also returned by the events API - * https://docs.syncthing.net/events/foldercompletion.html - * - */ -public class CompletionInfo { - public double completion = 100; - - /** - * The following values are only returned by the REST API call - * to ""/completion". We will need them in the future to show - * more statistics in the device UI. - */ - // public long globalBytes = 0; - // public long needBytes = 0; - // public long needDeletes = 0; - // public long needItems = 0; -} +package com.nutomic.syncthingandroid.model + +/** + * According to syncthing REST API + * https://docs.syncthing.net/rest/db-completion-get.html + * + * completion is also returned by the events API + * https://docs.syncthing.net/events/foldercompletion.html + * + */ +class CompletionInfo { + var completion: Double = 100.0 + /** + * The following values are only returned by the REST API call + * to ""/completion". We will need them in the future to show + * more statistics in the device UI. + */ + // public long globalBytes = 0; + // public long needBytes = 0; + // public long needDeletes = 0; + // public long needItems = 0; +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Config.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Config.java deleted file mode 100644 index 183f3f8d..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Config.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.nutomic.syncthingandroid.model; - -import java.util.List; - -public class Config { - public int version; - public List devices; - public List folders; - public Gui gui; - public Options options; - public List remoteIgnoredDevices; - - public class Gui { - public boolean enabled; - public String address; - public String user; - public String password; - public boolean useTLS; - public String apiKey; - public boolean insecureAdminAccess; - public String theme; - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Config.kt b/app/src/main/java/com/nutomic/syncthingandroid/model/Config.kt new file mode 100644 index 00000000..93266743 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Config.kt @@ -0,0 +1,23 @@ +package com.nutomic.syncthingandroid.model + +class Config { + var version: Int = 0 + var devices: MutableList? = null + var folders: MutableList? = null + var gui: Gui? = null + var options: Options? = null + var remoteIgnoredDevices: MutableList? = null + + class Gui { + var enabled: Boolean = false + @JvmField + var address: String? = null + var user: String? = null +// var password: String? = null +// var useTLS: Boolean = false + @JvmField + var apiKey: String? = null +// var insecureAdminAccess: Boolean = false + var theme: String? = null + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt index d5d40610..6c5fdc77 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt @@ -278,8 +278,8 @@ class RestApi( fun ignoreDevice(deviceId: String, deviceName: String?, deviceAddress: String?) { synchronized(mConfigLock) { // Check if the device has already been ignored. - for (remoteIgnoredDevice in mConfig!!.remoteIgnoredDevices) { - if (deviceId == remoteIgnoredDevice.deviceID) { + for (remoteIgnoredDevice in mConfig!!.remoteIgnoredDevices!!) { + if (deviceId == remoteIgnoredDevice?.deviceID) { // Device already ignored. Log.d(TAG, "Device already ignored [$deviceId]") return @@ -291,7 +291,7 @@ class RestApi( remoteIgnoredDevice.address = deviceAddress remoteIgnoredDevice.name = deviceName remoteIgnoredDevice.time = dateFormat.format(Date()) - mConfig!!.remoteIgnoredDevices.add(remoteIgnoredDevice) + mConfig!!.remoteIgnoredDevices!!.add(remoteIgnoredDevice) sendConfig() Log.d(TAG, "Ignored device [$deviceId]") } @@ -304,8 +304,8 @@ class RestApi( */ fun ignoreFolder(deviceId: String, folderId: String, folderLabel: String?) { synchronized(mConfigLock) { - for (device in mConfig!!.devices) { - if (deviceId == device.deviceID) { + for (device in mConfig!!.devices!!) { + if (deviceId == device?.deviceID) { /* * Check if the folder has already been ignored. */ @@ -351,9 +351,9 @@ class RestApi( fun undoIgnoredDevicesAndFolders() { Log.d(TAG, "Undo ignoring devices and folders ...") synchronized(mConfigLock) { - mConfig!!.remoteIgnoredDevices.clear() - for (device in mConfig!!.devices) { - device.ignoredFolders.clear() + mConfig!!.remoteIgnoredDevices?.clear() + for (device in mConfig!!.devices!!) { + device?.ignoredFolders?.clear() } } } @@ -440,7 +440,7 @@ class RestApi( fun createFolder(folder: Folder?) { synchronized(mConfigLock) { // Add the new folder to the model. - mConfig!!.folders.add(folder) + mConfig!!.folders?.add(folder) // Send model changes to syncthing, does not require a restart. sendConfig() } @@ -449,7 +449,7 @@ class RestApi( fun updateFolder(newFolder: Folder) { synchronized(mConfigLock) { removeFolderInternal(newFolder.id) - mConfig!!.folders.add(newFolder) + mConfig!!.folders?.add(newFolder) sendConfig() } } @@ -467,10 +467,10 @@ class RestApi( private fun removeFolderInternal(id: String?) { synchronized(mConfigLock) { - val it = mConfig!!.folders.iterator() + val it = mConfig!!.folders?.iterator()!! while (it.hasNext()) { val f = it.next() - if (f.id == id) { + if (f?.id == id) { it.remove() break } @@ -489,7 +489,7 @@ class RestApi( devices = deepCopy( mConfig!!.devices, object : - com.google.common.reflect.TypeToken>() {}.type + com.google.common.reflect.TypeToken>() {}.type )!! } @@ -530,7 +530,7 @@ class RestApi( fun addDevice(device: Device, errorListener: OnResultListener1) { normalizeDeviceId(device.deviceID, { _: String? -> synchronized(mConfigLock) { - mConfig!!.devices.add(device) + mConfig!!.devices?.add(device) sendConfig() } }, errorListener) @@ -539,7 +539,7 @@ class RestApi( fun editDevice(newDevice: Device) { synchronized(mConfigLock) { removeDeviceInternal(newDevice.deviceID) - mConfig!!.devices.add(newDevice) + mConfig!!.devices?.add(newDevice) sendConfig() } } @@ -554,10 +554,10 @@ class RestApi( private fun removeDeviceInternal(deviceId: String?) { synchronized(mConfigLock) { - val it = mConfig!!.devices.iterator() + val it = mConfig!!.devices?.iterator()!! while (it.hasNext()) { val d = it.next() - if (d.deviceID == deviceId) { + if (d?.deviceID == deviceId) { it.remove() break } From 0845fc82029c33ff82f3d834f056c562ebe429e0 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 10:12:47 -0800 Subject: [PATCH 34/80] Ignore kotlin files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 65b22189..e71f7c03 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ # Java class files *.class +# Kotlin session files +.kotlin/ + # generated files bin/ build/ From 9510f5c736c14ec5ca153eb7419649455a048649 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 10:21:11 -0800 Subject: [PATCH 35/80] Kotlin upgrade and warning fixes --- .../syncthingandroid/model/Device.java | 25 ---------------- .../nutomic/syncthingandroid/model/Device.kt | 29 +++++++++++++++++++ .../nutomic/syncthingandroid/model/Event.java | 13 --------- .../nutomic/syncthingandroid/model/Event.kt | 8 +++++ .../service/EventProcessor.kt | 11 ++++--- .../syncthingandroid/service/RestApi.kt | 24 +++++++-------- 6 files changed, 54 insertions(+), 56 deletions(-) delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/Device.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/Device.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/Event.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/Event.kt diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Device.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Device.java deleted file mode 100644 index 71dbe507..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Device.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.nutomic.syncthingandroid.model; - -import android.text.TextUtils; - -import java.util.List; - -public class Device { - public String deviceID; - public String name = ""; - public List addresses; - public String compression; - public String certName; - public boolean introducer; - public boolean paused; - public List ignoredFolders; - - /** - * Returns the device name, or the first characters of the ID if the name is empty. - */ - public String getDisplayName() { - return (TextUtils.isEmpty(name)) - ? deviceID.substring(0, 7) - : name; - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Device.kt b/app/src/main/java/com/nutomic/syncthingandroid/model/Device.kt new file mode 100644 index 00000000..e07f5ee7 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Device.kt @@ -0,0 +1,29 @@ +package com.nutomic.syncthingandroid.model + +import android.text.TextUtils + +class Device { + @JvmField + var deviceID: String? = null + @JvmField + var name: String = "" + @JvmField + var addresses: MutableList? = null + @JvmField + var compression: String? = null + + @JvmField + var introducer: Boolean = false + @JvmField + var paused: Boolean = false + var ignoredFolders: MutableList? = null + + val displayName: String? + /** + * Returns the device name, or the first characters of the ID if the name is empty. + */ + get() = if (TextUtils.isEmpty(name)) + deviceID!!.substring(0, 7) + else + name +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Event.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Event.java deleted file mode 100644 index 592fd002..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Event.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.nutomic.syncthingandroid.model; - -import java.util.Map; - -public class Event { - - public int id; - public int globalID; - public String type; - public String time; - public Object data; - -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Event.kt b/app/src/main/java/com/nutomic/syncthingandroid/model/Event.kt new file mode 100644 index 00000000..57578519 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Event.kt @@ -0,0 +1,8 @@ +package com.nutomic.syncthingandroid.model + +class Event { + var id: Int = 0 + var type: String? = null + var time: String? = null + var data: Any? = null +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.kt index bcc37385..49fddf49 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/EventProcessor.kt @@ -92,7 +92,8 @@ class EventProcessor(context: Context, api: RestApi?) : Runnable, OnReceiveEvent override fun onEvent(event: Event?) { var mapData: MutableMap? = null try { - mapData = event?.data as MutableMap? + @Suppress("UNCHECKED_CAST") + mapData = event?.data as? MutableMap? } catch (_: ClassCastException) { } when (event?.type) { @@ -102,8 +103,9 @@ class EventProcessor(context: Context, api: RestApi?) : Runnable, OnReceiveEvent } "PendingDevicesChanged" -> { + @Suppress("UNCHECKED_CAST") mapNullable?>( - mapData!!["added"] as MutableList?>? + mapData!!["added"] as? MutableList?>? ) { added: MutableMap? -> this.onPendingDevicesChanged(added!!) } @@ -113,15 +115,16 @@ class EventProcessor(context: Context, api: RestApi?) : Runnable, OnReceiveEvent val completionInfo = CompletionInfo() completionInfo.completion = (mapData!!["completion"] as Double?)!! mApi!!.setCompletionInfo( - mapData!!["device"] as String?, // deviceId + mapData["device"] as String?, // deviceId mapData["folder"] as String?, // folderId completionInfo ) } "PendingFoldersChanged" -> { + @Suppress("UNCHECKED_CAST") mapNullable?>( - mapData!!["added"] as MutableList?>? + mapData!!["added"] as? MutableList?>? ) { added: MutableMap? -> this.onPendingFoldersChanged(added!!) } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt index 6c5fdc77..7e5f2400 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt @@ -2,9 +2,9 @@ package com.nutomic.syncthingandroid.service import android.content.Context import android.content.Intent -import android.os.Build -import androidx.preference.PreferenceManager import android.util.Log +import androidx.core.content.edit +import androidx.preference.PreferenceManager import com.google.common.base.Function import com.google.common.base.Objects import com.google.common.base.Optional @@ -40,7 +40,6 @@ import java.util.Collections import java.util.Date import java.util.Locale import javax.inject.Inject -import androidx.core.content.edit /** * Provides functions to interact with the syncthing REST API. @@ -305,12 +304,12 @@ class RestApi( fun ignoreFolder(deviceId: String, folderId: String, folderLabel: String?) { synchronized(mConfigLock) { for (device in mConfig!!.devices!!) { - if (deviceId == device?.deviceID) { + if (deviceId == device.deviceID) { /* * Check if the folder has already been ignored. */ - for (ignoredFolder in device.ignoredFolders) { - if (folderId == ignoredFolder.id) { + for (ignoredFolder in device.ignoredFolders!!) { + if (folderId == ignoredFolder?.id) { // Folder already ignored. Log.d( TAG, @@ -328,7 +327,7 @@ class RestApi( ignoredFolder.id = folderId ignoredFolder.label = folderLabel ignoredFolder.time = dateFormat.format(Date()) - device.ignoredFolders.add(ignoredFolder) + device.ignoredFolders!!.add(ignoredFolder) // if (BuildConfig.DEBUG) { // Log.v(TAG, "device.ignoredFolders = " + new Gson().toJson(device.ignoredFolders)); // } @@ -353,7 +352,7 @@ class RestApi( synchronized(mConfigLock) { mConfig!!.remoteIgnoredDevices?.clear() for (device in mConfig!!.devices!!) { - device?.ignoredFolders?.clear() + device.ignoredFolders?.clear() } } } @@ -528,7 +527,7 @@ class RestApi( } fun addDevice(device: Device, errorListener: OnResultListener1) { - normalizeDeviceId(device.deviceID, { _: String? -> + normalizeDeviceId(device.deviceID!!, { _: String? -> synchronized(mConfigLock) { mConfig!!.devices?.add(device) sendConfig() @@ -557,7 +556,7 @@ class RestApi( val it = mConfig!!.devices?.iterator()!! while (it.hasNext()) { val d = it.next() - if (d?.deviceID == deviceId) { + if (d.deviceID == deviceId) { it.remove() break } @@ -828,11 +827,8 @@ class RestApi( companion object { private const val TAG = "RestApi" - private val dateFormat: SimpleDateFormat = if (Build.VERSION.SDK_INT < 24) { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) - } else { + private val dateFormat: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US) - } /** * Compares folders by labels, uses the folder ID as fallback if the label is empty From 1b822c77b7c56dfc187f7f6049b6f83d25066d8c Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 10:21:41 -0800 Subject: [PATCH 36/80] Update ignore file --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index e71f7c03..e8c656ed 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ eclipse-java-style.xml .classpath .project .settings +settings.json +.vscode # Gradle wrapper gradle/wrapper/gradle/ From dda56f3303e3aba9503f19cdce11868416d773f8 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 10:21:51 -0800 Subject: [PATCH 37/80] Upgrade preferences version --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 906be238..5474e01d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,7 +17,7 @@ dependencies { implementation("com.android.volley:volley:1.2.1") implementation("commons-io:commons-io:2.21.0") implementation("androidx.documentfile:documentfile:1.1.0") - implementation("androidx.preference:preference:1.2.0") + implementation("androidx.preference:preference:1.2.1") implementation("com.journeyapps:zxing-android-embedded:4.3.0") { isTransitive = false From 585a9720b67ee1c751b5657160819af925832b16 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 10:33:33 -0800 Subject: [PATCH 38/80] Kotlin for model files --- .../activities/SettingsActivity.java | 10 +- .../activities/StateDialogActivity.java | 2 +- .../syncthingandroid/model/Folder.java | 89 ------------------ .../nutomic/syncthingandroid/model/Folder.kt | 94 +++++++++++++++++++ .../model/FolderIgnoreList.java | 10 -- .../model/FolderIgnoreList.kt | 10 ++ .../syncthingandroid/model/FolderStatus.java | 30 ------ .../syncthingandroid/model/FolderStatus.kt | 42 +++++++++ .../syncthingandroid/model/IgnoredFolder.java | 18 ---- .../syncthingandroid/model/IgnoredFolder.kt | 18 ++++ .../syncthingandroid/model/Options.java | 47 ---------- .../nutomic/syncthingandroid/model/Options.kt | 60 ++++++++++++ .../model/RemoteIgnoredDevice.java | 19 ---- .../model/RemoteIgnoredDevice.kt | 19 ++++ .../model/RunConditionCheckResult.java | 78 --------------- .../model/RunConditionCheckResult.kt | 50 ++++++++++ .../syncthingandroid/model/SystemInfo.java | 15 --- .../syncthingandroid/model/SystemInfo.kt | 18 ++++ .../syncthingandroid/model/SystemVersion.java | 9 -- .../syncthingandroid/model/SystemVersion.kt | 11 +++ .../syncthingandroid/service/RestApi.kt | 12 +-- .../service/RunConditionMonitor.kt | 4 +- 22 files changed, 339 insertions(+), 326 deletions(-) delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/Folder.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/Folder.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/FolderIgnoreList.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/FolderIgnoreList.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/FolderStatus.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/FolderStatus.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/IgnoredFolder.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/IgnoredFolder.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/Options.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/Options.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/RemoteIgnoredDevice.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/RemoteIgnoredDevice.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/RunConditionCheckResult.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/RunConditionCheckResult.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/SystemInfo.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/SystemInfo.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/SystemVersion.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/model/SystemVersion.kt diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/SettingsActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/SettingsActivity.java index f079bf38..02e409ba 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/SettingsActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/SettingsActivity.java @@ -40,6 +40,7 @@ import java.lang.ref.WeakReference; import java.security.InvalidParameterException; import java.util.HashSet; +import java.util.Objects; import java.util.Set; import javax.inject.Inject; @@ -338,7 +339,8 @@ public void onServiceStateChange(SyncthingService.State currentState) { mGui = mApi.getGui(); Joiner joiner = Joiner.on(", "); - mDeviceName.setText(mApi.getLocalDevice().name); + mDeviceName.setText(Objects.requireNonNull(mApi.getLocalDevice()).name); + assert mOptions.listenAddresses != null; mListenAddresses.setText(joiner.join(mOptions.listenAddresses)); mMaxRecvKbps.setText(Integer.toString(mOptions.maxRecvKbps)); mMaxSendKbps.setText(Integer.toString(mOptions.maxSendKbps)); @@ -346,10 +348,14 @@ public void onServiceStateChange(SyncthingService.State currentState) { mLocalAnnounceEnabled.setChecked(mOptions.localAnnounceEnabled); mGlobalAnnounceEnabled.setChecked(mOptions.globalAnnounceEnabled); mRelaysEnabled.setChecked(mOptions.relaysEnabled); + assert mOptions.globalAnnounceServers != null; mGlobalAnnounceServers.setText(joiner.join(mOptions.globalAnnounceServers)); mAddress.setText(mGui.address); mApi.getSystemInfo(systemInfo -> - mUrAccepted.setChecked(mOptions.isUsageReportingAccepted(systemInfo.urVersionMax))); + { + assert systemInfo != null; + mUrAccepted.setChecked(mOptions.isUsageReportingAccepted(systemInfo.urVersionMax)); + }); } @Override diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/StateDialogActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/StateDialogActivity.java index 3578df90..76add80b 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/StateDialogActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/StateDialogActivity.java @@ -139,7 +139,7 @@ private StringBuilder getDisabledDialogMessage() { count++; message.append("\n"); if (reasons.size() > 1) message.append(count + ". "); - message.append(this.getString(reason.getResId())); + message.append(this.getString(reason.resId)); } } return message; diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Folder.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Folder.java deleted file mode 100644 index 00fdcd02..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Folder.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.nutomic.syncthingandroid.model; - -import android.text.TextUtils; - -import com.nutomic.syncthingandroid.service.Constants; - -import java.io.Serializable; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; - -public class Folder { - - public String id; - public String label; - public String filesystemType = "basic"; - public String path; - public String type = Constants.FOLDER_TYPE_SEND_RECEIVE; - public boolean fsWatcherEnabled = true; - public int fsWatcherDelayS = 10; - private List devices = new ArrayList<>(); - public int rescanIntervalS; - public final boolean ignorePerms = true; - public boolean autoNormalize = true; - public MinDiskFree minDiskFree; - public Versioning versioning; - public int copiers; - public int pullerMaxPendingKiB; - public int hashers; - public String order; - public boolean ignoreDelete; - public int scanProgressIntervalS; - public int pullerPauseS; - public int maxConflicts = 10; - public boolean disableSparseFiles; - public boolean disableTempIndexes; - public boolean paused; - public boolean useLargeBlocks; - public int weakHashThresholdPct = 25; - public String markerName = ".stfolder"; - public String invalid; - - public static class Versioning implements Serializable { - public String type; - public Map params = new HashMap<>(); - } - - public static class MinDiskFree { - public float value; - public String unit; - } - - public void addDevice(String deviceId) { - Device d = new Device(); - d.deviceID = deviceId; - devices.add(d); - } - - public Device getDevice(String deviceId) { - for (Device d : devices) { - if (d.deviceID.equals(deviceId)) { - return d; - } - } - return null; - } - - public void removeDevice(String deviceId) { - for (Iterator it = devices.iterator(); it.hasNext();) { - String currentId = it.next().deviceID; - if (currentId.equals(deviceId)) { - it.remove(); - } - } - } - - @Override - public String toString() { - return !TextUtils.isEmpty(label) ? label : id; - } - - public class Device { - public String deviceID; - public String introducedBy; - public String encryptionPassword; - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Folder.kt b/app/src/main/java/com/nutomic/syncthingandroid/model/Folder.kt new file mode 100644 index 00000000..d99f501e --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Folder.kt @@ -0,0 +1,94 @@ +package com.nutomic.syncthingandroid.model + +import com.nutomic.syncthingandroid.service.Constants +import java.io.Serializable + +@Suppress("unused") +class Folder { + @JvmField + var id: String? = null + @JvmField + var label: String? = null + var filesystemType: String = "basic" + @JvmField + var path: String? = null + @JvmField + var type: String = Constants.FOLDER_TYPE_SEND_RECEIVE + @JvmField + var fsWatcherEnabled: Boolean = true + @JvmField + var fsWatcherDelayS: Int = 10 + private val devices: MutableList = ArrayList() + @JvmField + var rescanIntervalS: Int = 0 + val ignorePerms: Boolean = true + var autoNormalize: Boolean = true + var minDiskFree: MinDiskFree? = null + @JvmField + var versioning: Versioning? = null + var copiers: Int = 0 + var pullerMaxPendingKiB: Int = 0 + var hashers: Int = 0 + @JvmField + var order: String? = null + var ignoreDelete: Boolean = false + var scanProgressIntervalS: Int = 0 + var pullerPauseS: Int = 0 + var maxConflicts: Int = 10 + var disableSparseFiles: Boolean = false + var disableTempIndexes: Boolean = false + @JvmField + var paused: Boolean = false + var useLargeBlocks: Boolean = false + var weakHashThresholdPct: Int = 25 + var markerName: String = ".stfolder" + @JvmField + var invalid: String? = null + + class Versioning : Serializable { + @JvmField + var type: String? = null + @JvmField + var params: MutableMap = HashMap() + } + + class MinDiskFree { + var value: Float = 0f + var unit: String? = null + } + + fun addDevice(deviceId: String) { + val d = Device() + d.deviceID = deviceId + devices.add(d) + } + + fun getDevice(deviceId: String?): Device? { + for (d in devices) { + if (d.deviceID == deviceId) { + return d + } + } + return null + } + + fun removeDevice(deviceId: String?) { + val it = devices.iterator() + while (it.hasNext()) { + val currentId = it.next().deviceID!! + if (currentId == deviceId) { + it.remove() + } + } + } + + override fun toString(): String { + return (if (!android.text.TextUtils.isEmpty(label)) label else id)!! + } + + class Device { + var deviceID: String? = null + var introducedBy: String? = null + var encryptionPassword: String? = null + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/FolderIgnoreList.java b/app/src/main/java/com/nutomic/syncthingandroid/model/FolderIgnoreList.java deleted file mode 100644 index 26811823..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/FolderIgnoreList.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.nutomic.syncthingandroid.model; - -/** - * To avoid name confusion: - * This is the exclude and include items list associated with every folder. - */ -public class FolderIgnoreList { - public String[] expanded; - public String[] ignore; -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/FolderIgnoreList.kt b/app/src/main/java/com/nutomic/syncthingandroid/model/FolderIgnoreList.kt new file mode 100644 index 00000000..f23baa53 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/FolderIgnoreList.kt @@ -0,0 +1,10 @@ +//package com.nutomic.syncthingandroid.model +// +///** +// * To avoid name confusion: +// * This is the exclude and include items list associated with every folder. +// */ +//class FolderIgnoreList { +// var expanded: Array? +// var ignore: Array? +//} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/FolderStatus.java b/app/src/main/java/com/nutomic/syncthingandroid/model/FolderStatus.java deleted file mode 100644 index 2f5f4d62..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/FolderStatus.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.nutomic.syncthingandroid.model; - -public class FolderStatus { - public long globalBytes; - public long globalDeleted; - public long globalDirectories; - public long globalFiles; - public long globalSymlinks; - public boolean ignorePatterns; - public String invalid; - public long localBytes; - public long localDeleted; - public long localDirectories; - public long localSymlinks; - public long localFiles; - public long inSyncBytes; - public long inSyncFiles; - public long needBytes; - public long needDeletes; - public long needDirectories; - public long needFiles; - public long needSymlinks; - public long pullErrors; - public long sequence; - public String state; - public String stateChanged; - public long version; - public String error; - public String watchError; -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/FolderStatus.kt b/app/src/main/java/com/nutomic/syncthingandroid/model/FolderStatus.kt new file mode 100644 index 00000000..d0f3da3c --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/FolderStatus.kt @@ -0,0 +1,42 @@ +package com.nutomic.syncthingandroid.model + +@Suppress("unused") +class FolderStatus { + @JvmField + var globalBytes: Long = 0 + var globalDeleted: Long = 0 + var globalDirectories: Long = 0 + @JvmField + var globalFiles: Long = 0 + var globalSymlinks: Long = 0 + var ignorePatterns: Boolean = false + @JvmField + var invalid: String? = null + var localBytes: Long = 0 + var localDeleted: Long = 0 + var localDirectories: Long = 0 + var localSymlinks: Long = 0 + var localFiles: Long = 0 + @JvmField + var inSyncBytes: Long = 0 + @JvmField + var inSyncFiles: Long = 0 + var needBytes: Long = 0 + @JvmField + var needDeletes: Long = 0 + @JvmField + var needDirectories: Long = 0 + @JvmField + var needFiles: Long = 0 + @JvmField + var needSymlinks: Long = 0 + var pullErrors: Long = 0 + var sequence: Long = 0 + @JvmField + var state: String? = null + var stateChanged: String? = null + var version: Long = 0 + @JvmField + var error: String? = null + var watchError: String? = null +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/IgnoredFolder.java b/app/src/main/java/com/nutomic/syncthingandroid/model/IgnoredFolder.java deleted file mode 100644 index 01264c2f..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/IgnoredFolder.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.nutomic.syncthingandroid.model; - -import android.text.TextUtils; - -public class IgnoredFolder { - public String time = ""; - public String id = ""; - public String label = ""; - - /** - * Returns the folder label, or the first characters of the ID if the label is empty. - */ - public String getDisplayLabel() { - return (TextUtils.isEmpty(label)) - ? id.substring(0, 7) - : label; - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/IgnoredFolder.kt b/app/src/main/java/com/nutomic/syncthingandroid/model/IgnoredFolder.kt new file mode 100644 index 00000000..c0fbc83f --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/IgnoredFolder.kt @@ -0,0 +1,18 @@ +package com.nutomic.syncthingandroid.model + +import android.text.TextUtils + +class IgnoredFolder { + var time: String = "" + var id: String = "" + var label: String = "" + +// val displayLabel: String? +// /** +// * Returns the folder label, or the first characters of the ID if the label is empty. +// */ +// get() = if (TextUtils.isEmpty(label)) +// id.take(7) +// else +// label +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Options.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Options.java deleted file mode 100644 index c876cc85..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Options.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.nutomic.syncthingandroid.model; - -public class Options { - public String[] listenAddresses; - public String[] globalAnnounceServers; - public boolean globalAnnounceEnabled; - public boolean localAnnounceEnabled; - public int localAnnouncePort; - public String localAnnounceMCAddr; - public int maxSendKbps; - public int maxRecvKbps; - public int reconnectionIntervalS; - public boolean relaysEnabled; - public int relayReconnectIntervalM; - public boolean startBrowser; - public boolean natEnabled; - public int natLeaseMinutes; - public int natRenewalMinutes; - public int natTimeoutSeconds; - public int urAccepted; - public String urUniqueId; - public String urURL; - public boolean urPostInsecurely; - public int urInitialDelayS; - public int autoUpgradeIntervalH; - public int keepTemporariesH; - public boolean cacheIgnoredFiles; - public int progressUpdateIntervalS; - public boolean symlinksEnabled; - public boolean limitBandwidthInLan; - public int minHomeDiskFreePct; - public String releasesURL; - public String[] alwaysLocalNets; - public boolean overwriteRemoteDeviceNamesOnConnect; - public int tempIndexMinBlocks; - - public static final int USAGE_REPORTING_UNDECIDED = 0; - public static final int USAGE_REPORTING_DENIED = -1; - - public boolean isUsageReportingAccepted(int urVersionMax) { - return urAccepted == urVersionMax; - } - - public boolean isUsageReportingDecided(int urVersionMax) { - return isUsageReportingAccepted(urVersionMax) || urAccepted == USAGE_REPORTING_DENIED; - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Options.kt b/app/src/main/java/com/nutomic/syncthingandroid/model/Options.kt new file mode 100644 index 00000000..7ea7d8e3 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Options.kt @@ -0,0 +1,60 @@ +package com.nutomic.syncthingandroid.model + +@Suppress("unused") +class Options { + @JvmField + var listenAddresses: Array? = arrayOf() + @JvmField + var globalAnnounceServers: Array? = arrayOf() + @JvmField + var globalAnnounceEnabled: Boolean = false + @JvmField + var localAnnounceEnabled: Boolean = false + var localAnnouncePort: Int = 0 + var localAnnounceMCAddr: String? = null + @JvmField + var maxSendKbps: Int = 0 + @JvmField + var maxRecvKbps: Int = 0 + var reconnectionIntervalS: Int = 0 + @JvmField + var relaysEnabled: Boolean = false + var relayReconnectIntervalM: Int = 0 + var startBrowser: Boolean = false + @JvmField + var natEnabled: Boolean = false + var natLeaseMinutes: Int = 0 + var natRenewalMinutes: Int = 0 + var natTimeoutSeconds: Int = 0 + @JvmField + var urAccepted: Int = 0 + var urUniqueId: String? = null + var urURL: String? = null + var urPostInsecurely: Boolean = false + var urInitialDelayS: Int = 0 + var autoUpgradeIntervalH: Int = 0 + var keepTemporariesH: Int = 0 + var cacheIgnoredFiles: Boolean = false + var progressUpdateIntervalS: Int = 0 + var symlinksEnabled: Boolean = false + var limitBandwidthInLan: Boolean = false + var minHomeDiskFreePct: Int = 0 + var releasesURL: String? = null +// var alwaysLocalNets: Array? + var overwriteRemoteDeviceNamesOnConnect: Boolean = false + var tempIndexMinBlocks: Int = 0 + + fun isUsageReportingAccepted(urVersionMax: Int): Boolean { + return urAccepted == urVersionMax + } + + fun isUsageReportingDecided(urVersionMax: Int): Boolean { + return isUsageReportingAccepted(urVersionMax) || urAccepted == USAGE_REPORTING_DENIED + } + + companion object { + const val USAGE_REPORTING_UNDECIDED: Int = 0 + @JvmField + val USAGE_REPORTING_DENIED: Int = -1 + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/RemoteIgnoredDevice.java b/app/src/main/java/com/nutomic/syncthingandroid/model/RemoteIgnoredDevice.java deleted file mode 100644 index 14e16777..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/RemoteIgnoredDevice.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.nutomic.syncthingandroid.model; - -import android.text.TextUtils; - -public class RemoteIgnoredDevice { - public String time = ""; - public String deviceID = ""; - public String name = ""; - public String address = ""; - - /** - * Returns the device name, or the first characters of the ID if the name is empty. - */ - public String getDisplayName() { - return (TextUtils.isEmpty(name)) - ? deviceID.substring(0, 7) - : name; - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/RemoteIgnoredDevice.kt b/app/src/main/java/com/nutomic/syncthingandroid/model/RemoteIgnoredDevice.kt new file mode 100644 index 00000000..8d6a06b6 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/RemoteIgnoredDevice.kt @@ -0,0 +1,19 @@ +package com.nutomic.syncthingandroid.model + +import android.text.TextUtils + +class RemoteIgnoredDevice { + var time: String = "" + var deviceID: String = "" + var name: String = "" + var address: String = "" + +// val displayName: String? +// /** +// * Returns the device name, or the first characters of the ID if the name is empty. +// */ +// get() = if (TextUtils.isEmpty(name)) +// deviceID.substring(0, 7) +// else +// name +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/RunConditionCheckResult.java b/app/src/main/java/com/nutomic/syncthingandroid/model/RunConditionCheckResult.java deleted file mode 100644 index 7250ed4d..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/RunConditionCheckResult.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.nutomic.syncthingandroid.model; - -import com.nutomic.syncthingandroid.R; - -import java.util.Collections; -import java.util.List; - -public class RunConditionCheckResult { - - public enum BlockerReason { - ON_BATTERY(R.string.syncthing_disabled_reason_on_battery), - ON_CHARGER(R.string.syncthing_disabled_reason_on_charger), - POWERSAVING_ENABLED(R.string.syncthing_disabled_reason_powersaving), - GLOBAL_SYNC_DISABLED(R.string.syncthing_disabled_reason_android_sync_disabled), - WIFI_SSID_NOT_WHITELISTED(R.string.syncthing_disabled_reason_wifi_ssid_not_whitelisted), - WIFI_WIFI_IS_METERED(R.string.syncthing_disabled_reason_wifi_is_metered), - NO_NETWORK_OR_FLIGHTMODE(R.string.syncthing_disabled_reason_no_network_or_flightmode), - NO_MOBILE_CONNECTION(R.string.syncthing_disabled_reason_no_mobile_connection), - NO_WIFI_CONNECTION(R.string.syncthing_disabled_reason_no_wifi_connection), - NO_ALLOWED_NETWORK(R.string.syncthing_disabled_reason_no_allowed_method); - - private final int resId; - - BlockerReason(int resId) { - this.resId = resId; - } - - public int getResId() { - return resId; - } - } - - public static final RunConditionCheckResult SHOULD_RUN = new RunConditionCheckResult(); - - private final boolean mShouldRun; - private final List mBlockReasons; - - /** - * Use SHOULD_RUN instead. - * Note: of course anybody could still construct it by providing an empty list to the other - * constructor. - */ - private RunConditionCheckResult() { - this(Collections.emptyList()); - } - - public RunConditionCheckResult(List blockReasons) { - mBlockReasons = Collections.unmodifiableList(blockReasons); - mShouldRun = blockReasons.isEmpty(); - } - - - public List getBlockReasons() { - return mBlockReasons; - } - - public boolean isShouldRun() { - return mShouldRun; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - RunConditionCheckResult that = (RunConditionCheckResult) o; - - if (mShouldRun != that.mShouldRun) return false; - return mBlockReasons.equals(that.mBlockReasons); - } - - @Override - public int hashCode() { - int result = (mShouldRun ? 1 : 0); - result = 31 * result + mBlockReasons.hashCode(); - return result; - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/RunConditionCheckResult.kt b/app/src/main/java/com/nutomic/syncthingandroid/model/RunConditionCheckResult.kt new file mode 100644 index 00000000..3b6b41ca --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/RunConditionCheckResult.kt @@ -0,0 +1,50 @@ +package com.nutomic.syncthingandroid.model + +import com.nutomic.syncthingandroid.R +import java.util.Collections + +class RunConditionCheckResult(blockReasons: MutableList) { + enum class BlockerReason(@JvmField val resId: Int) { + ON_BATTERY(R.string.syncthing_disabled_reason_on_battery), + ON_CHARGER(R.string.syncthing_disabled_reason_on_charger), + POWER_SAVING_ENABLED(R.string.syncthing_disabled_reason_powersaving), + GLOBAL_SYNC_DISABLED(R.string.syncthing_disabled_reason_android_sync_disabled), + WIFI_SSID_NOT_WHITELISTED(R.string.syncthing_disabled_reason_wifi_ssid_not_whitelisted), + WIFI_WIFI_IS_METERED(R.string.syncthing_disabled_reason_wifi_is_metered), + NO_NETWORK_OR_FLIGHT_MODE(R.string.syncthing_disabled_reason_no_network_or_flightmode), + NO_MOBILE_CONNECTION(R.string.syncthing_disabled_reason_no_mobile_connection), + NO_WIFI_CONNECTION(R.string.syncthing_disabled_reason_no_wifi_connection), + NO_ALLOWED_NETWORK(R.string.syncthing_disabled_reason_no_allowed_method) + } + + val isShouldRun: Boolean = blockReasons.isEmpty() + val blockReasons: MutableList = Collections.unmodifiableList(blockReasons) + + /** + * Use SHOULD_RUN instead. + * Note: of course anybody could still construct it by providing an empty list to the other + * constructor. + */ + private constructor() : this(mutableListOf()) + + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + + val that = other as RunConditionCheckResult + + if (this.isShouldRun != that.isShouldRun) return false + return this.blockReasons == that.blockReasons + } + + override fun hashCode(): Int { + var result = (if (this.isShouldRun) 1 else 0) + result = 31 * result + blockReasons.hashCode() + return result + } + + companion object { + val SHOULD_RUN: RunConditionCheckResult = RunConditionCheckResult() + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/SystemInfo.java b/app/src/main/java/com/nutomic/syncthingandroid/model/SystemInfo.java deleted file mode 100644 index 9bb085d8..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/SystemInfo.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.nutomic.syncthingandroid.model; - -import java.util.Map; - -public class SystemInfo { - public long alloc; - public double cpuPercent; - public int goroutines; - public String myID; - public long sys; - public boolean discoveryEnabled; - public int discoveryMethods; - public Map discoveryErrors; - public int urVersionMax; -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/SystemInfo.kt b/app/src/main/java/com/nutomic/syncthingandroid/model/SystemInfo.kt new file mode 100644 index 00000000..28366530 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/SystemInfo.kt @@ -0,0 +1,18 @@ +package com.nutomic.syncthingandroid.model + +@Suppress("unused") +class SystemInfo { + var alloc: Long = 0 + var cpuPercent: Double = 0.0 + var goroutines: Int = 0 + var myID: String? = null + @JvmField + var sys: Long = 0 + var discoveryEnabled: Boolean = false + @JvmField + var discoveryMethods: Int = 0 + @JvmField + var discoveryErrors: MutableMap? = null + @JvmField + var urVersionMax: Int = 0 +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/SystemVersion.java b/app/src/main/java/com/nutomic/syncthingandroid/model/SystemVersion.java deleted file mode 100644 index 5fd1b766..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/SystemVersion.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.nutomic.syncthingandroid.model; - -public class SystemVersion { - public String arch; - public String codename; - public String longVersion; - public String os; - public String version; -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/SystemVersion.kt b/app/src/main/java/com/nutomic/syncthingandroid/model/SystemVersion.kt new file mode 100644 index 00000000..a393a524 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/SystemVersion.kt @@ -0,0 +1,11 @@ +package com.nutomic.syncthingandroid.model + +@Suppress("unused") +class SystemVersion { + var arch: String? = null + var codename: String? = null + var longVersion: String? = null + var os: String? = null + @JvmField + var version: String? = null +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt index 7e5f2400..b841758b 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt @@ -287,8 +287,8 @@ class RestApi( val remoteIgnoredDevice = RemoteIgnoredDevice() remoteIgnoredDevice.deviceID = deviceId - remoteIgnoredDevice.address = deviceAddress - remoteIgnoredDevice.name = deviceName + remoteIgnoredDevice.address = deviceAddress!! + remoteIgnoredDevice.name = deviceName!! remoteIgnoredDevice.time = dateFormat.format(Date()) mConfig!!.remoteIgnoredDevices!!.add(remoteIgnoredDevice) sendConfig() @@ -325,7 +325,7 @@ class RestApi( */ val ignoredFolder = IgnoredFolder() ignoredFolder.id = folderId - ignoredFolder.label = folderLabel + ignoredFolder.label = folderLabel!! ignoredFolder.time = dateFormat.format(Date()) device.ignoredFolders!!.add(ignoredFolder) // if (BuildConfig.DEBUG) { @@ -835,10 +835,10 @@ class RestApi( */ private val FOLDERS_COMPARATOR = Comparator { lhs: Folder?, rhs: Folder? -> val lhsLabel = - if (lhs!!.label != null && !lhs.label.isEmpty()) lhs.label else lhs.id + if (lhs!!.label != null && !lhs.label!!.isEmpty()) lhs.label else lhs.id val rhsLabel = - if (rhs!!.label != null && !rhs.label.isEmpty()) rhs.label else rhs.id - lhsLabel.compareTo(rhsLabel) + if (rhs!!.label != null && !rhs.label!!.isEmpty()) rhs.label else rhs.id + lhsLabel!!.compareTo(rhsLabel!!) } } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt index ed60c623..1455ba07 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.kt @@ -197,7 +197,7 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe // Power saving if (prefRespectPowerSaving && this.isPowerSaving) { Log.v(TAG, "decideShouldRun: prefRespectPowerSaving && isPowerSaving") - blockerReasons.add(BlockerReason.POWERSAVING_ENABLED) + blockerReasons.add(BlockerReason.POWER_SAVING_ENABLED) } // Android global AutoSync setting. @@ -255,7 +255,7 @@ class RunConditionMonitor(context: Context, listener: OnRunConditionChangedListe Log.v(TAG, "decideShouldRun: return false") if (blockerReasons.isEmpty()) { if (this.isFlightMode) { - blockerReasons.add(BlockerReason.NO_NETWORK_OR_FLIGHTMODE) + blockerReasons.add(BlockerReason.NO_NETWORK_OR_FLIGHT_MODE) } else if (!prefRunOnWifi && !prefRunOnMobileData) { blockerReasons.add(BlockerReason.NO_ALLOWED_NETWORK) } else if (prefRunOnMobileData) { From 82b8b38365fab8e779a7d41141e4bf6fa8ae618b Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 10:43:47 -0800 Subject: [PATCH 39/80] http to kotlin plus warning fixes --- .../fragments/DrawerFragment.java | 6 +- .../syncthingandroid/http/ApiRequest.java | 181 ----------------- .../syncthingandroid/http/ApiRequest.kt | 189 ++++++++++++++++++ .../syncthingandroid/http/GetRequest.java | 37 ---- .../syncthingandroid/http/GetRequest.kt | 33 +++ .../http/ImageGetRequest.java | 25 --- .../syncthingandroid/http/ImageGetRequest.kt | 22 ++ .../http/PollWebGuiAvailableTask.java | 89 --------- .../http/PollWebGuiAvailableTask.kt | 92 +++++++++ .../http/PostConfigRequest.java | 22 -- .../http/PostConfigRequest.kt | 19 ++ .../syncthingandroid/http/PostRequest.java | 26 --- .../syncthingandroid/http/PostRequest.kt | 22 ++ .../http/SyncthingTrustManager.java | 71 ------- .../http/SyncthingTrustManager.kt | 72 +++++++ .../syncthingandroid/service/RestApi.kt | 6 +- 16 files changed, 456 insertions(+), 456 deletions(-) delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/http/GetRequest.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/http/GetRequest.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/http/ImageGetRequest.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/http/ImageGetRequest.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/http/PollWebGuiAvailableTask.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/http/PollWebGuiAvailableTask.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/http/PostConfigRequest.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/http/PostConfigRequest.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/http/PostRequest.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/http/PostRequest.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/http/SyncthingTrustManager.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/http/SyncthingTrustManager.kt diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/DrawerFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/DrawerFragment.java index 3829e6fc..7d35bf90 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/DrawerFragment.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/DrawerFragment.java @@ -33,6 +33,7 @@ import java.text.NumberFormat; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Timer; import java.util.TimerTask; @@ -218,10 +219,11 @@ private void showQrCode() { return; } try { - String apiKey = restApi.getGui().apiKey; - String deviceId = restApi.getLocalDevice().deviceID; + String apiKey = Objects.requireNonNull(restApi.getGui()).apiKey; + String deviceId = Objects.requireNonNull(restApi.getLocalDevice()).deviceID; URL url = restApi.getUrl(); //The QRCode request takes one paramteer called "text", which is the text to be converted to a QRCode. + assert deviceId != null; new ImageGetRequest(mActivity, url, ImageGetRequest.QR_CODE_GENERATOR, apiKey, ImmutableMap.of("text", deviceId),qrCodeBitmap -> { mActivity.showQrCodeDialog(deviceId, qrCodeBitmap); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.java b/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.java deleted file mode 100644 index 4bc2c614..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.java +++ /dev/null @@ -1,181 +0,0 @@ -package com.nutomic.syncthingandroid.http; - - -import android.content.Context; -import android.graphics.Bitmap; -import android.net.Uri; -import androidx.annotation.Nullable; -import android.util.Log; -import android.widget.ImageView; - -import com.android.volley.AuthFailureError; -import com.android.volley.DefaultRetryPolicy; -import com.android.volley.RequestQueue; -import com.android.volley.VolleyError; -import com.android.volley.toolbox.HurlStack; -import com.android.volley.toolbox.ImageRequest; -import com.android.volley.toolbox.StringRequest; -import com.android.volley.toolbox.Volley; -import com.google.common.base.Optional; -import com.google.common.collect.ImmutableMap; -import com.nutomic.syncthingandroid.service.Constants; - -import java.io.File; -import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.URL; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.Map; - -import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; - -public abstract class ApiRequest { - - private static final String TAG = "ApiRequest"; - - /** - * The name of the HTTP header used for the syncthing API key. - */ - private static final String HEADER_API_KEY = "X-API-Key"; - - public interface OnSuccessListener { - void onSuccess(String result); - } - - public interface OnImageSuccessListener { - void onImageSuccess(Bitmap result); - } - - public interface OnErrorListener { - void onError(VolleyError error); - } - - private static RequestQueue sVolleyQueue; - - private RequestQueue getVolleyQueue() { - if (sVolleyQueue == null) { - Context context = mContext.getApplicationContext(); - sVolleyQueue = Volley.newRequestQueue(context, new NetworkStack()); - } - return sVolleyQueue; - } - - private final Context mContext; - private final URL mUrl; - private final String mPath; - private final String mApiKey; - - ApiRequest(Context context, URL url, String path, String apiKey) { - mContext = context; - mUrl = url; - mPath = path; - mApiKey = apiKey; - } - - Uri buildUri(Map params) { - Uri.Builder uriBuilder = Uri.parse(mUrl.toString()) - .buildUpon() - .path(mPath); - for (Map.Entry entry : params.entrySet()) { - uriBuilder.appendQueryParameter(entry.getKey(), entry.getValue()); - } - return uriBuilder.build(); - } - - /** - * Opens the connection, then returns success status and response string. - */ - void connect(int requestMethod, Uri uri, @Nullable String requestBody, - @Nullable OnSuccessListener listener, @Nullable OnErrorListener errorListener) { - Log.v(TAG, "Performing request to " + uri.toString()); - StringRequest request = new StringRequest(requestMethod, uri.toString(), reply -> { - if (listener != null) { - listener.onSuccess(reply); - } - }, error -> { - if (errorListener != null) { - errorListener.onError(error); - } else { - Log.w(TAG, "Request to " + uri + " failed, " + error.getMessage()); - } - }) { - @Override - public Map getHeaders() throws AuthFailureError { - return ImmutableMap.of(HEADER_API_KEY, mApiKey); - } - - @Override - public byte[] getBody() throws AuthFailureError { - return Optional.fromNullable(requestBody).transform(String::getBytes).orNull(); - } - }; - - // Some requests seem to be slow or fail, make sure this doesn't break the app - // (eg if an event request fails, new event requests won't be triggered). - request.setRetryPolicy(new DefaultRetryPolicy(5000, 5, - DefaultRetryPolicy.DEFAULT_BACKOFF_MULT)); - getVolleyQueue().add(request); - } - - /** - * Opens the connection, then returns success status and response bitmap. - */ - void makeImageRequest(Uri uri, @Nullable OnImageSuccessListener imageListener, - @Nullable OnErrorListener errorListener) { - ImageRequest imageRequest = new ImageRequest(uri.toString(), bitmap -> { - if (imageListener != null) { - imageListener.onImageSuccess(bitmap); - } - }, 0, 0, ImageView.ScaleType.CENTER, Bitmap.Config.RGB_565, volleyError -> { - if(errorListener != null) { - errorListener.onError(volleyError); - } - Log.d(TAG, "onErrorResponse: " + volleyError); - }) { - @Override - public Map getHeaders() throws AuthFailureError { - return ImmutableMap.of(HEADER_API_KEY, mApiKey); - } - }; - - getVolleyQueue().add(imageRequest); - } - - /** - * Extends {@link HurlStack}, uses {@link #getSslSocketFactory()} and disables hostname - * verification. - */ - private class NetworkStack extends HurlStack { - - public NetworkStack() { - super(null, getSslSocketFactory()); - } - @Override - protected HttpURLConnection createConnection(URL url) throws IOException { - if (mUrl.toString().startsWith("https://")) { - HttpsURLConnection connection = (HttpsURLConnection) super.createConnection(url); - connection.setHostnameVerifier((hostname, session) -> true); - return connection; - } - return super.createConnection(url); - } - } - - private SSLSocketFactory getSslSocketFactory() { - try { - SSLContext sslContext = SSLContext.getInstance("TLS"); - File httpsCertPath = Constants.getHttpsCertFile(mContext); - sslContext.init(null, new TrustManager[]{new SyncthingTrustManager(httpsCertPath)}, - new SecureRandom()); - return sslContext.getSocketFactory(); - } catch (NoSuchAlgorithmException | KeyManagementException e) { - Log.w(TAG, e); - return null; - } - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.kt b/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.kt new file mode 100644 index 00000000..860990f6 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.kt @@ -0,0 +1,189 @@ +package com.nutomic.syncthingandroid.http + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import android.widget.ImageView +import com.android.volley.AuthFailureError +import com.android.volley.DefaultRetryPolicy +import com.android.volley.RequestQueue +import com.android.volley.Response +import com.android.volley.VolleyError +import com.android.volley.toolbox.HurlStack +import com.android.volley.toolbox.ImageRequest +import com.android.volley.toolbox.StringRequest +import com.android.volley.toolbox.Volley +import com.google.common.base.Function +import com.google.common.base.Optional +import com.google.common.collect.ImmutableMap +import com.nutomic.syncthingandroid.service.Constants.getHttpsCertFile +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.security.KeyManagementException +import java.security.NoSuchAlgorithmException +import java.security.SecureRandom +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSession +import javax.net.ssl.TrustManager +import androidx.core.net.toUri +import javax.net.ssl.SSLSocketFactory + + +abstract class ApiRequest internal constructor( + private val mContext: Context, + private val mUrl: URL, + private val mPath: String?, + private val mApiKey: String +) { + fun interface OnSuccessListener { + fun onSuccess(result: String?) + } + + interface OnImageSuccessListener { + fun onImageSuccess(result: Bitmap?) + } + + fun interface OnErrorListener { + fun onError(error: VolleyError?) + } + + private val volleyQueue: RequestQueue? + get() { + if (sVolleyQueue == null) { + val context = mContext.applicationContext + sVolleyQueue = Volley.newRequestQueue(context, NetworkStack()) + } + return sVolleyQueue + } + + fun buildUri(params: MutableMap): Uri? { + val uriBuilder = mUrl.toString().toUri() + .buildUpon() + .path(mPath) + for (entry in params.entries) { + uriBuilder.appendQueryParameter(entry.key, entry.value) + } + return uriBuilder.build() + } + + /** + * Opens the connection, then returns success status and response string. + */ + fun connect( + requestMethod: Int, uri: Uri, requestBody: String?, + listener: OnSuccessListener?, errorListener: OnErrorListener? + ) { + Log.v(TAG, "Performing request to $uri") + val request: StringRequest = object : + StringRequest(requestMethod, uri.toString(), Response.Listener { reply: String? -> + listener?.onSuccess(reply) + }, Response.ErrorListener { error: VolleyError? -> + if (errorListener != null) { + errorListener.onError(error) + } else { + Log.w(TAG, "Request to " + uri + " failed, " + error!!.message) + } + }) { + @Throws(AuthFailureError::class) + override fun getHeaders(): MutableMap { + return ImmutableMap.of(HEADER_API_KEY, mApiKey) + } + + @Throws(AuthFailureError::class) + override fun getBody(): ByteArray? { + return Optional.fromNullable(requestBody) + .transform(Function { obj: String? -> obj!!.toByteArray() }) + .orNull() + } + } + + // Some requests seem to be slow or fail, make sure this doesn't break the app + // (eg if an event request fails, new event requests won't be triggered). + request.retryPolicy = DefaultRetryPolicy( + 5000, 5, + DefaultRetryPolicy.DEFAULT_BACKOFF_MULT + ) + this.volleyQueue?.add(request) + } + + /** + * Opens the connection, then returns success status and response bitmap. + */ + fun makeImageRequest( + uri: Uri, imageListener: OnImageSuccessListener?, + errorListener: OnErrorListener? + ) { + val imageRequest: ImageRequest = object : ImageRequest( + uri.toString(), + Response.Listener { bitmap: Bitmap? -> + imageListener?.onImageSuccess(bitmap) + }, + 0, + 0, + ImageView.ScaleType.CENTER, + Bitmap.Config.RGB_565, + Response.ErrorListener { volleyError: VolleyError? -> + errorListener?.onError(volleyError) + Log.d(TAG, "onErrorResponse: $volleyError") + }) { + @Throws(AuthFailureError::class) + override fun getHeaders(): MutableMap { + return ImmutableMap.of(HEADER_API_KEY, mApiKey) + } + } + + this.volleyQueue?.add(imageRequest) + } + + /** + * Extends [HurlStack], uses [.getSslSocketFactory] and disables hostname + * verification. + */ + private inner class NetworkStack : HurlStack(null, this.sslSocketFactory) { + @Throws(IOException::class) + override fun createConnection(url: URL?): HttpURLConnection? { + if (mUrl.toString().startsWith("https://")) { + val connection = super.createConnection(url) as HttpsURLConnection + connection.setHostnameVerifier { _: String?, _: SSLSession? -> true } + return connection + } + return super.createConnection(url) + } + } + + private val sslSocketFactory: SSLSocketFactory? + get() { + try { + val sslContext = + SSLContext.getInstance("TLS") + val httpsCertPath = + getHttpsCertFile(mContext) + sslContext.init( + null, + arrayOf(SyncthingTrustManager(httpsCertPath)), + SecureRandom() + ) + return sslContext.socketFactory + } catch (e: NoSuchAlgorithmException) { + Log.w(TAG, e) + return null + } catch (e: KeyManagementException) { + Log.w(TAG, e) + return null + } + } + + companion object { + private const val TAG = "ApiRequest" + + /** + * The name of the HTTP header used for the syncthing API key. + */ + private const val HEADER_API_KEY = "X-API-Key" + + private var sVolleyQueue: RequestQueue? = null + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/GetRequest.java b/app/src/main/java/com/nutomic/syncthingandroid/http/GetRequest.java deleted file mode 100644 index f639a372..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/http/GetRequest.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.nutomic.syncthingandroid.http; - -import android.content.Context; -import android.net.Uri; -import androidx.annotation.Nullable; - -import com.android.volley.Request; -import com.google.common.base.Optional; - -import java.net.URL; -import java.util.Collections; -import java.util.Map; - -/** - * Performs a GET request to the Syncthing API - */ -public class GetRequest extends ApiRequest { - - public static final String URI_CONFIG = "/rest/system/config"; - public static final String URI_DEBUG = "/rest/system/debug"; - public static final String URI_VERSION = "/rest/system/version"; - public static final String URI_SYSTEM = "/rest/system/status"; - public static final String URI_CONNECTIONS = "/rest/system/connections"; - public static final String URI_STATUS = "/rest/db/status"; - public static final String URI_DEVICEID = "/rest/svc/deviceid"; - public static final String URI_REPORT = "/rest/svc/report"; - public static final String URI_EVENTS = "/rest/events"; - - public GetRequest(Context context, URL url, String path, String apiKey, - @Nullable Map params, OnSuccessListener listener) { - super(context, url, path, apiKey); - Map safeParams = Optional.fromNullable(params).or(Collections.emptyMap()); - Uri uri = buildUri(safeParams); - connect(Request.Method.GET, uri, null, listener, null); - } - -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/GetRequest.kt b/app/src/main/java/com/nutomic/syncthingandroid/http/GetRequest.kt new file mode 100644 index 00000000..908903b2 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/http/GetRequest.kt @@ -0,0 +1,33 @@ +package com.nutomic.syncthingandroid.http + +import android.content.Context +import com.android.volley.Request +import com.google.common.base.Optional +import java.net.URL + +/** + * Performs a GET request to the Syncthing API + */ +class GetRequest( + context: Context?, url: URL?, path: String?, apiKey: String?, + params: MutableMap?, listener: OnSuccessListener? +) : ApiRequest(context!!, url!!, path, apiKey!!) { + init { + val safeParams = Optional.fromNullable(params) + .or(mutableMapOf()) + val uri = buildUri(safeParams) + connect(Request.Method.GET, uri!!, null, listener, null) + } + + companion object { + const val URI_CONFIG: String = "/rest/system/config" + const val URI_DEBUG: String = "/rest/system/debug" + const val URI_VERSION: String = "/rest/system/version" + const val URI_SYSTEM: String = "/rest/system/status" + const val URI_CONNECTIONS: String = "/rest/system/connections" + const val URI_STATUS: String = "/rest/db/status" + const val URI_DEVICE_ID: String = "/rest/svc/deviceid" + const val URI_REPORT: String = "/rest/svc/report" + const val URI_EVENTS: String = "/rest/events" + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/ImageGetRequest.java b/app/src/main/java/com/nutomic/syncthingandroid/http/ImageGetRequest.java deleted file mode 100644 index 9917d38f..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/http/ImageGetRequest.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.nutomic.syncthingandroid.http; - -import android.content.Context; -import android.net.Uri; -import androidx.annotation.Nullable; - -import com.google.common.base.Optional; - -import java.net.URL; -import java.util.Collections; -import java.util.Map; - -public class ImageGetRequest extends ApiRequest { - - public static final String QR_CODE_GENERATOR = "/qr/"; - - public ImageGetRequest(Context context, URL url, String path, String apiKey, - @Nullable Map params, - OnImageSuccessListener onSuccessListener, OnErrorListener onErrorListener) { - super(context, url, path, apiKey); - Map safeParams = Optional.fromNullable(params).or(Collections.emptyMap()); - Uri uri = buildUri(safeParams); - makeImageRequest(uri, onSuccessListener, onErrorListener); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/ImageGetRequest.kt b/app/src/main/java/com/nutomic/syncthingandroid/http/ImageGetRequest.kt new file mode 100644 index 00000000..cb29f2b0 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/http/ImageGetRequest.kt @@ -0,0 +1,22 @@ +package com.nutomic.syncthingandroid.http + +import android.content.Context +import com.google.common.base.Optional +import java.net.URL + +class ImageGetRequest( + context: Context?, url: URL?, path: String?, apiKey: String?, + params: MutableMap?, + onSuccessListener: OnImageSuccessListener?, onErrorListener: OnErrorListener? +) : ApiRequest(context!!, url!!, path, apiKey!!) { + init { + val safeParams = Optional.fromNullable(params) + .or(mutableMapOf()) + val uri = buildUri(safeParams) + makeImageRequest(uri!!, onSuccessListener, onErrorListener) + } + + companion object { + const val QR_CODE_GENERATOR: String = "/qr/" + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/PollWebGuiAvailableTask.java b/app/src/main/java/com/nutomic/syncthingandroid/http/PollWebGuiAvailableTask.java deleted file mode 100644 index 6c0ffc68..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/http/PollWebGuiAvailableTask.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.nutomic.syncthingandroid.http; - - -import android.content.Context; -import android.net.Uri; -import android.os.Handler; -import android.util.Log; - -import com.android.volley.Request; -import com.android.volley.VolleyError; - -import java.net.ConnectException; -import java.net.URL; -import java.util.Collections; - -/** - * Polls to load the web interface, until it is available. - */ -public class PollWebGuiAvailableTask extends ApiRequest { - - private static final String TAG = "PollWebGuiAvailableTask"; - /** - * Interval in ms, at which connections to the web gui are performed on first start - * to find out if it's online. - */ - private static final long WEB_GUI_POLL_INTERVAL = 100; - - private final Handler mHandler = new Handler(); - - private OnSuccessListener mListener; - - private Integer logIncidence = 0; - - /** - * Object that must be locked upon accessing mListener - */ - private final Object mListenerLock = new Object(); - - public PollWebGuiAvailableTask(Context context, URL url, String apiKey, - OnSuccessListener listener) { - super(context, url, "", apiKey); - Log.i(TAG, "Starting to poll for web gui availability"); - mListener = listener; - performRequest(); - } - - public void cancelRequestsAndCallback() { - synchronized(mListenerLock) { - mListener = null; - } - } - - private void performRequest() { - Uri uri = buildUri(Collections.emptyMap()); - connect(Request.Method.GET, uri, null, this::onSuccess, this::onError); - } - - private void onSuccess(String result) { - synchronized(mListenerLock) { - if (mListener != null) { - mListener.onSuccess(result); - } else { - Log.v(TAG, "Cancelled callback and outstanding requests"); - } - } - } - - private void onError(VolleyError error) { - synchronized(mListenerLock) { - if (mListener == null) { - Log.v(TAG, "Cancelled callback and outstanding requests"); - return; - } - } - - mHandler.postDelayed(this::performRequest, WEB_GUI_POLL_INTERVAL); - Throwable cause = error.getCause(); - if (cause == null || cause.getClass().equals(ConnectException.class)) { - // Reduce lag caused by massively logging the same line while waiting. - logIncidence++; - if (logIncidence == 1 || logIncidence % 10 == 0) { - Log.v(TAG, "Polling web gui ... (" + logIncidence + ")"); - } - } else { - Log.w(TAG, "Unexpected error while polling web gui", error); - } - } - -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/PollWebGuiAvailableTask.kt b/app/src/main/java/com/nutomic/syncthingandroid/http/PollWebGuiAvailableTask.kt new file mode 100644 index 00000000..5f0b6f48 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/http/PollWebGuiAvailableTask.kt @@ -0,0 +1,92 @@ +package com.nutomic.syncthingandroid.http + +import android.content.Context +import android.os.Handler +import android.util.Log +import com.android.volley.Request +import com.android.volley.VolleyError +import java.net.ConnectException +import java.net.URL + + +/** + * Polls to load the web interface, until it is available. + */ +class PollWebGuiAvailableTask( + context: Context?, url: URL?, apiKey: String?, + listener: OnSuccessListener? +) : ApiRequest(context!!, url!!, "", apiKey!!) { + private val mHandler = Handler() + + private var mListener: OnSuccessListener? + + private var logIncidence = 0 + + /** + * Object that must be locked upon accessing mListener + */ + private val mListenerLock = Any() + + init { + Log.i(TAG, "Starting to poll for web gui availability") + mListener = listener + performRequest() + } + + fun cancelRequestsAndCallback() { + synchronized(mListenerLock) { + mListener = null + } + } + + private fun performRequest() { + val uri = buildUri(mutableMapOf()) + connect( + Request.Method.GET, + uri!!, + null, + { result: String? -> this.onSuccess(result) }, + { error: VolleyError? -> this.onError(error!!) }) + } + + private fun onSuccess(result: String?) { + synchronized(mListenerLock) { + if (mListener != null) { + mListener!!.onSuccess(result) + } else { + Log.v(TAG, "Cancelled callback and outstanding requests") + } + } + } + + private fun onError(error: VolleyError) { + synchronized(mListenerLock) { + if (mListener == null) { + Log.v(TAG, "Cancelled callback and outstanding requests") + return + } + } + + mHandler.postDelayed({ this.performRequest() }, WEB_GUI_POLL_INTERVAL) + val cause = error.cause + if (cause == null || cause.javaClass == ConnectException::class.java) { + // Reduce lag caused by massively logging the same line while waiting. + logIncidence++ + if (logIncidence == 1 || logIncidence % 10 == 0) { + Log.v(TAG, "Polling web gui ... ($logIncidence)") + } + } else { + Log.w(TAG, "Unexpected error while polling web gui", error) + } + } + + companion object { + private const val TAG = "PollWebGuiAvailableTask" + + /** + * Interval in ms, at which connections to the web gui are performed on first start + * to find out if it's online. + */ + private const val WEB_GUI_POLL_INTERVAL: Long = 100 + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/PostConfigRequest.java b/app/src/main/java/com/nutomic/syncthingandroid/http/PostConfigRequest.java deleted file mode 100644 index eb759748..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/http/PostConfigRequest.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.nutomic.syncthingandroid.http; - -import android.content.Context; -import android.net.Uri; - -import com.android.volley.Request; - -import java.net.URL; -import java.util.Collections; - -public class PostConfigRequest extends ApiRequest { - - private static final String URI_CONFIG = "/rest/system/config"; - - public PostConfigRequest(Context context, URL url, String apiKey, String config, - OnSuccessListener listener) { - super(context, url, URI_CONFIG, apiKey); - Uri uri = buildUri(Collections.emptyMap()); - connect(Request.Method.POST, uri, config, listener, null); - } - -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/PostConfigRequest.kt b/app/src/main/java/com/nutomic/syncthingandroid/http/PostConfigRequest.kt new file mode 100644 index 00000000..7d587d40 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/http/PostConfigRequest.kt @@ -0,0 +1,19 @@ +package com.nutomic.syncthingandroid.http + +import android.content.Context +import com.android.volley.Request +import java.net.URL + +class PostConfigRequest( + context: Context?, url: URL?, apiKey: String?, config: String?, + listener: OnSuccessListener? +) : ApiRequest(context!!, url!!, URI_CONFIG, apiKey!!) { + init { + val uri = buildUri(mutableMapOf()) + connect(Request.Method.POST, uri!!, config, listener, null) + } + + companion object { + private const val URI_CONFIG = "/rest/system/config" + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/PostRequest.java b/app/src/main/java/com/nutomic/syncthingandroid/http/PostRequest.java deleted file mode 100644 index 71190de0..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/http/PostRequest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.nutomic.syncthingandroid.http; - -import android.content.Context; -import android.net.Uri; -import androidx.annotation.Nullable; - -import com.android.volley.Request; -import com.google.common.base.Optional; - -import java.net.URL; -import java.util.Collections; -import java.util.Map; - -public class PostRequest extends ApiRequest { - - public static final String URI_DB_OVERRIDE = "/rest/db/override"; - - public PostRequest(Context context, URL url, String path, String apiKey, - @Nullable Map params, OnSuccessListener listener) { - super(context, url, path, apiKey); - Map safeParams = Optional.fromNullable(params).or(Collections.emptyMap()); - Uri uri = buildUri(safeParams); - connect(Request.Method.POST, uri, null, listener, null); - } - -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/PostRequest.kt b/app/src/main/java/com/nutomic/syncthingandroid/http/PostRequest.kt new file mode 100644 index 00000000..c19a2b31 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/http/PostRequest.kt @@ -0,0 +1,22 @@ +package com.nutomic.syncthingandroid.http + +import android.content.Context +import com.android.volley.Request +import com.google.common.base.Optional +import java.net.URL + +class PostRequest( + context: Context?, url: URL?, path: String?, apiKey: String?, + params: MutableMap?, listener: OnSuccessListener? +) : ApiRequest(context!!, url!!, path, apiKey!!) { + init { + val safeParams = Optional.fromNullable(params) + .or(mutableMapOf()) + val uri = buildUri(safeParams) + connect(Request.Method.POST, uri!!, null, listener, null) + } + + companion object { + const val URI_DB_OVERRIDE: String = "/rest/db/override" + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/SyncthingTrustManager.java b/app/src/main/java/com/nutomic/syncthingandroid/http/SyncthingTrustManager.java deleted file mode 100644 index 544999d9..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/http/SyncthingTrustManager.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.nutomic.syncthingandroid.http; - -import android.annotation.SuppressLint; -import android.util.Log; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.security.NoSuchProviderException; -import java.security.SignatureException; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; - -import javax.net.ssl.X509TrustManager; - -/* - * TrustManager checking against the local Syncthing instance's https public key. - * - * Based on http://stackoverflow.com/questions/16719959#16759793 - */ -class SyncthingTrustManager implements X509TrustManager { - - private static final String TAG = "SyncthingTrustManager"; - - private final File mHttpsCertPath; - - SyncthingTrustManager(File httpsCertPath) { - mHttpsCertPath = httpsCertPath; - } - - @Override - @SuppressLint("TrustAllX509TrustManager") - public void checkClientTrusted(X509Certificate[] chain, String authType) - throws CertificateException { - } - - /** - * Verifies certs against public key of the local syncthing instance - */ - @Override - public void checkServerTrusted(X509Certificate[] certs, - String authType) throws CertificateException { - InputStream is = null; - try { - is = new FileInputStream(mHttpsCertPath); - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - X509Certificate ca = (X509Certificate) cf.generateCertificate(is); - for (X509Certificate cert : certs) { - cert.verify(ca.getPublicKey()); - } - } catch (FileNotFoundException | NoSuchAlgorithmException | InvalidKeyException | - NoSuchProviderException | SignatureException e) { - throw new CertificateException("Untrusted Certificate!", e); - } finally { - try { - if (is != null) - is.close(); - } catch (IOException e) { - Log.w(TAG, e); - } - } - } - public X509Certificate[] getAcceptedIssuers() { - return null; - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/SyncthingTrustManager.kt b/app/src/main/java/com/nutomic/syncthingandroid/http/SyncthingTrustManager.kt new file mode 100644 index 00000000..e5bba14c --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/http/SyncthingTrustManager.kt @@ -0,0 +1,72 @@ +package com.nutomic.syncthingandroid.http + +import android.annotation.SuppressLint +import android.util.Log +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.security.InvalidKeyException +import java.security.NoSuchAlgorithmException +import java.security.NoSuchProviderException +import java.security.SignatureException +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import javax.net.ssl.X509TrustManager + +/* + * TrustManager checking against the local Syncthing instance's https public key. + * + * Based on http://stackoverflow.com/questions/16719959#16759793 + */ +internal class SyncthingTrustManager(private val mHttpsCertPath: File?) : X509TrustManager { + @SuppressLint("TrustAllX509TrustManager") + @Throws(CertificateException::class) + override fun checkClientTrusted(chain: Array?, authType: String?) { + } + + /** + * Verifies certs against public key of the local syncthing instance + */ + @Throws(CertificateException::class) + override fun checkServerTrusted( + certs: Array, + authType: String? + ) { + var `is`: InputStream? = null + try { + `is` = FileInputStream(mHttpsCertPath) + val cf = CertificateFactory.getInstance("X.509") + val ca = cf.generateCertificate(`is`) as X509Certificate + for (cert in certs) { + cert.verify(ca.publicKey) + } + } catch (e: FileNotFoundException) { + throw CertificateException("Untrusted Certificate!", e) + } catch (e: NoSuchAlgorithmException) { + throw CertificateException("Untrusted Certificate!", e) + } catch (e: InvalidKeyException) { + throw CertificateException("Untrusted Certificate!", e) + } catch (e: NoSuchProviderException) { + throw CertificateException("Untrusted Certificate!", e) + } catch (e: SignatureException) { + throw CertificateException("Untrusted Certificate!", e) + } finally { + try { + `is`?.close() + } catch (e: IOException) { + Log.w(TAG, e) + } + } + } + + override fun getAcceptedIssuers(): Array? { + return null + } + + companion object { + private const val TAG = "SyncthingTrustManager" + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt index b841758b..c96a3644 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.kt @@ -366,7 +366,7 @@ class RestApi( PostRequest( mContext, this.url, PostRequest.URI_DB_OVERRIDE, mApiKey, - ImmutableMap.of("folder", folderId), null + ImmutableMap.of("folder", folderId), null ) } @@ -694,7 +694,7 @@ class RestApi( this.url, GetRequest.URI_STATUS, mApiKey, - ImmutableMap.of("folder", folderId) + ImmutableMap.of("folder", folderId) ) { result: String? -> val m = Gson().fromJson(result, FolderStatus::class.java) mCachedFolderStatuses[folderId] = m @@ -762,7 +762,7 @@ class RestApi( ) { GetRequest( mContext, - this.url, GetRequest.URI_DEVICEID, mApiKey, + this.url, GetRequest.URI_DEVICE_ID, mApiKey, ImmutableMap.of("id", id) ) { result: String? -> val json = JsonParser.parseString(result).getAsJsonObject() From 696a686f726b950d463b3844cfd5512242cca634 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 10:45:07 -0800 Subject: [PATCH 40/80] Fix build warning --- .../nutomic/syncthingandroid/http/PollWebGuiAvailableTask.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/PollWebGuiAvailableTask.kt b/app/src/main/java/com/nutomic/syncthingandroid/http/PollWebGuiAvailableTask.kt index 5f0b6f48..3bd89ae9 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/http/PollWebGuiAvailableTask.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/http/PollWebGuiAvailableTask.kt @@ -2,6 +2,7 @@ package com.nutomic.syncthingandroid.http import android.content.Context import android.os.Handler +import android.os.Looper import android.util.Log import com.android.volley.Request import com.android.volley.VolleyError @@ -16,7 +17,7 @@ class PollWebGuiAvailableTask( context: Context?, url: URL?, apiKey: String?, listener: OnSuccessListener? ) : ApiRequest(context!!, url!!, "", apiKey!!) { - private val mHandler = Handler() + private val mHandler = Handler(Looper.getMainLooper()) private var mListener: OnSuccessListener? From 23e317506bd85b7780aa6cd47d82ca288de58539 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 10:57:43 -0800 Subject: [PATCH 41/80] Kotlin fragments --- .../fragments/DeviceListFragment.java | 134 -------- .../fragments/DeviceListFragment.kt | 120 +++++++ .../fragments/DrawerFragment.java | 291 ---------------- .../fragments/DrawerFragment.kt | 323 ++++++++++++++++++ .../fragments/FolderListFragment.java | 128 ------- .../fragments/FolderListFragment.kt | 112 ++++++ .../fragments/NumberPickerFragment.java | 39 --- .../fragments/NumberPickerFragment.kt | 39 +++ .../dialog/SimpleVersioningFragment.java | 2 +- .../dialog/StaggeredVersioningFragment.java | 2 +- .../dialog/TrashCanVersioningFragment.java | 2 +- .../syncthingandroid/http/ApiRequest.kt | 2 +- 12 files changed, 598 insertions(+), 596 deletions(-) delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/DrawerFragment.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/DrawerFragment.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/NumberPickerFragment.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/NumberPickerFragment.kt diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.java deleted file mode 100644 index a0c0e9b0..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.java +++ /dev/null @@ -1,134 +0,0 @@ -package com.nutomic.syncthingandroid.fragments; - -import android.content.Intent; -import android.os.Bundle; -import androidx.fragment.app.ListFragment; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.widget.AdapterView; -import android.widget.ListView; - -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.activities.DeviceActivity; -import com.nutomic.syncthingandroid.activities.SyncthingActivity; -import com.nutomic.syncthingandroid.model.Device; -import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.service.RestApi; -import com.nutomic.syncthingandroid.service.SyncthingService; -import com.nutomic.syncthingandroid.views.DevicesAdapter; - -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Timer; -import java.util.TimerTask; - -/** - * Displays a list of all existing devices. - */ -public class DeviceListFragment extends ListFragment implements SyncthingService.OnServiceStateChangeListener, - ListView.OnItemClickListener { - - private final static Comparator DEVICES_COMPARATOR = (lhs, rhs) -> lhs.name.compareTo(rhs.name); - - private DevicesAdapter mAdapter; - - private Timer mTimer; - - @Override - public void onPause() { - super.onPause(); - if (mTimer != null) { - mTimer.cancel(); - } - } - - @Override - public void onServiceStateChange(SyncthingService.State currentState) { - if (currentState != SyncthingService.State.ACTIVE) - return; - - mTimer = new Timer(); - mTimer.schedule(new TimerTask() { - @Override - public void run() { - if (getActivity() == null) - return; - - getActivity().runOnUiThread(DeviceListFragment.this::updateList); - } - - }, 0, Constants.GUI_UPDATE_INTERVAL); - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - setHasOptionsMenu(true); - setEmptyText(getString(R.string.devices_list_empty)); - getListView().setOnItemClickListener(this); - } - - /** - * Refreshes ListView by updating devices and info. - * - * Also creates adapter if it doesn't exist yet. - */ - private void updateList() { - SyncthingActivity activity = (SyncthingActivity) getActivity(); - if (activity == null || getView() == null || activity.isFinishing()) { - return; - } - RestApi restApi = activity.getApi(); - if (restApi == null || !restApi.isConfigLoaded()) { - return; - } - List devices = restApi.getDevices(false); - if (devices == null) { - return; - } - if (mAdapter == null) { - mAdapter = new DevicesAdapter(activity); - setListAdapter(mAdapter); - } - - // Prevent scroll position reset due to list update from clear(). - mAdapter.setNotifyOnChange(false); - mAdapter.clear(); - Collections.sort(devices, DEVICES_COMPARATOR); - mAdapter.addAll(devices); - mAdapter.updateConnections(restApi); - mAdapter.notifyDataSetChanged(); - setListShown(true); - } - - @Override - public void onItemClick(AdapterView adapterView, View view, int i, long l) { - Intent intent = new Intent(getActivity(), DeviceActivity.class); - intent.putExtra(DeviceActivity.EXTRA_IS_CREATE, false); - intent.putExtra(DeviceActivity.EXTRA_DEVICE_ID, mAdapter.getItem(i).deviceID); - startActivity(intent); - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.device_list, menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.add_device: - Intent intent = new Intent(getActivity(), DeviceActivity.class) - .putExtra(DeviceActivity.EXTRA_IS_CREATE, true); - startActivity(intent); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.kt b/app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.kt new file mode 100644 index 00000000..6b26497b --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.kt @@ -0,0 +1,120 @@ +package com.nutomic.syncthingandroid.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.widget.AdapterView +import android.widget.AdapterView.OnItemClickListener +import androidx.fragment.app.ListFragment +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.activities.DeviceActivity +import com.nutomic.syncthingandroid.activities.SyncthingActivity +import com.nutomic.syncthingandroid.model.Device +import com.nutomic.syncthingandroid.service.Constants +import com.nutomic.syncthingandroid.service.SyncthingService +import com.nutomic.syncthingandroid.service.SyncthingService.OnServiceStateChangeListener +import com.nutomic.syncthingandroid.views.DevicesAdapter +import java.util.Collections +import java.util.Timer +import java.util.TimerTask + +/** + * Displays a list of all existing devices. + */ +class DeviceListFragment : ListFragment(), OnServiceStateChangeListener, OnItemClickListener { + private var mAdapter: DevicesAdapter? = null + + private var mTimer: Timer? = null + + override fun onPause() { + super.onPause() + if (mTimer != null) { + mTimer!!.cancel() + } + } + + override fun onServiceStateChange(currentState: SyncthingService.State?) { + if (currentState != SyncthingService.State.ACTIVE) return + + mTimer = Timer() + mTimer!!.schedule(object : TimerTask() { + override fun run() { + if (activity == null) return + + requireActivity().runOnUiThread { this@DeviceListFragment.updateList() } + } + }, 0, Constants.GUI_UPDATE_INTERVAL) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setHasOptionsMenu(true) + setEmptyText(getString(R.string.devices_list_empty)) + getListView().onItemClickListener = this + } + + /** + * Refreshes ListView by updating devices and info. + * + * Also creates adapter if it doesn't exist yet. + */ + private fun updateList() { + val activity = activity as SyncthingActivity? + if (activity == null || view == null || activity.isFinishing) { + return + } + val restApi = activity.api + if (restApi == null || !restApi.isConfigLoaded) { + return + } + val devices: MutableList = restApi.getDevices(false) + if (mAdapter == null) { + mAdapter = DevicesAdapter(activity) + setListAdapter(mAdapter) + } + + // Prevent scroll position reset due to list update from clear(). + mAdapter!!.setNotifyOnChange(false) + mAdapter!!.clear() + Collections.sort(devices, DEVICES_COMPARATOR) + mAdapter!!.addAll(devices) + mAdapter!!.updateConnections(restApi) + mAdapter!!.notifyDataSetChanged() + setListShown(true) + } + + override fun onItemClick(adapterView: AdapterView<*>?, view: View?, i: Int, l: Long) { + val intent = Intent(activity, DeviceActivity::class.java) + intent.putExtra(DeviceActivity.EXTRA_IS_CREATE, false) + intent.putExtra(DeviceActivity.EXTRA_DEVICE_ID, mAdapter!!.getItem(i)!!.deviceID) + startActivity(intent) + } + + @Deprecated("Deprecated in Java") + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.device_list, menu) + } + + @Deprecated("Deprecated in Java") + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.add_device -> { + val intent = Intent(activity, DeviceActivity::class.java) + .putExtra(DeviceActivity.EXTRA_IS_CREATE, true) + startActivity(intent) + return true + } + + else -> return super.onOptionsItemSelected(item) + } + } + + companion object { + private val DEVICES_COMPARATOR = + Comparator { lhs: Device?, rhs: Device? -> lhs!!.name.compareTo(rhs!!.name) } + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/DrawerFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/DrawerFragment.java deleted file mode 100644 index 7d35bf90..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/DrawerFragment.java +++ /dev/null @@ -1,291 +0,0 @@ -package com.nutomic.syncthingandroid.fragments; - -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.preference.PreferenceManager; -import androidx.fragment.app.Fragment; -import androidx.core.content.ContextCompat; -import androidx.appcompat.app.AlertDialog; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import android.widget.Toast; - -import com.google.common.base.Optional; -import com.google.common.collect.ImmutableMap; -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.activities.MainActivity; -import com.nutomic.syncthingandroid.activities.SettingsActivity; -import com.nutomic.syncthingandroid.activities.WebGuiActivity; -import com.nutomic.syncthingandroid.http.ImageGetRequest; -import com.nutomic.syncthingandroid.model.Connections; -import com.nutomic.syncthingandroid.model.SystemInfo; -import com.nutomic.syncthingandroid.model.SystemVersion; -import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.service.RestApi; -import com.nutomic.syncthingandroid.service.SyncthingService; -import com.nutomic.syncthingandroid.util.Util; - -import java.net.URL; -import java.text.NumberFormat; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Timer; -import java.util.TimerTask; - -/** - * Displays information about the local device. - */ -public class DrawerFragment extends Fragment implements View.OnClickListener { - - private static final String TAG = "DrawerFragment"; - - private TextView mRamUsage; - private TextView mDownload; - private TextView mUpload; - private TextView mAnnounceServer; - private TextView mVersion; - private TextView mExitButton; - - private Timer mTimer; - - private MainActivity mActivity; - private SharedPreferences sharedPreferences = null; - - public void onDrawerOpened() { - mTimer = new Timer(); - mTimer.schedule(new TimerTask() { - @Override - public void run() { - updateGui(); - } - - }, 0, Constants.GUI_UPDATE_INTERVAL); - } - - @Override - public void onResume() { - super.onResume(); - updateExitButtonVisibility(); - } - - public void onDrawerClosed() { - if (mTimer != null) { - mTimer.cancel(); - mTimer = null; - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - onDrawerClosed(); - } - - /** - * Populates views and menu. - */ - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_drawer, container, false); - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - mActivity = (MainActivity) getActivity(); - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(mActivity); - - mRamUsage = view.findViewById(R.id.ram_usage); - mDownload = view.findViewById(R.id.download); - mUpload = view.findViewById(R.id.upload); - mAnnounceServer = view.findViewById(R.id.announce_server); - mVersion = view.findViewById(R.id.version); - mExitButton = view.findViewById(R.id.drawerActionExit); - - view.findViewById(R.id.drawerActionWebGui) - .setOnClickListener(this); - view.findViewById(R.id.drawerActionRestart) - .setOnClickListener(this); - view.findViewById(R.id.drawerActionSettings) - .setOnClickListener(this); - view.findViewById(R.id.drawerActionShowQrCode) - .setOnClickListener(this); - mExitButton.setOnClickListener(this); - - updateExitButtonVisibility(); - } - - private void updateExitButtonVisibility() { - boolean alwaysInBackground = alwaysRunInBackground(); - mExitButton.setVisibility(alwaysInBackground ? View.GONE : View.VISIBLE); - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - mActivity = (MainActivity) getActivity(); - - if (savedInstanceState != null && savedInstanceState.getBoolean("active")) { - onDrawerOpened(); - } - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putBoolean("active", mTimer != null); - } - - /** - * Invokes status callbacks. - */ - private void updateGui() { - MainActivity mainActivity = (MainActivity) getActivity(); - if (mainActivity == null) { - return; - } - if (mainActivity.isFinishing()) { - return; - } - - RestApi mApi = mainActivity.getApi(); - if (mApi != null) { - mApi.getSystemInfo(this::onReceiveSystemInfo); - mApi.getSystemVersion(this::onReceiveSystemVersion); - mApi.getConnections(this::onReceiveConnections); - } - } - - /** - * This will not do anything if gui updates are already scheduled. - */ - public void requestGuiUpdate() { - if (mTimer == null) { - updateGui(); - } - } - - /** - * Populates views with status received via {@link RestApi#getSystemInfo}. - */ - private void onReceiveSystemInfo(SystemInfo info) { - if (getActivity() == null) - return; - NumberFormat percentFormat = NumberFormat.getPercentInstance(); - percentFormat.setMaximumFractionDigits(2); - mRamUsage.setText(Util.readableFileSize(mActivity, info.sys)); - int announceTotal = info.discoveryMethods; - int announceConnected = - announceTotal - Optional.fromNullable(info.discoveryErrors).transform(Map::size).or(0); - mAnnounceServer.setText(String.format(Locale.getDefault(), "%1$d/%2$d", - announceConnected, announceTotal)); - int color = (announceConnected > 0) - ? R.color.text_green - : R.color.text_red; - mAnnounceServer.setTextColor(ContextCompat.getColor(getContext(), color)); - } - - /** - * Populates views with status received via {@link RestApi#getSystemInfo}. - */ - private void onReceiveSystemVersion(SystemVersion info) { - if (getActivity() == null) - return; - - mVersion.setText(info.version); - } - - /** - * Populates views with status received via {@link RestApi#getConnections}. - */ - private void onReceiveConnections(Connections connections) { - Connections.Connection c = connections.total; - mDownload.setText(Util.readableTransferRate(mActivity, c.inBits)); - mUpload.setText(Util.readableTransferRate(mActivity, c.outBits)); - } - - /** - * Gets QRCode and displays it in a Dialog. - */ - - private void showQrCode() { - RestApi restApi = mActivity.getApi(); - if (restApi == null) { - Toast.makeText(mActivity, R.string.syncthing_terminated, Toast.LENGTH_SHORT).show(); - return; - } - try { - String apiKey = Objects.requireNonNull(restApi.getGui()).apiKey; - String deviceId = Objects.requireNonNull(restApi.getLocalDevice()).deviceID; - URL url = restApi.getUrl(); - //The QRCode request takes one paramteer called "text", which is the text to be converted to a QRCode. - assert deviceId != null; - new ImageGetRequest(mActivity, url, ImageGetRequest.QR_CODE_GENERATOR, apiKey, - ImmutableMap.of("text", deviceId),qrCodeBitmap -> { - mActivity.showQrCodeDialog(deviceId, qrCodeBitmap); - mActivity.closeDrawer(); - }, error -> Toast.makeText(mActivity, R.string.could_not_access_deviceid, Toast.LENGTH_SHORT).show()); - } catch (Exception e) { - Log.e(TAG, "showQrCode", e); - } - } - - @Override - public void onClick(View v) { - switch (v.getId()) { - case R.id.drawerActionWebGui: - startActivity(new Intent(mActivity, WebGuiActivity.class)); - mActivity.closeDrawer(); - break; - case R.id.drawerActionSettings: - startActivity(new Intent(mActivity, SettingsActivity.class)); - mActivity.closeDrawer(); - break; - case R.id.drawerActionRestart: - mActivity.showRestartDialog(); - mActivity.closeDrawer(); - break; - case R.id.drawerActionExit: - if (sharedPreferences != null && sharedPreferences.getBoolean(Constants.PREF_START_SERVICE_ON_BOOT, false)) { - /** - * App is running as a service. Show an explanation why exiting syncthing is an - * extraordinary request, then ask the user to confirm. - */ - AlertDialog mExitConfirmationDialog = Util.getAlertDialogBuilder(mActivity) - .setTitle(R.string.dialog_exit_while_running_as_service_title) - .setMessage(R.string.dialog_exit_while_running_as_service_message) - .setPositiveButton(R.string.yes, (d, i) -> { - doExit(); - }) - .setNegativeButton(R.string.no, (d, i) -> {}) - .show(); - } else { - // App is not running as a service. - doExit(); - } - mActivity.closeDrawer(); - break; - case R.id.drawerActionShowQrCode: - showQrCode(); - break; - } - } - - private boolean alwaysRunInBackground() { - SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(getActivity()); - return sp.getBoolean(Constants.PREF_START_SERVICE_ON_BOOT, false); - } - - private void doExit() { - if (mActivity == null || mActivity.isFinishing()) { - return; - } - Log.i(TAG, "Exiting app on user request"); - mActivity.stopService(new Intent(mActivity, SyncthingService.class)); - mActivity.finishAndRemoveTask(); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/DrawerFragment.kt b/app/src/main/java/com/nutomic/syncthingandroid/fragments/DrawerFragment.kt new file mode 100644 index 00000000..1d1d3ac5 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/DrawerFragment.kt @@ -0,0 +1,323 @@ +package com.nutomic.syncthingandroid.fragments + +import android.content.DialogInterface +import android.content.Intent +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceManager +import com.android.volley.VolleyError +import com.google.common.base.Optional +import com.google.common.collect.ImmutableMap +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.activities.MainActivity +import com.nutomic.syncthingandroid.activities.SettingsActivity +import com.nutomic.syncthingandroid.activities.WebGuiActivity +import com.nutomic.syncthingandroid.http.ImageGetRequest +import com.nutomic.syncthingandroid.model.Config.Gui +import com.nutomic.syncthingandroid.model.Connections +import com.nutomic.syncthingandroid.model.Device +import com.nutomic.syncthingandroid.model.SystemInfo +import com.nutomic.syncthingandroid.model.SystemVersion +import com.nutomic.syncthingandroid.service.Constants +import com.nutomic.syncthingandroid.service.RestApi +import com.nutomic.syncthingandroid.service.SyncthingService +import com.nutomic.syncthingandroid.util.Util +import java.text.NumberFormat +import java.util.Locale +import java.util.Objects +import java.util.Timer +import java.util.TimerTask + +/** + * Displays information about the local device. + */ +class DrawerFragment : Fragment(), View.OnClickListener { + private var mRamUsage: TextView? = null + private var mDownload: TextView? = null + private var mUpload: TextView? = null + private var mAnnounceServer: TextView? = null + private var mVersion: TextView? = null + private var mExitButton: TextView? = null + + private var mTimer: Timer? = null + + private var mActivity: MainActivity? = null + private var sharedPreferences: SharedPreferences? = null + + fun onDrawerOpened() { + mTimer = Timer() + mTimer!!.schedule(object : TimerTask() { + override fun run() { + updateGui() + } + }, 0, Constants.GUI_UPDATE_INTERVAL) + } + + override fun onResume() { + super.onResume() + updateExitButtonVisibility() + } + + fun onDrawerClosed() { + if (mTimer != null) { + mTimer!!.cancel() + mTimer = null + } + } + + override fun onDestroy() { + super.onDestroy() + onDrawerClosed() + } + + /** + * Populates views and menu. + */ + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_drawer, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + mActivity = activity as MainActivity? + sharedPreferences = mActivity?.let { PreferenceManager.getDefaultSharedPreferences(it) } + + mRamUsage = view.findViewById(R.id.ram_usage) + mDownload = view.findViewById(R.id.download) + mUpload = view.findViewById(R.id.upload) + mAnnounceServer = view.findViewById(R.id.announce_server) + mVersion = view.findViewById(R.id.version) + mExitButton = view.findViewById(R.id.drawerActionExit) + + view.findViewById(R.id.drawerActionWebGui) + .setOnClickListener(this) + view.findViewById(R.id.drawerActionRestart) + .setOnClickListener(this) + view.findViewById(R.id.drawerActionSettings) + .setOnClickListener(this) + view.findViewById(R.id.drawerActionShowQrCode) + .setOnClickListener(this) + mExitButton!!.setOnClickListener(this) + + updateExitButtonVisibility() + } + + private fun updateExitButtonVisibility() { + val alwaysInBackground = alwaysRunInBackground() + mExitButton!!.visibility = if (alwaysInBackground) View.GONE else View.VISIBLE + } + + @Deprecated("Deprecated in Java") + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + mActivity = activity as MainActivity? + + if (savedInstanceState != null && savedInstanceState.getBoolean("active")) { + onDrawerOpened() + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean("active", mTimer != null) + } + + /** + * Invokes status callbacks. + */ + private fun updateGui() { + val mainActivity = activity as MainActivity? ?: return + if (mainActivity.isFinishing) { + return + } + + val mApi = mainActivity.api + if (mApi != null) { + mApi.getSystemInfo { info: SystemInfo? -> + this.onReceiveSystemInfo( + info!! + ) + } + mApi.getSystemVersion { info: SystemVersion? -> + this.onReceiveSystemVersion( + info!! + ) + } + mApi.getConnections { connections: Connections? -> + this.onReceiveConnections( + connections!! + ) + } + } + } + + /** + * This will not do anything if gui updates are already scheduled. + */ + fun requestGuiUpdate() { + if (mTimer == null) { + updateGui() + } + } + + /** + * Populates views with status received via [RestApi.getSystemInfo]. + */ + private fun onReceiveSystemInfo(info: SystemInfo) { + if (activity == null) return + val percentFormat = NumberFormat.getPercentInstance() + percentFormat.setMaximumFractionDigits(2) + mRamUsage!!.text = Util.readableFileSize(mActivity!!, info.sys) + val announceTotal = info.discoveryMethods + val announceConnected = + announceTotal - Optional.fromNullable( + info.discoveryErrors + ) + .transform(com.google.common.base.Function { obj: MutableMap -> obj.size }) + .or(0) + mAnnounceServer!!.text = String.format( + Locale.getDefault(), $$"%1$d/%2$d", + announceConnected, announceTotal + ) + val color = if (announceConnected > 0) + R.color.text_green + else + R.color.text_red + mAnnounceServer!!.setTextColor(ContextCompat.getColor(requireContext(), color)) + } + + /** + * Populates views with status received via [RestApi.getSystemInfo]. + */ + private fun onReceiveSystemVersion(info: SystemVersion) { + if (activity == null) return + + mVersion!!.text = info.version + } + + /** + * Populates views with status received via [RestApi.getConnections]. + */ + private fun onReceiveConnections(connections: Connections) { + val c = connections.total + mDownload!!.text = Util.readableTransferRate(mActivity!!, c!!.inBits) + mUpload!!.text = Util.readableTransferRate(mActivity!!, c.outBits) + } + + /** + * Gets QRCode and displays it in a Dialog. + */ + private fun showQrCode() { + val restApi = mActivity!!.api + if (restApi == null) { + Toast.makeText(mActivity, R.string.syncthing_terminated, Toast.LENGTH_SHORT).show() + return + } + try { + val apiKey = Objects.requireNonNull(restApi.gui).apiKey + val deviceId = Objects.requireNonNull(restApi.localDevice).deviceID + val url = restApi.url + //The QRCode request takes one parameter called "text", which is the text to be converted to a QRCode. + checkNotNull(deviceId) + ImageGetRequest( + mActivity, + url, + ImageGetRequest.QR_CODE_GENERATOR, + apiKey, + ImmutableMap.of("text", deviceId), + { qrCodeBitmap: Bitmap? -> + mActivity!!.showQrCodeDialog(deviceId, qrCodeBitmap) + mActivity!!.closeDrawer() + } + ) { _: VolleyError? -> + Toast.makeText( + mActivity, + R.string.could_not_access_deviceid, + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.e(TAG, "showQrCode", e) + } + } + + override fun onClick(v: View) { + when (v.id) { + R.id.drawerActionWebGui -> { + startActivity(Intent(mActivity, WebGuiActivity::class.java)) + mActivity!!.closeDrawer() + } + + R.id.drawerActionSettings -> { + startActivity(Intent(mActivity, SettingsActivity::class.java)) + mActivity!!.closeDrawer() + } + + R.id.drawerActionRestart -> { + mActivity!!.showRestartDialog() + mActivity!!.closeDrawer() + } + + R.id.drawerActionExit -> { + if (sharedPreferences != null && sharedPreferences!!.getBoolean( + Constants.PREF_START_SERVICE_ON_BOOT, + false + ) + ) { + /** + * App is running as a service. Show an explanation why exiting syncthing is an + * extraordinary request, then ask the user to confirm. + */ + val mExitConfirmationDialog = Util.getAlertDialogBuilder(mActivity!!) + .setTitle(R.string.dialog_exit_while_running_as_service_title) + .setMessage(R.string.dialog_exit_while_running_as_service_message) + .setPositiveButton( + R.string.yes + ) { _: DialogInterface?, _: Int -> + doExit() + } + .setNegativeButton( + R.string.no + ) { _: DialogInterface?, _: Int -> } + .show() + } else { + // App is not running as a service. + doExit() + } + mActivity!!.closeDrawer() + } + + R.id.drawerActionShowQrCode -> showQrCode() + } + } + + private fun alwaysRunInBackground(): Boolean { + val sp = activity?.let { PreferenceManager.getDefaultSharedPreferences(it) } + return sp?.getBoolean(Constants.PREF_START_SERVICE_ON_BOOT, false) ?: false + } + + private fun doExit() { + if (mActivity == null || mActivity!!.isFinishing) { + return + } + Log.i(TAG, "Exiting app on user request") + mActivity!!.stopService(Intent(mActivity, SyncthingService::class.java)) + mActivity!!.finishAndRemoveTask() + } + + companion object { + private const val TAG = "DrawerFragment" + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.java deleted file mode 100644 index 8da44b77..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.nutomic.syncthingandroid.fragments; - -import android.content.Intent; -import android.os.Bundle; -import androidx.fragment.app.ListFragment; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.widget.AdapterView; - -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.activities.FolderActivity; -import com.nutomic.syncthingandroid.activities.SyncthingActivity; -import com.nutomic.syncthingandroid.model.Folder; -import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.service.RestApi; -import com.nutomic.syncthingandroid.service.SyncthingService; -import com.nutomic.syncthingandroid.views.FoldersAdapter; - -import java.util.List; -import java.util.Timer; -import java.util.TimerTask; - -/** - * Displays a list of all existing folders. - */ -public class FolderListFragment extends ListFragment implements SyncthingService.OnServiceStateChangeListener, - AdapterView.OnItemClickListener { - - private FoldersAdapter mAdapter; - - private Timer mTimer; - - @Override - public void onPause() { - super.onPause(); - if (mTimer != null) { - mTimer.cancel(); - } - } - - @Override - public void onServiceStateChange(SyncthingService.State currentState) { - if (currentState != SyncthingService.State.ACTIVE) - return; - - mTimer = new Timer(); - mTimer.schedule(new TimerTask() { - @Override - public void run() { - if (getActivity() == null) - return; - - getActivity().runOnUiThread(FolderListFragment.this::updateList); - } - - }, 0, Constants.GUI_UPDATE_INTERVAL); - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - setHasOptionsMenu(true); - setEmptyText(getString(R.string.folder_list_empty)); - getListView().setOnItemClickListener(this); - } - - /** - * Refreshes ListView by updating folders and info. - * - * Also creates adapter if it doesn't exist yet. - */ - private void updateList() { - SyncthingActivity activity = (SyncthingActivity) getActivity(); - if (activity == null || getView() == null || activity.isFinishing()) { - return; - } - RestApi restApi = activity.getApi(); - if (restApi == null || !restApi.isConfigLoaded()) { - return; - } - List folders = restApi.getFolders(); - if (folders == null) { - return; - } - if (mAdapter == null) { - mAdapter = new FoldersAdapter(activity); - setListAdapter(mAdapter); - } - - // Prevent scroll position reset due to list update from clear(). - mAdapter.setNotifyOnChange(false); - mAdapter.clear(); - mAdapter.addAll(folders); - mAdapter.updateFolderStatus(restApi); - mAdapter.notifyDataSetChanged(); - setListShown(true); - } - - @Override - public void onItemClick(AdapterView adapterView, View view, int i, long l) { - Intent intent = new Intent(getActivity(), FolderActivity.class) - .putExtra(FolderActivity.EXTRA_IS_CREATE, false) - .putExtra(FolderActivity.EXTRA_FOLDER_ID, mAdapter.getItem(i).id); - startActivity(intent); - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.folder_list, menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.add_folder: - Intent intent = new Intent(getActivity(), FolderActivity.class) - .putExtra(FolderActivity.EXTRA_IS_CREATE, true); - startActivity(intent); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.kt b/app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.kt new file mode 100644 index 00000000..90a063ef --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.kt @@ -0,0 +1,112 @@ +package com.nutomic.syncthingandroid.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.widget.AdapterView +import android.widget.AdapterView.OnItemClickListener +import androidx.fragment.app.ListFragment +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.activities.FolderActivity +import com.nutomic.syncthingandroid.activities.SyncthingActivity +import com.nutomic.syncthingandroid.service.Constants +import com.nutomic.syncthingandroid.service.SyncthingService +import com.nutomic.syncthingandroid.service.SyncthingService.OnServiceStateChangeListener +import com.nutomic.syncthingandroid.views.FoldersAdapter +import java.util.Timer +import java.util.TimerTask + +/** + * Displays a list of all existing folders. + */ +class FolderListFragment : ListFragment(), OnServiceStateChangeListener, OnItemClickListener { + private var mAdapter: FoldersAdapter? = null + + private var mTimer: Timer? = null + + override fun onPause() { + super.onPause() + if (mTimer != null) { + mTimer!!.cancel() + } + } + + override fun onServiceStateChange(currentState: SyncthingService.State?) { + if (currentState != SyncthingService.State.ACTIVE) return + + mTimer = Timer() + mTimer!!.schedule(object : TimerTask() { + override fun run() { + if (activity == null) return + + activity!!.runOnUiThread { this@FolderListFragment.updateList() } + } + }, 0, Constants.GUI_UPDATE_INTERVAL) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setHasOptionsMenu(true) + setEmptyText(getString(R.string.folder_list_empty)) + getListView().onItemClickListener = this + } + + /** + * Refreshes ListView by updating folders and info. + * + * Also creates adapter if it doesn't exist yet. + */ + private fun updateList() { + val activity = activity as SyncthingActivity? + if (activity == null || view == null || activity.isFinishing) { + return + } + val restApi = activity.api + if (restApi == null || !restApi.isConfigLoaded) { + return + } + val folders = restApi.folders + if (mAdapter == null) { + mAdapter = FoldersAdapter(activity) + setListAdapter(mAdapter) + } + + // Prevent scroll position reset due to list update from clear(). + mAdapter!!.setNotifyOnChange(false) + mAdapter!!.clear() + mAdapter!!.addAll(folders) + mAdapter!!.updateFolderStatus(restApi) + mAdapter!!.notifyDataSetChanged() + setListShown(true) + } + + override fun onItemClick(adapterView: AdapterView<*>?, view: View?, i: Int, l: Long) { + val intent = Intent(activity, FolderActivity::class.java) + .putExtra(FolderActivity.EXTRA_IS_CREATE, false) + .putExtra(FolderActivity.EXTRA_FOLDER_ID, mAdapter!!.getItem(i)!!.id) + startActivity(intent) + } + + @Deprecated("Deprecated in Java") + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.folder_list, menu) + } + + @Deprecated("Deprecated in Java") + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.add_folder -> { + val intent = Intent(activity, FolderActivity::class.java) + .putExtra(FolderActivity.EXTRA_IS_CREATE, true) + startActivity(intent) + return true + } + + else -> return super.onOptionsItemSelected(item) + } + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/NumberPickerFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/NumberPickerFragment.java deleted file mode 100644 index 87e20616..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/NumberPickerFragment.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.nutomic.syncthingandroid.fragments; - -import android.os.Bundle; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.NumberPicker; - -import com.nutomic.syncthingandroid.R; - -/** - * Simply displays a numberpicker and allows easy access to configure it with the public functions. - */ - -public class NumberPickerFragment extends Fragment { - - private NumberPicker mNumberPicker; - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - mNumberPicker = (NumberPicker) inflater.inflate(R.layout.numberpicker_fragment, container, false); - mNumberPicker.setWrapSelectorWheel(false); - - return mNumberPicker; - } - - public void setOnValueChangedLisenter(NumberPicker.OnValueChangeListener onValueChangeListener){ - mNumberPicker.setOnValueChangedListener(onValueChangeListener); - } - - public void updateNumberPicker(int maxValue, int minValue, int currentValue){ - mNumberPicker.setMaxValue(maxValue); - mNumberPicker.setMinValue(minValue); - mNumberPicker.setValue(currentValue); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/NumberPickerFragment.kt b/app/src/main/java/com/nutomic/syncthingandroid/fragments/NumberPickerFragment.kt new file mode 100644 index 00000000..abc00b4d --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/NumberPickerFragment.kt @@ -0,0 +1,39 @@ +package com.nutomic.syncthingandroid.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.NumberPicker +import android.widget.NumberPicker.OnValueChangeListener +import androidx.fragment.app.Fragment +import com.nutomic.syncthingandroid.R + +/** + * Simply displays a numberpicker and allows easy access to configure it with the public functions. + */ +class NumberPickerFragment : Fragment() { + private var mNumberPicker: NumberPicker? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + mNumberPicker = + inflater.inflate(R.layout.numberpicker_fragment, container, false) as NumberPicker + mNumberPicker!!.setWrapSelectorWheel(false) + + return mNumberPicker + } + + fun setOnValueChangedListener(onValueChangeListener: OnValueChangeListener?) { + mNumberPicker!!.setOnValueChangedListener(onValueChangeListener) + } + + fun updateNumberPicker(maxValue: Int, minValue: Int, currentValue: Int) { + mNumberPicker!!.setMaxValue(maxValue) + mNumberPicker!!.setMinValue(minValue) + mNumberPicker!!.value = currentValue + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/SimpleVersioningFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/SimpleVersioningFragment.java index d16aafcb..b31500d4 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/SimpleVersioningFragment.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/SimpleVersioningFragment.java @@ -42,7 +42,7 @@ private boolean missingParameters() { private void updateNumberPicker() { NumberPickerFragment numberPicker = (NumberPickerFragment) getChildFragmentManager().findFragmentByTag("numberpicker_simple_versioning"); numberPicker.updateNumberPicker(100000, 1, getKeepVersions()); - numberPicker.setOnValueChangedLisenter((picker, oldVal, newVal) -> updateKeepVersions((String.valueOf(newVal)))); + numberPicker.setOnValueChangedListener((picker, oldVal, newVal) -> updateKeepVersions((String.valueOf(newVal)))); } private void updateKeepVersions(String newValue) { diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/StaggeredVersioningFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/StaggeredVersioningFragment.java index 79ea2744..e164238b 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/StaggeredVersioningFragment.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/StaggeredVersioningFragment.java @@ -56,7 +56,7 @@ private boolean missingParameters() { private void updateNumberPicker() { NumberPickerFragment numberPicker = (NumberPickerFragment) getChildFragmentManager().findFragmentByTag("numberpicker_staggered_versioning"); numberPicker.updateNumberPicker(100, 0, getMaxAgeInDays()); - numberPicker.setOnValueChangedLisenter((picker, oldVal, newVal) -> updatePreference("maxAge", (String.valueOf(TimeUnit.DAYS.toSeconds(newVal))))); + numberPicker.setOnValueChangedListener((picker, oldVal, newVal) -> updatePreference("maxAge", (String.valueOf(TimeUnit.DAYS.toSeconds(newVal))))); } private void initiateVersionsPathTextView() { diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/TrashCanVersioningFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/TrashCanVersioningFragment.java index 7e0cfb63..581f7858 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/TrashCanVersioningFragment.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/TrashCanVersioningFragment.java @@ -42,7 +42,7 @@ private boolean missingParameters() { private void updateNumberPicker() { NumberPickerFragment numberPicker = (NumberPickerFragment) getChildFragmentManager().findFragmentByTag("numberpicker_trashcan_versioning"); numberPicker.updateNumberPicker(100, 0, getCleanoutDays()); - numberPicker.setOnValueChangedLisenter((picker, oldVal, newVal) -> updateCleanoutDays((String.valueOf(newVal)))); + numberPicker.setOnValueChangedListener((picker, oldVal, newVal) -> updateCleanoutDays((String.valueOf(newVal)))); } private int getCleanoutDays() { diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.kt b/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.kt index 860990f6..2fb5effb 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.kt @@ -42,7 +42,7 @@ abstract class ApiRequest internal constructor( fun onSuccess(result: String?) } - interface OnImageSuccessListener { + fun interface OnImageSuccessListener { fun onImageSuccess(result: Bitmap?) } From 425ca04ea4ba346a9cad1e87ff4a5ab747b941b1 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 10:59:54 -0800 Subject: [PATCH 42/80] Dialog to kotlin --- .../dialog/ExternalVersioningFragment.java | 74 ------------- .../dialog/ExternalVersioningFragment.kt | 66 ++++++++++++ .../dialog/NoVersioningFragment.java | 19 ---- .../fragments/dialog/NoVersioningFragment.kt | 18 ++++ .../dialog/SimpleVersioningFragment.java | 56 ---------- .../dialog/SimpleVersioningFragment.kt | 58 ++++++++++ .../dialog/StaggeredVersioningFragment.java | 95 ---------------- .../dialog/StaggeredVersioningFragment.kt | 102 ++++++++++++++++++ .../dialog/TrashCanVersioningFragment.java | 55 ---------- .../dialog/TrashCanVersioningFragment.kt | 58 ++++++++++ 10 files changed, 302 insertions(+), 299 deletions(-) delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/ExternalVersioningFragment.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/ExternalVersioningFragment.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/NoVersioningFragment.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/NoVersioningFragment.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/SimpleVersioningFragment.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/SimpleVersioningFragment.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/StaggeredVersioningFragment.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/StaggeredVersioningFragment.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/TrashCanVersioningFragment.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/TrashCanVersioningFragment.kt diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/ExternalVersioningFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/ExternalVersioningFragment.java deleted file mode 100644 index 58a0e722..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/ExternalVersioningFragment.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.nutomic.syncthingandroid.fragments.dialog; - -import android.os.Bundle; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import com.nutomic.syncthingandroid.R; - -/** - * Contains the configuration options for external file versioning. - */ - -public class ExternalVersioningFragment extends Fragment { - - private View mView; - - private Bundle mArguments; - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - mView = inflater.inflate(R.layout.fragment_external_versioning, container, false); - mArguments = getArguments(); - fillArguments(); - initateTextView(); - return mView; - } - - private void fillArguments() { - if (missingParameters()){ - mArguments.putString("command", ""); - } - } - - private boolean missingParameters() { - return !mArguments.containsKey("command"); - } - - private void initateTextView() { - TextView commandTextView = mView.findViewById(R.id.commandTextView); - - commandTextView.setText(getCommand()); - commandTextView.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - - } - - @Override - public void onTextChanged(CharSequence command, int start, int before, int count) { - updateCommand(command.toString()); - } - - @Override - public void afterTextChanged(Editable s) { - - } - }); - } - - private void updateCommand(String command) { - mArguments.putString("command", command); - } - - private String getCommand() { - return mArguments.containsKey("command") ? mArguments.getString("command") : "" ; - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/ExternalVersioningFragment.kt b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/ExternalVersioningFragment.kt new file mode 100644 index 00000000..a1f73348 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/ExternalVersioningFragment.kt @@ -0,0 +1,66 @@ +package com.nutomic.syncthingandroid.fragments.dialog + +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import com.nutomic.syncthingandroid.R + +/** + * Contains the configuration options for external file versioning. + */ +class ExternalVersioningFragment : Fragment() { + private var mView: View? = null + + private var mArguments: Bundle? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + mView = inflater.inflate(R.layout.fragment_external_versioning, container, false) + mArguments = arguments + fillArguments() + initiateTextView() + return mView + } + + private fun fillArguments() { + if (missingParameters()) { + mArguments!!.putString("command", "") + } + } + + private fun missingParameters(): Boolean { + return !mArguments!!.containsKey("command") + } + + private fun initiateTextView() { + val commandTextView = mView!!.findViewById(R.id.commandTextView) + + commandTextView.text = this.command + commandTextView.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + } + + override fun onTextChanged(command: CharSequence, start: Int, before: Int, count: Int) { + updateCommand(command.toString()) + } + + override fun afterTextChanged(s: Editable?) { + } + }) + } + + private fun updateCommand(command: String?) { + mArguments!!.putString("command", command) + } + + private val command: String? + get() = if (mArguments!!.containsKey("command")) mArguments!!.getString("command") else "" +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/NoVersioningFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/NoVersioningFragment.java deleted file mode 100644 index 7c5882c0..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/NoVersioningFragment.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.nutomic.syncthingandroid.fragments.dialog; - -import android.os.Bundle; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.nutomic.syncthingandroid.R; - -public class NoVersioningFragment extends Fragment { - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_no_versioning, container, false); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/NoVersioningFragment.kt b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/NoVersioningFragment.kt new file mode 100644 index 00000000..1245556a --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/NoVersioningFragment.kt @@ -0,0 +1,18 @@ +package com.nutomic.syncthingandroid.fragments.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.nutomic.syncthingandroid.R + +class NoVersioningFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_no_versioning, container, false) + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/SimpleVersioningFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/SimpleVersioningFragment.java deleted file mode 100644 index b31500d4..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/SimpleVersioningFragment.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.nutomic.syncthingandroid.fragments.dialog; - -import android.os.Bundle; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.fragments.NumberPickerFragment; - -/** - * Contains the configuration options for simple file versioning. - */ - -public class SimpleVersioningFragment extends Fragment { - - private Bundle mArguments; - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_simple_versioning, container, false); - mArguments = getArguments(); - fillArguments(); - updateNumberPicker(); - return view; - } - - private void fillArguments() { - if (missingParameters()){ - mArguments.putString("keep", "5"); - } - } - - private boolean missingParameters() { - return !mArguments.containsKey("keep"); - } - - //a NumberPickerFragment is nested in the fragment_simple_versioning layout, the values for it are update below. - private void updateNumberPicker() { - NumberPickerFragment numberPicker = (NumberPickerFragment) getChildFragmentManager().findFragmentByTag("numberpicker_simple_versioning"); - numberPicker.updateNumberPicker(100000, 1, getKeepVersions()); - numberPicker.setOnValueChangedListener((picker, oldVal, newVal) -> updateKeepVersions((String.valueOf(newVal)))); - } - - private void updateKeepVersions(String newValue) { - mArguments.putString("keep", newValue); - } - - private int getKeepVersions() { - return Integer.valueOf(mArguments.getString("keep")); - } - -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/SimpleVersioningFragment.kt b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/SimpleVersioningFragment.kt new file mode 100644 index 00000000..5cfb5511 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/SimpleVersioningFragment.kt @@ -0,0 +1,58 @@ +package com.nutomic.syncthingandroid.fragments.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.NumberPicker +import androidx.fragment.app.Fragment +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.fragments.NumberPickerFragment + +/** + * Contains the configuration options for simple file versioning. + */ +class SimpleVersioningFragment : Fragment() { + private var mArguments: Bundle? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.fragment_simple_versioning, container, false) + mArguments = arguments + fillArguments() + updateNumberPicker() + return view + } + + private fun fillArguments() { + if (missingParameters()) { + mArguments!!.putString("keep", "5") + } + } + + private fun missingParameters(): Boolean { + return !mArguments!!.containsKey("keep") + } + + //a NumberPickerFragment is nested in the fragment_simple_versioning layout, the values for it are update below. + private fun updateNumberPicker() { + val numberPicker = + getChildFragmentManager().findFragmentByTag("numberpicker_simple_versioning") as NumberPickerFragment? + numberPicker!!.updateNumberPicker(100000, 1, this.keepVersions) + numberPicker.setOnValueChangedListener { _: NumberPicker?, _: Int, newVal: Int -> + updateKeepVersions( + (newVal.toString()) + ) + } + } + + private fun updateKeepVersions(newValue: String?) { + mArguments!!.putString("keep", newValue) + } + + private val keepVersions: Int + get() = mArguments!!.getString("keep")!!.toInt() +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/StaggeredVersioningFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/StaggeredVersioningFragment.java deleted file mode 100644 index e164238b..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/StaggeredVersioningFragment.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.nutomic.syncthingandroid.fragments.dialog; - -import android.app.Activity; -import android.content.Intent; -import android.os.Bundle; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.activities.FolderPickerActivity; -import com.nutomic.syncthingandroid.fragments.NumberPickerFragment; - -import java.util.concurrent.TimeUnit; - - -/** - * Contains the configuration options for Staggered file versioning. - */ - -public class StaggeredVersioningFragment extends Fragment { - - private View mView; - - private Bundle mArguments; - - private TextView mPathView; - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - mView = inflater.inflate(R.layout.fragment_staggered_versioning, container, false); - mArguments = getArguments(); - fillArguments(); - updateNumberPicker(); - initiateVersionsPathTextView(); - return mView; - } - - private void fillArguments() { - if (missingParameters()) { - mArguments.putString("maxAge", "0"); - mArguments.putString("versionsPath", ""); - } - } - - private boolean missingParameters() { - return !mArguments.containsKey("maxAge"); - } - - //The maxAge parameter is displayed in days but stored in seconds since Syncthing needs it in seconds. - //A NumberPickerFragment is nested in the fragment_staggered_versioning layout, the values for it are update below. - private void updateNumberPicker() { - NumberPickerFragment numberPicker = (NumberPickerFragment) getChildFragmentManager().findFragmentByTag("numberpicker_staggered_versioning"); - numberPicker.updateNumberPicker(100, 0, getMaxAgeInDays()); - numberPicker.setOnValueChangedListener((picker, oldVal, newVal) -> updatePreference("maxAge", (String.valueOf(TimeUnit.DAYS.toSeconds(newVal))))); - } - - private void initiateVersionsPathTextView() { - mPathView = mView.findViewById(R.id.directoryTextView); - String currentPath = getVersionsPath(); - - mPathView.setText(currentPath); - mPathView.setOnClickListener(view -> - startActivityForResult(FolderPickerActivity.createIntent(getContext(), currentPath, null), FolderPickerActivity.DIRECTORY_REQUEST_CODE)); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (resultCode == Activity.RESULT_OK && requestCode == FolderPickerActivity.DIRECTORY_REQUEST_CODE ) { - updatePath(data.getStringExtra(FolderPickerActivity.EXTRA_RESULT_DIRECTORY)); - } - } - - private void updatePath(String directory) { - mPathView.setText(directory); - updatePreference("versionsPath", directory); - } - - private String getVersionsPath() { - return mArguments.getString("versionsPath"); - } - - private void updatePreference(String key, String newValue) { - getArguments().putString(key, newValue); - } - - private int getMaxAgeInDays() { - return (int) TimeUnit.SECONDS.toDays(Long.valueOf(mArguments.getString("maxAge"))); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/StaggeredVersioningFragment.kt b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/StaggeredVersioningFragment.kt new file mode 100644 index 00000000..a5cb5002 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/StaggeredVersioningFragment.kt @@ -0,0 +1,102 @@ +package com.nutomic.syncthingandroid.fragments.dialog + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.NumberPicker +import android.widget.TextView +import androidx.fragment.app.Fragment +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.activities.FolderPickerActivity +import com.nutomic.syncthingandroid.fragments.NumberPickerFragment +import java.util.concurrent.TimeUnit + +/** + * Contains the configuration options for Staggered file versioning. + */ +class StaggeredVersioningFragment : Fragment() { + private var mView: View? = null + + private var mArguments: Bundle? = null + + private var mPathView: TextView? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + mView = inflater.inflate(R.layout.fragment_staggered_versioning, container, false) + mArguments = arguments + fillArguments() + updateNumberPicker() + initiateVersionsPathTextView() + return mView + } + + private fun fillArguments() { + if (missingParameters()) { + mArguments!!.putString("maxAge", "0") + mArguments!!.putString("versionsPath", "") + } + } + + private fun missingParameters(): Boolean { + return !mArguments!!.containsKey("maxAge") + } + + //The maxAge parameter is displayed in days but stored in seconds since Syncthing needs it in seconds. + //A NumberPickerFragment is nested in the fragment_staggered_versioning layout, the values for it are update below. + private fun updateNumberPicker() { + val numberPicker = + getChildFragmentManager().findFragmentByTag("numberpicker_staggered_versioning") as NumberPickerFragment? + numberPicker!!.updateNumberPicker(100, 0, this.maxAgeInDays) + numberPicker.setOnValueChangedListener { _: NumberPicker?, _: Int, newVal: Int -> + updatePreference( + "maxAge", + (TimeUnit.DAYS.toSeconds(newVal.toLong()).toString()) + ) + } + } + + private fun initiateVersionsPathTextView() { + mPathView = mView!!.findViewById(R.id.directoryTextView) + val currentPath = this.versionsPath + + mPathView!!.text = currentPath + mPathView!!.setOnClickListener { _: View? -> + startActivityForResult( + FolderPickerActivity.createIntent(context, currentPath, null), + FolderPickerActivity.DIRECTORY_REQUEST_CODE + ) + } + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (resultCode == Activity.RESULT_OK && requestCode == FolderPickerActivity.DIRECTORY_REQUEST_CODE) { + updatePath(data?.getStringExtra(FolderPickerActivity.EXTRA_RESULT_DIRECTORY)) + } + } + + private fun updatePath(directory: String?) { + mPathView!!.text = directory + updatePreference("versionsPath", directory) + } + + private val versionsPath: String? + get() = mArguments!!.getString("versionsPath") + + private fun updatePreference(key: String?, newValue: String?) { + requireArguments().putString(key, newValue) + } + + private val maxAgeInDays: Int + get() = TimeUnit.SECONDS.toDays( + mArguments!!.getString("maxAge")!!.toLong() + ).toInt() +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/TrashCanVersioningFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/TrashCanVersioningFragment.java deleted file mode 100644 index 581f7858..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/TrashCanVersioningFragment.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.nutomic.syncthingandroid.fragments.dialog; - -import android.os.Bundle; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.fragments.NumberPickerFragment; - -/** - * Contains the configuration options for trashcan file versioning. - */ - -public class TrashCanVersioningFragment extends Fragment { - - private Bundle mArguments; - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_trashcan_versioning, container, false); - mArguments = getArguments(); - fillArguments(); - updateNumberPicker(); - return view; - } - - private void fillArguments() { - if (missingParameters()) { - mArguments.putString("cleanoutDays", "0"); - } - } - - private boolean missingParameters() { - return !mArguments.containsKey("cleanoutDays"); - } - - //a NumberPickerFragment is nested in the fragment_trashcan_versioning layout, the values for it are update below. - private void updateNumberPicker() { - NumberPickerFragment numberPicker = (NumberPickerFragment) getChildFragmentManager().findFragmentByTag("numberpicker_trashcan_versioning"); - numberPicker.updateNumberPicker(100, 0, getCleanoutDays()); - numberPicker.setOnValueChangedListener((picker, oldVal, newVal) -> updateCleanoutDays((String.valueOf(newVal)))); - } - - private int getCleanoutDays() { - return Integer.valueOf(mArguments.getString("cleanoutDays")); - } - - private void updateCleanoutDays(String newValue) { - mArguments.putString("cleanoutDays", newValue); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/TrashCanVersioningFragment.kt b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/TrashCanVersioningFragment.kt new file mode 100644 index 00000000..963de3b7 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/dialog/TrashCanVersioningFragment.kt @@ -0,0 +1,58 @@ +package com.nutomic.syncthingandroid.fragments.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.NumberPicker +import androidx.fragment.app.Fragment +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.fragments.NumberPickerFragment + +/** + * Contains the configuration options for trashcan file versioning. + */ +class TrashCanVersioningFragment : Fragment() { + private var mArguments: Bundle? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.fragment_trashcan_versioning, container, false) + mArguments = arguments + fillArguments() + updateNumberPicker() + return view + } + + private fun fillArguments() { + if (missingParameters()) { + mArguments!!.putString("cleanoutDays", "0") + } + } + + private fun missingParameters(): Boolean { + return !mArguments!!.containsKey("cleanoutDays") + } + + //a NumberPickerFragment is nested in the fragment_trashcan_versioning layout, the values for it are update below. + private fun updateNumberPicker() { + val numberPicker = + getChildFragmentManager().findFragmentByTag("numberpicker_trashcan_versioning") as NumberPickerFragment? + numberPicker!!.updateNumberPicker(100, 0, this.cleanoutDays) + numberPicker.setOnValueChangedListener { _: NumberPicker?, _: Int, newVal: Int -> + updateCleanoutDays( + (newVal.toString()) + ) + } + } + + private val cleanoutDays: Int + get() = mArguments!!.getString("cleanoutDays")!!.toInt() + + private fun updateCleanoutDays(newValue: String?) { + mArguments!!.putString("cleanoutDays", newValue) + } +} From 2cfc2e2d945f9860d1785dd56a600677ad82ee79 Mon Sep 17 00:00:00 2001 From: bsilvia <5374098+bsilvia@users.noreply.github.com> Date: Sat, 22 Nov 2025 12:01:32 -0800 Subject: [PATCH 43/80] Activities to kotlin, compiler errors resolved, compiler warnings next --- .../activities/DeviceActivity.java | 473 ---------- .../activities/DeviceActivity.kt | 513 +++++++++++ .../activities/FirstStartActivity.java | 482 ---------- .../activities/FirstStartActivity.kt | 450 +++++++++ .../activities/FolderActivity.java | 780 ---------------- .../activities/FolderActivity.kt | 852 ++++++++++++++++++ .../activities/FolderPickerActivity.java | 355 -------- .../activities/FolderPickerActivity.kt | 352 ++++++++ .../activities/FolderTypeDialogActivity.java | 77 -- .../activities/FolderTypeDialogActivity.kt | 78 ++ .../activities/LogActivity.java | 177 ---- .../activities/LogActivity.kt | 188 ++++ .../activities/MainActivity.java | 529 ----------- .../activities/MainActivity.kt | 555 ++++++++++++ .../activities/PullOrderDialogActivity.java | 77 -- .../activities/PullOrderDialogActivity.kt | 80 ++ .../activities/QRScannerActivity.java | 105 --- .../activities/QRScannerActivity.kt | 103 +++ .../activities/SettingsActivity.java | 721 --------------- .../activities/SettingsActivity.kt | 836 +++++++++++++++++ .../activities/ShareActivity.java | 392 -------- .../activities/ShareActivity.kt | 408 +++++++++ .../activities/StateDialogActivity.java | 188 ---- .../activities/StateDialogActivity.kt | 214 +++++ .../activities/SyncthingActivity.java | 108 --- .../activities/SyncthingActivity.kt | 98 ++ .../activities/ThemedAppCompatActivity.java | 33 - .../activities/ThemedAppCompatActivity.kt | 33 + .../activities/VersioningDialogActivity.java | 133 --- .../activities/VersioningDialogActivity.kt | 125 +++ .../activities/WebGuiActivity.java | 284 ------ .../activities/WebGuiActivity.kt | 297 ++++++ .../service/SyncthingService.kt | 4 +- 33 files changed, 5184 insertions(+), 4916 deletions(-) delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/FolderTypeDialogActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/FolderTypeDialogActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/LogActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/LogActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/PullOrderDialogActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/PullOrderDialogActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/QRScannerActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/QRScannerActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/SettingsActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/SettingsActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/ShareActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/ShareActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/StateDialogActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/StateDialogActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/SyncthingActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/SyncthingActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/ThemedAppCompatActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/ThemedAppCompatActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/VersioningDialogActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/VersioningDialogActivity.kt delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/WebGuiActivity.java create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/activities/WebGuiActivity.kt 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 bf5951e7..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java +++ /dev/null @@ -1,473 +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 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 != SyncthingService.State.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 = 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..10c403ef --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.kt @@ -0,0 +1,513 @@ +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.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.RestApi.OnResultListener1 +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 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 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) + setContentView(binding!!.getRoot()) + + mIsCreateMode = intent.getBooleanExtra(EXTRA_IS_CREATE, false) + registerOnServiceConnectedListener(OnServiceConnectedListener { 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() + } + } + + 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(OnResultListener1 { 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!!, + OnResultListener1 { 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 -> { + 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.yes + ) { _: DialogInterface?, _: Int -> + api?.removeDevice(mDevice!!.deviceID) + finish() + } + .setNegativeButton(android.R.string.no, null) + .create() + } + + /** + * Receives value of scanned QR code and sets it as device ID. + */ + public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, intent) + + if (requestCode == QR_SCAN_REQUEST_CODE) { + if (resultCode == RESULT_OK) { + val scanResult = intent.getStringExtra(QRScannerActivity.QR_RESULT_ARG) + if (scanResult != null) { + mDevice!!.deviceID = scanResult + binding!!.id.setText(mDevice!!.deviceID) + } + } + } + } + + private fun initDevice() { + mDevice = Device() + 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) + startActivityForResult(qrIntent, QR_SCAN_REQUEST_CODE) + } + 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) + ) + ) + } + + override fun onBackPressed() { + if (mIsCreateMode) { + showDiscardDialog() + } else { + super.onBackPressed() + } + } + + 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..08823a9e --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.kt @@ -0,0 +1,450 @@ +package com.nutomic.syncthingandroid.activities + +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.Build +import android.os.Bundle +import android.provider.Settings +import android.text.Html +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.annotation.RequiresApi +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.viewpager.widget.PagerAdapter +import androidx.viewpager.widget.ViewPager.OnPageChangeListener +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 com.nutomic.syncthingandroid.util.Util.runShellCommand +import org.apache.commons.io.FileUtils +import java.io.File +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), + API_LEVEL_30(R.layout.activity_firststart_slide_api_level_30), + 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 + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + @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) && upgradedToApiLevel30()) { + startApp() + return + } + + // Show first start welcome wizard UI. + binding = ActivityFirstStartBinding.inflate(layoutInflater) + setContentView(binding!!.getRoot()) + + 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.setAdapter(mViewPagerAdapter) + binding!!.viewPager.addOnPageChangeListener(mViewPagerPageChangeListener) + + binding!!.btnBack.setOnClickListener { onBtnBackClick() } + + binding!!.btnNext.setOnClickListener { onBtnNextClick() } + + if (!this.isFirstStart) { + // Skip intro slide + onBtnNextClick() + } + } + + fun onBtnBackClick() { + val current = binding!!.viewPager.currentItem - 1 + if (current >= 0) { + // Move to previous slider. + binding!!.viewPager.setCurrentItem(current) + if (current == 0) { + binding!!.btnBack.visibility = View.GONE + } + } + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + 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 + } + } + + Slide.API_LEVEL_30 -> if (!upgradedToApiLevel30()) { + Toast.makeText( + this, R.string.toast_api_level_30_must_reset, + Toast.LENGTH_LONG + ).show() + return + } + + else -> {} + } + + var next = binding!!.viewPager.currentItem + 1 + while (next < slides.size) { + if (!shouldSkipSlide(slides[next])) { + binding!!.viewPager.setCurrentItem(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 + ) + + @get:RequiresApi(33) + private val isNotificationPermissionGranted: Boolean + get() { + + return ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + + + private fun upgradedToApiLevel30(): Boolean { + if (mPreferences!!.getBoolean(Constants.PREF_UPGRADED_TO_API_LEVEL_30, false)) { + return true + } + if (this.isFirstStart) { + mPreferences!!.edit { putBoolean(Constants.PREF_UPGRADED_TO_API_LEVEL_30, true) } + return true + } + return false + } + + private fun upgradeToApiLevel30() { + val dbDir = File(this.filesDir, "index-v0.14.0.db") + if (dbDir.exists()) { + try { + FileUtils.deleteQuietly(dbDir) + } catch (e: Throwable) { + Log.w(TAG, "Deleting database with FileUtils failed", e) + runShellCommand("rm -r " + dbDir.absolutePath, false) + if (dbDir.exists()) { + throw RuntimeException("Failed to delete existing database") + } + } + } + mPreferences!!.edit { putBoolean(Constants.PREF_UPGRADED_TO_API_LEVEL_30, true) } + } + + private fun currentSlide(): Slide { + return slides[binding!!.viewPager.currentItem] + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun shouldSkipSlide(slide: Slide): Boolean { + return when (slide) { + Slide.INTRO -> !this.isFirstStart + Slide.STORAGE -> haveStoragePermission(this) + Slide.LOCATION -> hasLocationPermission() + Slide.API_LEVEL_30 -> // Skip if running as root, as that circumvents any Android FS restrictions. + upgradedToApiLevel30() + || mPreferences!!.getBoolean(Constants.PREF_USE_ROOT, false) + + Slide.NOTIFICATION -> this.isNotificationPermissionGranted + + } + } + + private fun addBottomDots() { + mDots = arrayOfNulls(slides.size) + for (i in mDots.indices) { + mDots[i] = TextView(this) + mDots[i]!!.text = Html.fromHtml("•") + mDots[i]!!.textSize = 35f + binding!!.layoutDots.addView(mDots[i]) + } + } + + private fun setActiveBottomDot(currentPage: Int) { + val colorInactive = MaterialColors.getColor(this, R.attr.colorPrimary, Color.BLUE) + val colorActive = MaterialColors.getColor(this, R.attr.colorSecondary, Color.BLUE) + for (mDot in mDots) { + mDot!!.setTextColor(colorInactive) + } + mDots[currentPage]!!.setTextColor(colorActive) + } + + // ViewPager change listener + var mViewPagerPageChangeListener: OnPageChangeListener = object : OnPageChangeListener { + 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) + } + + override fun onPageScrolled(arg0: Int, arg1: Float, arg2: Int) { + } + + override fun onPageScrollStateChanged(arg0: Int) { + } + } + + /** + * View pager adapter + */ + inner class ViewPagerAdapter : PagerAdapter() { + private var layoutInflater: LayoutInflater? = null + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun instantiateItem(container: ViewGroup, position: Int): Any { + layoutInflater = getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater + + val view: View = layoutInflater!!.inflate(slides[position].layout, container, false) + + when (slides[position]) { + Slide.INTRO -> {} + Slide.STORAGE -> { + val btnGrantStoragePerm = + view.findViewById(R.id.btnGrantStoragePerm) as Button + btnGrantStoragePerm.setOnClickListener { requestStoragePermission() } + } + + Slide.LOCATION -> { + val btnGrantLocationPerm = + view.findViewById(R.id.btnGrantLocationPerm) as Button + btnGrantLocationPerm.setOnClickListener { requestLocationPermission() } + } + + Slide.API_LEVEL_30 -> { + val btnResetDatabase = view.findViewById(R.id.btnResetDatabase) as Button + btnResetDatabase.setOnClickListener { + upgradeToApiLevel30() + onBtnNextClick() + } + } + + Slide.NOTIFICATION -> { + val notificationBtn = view.findViewById(R.id.btn_notification) as Button + notificationBtn.setOnClickListener { requestNotificationPermission() } + } + } + + container.addView(view) + return view + } + + override fun getCount(): Int { + return slides.size + } + + override fun isViewFromObject(view: View, obj: Any): Boolean { + return view === obj + } + + override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { + val view = `object` as View? + container.removeView(view) + } + } + + /** + * Preconditions: + * Storage permission has been granted. + */ + private fun startApp() { + val doInitialKeyGeneration = !getConfigFile(this).exists() + val mainIntent = Intent(this, MainActivity::class.java) + mainIntent.putExtra( + SyncthingActivity.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(arrayOf(mainIntent, Intent(this, WebGuiActivity::class.java))) + } else { + startActivity(mainIntent) + } + finish() + } + + private fun hasLocationPermission(): Boolean { + for (perm in locationPermissions) { + if (ContextCompat.checkSelfPermission( + this, + perm!! + ) != PackageManager.PERMISSION_GRANTED + ) { + return false + } + } + return true + } + + /** + * Permission check and request functions + */ + private fun requestLocationPermission() { + ActivityCompat.requestPermissions( + this, + locationPermissions, + PermissionRequestType.LOCATION.ordinal + ) + } + + @RequiresApi(33) + private fun requestNotificationPermission() { + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1) + } + } + + private fun requestStoragePermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + requestAllFilesAccessPermission() + } else { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + PermissionRequestType.STORAGE.ordinal + ) + } + } + + @RequiresApi(30) + private fun requestAllFilesAccessPermission() { + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) + intent.setData(("package:$packageName").toUri()) + try { + val componentName = intent.resolveActivity(packageManager) + if (componentName != null) { + // Launch "Allow all files access?" dialog. + startActivity(intent) + return + } + Log.w(TAG, "Request all files access not supported") + } catch (e: ActivityNotFoundException) { + 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 fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, + grantResults: IntArray + ) { + when (PermissionRequestType.entries[requestCode]) { + PermissionRequestType.LOCATION -> { + if (grantResults.isEmpty() || grantResults[0] != PackageManager.PERMISSION_GRANTED) { + Log.i(TAG, "User denied foreground location permission") + return + } + Log.i(TAG, "User granted foreground location permission") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ActivityCompat.requestPermissions( + this, + locationPermissions, + PermissionRequestType.LOCATION_BACKGROUND.ordinal + ) + } + } + + PermissionRequestType.LOCATION_BACKGROUND -> { + if (grantResults.isEmpty() || grantResults[0] != PackageManager.PERMISSION_GRANTED) { + Log.i(TAG, "User denied background location permission") + return + } + Log.i(TAG, "User granted background location permission") + Toast.makeText(this, R.string.permission_granted, Toast.LENGTH_SHORT).show() + } + + PermissionRequestType.STORAGE -> if (grantResults.isEmpty() || + 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.") + } + + } + } + + companion object { + private val slides = Slide.entries.toTypedArray() + private const val TAG = "FirstStartActivity" + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java deleted file mode 100644 index 5fd737ba..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java +++ /dev/null @@ -1,780 +0,0 @@ -package com.nutomic.syncthingandroid.activities; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.Dialog; -import android.content.ActivityNotFoundException; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import androidx.documentfile.provider.DocumentFile; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.util.Log; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuItem; -import android.widget.CompoundButton; -import android.widget.LinearLayout; -import android.widget.TextView; -import android.widget.Toast; - -import com.google.android.material.materialswitch.MaterialSwitch; -import com.google.gson.Gson; -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.databinding.FragmentFolderBinding; -import com.nutomic.syncthingandroid.model.Device; -import com.nutomic.syncthingandroid.model.Folder; -import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.service.RestApi; -import com.nutomic.syncthingandroid.service.SyncthingService; -import com.nutomic.syncthingandroid.util.FileUtils; -import com.nutomic.syncthingandroid.util.TextWatcherAdapter; -import com.nutomic.syncthingandroid.util.Util; - -import java.io.File; -import java.io.IOException; -import java.util.List; -import java.util.Objects; -import java.util.Random; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -import static androidx.core.view.MarginLayoutParamsCompat.setMarginEnd; -import static androidx.core.view.MarginLayoutParamsCompat.setMarginStart; -import static android.util.TypedValue.COMPLEX_UNIT_DIP; -import static android.view.Gravity.CENTER_VERTICAL; -import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; -import static com.nutomic.syncthingandroid.service.SyncthingService.State.ACTIVE; - -/** - * Shows folder details and allows changing them. - */ -public class FolderActivity extends SyncthingActivity - implements SyncthingActivity.OnServiceConnectedListener, SyncthingService.OnServiceStateChangeListener { - - public static final String EXTRA_NOTIFICATION_ID = - "com.nutomic.syncthingandroid.activities.FolderActivity.NOTIFICATION_ID"; - public static final String EXTRA_IS_CREATE = - "com.nutomic.syncthingandroid.activities.FolderActivity.IS_CREATE"; - public static final String EXTRA_FOLDER_ID = - "com.nutomic.syncthingandroid.activities.FolderActivity.FOLDER_ID"; - public static final String EXTRA_FOLDER_LABEL = - "com.nutomic.syncthingandroid.activities.FolderActivity.FOLDER_LABEL"; - public static final String EXTRA_DEVICE_ID = - "com.nutomic.syncthingandroid.activities.FolderActivity.DEVICE_ID"; - - private static final String TAG = "FolderActivity"; - - private static final String IS_SHOWING_DELETE_DIALOG = "DELETE_FOLDER_DIALOG_STATE"; - private static final String IS_SHOW_DISCARD_DIALOG = "DISCARD_FOLDER_DIALOG_STATE"; - - private static final int FILE_VERSIONING_DIALOG_REQUEST = 3454; - private static final int PULL_ORDER_DIALOG_REQUEST = 3455; - private static final int FOLDER_TYPE_DIALOG_REQUEST =3456; - private static final int CHOOSE_FOLDER_REQUEST = 3459; - - private static final String FOLDER_MARKER_NAME = ".stfolder"; - private static final String IGNORE_FILE_NAME = ".stignore"; - - private Folder mFolder; - private Uri mFolderUri = null; - // Indicates the result of the write test to mFolder.path on dialog init or after a path change. - Boolean mCanWriteToPath = false; - - private FragmentFolderBinding binding; - - private boolean mIsCreateMode; - private boolean mFolderNeedsToUpdate = false; - - private Dialog mDeleteDialog; - private Dialog mDiscardDialog; - - private Folder.Versioning mVersioning; - - private final TextWatcher mTextWatcher = new TextWatcherAdapter() { - @Override - public void afterTextChanged(Editable s) { - mFolder.label = binding.label.getText().toString(); - mFolder.id = binding.id.getText().toString(); - // binding.directoryTextView must not be handled here as it's handled by {@link onActivityResult} - mFolderNeedsToUpdate = true; - } - }; - - private final CompoundButton.OnCheckedChangeListener mCheckedListener = - new CompoundButton.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton view, boolean isChecked) { - switch (view.getId()) { - case R.id.fileWatcher: - mFolder.fsWatcherEnabled = isChecked; - mFolderNeedsToUpdate = true; - break; - case R.id.folderPause: - mFolder.paused = isChecked; - mFolderNeedsToUpdate = true; - break; - case R.id.device_toggle: - Device device = (Device) view.getTag(); - if (isChecked) { - mFolder.addDevice(device.deviceID); - } else { - mFolder.removeDevice(device.deviceID); - } - mFolderNeedsToUpdate = true; - break; - } - } - }; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - binding = FragmentFolderBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - mIsCreateMode = getIntent().getBooleanExtra(EXTRA_IS_CREATE, false); - setTitle(mIsCreateMode ? R.string.create_folder : R.string.edit_folder); - registerOnServiceConnectedListener(this); - - binding.directoryTextView.setOnClickListener(view -> onPathViewClick()); - - findViewById(R.id.folderTypeContainer).setOnClickListener(v -> showFolderTypeDialog()); - findViewById(R.id.pullOrderContainer).setOnClickListener(v -> showPullOrderDialog()); - findViewById(R.id.versioningContainer).setOnClickListener(v -> showVersioningDialog()); - binding.editIgnores.setOnClickListener(v -> editIgnores()); - - if (mIsCreateMode) { - if (savedInstanceState != null) { - mFolder = new Gson().fromJson(savedInstanceState.getString("folder"), Folder.class); - if (savedInstanceState.getBoolean(IS_SHOW_DISCARD_DIALOG)){ - showDiscardDialog(); - } - } - if (mFolder == null) { - initFolder(); - } - // Open keyboard on label view in edit mode. - binding.label.requestFocus(); - binding.editIgnores.setEnabled(false); - } - else { - // Prepare edit mode. - binding.id.clearFocus(); - binding.id.setFocusable(false); - binding.id.setEnabled(false); - binding.directoryTextView.setEnabled(false); - } - - if (savedInstanceState != null){ - if (savedInstanceState.getBoolean(IS_SHOWING_DELETE_DIALOG)){ - showDeleteDialog(); - } - } - - if (savedInstanceState != null){ - if (savedInstanceState.getBoolean(IS_SHOWING_DELETE_DIALOG)){ - showDeleteDialog(); - } - } - } - - /** - * Invoked after user clicked on the directoryTextView label. - */ - @SuppressLint("InlinedAPI") - private void onPathViewClick() { - // This has to be android.net.Uri as it implements a Parcelable. - android.net.Uri externalFilesDirUri = FileUtils.getExternalFilesDirUri(FolderActivity.this); - - // Display storage access framework directory picker UI. - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); - if (externalFilesDirUri != null) { - intent.putExtra("android.provider.extra.INITIAL_URI", externalFilesDirUri); - } - intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true); - intent.putExtra("android.content.extra.SHOW_ADVANCED", true); - try { - startActivityForResult(intent, CHOOSE_FOLDER_REQUEST); - } catch (android.content.ActivityNotFoundException e) { - Log.e(TAG, "onPathViewClick exception, falling back to built-in FolderPickerActivity.", e); - startActivityForResult(FolderPickerActivity.createIntent(this, mFolder.path, null), - FolderPickerActivity.DIRECTORY_REQUEST_CODE); - } - } - - private void editIgnores() { - try { - File ignoreFile = new File(mFolder.path, IGNORE_FILE_NAME); - if (!ignoreFile.exists() && !ignoreFile.createNewFile()) { - Toast.makeText(this, R.string.create_ignore_file_error, Toast.LENGTH_SHORT).show(); - return; - } - Intent intent = new Intent(Intent.ACTION_EDIT); - Uri uri = Uri.fromFile(ignoreFile); - intent.setDataAndType(uri, "text/plain"); - intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - - startActivity(intent); - } catch (IOException e) { - Log.w(TAG, e); - } catch (ActivityNotFoundException e) { - Log.w(TAG, e); - Toast.makeText(this, R.string.edit_ignore_file_error, Toast.LENGTH_SHORT).show(); - } - } - - private void showFolderTypeDialog() { - if (TextUtils.isEmpty(mFolder.path)) { - Toast.makeText(this, R.string.folder_path_required, Toast.LENGTH_LONG) - .show(); - return; - } - if (!mCanWriteToPath) { - /* - * Do not handle the click as the children in the folder type layout are disabled - * and an explanation is already given on the UI why the only allowed folder type - * is "sendonly". - */ - Toast.makeText(this, R.string.folder_path_readonly, Toast.LENGTH_LONG) - .show(); - return; - } - // The user selected folder path is writeable, offer to choose from all available folder types. - Intent intent = new Intent(this, FolderTypeDialogActivity.class); - intent.putExtra(FolderTypeDialogActivity.EXTRA_FOLDER_TYPE, mFolder.type); - startActivityForResult(intent, FOLDER_TYPE_DIALOG_REQUEST); - } - - private void showPullOrderDialog() { - Intent intent = new Intent(this, PullOrderDialogActivity.class); - intent.putExtra(PullOrderDialogActivity.EXTRA_PULL_ORDER, mFolder.order); - startActivityForResult(intent, PULL_ORDER_DIALOG_REQUEST); - } - - private void showVersioningDialog() { - Intent intent = new Intent(this, VersioningDialogActivity.class); - intent.putExtras(getVersioningBundle()); - startActivityForResult(intent, FILE_VERSIONING_DIALOG_REQUEST); - } - - private Bundle getVersioningBundle() { - Bundle bundle = new Bundle(); - for (Map.Entry entry: mFolder.versioning.params.entrySet()){ - bundle.putString(entry.getKey(), entry.getValue()); - } - - if (TextUtils.isEmpty(mFolder.versioning.type)){ - bundle.putString("type", "none"); - } else{ - bundle.putString("type", mFolder.versioning.type); - } - - return bundle; - } - - @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.label.removeTextChangedListener(mTextWatcher); - binding.id.removeTextChangedListener(mTextWatcher); - } - - @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 (mFolderNeedsToUpdate) { - updateFolder(); - } - } - - @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putBoolean(IS_SHOWING_DELETE_DIALOG, mDeleteDialog != null && mDeleteDialog.isShowing()); - Util.dismissDialogSafe(mDeleteDialog, this); - - if (mIsCreateMode){ - outState.putBoolean(IS_SHOW_DISCARD_DIALOG, mDiscardDialog != null && mDiscardDialog.isShowing()); - Util.dismissDialogSafe(mDiscardDialog, this); - } - } - - /** - * Save current settings in case we are in create mode and they aren't yet stored in the config. - */ - @Override - public void onServiceConnected() { - Log.v(TAG, "onServiceConnected"); - SyncthingService syncthingService = (SyncthingService) getService(); - syncthingService.getNotificationHandler().cancelConsentNotification(getIntent().getIntExtra(EXTRA_NOTIFICATION_ID, 0)); - syncthingService.registerOnServiceStateChangeListener(this); - } - - @Override - public void onServiceStateChange(SyncthingService.State currentState) { - if (currentState != ACTIVE) { - finish(); - return; - } - - if (!mIsCreateMode) { - List folders = getApi().getFolders(); - String passedId = getIntent().getStringExtra(EXTRA_FOLDER_ID); - mFolder = null; - for (Folder currentFolder : folders) { - if (currentFolder.id.equals(passedId)) { - mFolder = currentFolder; - break; - } - } - if (mFolder == null) { - Log.w(TAG, "Folder not found in API update, maybe it was deleted?"); - finish(); - return; - } - checkWriteAndUpdateUI(); - } - if (getIntent().hasExtra(EXTRA_DEVICE_ID)) { - mFolder.addDevice(getIntent().getStringExtra(EXTRA_DEVICE_ID)); - mFolderNeedsToUpdate = true; - } - - attemptToApplyVersioningConfig(); - - updateViewsAndSetListeners(); - } - - // If the FolderActivity gets recreated after the VersioningDialogActivity is closed, then the result from the VersioningDialogActivity will be received before - // the mFolder variable has been recreated, so the versioning config will be stored in the mVersioning variable until the mFolder variable has been - // recreated in the onServiceStateChange(). This has been observed to happen after the screen orientation has changed while the VersioningDialogActivity was open. - private void attemptToApplyVersioningConfig() { - if (mFolder != null && mVersioning != null){ - mFolder.versioning = mVersioning; - mVersioning = null; - } - } - - private void updateViewsAndSetListeners() { - binding.label.removeTextChangedListener(mTextWatcher); - binding.id.removeTextChangedListener(mTextWatcher); - binding.fileWatcher.setOnCheckedChangeListener(null); - binding.folderPause.setOnCheckedChangeListener(null); - - // Update views - binding.label.setText(mFolder.label); - binding.id.setText(mFolder.id); - updateFolderTypeDescription(); - updatePullOrderDescription(); - updateVersioningDescription(); - binding.fileWatcher.setChecked(mFolder.fsWatcherEnabled); - binding.folderPause.setChecked(mFolder.paused); - List devicesList = getApi().getDevices(false); - - binding.devicesContainer.removeAllViews(); - if (devicesList.isEmpty()) { - addEmptyDeviceListView(); - } else { - for (Device n : devicesList) { - addDeviceViewAndSetListener(n, getLayoutInflater()); - } - } - - // Keep state updated - binding.label.addTextChangedListener(mTextWatcher); - binding.id.addTextChangedListener(mTextWatcher); - binding.fileWatcher.setOnCheckedChangeListener(mCheckedListener); - binding.folderPause.setOnCheckedChangeListener(mCheckedListener); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.folder_settings, menu); - return super.onCreateOptionsMenu(menu); - } - - @Override - public boolean onPrepareOptionsMenu(Menu menu) { - menu.findItem(R.id.create).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 (TextUtils.isEmpty(mFolder.id)) { - Toast.makeText(this, R.string.folder_id_required, Toast.LENGTH_LONG) - .show(); - return true; - } - if (TextUtils.isEmpty(mFolder.path)) { - Toast.makeText(this, R.string.folder_path_required, Toast.LENGTH_LONG) - .show(); - return true; - } - if (mFolderUri != null) { - /* - * Normally, syncthing takes care of creating the ".stfolder" marker. - * This fails on newer android versions if the syncthing binary only has - * readonly access on the path and the user tries to configure a - * sendonly folder. To fix this, we'll precreate the marker using java code. - * We also create an empty file in the marker directory, to hopefully keep - * it alive in the face of overzealous disk cleaner apps. - */ - DocumentFile dfFolder = DocumentFile.fromTreeUri(this, mFolderUri); - if (dfFolder != null) { - Log.v(TAG, "Creating new directory " + mFolder.path + File.separator + FOLDER_MARKER_NAME); - DocumentFile marker = dfFolder.createDirectory(FOLDER_MARKER_NAME); - marker.createFile("text/plain", "empty"); - } - } - getApi().createFolder(mFolder); - finish(); - 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_folder_confirm) - .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> { - RestApi restApi = getApi(); - if (restApi != null) { - restApi.removeFolder(mFolder.id); - } - mFolderNeedsToUpdate = false; - finish(); - }) - .setNegativeButton(android.R.string.no, null) - .create(); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - - if (resultCode == Activity.RESULT_OK && requestCode == CHOOSE_FOLDER_REQUEST) { - mFolderUri = data.getData(); - if (mFolderUri == null) { - return; - } - // Get the folder path unix style, e.g. "/storage/0000-0000/DCIM" - String targetPath = FileUtils.getAbsolutePathFromSAFUri(FolderActivity.this, mFolderUri); - if (targetPath != null) { - targetPath = Util.formatPath(targetPath); - } - if (targetPath == null || TextUtils.isEmpty(targetPath) || (targetPath.equals(File.separator))) { - mFolder.path = ""; - mFolderUri = null; - checkWriteAndUpdateUI(); - // Show message to the user suggesting to select a folder on internal or external storage. - Toast.makeText(this, R.string.toast_invalid_folder_selected, Toast.LENGTH_LONG).show(); - return; - } - mFolder.path = FileUtils.cutTrailingSlash(targetPath); - Log.v(TAG, "onActivityResult/CHOOSE_FOLDER_REQUEST: Got directory path '" + mFolder.path + "'"); - checkWriteAndUpdateUI(); - // Postpone sending the config changes using syncthing REST API. - mFolderNeedsToUpdate = true; - } else if (resultCode == Activity.RESULT_OK && requestCode == FolderPickerActivity.DIRECTORY_REQUEST_CODE) { - mFolder.path = data.getStringExtra(FolderPickerActivity.EXTRA_RESULT_DIRECTORY); - checkWriteAndUpdateUI(); - // Postpone sending the config changes using syncthing REST API. - mFolderNeedsToUpdate = true; - } else if (resultCode == Activity.RESULT_OK && requestCode == FILE_VERSIONING_DIALOG_REQUEST) { - updateVersioning(data.getExtras()); - } else if (resultCode == Activity.RESULT_OK && requestCode == FOLDER_TYPE_DIALOG_REQUEST) { - mFolder.type = data.getStringExtra(FolderTypeDialogActivity.EXTRA_RESULT_FOLDER_TYPE); - updateFolderTypeDescription(); - mFolderNeedsToUpdate = true; - } else if (resultCode == Activity.RESULT_OK && requestCode == PULL_ORDER_DIALOG_REQUEST) { - mFolder.order = data.getStringExtra(PullOrderDialogActivity.EXTRA_RESULT_PULL_ORDER); - updatePullOrderDescription(); - mFolderNeedsToUpdate = true; - } - } - - /** - * Prerequisite: mFolder.path must be non-empty - */ - private void checkWriteAndUpdateUI() { - binding.directoryTextView.setText(mFolder.path); - if (TextUtils.isEmpty(mFolder.path)) { - return; - } - - /* - * Check if the permissions we have on that folder is readonly or readwrite. - * Access level readonly: folder can only be configured "sendonly". - * Access level readwrite: folder can be configured "sendonly" or "sendreceive". - */ - mCanWriteToPath = Util.nativeBinaryCanWriteToPath(FolderActivity.this, mFolder.path); - if (mCanWriteToPath) { - binding.accessExplanationView.setText(R.string.folder_path_readwrite); - binding.folderType.setEnabled(true); - binding.editIgnores.setEnabled(true); - if (mIsCreateMode) { - /* - * Suggest folder type FOLDER_TYPE_SEND_RECEIVE for folders to be created - * because the user most probably intentionally chose a special folder like - * "[storage]/Android/data/com.nutomic.syncthingandroid/files" - * or enabled root mode thus having write access. - */ - mFolder.type = Constants.FOLDER_TYPE_SEND_RECEIVE; - updateFolderTypeDescription(); - } - } else { - // Force "sendonly" folder. - binding.accessExplanationView.setText(R.string.folder_path_readonly); - binding.folderType.setEnabled(false); - binding.editIgnores.setEnabled(false); - mFolder.type = Constants.FOLDER_TYPE_SEND_ONLY; - updateFolderTypeDescription(); - } - } - - private String generateRandomFolderId() { - char[] chars = "abcdefghijklmnopqrstuvwxyz0123456789".toCharArray(); - StringBuilder sb = new StringBuilder(); - Random random = new Random(); - for (int i = 0; i < 10; i++) { - if (i == 5) { - sb.append("-"); - } - char c = chars[random.nextInt(chars.length)]; - sb.append(c); - } - return sb.toString(); - } - - private void initFolder() { - mFolder = new Folder(); - mFolder.id = (getIntent().hasExtra(EXTRA_FOLDER_ID)) - ? getIntent().getStringExtra(EXTRA_FOLDER_ID) - : generateRandomFolderId(); - mFolder.label = getIntent().getStringExtra(EXTRA_FOLDER_LABEL); - mFolder.fsWatcherEnabled = true; - mFolder.fsWatcherDelayS = 10; - /* - * Folder rescan interval defaults to 3600s as it is the default in - * syncthing when the file watcher is enabled and a new folder is created. - */ - mFolder.rescanIntervalS = 3600; - mFolder.paused = false; - mFolder.type = Constants.FOLDER_TYPE_SEND_RECEIVE; - mFolder.versioning = new Folder.Versioning(); - } - - private void addEmptyDeviceListView() { - int height = (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, 48, getResources().getDisplayMetrics()); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(WRAP_CONTENT, height); - int dividerInset = getResources().getDimensionPixelOffset(R.dimen.material_divider_inset); - int contentInset = getResources().getDimensionPixelOffset(R.dimen.abc_action_bar_content_inset_material); - setMarginStart(params, dividerInset); - setMarginEnd(params, contentInset); - TextView emptyView = new TextView(binding.devicesContainer.getContext()); - emptyView.setGravity(CENTER_VERTICAL); - emptyView.setText(R.string.devices_list_empty); - binding.devicesContainer.addView(emptyView, params); - } - - private void addDeviceViewAndSetListener(Device device, LayoutInflater inflater) { - inflater.inflate(R.layout.item_device_form, binding.devicesContainer); - MaterialSwitch deviceView = (MaterialSwitch) binding.devicesContainer.getChildAt(binding.devicesContainer.getChildCount()-1); - deviceView.setOnCheckedChangeListener(null); - deviceView.setChecked(mFolder.getDevice(device.deviceID) != null); - deviceView.setText(device.getDisplayName()); - deviceView.setTag(device); - deviceView.setOnCheckedChangeListener(mCheckedListener); - } - - private void updateFolder() { - if (!mIsCreateMode) { - /* - * RestApi is guaranteed not to be null as {@link onServiceStateChange} - * immediately finishes this activity if SyncthingService shuts down. - */ - getApi().updateFolder(mFolder); - } - } - - @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(); - } - - private void updateVersioning(Bundle arguments) { - if (mFolder != null){ - mVersioning = mFolder.versioning; - } else { - mVersioning = new Folder.Versioning(); - } - - String type = arguments.getString("type"); - arguments.remove("type"); - - assert type != null; - if (type.equals("none")){ - mVersioning = new Folder.Versioning(); - } else { - for (String key : arguments.keySet()) { - mVersioning.params.put(key, arguments.getString(key)); - } - mVersioning.type = type; - } - - attemptToApplyVersioningConfig(); - updateVersioningDescription(); - mFolderNeedsToUpdate = true; - } - - private void updateFolderTypeDescription() { - if (mFolder == null) { - return; - } - - switch (mFolder.type) { - case Constants.FOLDER_TYPE_SEND_RECEIVE: - setFolderTypeDescription(getString(R.string.folder_type_sendreceive), - getString(R.string.folder_type_sendreceive_description)); - break; - case Constants.FOLDER_TYPE_SEND_ONLY: - setFolderTypeDescription(getString(R.string.folder_type_sendonly), - getString(R.string.folder_type_sendonly_description)); - break; - case Constants.FOLDER_TYPE_RECEIVE_ONLY: - setFolderTypeDescription(getString(R.string.folder_type_receiveonly), - getString(R.string.folder_type_receiveonly_description)); - break; - } - } - - private void setFolderTypeDescription(String type, String description) { - binding.folderType.setText(type); - binding.folderTypeDescription.setText(description); - } - - private void updatePullOrderDescription() { - if (mFolder == null) { - return; - } - - if (TextUtils.isEmpty(mFolder.order)) { - setPullOrderDescription(getString(R.string.pull_order_type_random), - getString(R.string.pull_order_type_random_description)); - return; - } - - switch (mFolder.order) { - case "random": - setPullOrderDescription(getString(R.string.pull_order_type_random), - getString(R.string.pull_order_type_random_description)); - break; - case "alphabetic": - setPullOrderDescription(getString(R.string.pull_order_type_alphabetic), - getString(R.string.pull_order_type_alphabetic_description)); - break; - case "smallestFirst": - setPullOrderDescription(getString(R.string.pull_order_type_smallestFirst), - getString(R.string.pull_order_type_smallestFirst_description)); - break; - case "largestFirst": - setPullOrderDescription(getString(R.string.pull_order_type_largestFirst), - getString(R.string.pull_order_type_largestFirst_description)); - break; - case "oldestFirst": - setPullOrderDescription(getString(R.string.pull_order_type_oldestFirst), - getString(R.string.pull_order_type_oldestFirst_description)); - break; - case "newestFirst": - setPullOrderDescription(getString(R.string.pull_order_type_newestFirst), - getString(R.string.pull_order_type_newestFirst_description)); - break; - } - } - - private void setPullOrderDescription(String type, String description) { - binding.pullOrderType.setText(type); - binding.pullOrderDescription.setText(description); - } - - private void updateVersioningDescription() { - if (mFolder == null){ - return; - } - - if (TextUtils.isEmpty(mFolder.versioning.type)) { - setVersioningDescription(getString(R.string.none), ""); - return; - } - - switch (mFolder.versioning.type) { - case "simple": - setVersioningDescription(getString(R.string.type_simple), - getString(R.string.simple_versioning_info, mFolder.versioning.params.get("keep"))); - break; - case "trashcan": - setVersioningDescription(getString(R.string.type_trashcan), - getString(R.string.trashcan_versioning_info, mFolder.versioning.params.get("cleanoutDays"))); - break; - case "staggered": - int maxAge = (int) TimeUnit.SECONDS.toDays(Long.parseLong(Objects.requireNonNull(mFolder.versioning.params.get("maxAge")))); - setVersioningDescription(getString(R.string.type_staggered), - getString(R.string.staggered_versioning_info, maxAge, mFolder.versioning.params.get("versionsPath"))); - break; - case "external": - setVersioningDescription(getString(R.string.type_external), - getString(R.string.external_versioning_info, mFolder.versioning.params.get("command"))); - break; - } - } - - private void setVersioningDescription(String type, String description) { - binding.versioningType.setText(type); - binding.versioningDescription.setText(description); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.kt b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.kt new file mode 100644 index 00000000..d0f08743 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.kt @@ -0,0 +1,852 @@ +package com.nutomic.syncthingandroid.activities + +import android.annotation.SuppressLint +import android.app.Dialog +import android.content.ActivityNotFoundException +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.Editable +import android.text.TextUtils +import android.text.TextWatcher +import android.util.Log +import android.util.TypedValue +import android.view.Gravity +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.CompoundButton +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.core.view.MarginLayoutParamsCompat +import androidx.documentfile.provider.DocumentFile +import com.google.android.material.materialswitch.MaterialSwitch +import com.google.gson.Gson +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.activities.SyncthingActivity.OnServiceConnectedListener +import com.nutomic.syncthingandroid.databinding.FragmentFolderBinding +import com.nutomic.syncthingandroid.model.Device +import com.nutomic.syncthingandroid.model.Folder +import com.nutomic.syncthingandroid.service.Constants +import com.nutomic.syncthingandroid.service.SyncthingService +import com.nutomic.syncthingandroid.service.SyncthingService.OnServiceStateChangeListener +import com.nutomic.syncthingandroid.util.FileUtils.cutTrailingSlash +import com.nutomic.syncthingandroid.util.FileUtils.getAbsolutePathFromSAFUri +import com.nutomic.syncthingandroid.util.FileUtils.getExternalFilesDirUri +import com.nutomic.syncthingandroid.util.TextWatcherAdapter +import com.nutomic.syncthingandroid.util.Util.dismissDialogSafe +import com.nutomic.syncthingandroid.util.Util.formatPath +import com.nutomic.syncthingandroid.util.Util.getAlertDialogBuilder +import com.nutomic.syncthingandroid.util.Util.nativeBinaryCanWriteToPath +import java.io.File +import java.io.IOException +import java.util.Objects +import java.util.Random +import java.util.concurrent.TimeUnit + +/** + * Shows folder details and allows changing them. + */ +class FolderActivity : SyncthingActivity(), OnServiceConnectedListener, + OnServiceStateChangeListener { + private var mFolder: Folder? = null + private var mFolderUri: Uri? = null + + // Indicates the result of the write test to mFolder.path on dialog init or after a path change. + var mCanWriteToPath: Boolean = false + + private var binding: FragmentFolderBinding? = null + + private var mIsCreateMode = false + private var mFolderNeedsToUpdate = false + + private var mDeleteDialog: Dialog? = null + private var mDiscardDialog: Dialog? = null + + private var mVersioning: Folder.Versioning? = null + + private val mTextWatcher: TextWatcher = object : TextWatcherAdapter() { + override fun afterTextChanged(s: Editable?) { + mFolder!!.label = binding!!.label.getText().toString() + mFolder!!.id = binding!!.id.getText().toString() + // binding.directoryTextView must not be handled here as it's handled by {@link onActivityResult} + mFolderNeedsToUpdate = true + } + } + + private val mCheckedListener: CompoundButton.OnCheckedChangeListener = + CompoundButton.OnCheckedChangeListener { view, isChecked -> + when (view.id) { + R.id.fileWatcher -> { + mFolder!!.fsWatcherEnabled = isChecked + mFolderNeedsToUpdate = true + } + + R.id.folderPause -> { + mFolder!!.paused = isChecked + mFolderNeedsToUpdate = true + } + + R.id.device_toggle -> { + val device = view.tag as Device + if (isChecked) { + mFolder!!.addDevice(device.deviceID!!) + } else { + mFolder!!.removeDevice(device.deviceID) + } + mFolderNeedsToUpdate = true + } + } + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = FragmentFolderBinding.inflate(layoutInflater) + setContentView(binding!!.getRoot()) + + mIsCreateMode = intent.getBooleanExtra(EXTRA_IS_CREATE, false) + setTitle(if (mIsCreateMode) R.string.create_folder else R.string.edit_folder) + registerOnServiceConnectedListener(this) + + binding!!.directoryTextView.setOnClickListener { _: View? -> onPathViewClick() } + + findViewById(R.id.folderTypeContainer).setOnClickListener { _: View? -> showFolderTypeDialog() } + findViewById(R.id.pullOrderContainer).setOnClickListener { _: View? -> showPullOrderDialog() } + findViewById(R.id.versioningContainer).setOnClickListener { _: View? -> showVersioningDialog() } + binding!!.editIgnores.setOnClickListener { _: View? -> editIgnores() } + + if (mIsCreateMode) { + if (savedInstanceState != null) { + mFolder = Gson().fromJson( + savedInstanceState.getString("folder"), + Folder::class.java + ) + if (savedInstanceState.getBoolean(IS_SHOW_DISCARD_DIALOG)) { + showDiscardDialog() + } + } + if (mFolder == null) { + initFolder() + } + // Open keyboard on label view in edit mode. + binding!!.label.requestFocus() + binding!!.editIgnores.setEnabled(false) + } else { + // Prepare edit mode. + binding!!.id.clearFocus() + binding!!.id.setFocusable(false) + binding!!.id.setEnabled(false) + binding!!.directoryTextView.setEnabled(false) + } + + if (savedInstanceState != null) { + if (savedInstanceState.getBoolean(IS_SHOWING_DELETE_DIALOG)) { + showDeleteDialog() + } + } + + if (savedInstanceState != null) { + if (savedInstanceState.getBoolean(IS_SHOWING_DELETE_DIALOG)) { + showDeleteDialog() + } + } + } + + /** + * Invoked after user clicked on the directoryTextView label. + */ + @SuppressLint("InlinedAPI") + private fun onPathViewClick() { + // This has to be android.net.Uri as it implements a Parcelable. + val externalFilesDirUri = getExternalFilesDirUri(this@FolderActivity) + + // Display storage access framework directory picker UI. + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + if (externalFilesDirUri != null) { + intent.putExtra("android.provider.extra.INITIAL_URI", externalFilesDirUri) + } + intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true) + intent.putExtra("android.content.extra.SHOW_ADVANCED", true) + try { + startActivityForResult(intent, CHOOSE_FOLDER_REQUEST) + } catch (e: ActivityNotFoundException) { + Log.e( + TAG, + "onPathViewClick exception, falling back to built-in FolderPickerActivity.", + e + ) + startActivityForResult( + FolderPickerActivity.createIntent(this, mFolder!!.path, null), + FolderPickerActivity.DIRECTORY_REQUEST_CODE + ) + } + } + + private fun editIgnores() { + try { + val ignoreFile = File(mFolder!!.path, IGNORE_FILE_NAME) + if (!ignoreFile.exists() && !ignoreFile.createNewFile()) { + Toast.makeText(this, R.string.create_ignore_file_error, Toast.LENGTH_SHORT).show() + return + } + val intent = Intent(Intent.ACTION_EDIT) + val uri = Uri.fromFile(ignoreFile) + intent.setDataAndType(uri, "text/plain") + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + + startActivity(intent) + } catch (e: IOException) { + Log.w(TAG, e) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, e) + Toast.makeText(this, R.string.edit_ignore_file_error, Toast.LENGTH_SHORT).show() + } + } + + private fun showFolderTypeDialog() { + if (TextUtils.isEmpty(mFolder!!.path)) { + Toast.makeText(this, R.string.folder_path_required, Toast.LENGTH_LONG) + .show() + return + } + if (!mCanWriteToPath) { + /* + * Do not handle the click as the children in the folder type layout are disabled + * and an explanation is already given on the UI why the only allowed folder type + * is "sendonly". + */ + Toast.makeText(this, R.string.folder_path_readonly, Toast.LENGTH_LONG) + .show() + return + } + // The user selected folder path is writeable, offer to choose from all available folder types. + val intent = Intent(this, FolderTypeDialogActivity::class.java) + intent.putExtra(FolderTypeDialogActivity.EXTRA_FOLDER_TYPE, mFolder!!.type) + startActivityForResult(intent, FOLDER_TYPE_DIALOG_REQUEST) + } + + private fun showPullOrderDialog() { + val intent = Intent(this, PullOrderDialogActivity::class.java) + intent.putExtra(PullOrderDialogActivity.EXTRA_PULL_ORDER, mFolder!!.order) + startActivityForResult(intent, PULL_ORDER_DIALOG_REQUEST) + } + + private fun showVersioningDialog() { + val intent = Intent(this, VersioningDialogActivity::class.java) + intent.putExtras(this.versioningBundle) + startActivityForResult(intent, FILE_VERSIONING_DIALOG_REQUEST) + } + + private val versioningBundle: Bundle + get() { + val bundle = Bundle() + for (entry in mFolder!!.versioning!!.params.entries) { + bundle.putString(entry.key, entry.value) + } + + if (TextUtils.isEmpty(mFolder!!.versioning!!.type)) { + bundle.putString("type", "none") + } else { + bundle.putString("type", mFolder!!.versioning!!.type) + } + + return bundle + } + + public override fun onDestroy() { + super.onDestroy() + val syncthingService = service + if (syncthingService != null) { + syncthingService.getNotificationHandler()!!.cancelConsentNotification( + intent.getIntExtra( + EXTRA_NOTIFICATION_ID, 0 + ) + ) + syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange) + } + binding!!.label.removeTextChangedListener(mTextWatcher) + binding!!.id.removeTextChangedListener(mTextWatcher) + } + + 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 (mFolderNeedsToUpdate) { + updateFolder() + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean( + IS_SHOWING_DELETE_DIALOG, + mDeleteDialog != null && mDeleteDialog!!.isShowing + ) + dismissDialogSafe(mDeleteDialog, this) + + if (mIsCreateMode) { + outState.putBoolean( + IS_SHOW_DISCARD_DIALOG, + mDiscardDialog != null && mDiscardDialog!!.isShowing + ) + dismissDialogSafe(mDiscardDialog, this) + } + } + + /** + * Save current settings in case we are in create mode and they aren't yet stored in the config. + */ + override fun onServiceConnected() { + Log.v(TAG, "onServiceConnected") + val syncthingService = service as SyncthingService + syncthingService.getNotificationHandler()!!.cancelConsentNotification( + intent.getIntExtra( + EXTRA_NOTIFICATION_ID, 0 + ) + ) + syncthingService.registerOnServiceStateChangeListener(this) + } + + override fun onServiceStateChange(currentState: SyncthingService.State?) { + if (currentState != SyncthingService.State.ACTIVE) { + finish() + return + } + + if (!mIsCreateMode) { + val folders: MutableList = api!!.folders + val passedId = intent.getStringExtra(EXTRA_FOLDER_ID) + mFolder = null + for (currentFolder in folders) { + if (currentFolder?.id == passedId) { + mFolder = currentFolder + break + } + } + if (mFolder == null) { + Log.w(TAG, "Folder not found in API update, maybe it was deleted?") + finish() + return + } + checkWriteAndUpdateUI() + } + if (intent.hasExtra(EXTRA_DEVICE_ID)) { + mFolder!!.addDevice(intent.getStringExtra(EXTRA_DEVICE_ID)!!) + mFolderNeedsToUpdate = true + } + + attemptToApplyVersioningConfig() + + updateViewsAndSetListeners() + } + + // If the FolderActivity gets recreated after the VersioningDialogActivity is closed, then the result from the VersioningDialogActivity will be received before + // the mFolder variable has been recreated, so the versioning config will be stored in the mVersioning variable until the mFolder variable has been + // recreated in the onServiceStateChange(). This has been observed to happen after the screen orientation has changed while the VersioningDialogActivity was open. + private fun attemptToApplyVersioningConfig() { + if (mFolder != null && mVersioning != null) { + mFolder!!.versioning = mVersioning + mVersioning = null + } + } + + private fun updateViewsAndSetListeners() { + binding!!.label.removeTextChangedListener(mTextWatcher) + binding!!.id.removeTextChangedListener(mTextWatcher) + binding!!.fileWatcher.setOnCheckedChangeListener(null) + binding!!.folderPause.setOnCheckedChangeListener(null) + + // Update views + binding!!.label.setText(mFolder!!.label) + binding!!.id.setText(mFolder!!.id) + updateFolderTypeDescription() + updatePullOrderDescription() + updateVersioningDescription() + binding!!.fileWatcher.setChecked(mFolder!!.fsWatcherEnabled) + binding!!.folderPause.setChecked(mFolder!!.paused) + val devicesList = api?.getDevices(false) + + binding!!.devicesContainer.removeAllViews() + if (devicesList!!.isEmpty()) { + addEmptyDeviceListView() + } else { + for (n in devicesList) { + addDeviceViewAndSetListener(n, layoutInflater) + } + } + + // Keep state updated + binding!!.label.addTextChangedListener(mTextWatcher) + binding!!.id.addTextChangedListener(mTextWatcher) + binding!!.fileWatcher.setOnCheckedChangeListener(mCheckedListener) + binding!!.folderPause.setOnCheckedChangeListener(mCheckedListener) + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.folder_settings, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + menu.findItem(R.id.create).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(mFolder!!.id)) { + Toast.makeText(this, R.string.folder_id_required, Toast.LENGTH_LONG) + .show() + return true + } + if (TextUtils.isEmpty(mFolder!!.path)) { + Toast.makeText(this, R.string.folder_path_required, Toast.LENGTH_LONG) + .show() + return true + } + if (mFolderUri != null) { + /* + * Normally, syncthing takes care of creating the ".stfolder" marker. + * This fails on newer android versions if the syncthing binary only has + * readonly access on the path and the user tries to configure a + * sendonly folder. To fix this, we'll precreate the marker using java code. + * We also create an empty file in the marker directory, to hopefully keep + * it alive in the face of overzealous disk cleaner apps. + */ + val dfFolder = DocumentFile.fromTreeUri(this, mFolderUri!!) + if (dfFolder != null) { + Log.v( + TAG, + "Creating new directory " + mFolder!!.path + File.separator + FOLDER_MARKER_NAME + ) + val marker = dfFolder.createDirectory(FOLDER_MARKER_NAME) + marker!!.createFile("text/plain", "empty") + } + } + api?.createFolder(mFolder) + finish() + return true + } + + R.id.remove -> { + showDeleteDialog() + return true + } + + android.R.id.home -> { + 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_folder_confirm) + .setPositiveButton( + android.R.string.yes + ) { _: DialogInterface?, _: Int -> + val restApi = api + restApi?.removeFolder(mFolder!!.id) + mFolderNeedsToUpdate = false + finish() + } + .setNegativeButton(android.R.string.no, null) + .create() + } + + public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + when (resultCode) { + RESULT_OK if requestCode == CHOOSE_FOLDER_REQUEST -> { + mFolderUri = data?.data + if (mFolderUri == null) { + return + } + // Get the folder path unix style, e.g. "/storage/0000-0000/DCIM" + var targetPath = getAbsolutePathFromSAFUri(this@FolderActivity, mFolderUri) + if (targetPath != null) { + targetPath = formatPath(targetPath) + } + if (targetPath == null || TextUtils.isEmpty(targetPath) || (targetPath == File.separator)) { + mFolder!!.path = "" + mFolderUri = null + checkWriteAndUpdateUI() + // Show message to the user suggesting to select a folder on internal or external storage. + Toast.makeText(this, R.string.toast_invalid_folder_selected, Toast.LENGTH_LONG) + .show() + return + } + mFolder!!.path = cutTrailingSlash(targetPath) + Log.v( + TAG, + "onActivityResult/CHOOSE_FOLDER_REQUEST: Got directory path '" + mFolder!!.path + "'" + ) + checkWriteAndUpdateUI() + // Postpone sending the config changes using syncthing REST API. + mFolderNeedsToUpdate = true + } + RESULT_OK if requestCode == FolderPickerActivity.DIRECTORY_REQUEST_CODE -> { + mFolder!!.path = + data?.getStringExtra(FolderPickerActivity.EXTRA_RESULT_DIRECTORY) + checkWriteAndUpdateUI() + // Postpone sending the config changes using syncthing REST API. + mFolderNeedsToUpdate = true + } + RESULT_OK if requestCode == FILE_VERSIONING_DIALOG_REQUEST -> { + updateVersioning(data?.extras!!) + } + RESULT_OK if requestCode == FOLDER_TYPE_DIALOG_REQUEST -> { + mFolder!!.type = + data?.getStringExtra(FolderTypeDialogActivity.EXTRA_RESULT_FOLDER_TYPE)!! + updateFolderTypeDescription() + mFolderNeedsToUpdate = true + } + RESULT_OK if requestCode == PULL_ORDER_DIALOG_REQUEST -> { + mFolder!!.order = + data?.getStringExtra(PullOrderDialogActivity.EXTRA_RESULT_PULL_ORDER) + updatePullOrderDescription() + mFolderNeedsToUpdate = true + } + } + } + + /** + * Prerequisite: mFolder.path must be non-empty + */ + private fun checkWriteAndUpdateUI() { + binding!!.directoryTextView.text = mFolder!!.path + if (TextUtils.isEmpty(mFolder!!.path)) { + return + } + + /* + * Check if the permissions we have on that folder is readonly or readwrite. + * Access level readonly: folder can only be configured "sendonly". + * Access level readwrite: folder can be configured "sendonly" or "sendreceive". + */ + mCanWriteToPath = nativeBinaryCanWriteToPath(this@FolderActivity, mFolder!!.path) + if (mCanWriteToPath) { + binding!!.accessExplanationView.setText(R.string.folder_path_readwrite) + binding!!.folderType.setEnabled(true) + binding!!.editIgnores.setEnabled(true) + if (mIsCreateMode) { + /* + * Suggest folder type FOLDER_TYPE_SEND_RECEIVE for folders to be created + * because the user most probably intentionally chose a special folder like + * "[storage]/Android/data/com.nutomic.syncthingandroid/files" + * or enabled root mode thus having write access. + */ + mFolder!!.type = Constants.FOLDER_TYPE_SEND_RECEIVE + updateFolderTypeDescription() + } + } else { + // Force "sendonly" folder. + binding!!.accessExplanationView.setText(R.string.folder_path_readonly) + binding!!.folderType.setEnabled(false) + binding!!.editIgnores.setEnabled(false) + mFolder!!.type = Constants.FOLDER_TYPE_SEND_ONLY + updateFolderTypeDescription() + } + } + + private fun generateRandomFolderId(): String { + val chars = "abcdefghijklmnopqrstuvwxyz0123456789".toCharArray() + val sb = StringBuilder() + val random = Random() + for (i in 0..9) { + if (i == 5) { + sb.append("-") + } + val c = chars[random.nextInt(chars.size)] + sb.append(c) + } + return sb.toString() + } + + private fun initFolder() { + mFolder = Folder() + mFolder!!.id = if (intent.hasExtra(EXTRA_FOLDER_ID)) + intent.getStringExtra(EXTRA_FOLDER_ID) + else + generateRandomFolderId() + mFolder!!.label = intent.getStringExtra(EXTRA_FOLDER_LABEL) + mFolder!!.fsWatcherEnabled = true + mFolder!!.fsWatcherDelayS = 10 + /* + * Folder rescan interval defaults to 3600s as it is the default in + * syncthing when the file watcher is enabled and a new folder is created. + */ + mFolder!!.rescanIntervalS = 3600 + mFolder!!.paused = false + mFolder!!.type = Constants.FOLDER_TYPE_SEND_RECEIVE + mFolder!!.versioning = Folder.Versioning() + } + + private fun addEmptyDeviceListView() { + val height = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 48f, + getResources().displayMetrics + ).toInt() + val params = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, height) + val dividerInset = getResources().getDimensionPixelOffset(R.dimen.material_divider_inset) + val contentInset = + getResources().getDimensionPixelOffset(R.dimen.abc_action_bar_content_inset_material) + MarginLayoutParamsCompat.setMarginStart(params, dividerInset) + MarginLayoutParamsCompat.setMarginEnd(params, contentInset) + val emptyView = TextView(binding!!.devicesContainer.context) + emptyView.setGravity(Gravity.CENTER_VERTICAL) + emptyView.setText(R.string.devices_list_empty) + binding!!.devicesContainer.addView(emptyView, params) + } + + private fun addDeviceViewAndSetListener(device: Device, inflater: LayoutInflater) { + inflater.inflate(R.layout.item_device_form, binding!!.devicesContainer) + val deviceView = + binding!!.devicesContainer.getChildAt(binding!!.devicesContainer.childCount - 1) as MaterialSwitch + deviceView.setOnCheckedChangeListener(null) + deviceView.setChecked(mFolder!!.getDevice(device.deviceID) != null) + deviceView.text = device.displayName + deviceView.tag = device + deviceView.setOnCheckedChangeListener(mCheckedListener) + } + + private fun updateFolder() { + if (!mIsCreateMode) { + /* + * RestApi is guaranteed not to be null as {@link onServiceStateChange} + * immediately finishes this activity if SyncthingService shuts down. + */ + api?.updateFolder(mFolder!!) + } + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + if (mIsCreateMode) { + showDiscardDialog() + } else { + super.onBackPressed() + } + } + + 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() + } + + private fun updateVersioning(arguments: Bundle) { + mVersioning = if (mFolder != null) { + mFolder!!.versioning + } else { + Folder.Versioning() + } + + val type = arguments.getString("type") + arguments.remove("type") + + checkNotNull(type) + if (type == "none") { + mVersioning = Folder.Versioning() + } else { + for (key in arguments.keySet()) { + mVersioning!!.params[key] = arguments.getString(key) + } + mVersioning!!.type = type + } + + attemptToApplyVersioningConfig() + updateVersioningDescription() + mFolderNeedsToUpdate = true + } + + private fun updateFolderTypeDescription() { + if (mFolder == null) { + return + } + + when (mFolder!!.type) { + Constants.FOLDER_TYPE_SEND_RECEIVE -> setFolderTypeDescription( + getString(R.string.folder_type_sendreceive), + getString(R.string.folder_type_sendreceive_description) + ) + + Constants.FOLDER_TYPE_SEND_ONLY -> setFolderTypeDescription( + getString(R.string.folder_type_sendonly), + getString(R.string.folder_type_sendonly_description) + ) + + Constants.FOLDER_TYPE_RECEIVE_ONLY -> setFolderTypeDescription( + getString(R.string.folder_type_receiveonly), + getString(R.string.folder_type_receiveonly_description) + ) + } + } + + private fun setFolderTypeDescription(type: String?, description: String?) { + binding!!.folderType.text = type + binding!!.folderTypeDescription.text = description + } + + private fun updatePullOrderDescription() { + if (mFolder == null) { + return + } + + if (TextUtils.isEmpty(mFolder!!.order)) { + setPullOrderDescription( + getString(R.string.pull_order_type_random), + getString(R.string.pull_order_type_random_description) + ) + return + } + + when (mFolder!!.order) { + "random" -> setPullOrderDescription( + getString(R.string.pull_order_type_random), + getString(R.string.pull_order_type_random_description) + ) + + "alphabetic" -> setPullOrderDescription( + getString(R.string.pull_order_type_alphabetic), + getString(R.string.pull_order_type_alphabetic_description) + ) + + "smallestFirst" -> setPullOrderDescription( + getString(R.string.pull_order_type_smallestFirst), + getString(R.string.pull_order_type_smallestFirst_description) + ) + + "largestFirst" -> setPullOrderDescription( + getString(R.string.pull_order_type_largestFirst), + getString(R.string.pull_order_type_largestFirst_description) + ) + + "oldestFirst" -> setPullOrderDescription( + getString(R.string.pull_order_type_oldestFirst), + getString(R.string.pull_order_type_oldestFirst_description) + ) + + "newestFirst" -> setPullOrderDescription( + getString(R.string.pull_order_type_newestFirst), + getString(R.string.pull_order_type_newestFirst_description) + ) + } + } + + private fun setPullOrderDescription(type: String?, description: String?) { + binding!!.pullOrderType.text = type + binding!!.pullOrderDescription.text = description + } + + private fun updateVersioningDescription() { + if (mFolder == null) { + return + } + + if (TextUtils.isEmpty(mFolder!!.versioning!!.type)) { + setVersioningDescription(getString(R.string.none), "") + return + } + + when (mFolder!!.versioning!!.type) { + "simple" -> setVersioningDescription( + getString(R.string.type_simple), + getString( + R.string.simple_versioning_info, + mFolder!!.versioning!!.params["keep"] + ) + ) + + "trashcan" -> setVersioningDescription( + getString(R.string.type_trashcan), + getString( + R.string.trashcan_versioning_info, + mFolder!!.versioning!!.params["cleanoutDays"] + ) + ) + + "staggered" -> { + val maxAge = TimeUnit.SECONDS.toDays( + Objects.requireNonNull( + mFolder!!.versioning!!.params["maxAge"] + ).toLong() + ).toInt() + setVersioningDescription( + getString(R.string.type_staggered), + getString( + R.string.staggered_versioning_info, + maxAge, + mFolder!!.versioning!!.params["versionsPath"] + ) + ) + } + + "external" -> setVersioningDescription( + getString(R.string.type_external), + getString( + R.string.external_versioning_info, + mFolder!!.versioning!!.params["command"] + ) + ) + } + } + + private fun setVersioningDescription(type: String?, description: String?) { + binding!!.versioningType.text = type + binding!!.versioningDescription.text = description + } + + companion object { + const val EXTRA_NOTIFICATION_ID: String = + "com.nutomic.syncthingandroid.activities.FolderActivity.NOTIFICATION_ID" + const val EXTRA_IS_CREATE: String = + "com.nutomic.syncthingandroid.activities.FolderActivity.IS_CREATE" + const val EXTRA_FOLDER_ID: String = + "com.nutomic.syncthingandroid.activities.FolderActivity.FOLDER_ID" + const val EXTRA_FOLDER_LABEL: String = + "com.nutomic.syncthingandroid.activities.FolderActivity.FOLDER_LABEL" + const val EXTRA_DEVICE_ID: String = + "com.nutomic.syncthingandroid.activities.FolderActivity.DEVICE_ID" + + private const val TAG = "FolderActivity" + + private const val IS_SHOWING_DELETE_DIALOG = "DELETE_FOLDER_DIALOG_STATE" + private const val IS_SHOW_DISCARD_DIALOG = "DISCARD_FOLDER_DIALOG_STATE" + + private const val FILE_VERSIONING_DIALOG_REQUEST = 3454 + private const val PULL_ORDER_DIALOG_REQUEST = 3455 + private const val FOLDER_TYPE_DIALOG_REQUEST = 3456 + private const val CHOOSE_FOLDER_REQUEST = 3459 + + private const val FOLDER_MARKER_NAME = ".stfolder" + private const val IGNORE_FILE_NAME = ".stignore" + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java deleted file mode 100644 index 60886a6e..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java +++ /dev/null @@ -1,355 +0,0 @@ -package com.nutomic.syncthingandroid.activities; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.os.Environment; -import android.os.IBinder; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.appcompat.app.AlertDialog; -import android.text.TextUtils; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.view.inputmethod.InputMethodManager; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; -import android.widget.EditText; -import android.widget.ListView; -import android.widget.TextView; -import android.widget.Toast; - -import com.google.common.collect.Sets; -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.SyncthingApp; -import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.service.SyncthingService; -import com.nutomic.syncthingandroid.service.SyncthingServiceBinder; -import com.nutomic.syncthingandroid.util.Util; - -import java.io.File; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Iterator; -import java.util.Objects; - -import javax.inject.Inject; - -/** - * Activity that allows selecting a directory in the local file system. - */ -public class FolderPickerActivity extends SyncthingActivity - implements AdapterView.OnItemClickListener, SyncthingService.OnServiceStateChangeListener { - - private static final String EXTRA_INITIAL_DIRECTORY = - "com.nutomic.syncthingandroid.activities.FolderPickerActivity.INITIAL_DIRECTORY"; - - private static final String EXTRA_ROOT_DIRECTORY = - "com.nutomic.syncthingandroid.activities.FolderPickerActivity.ROOT_DIRECTORY"; - - public static final String EXTRA_RESULT_DIRECTORY = - "com.nutomic.syncthingandroid.activities.FolderPickerActivity.RESULT_DIRECTORY"; - - public static final int DIRECTORY_REQUEST_CODE = 234; - - private ListView mListView; - private FileAdapter mFilesAdapter; - private RootsAdapter mRootsAdapter; - - /** - * Location of null means that the list of roots is displayed. - */ - private File mLocation; - - @Inject - SharedPreferences mPreferences; - - public static Intent createIntent(Context context, String initialDirectory, @Nullable String rootDirectory) { - Intent intent = new Intent(context, FolderPickerActivity.class); - - if (!TextUtils.isEmpty(initialDirectory)) { - intent.putExtra(EXTRA_INITIAL_DIRECTORY, initialDirectory); - } - - if (!TextUtils.isEmpty(rootDirectory)) { - intent.putExtra(EXTRA_ROOT_DIRECTORY, rootDirectory); - } - - return intent; - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - ((SyncthingApp) getApplication()).component().inject(this); - - setContentView(R.layout.activity_folder_picker); - mListView = findViewById(android.R.id.list); - mListView.setOnItemClickListener(this); - mListView.setEmptyView(findViewById(android.R.id.empty)); - mFilesAdapter = new FileAdapter(this); - mRootsAdapter = new RootsAdapter(this); - mListView.setAdapter(mFilesAdapter); - - populateRoots(); - - if (getIntent().hasExtra(EXTRA_INITIAL_DIRECTORY)) { - displayFolder(new File(Objects.requireNonNull(getIntent().getStringExtra(EXTRA_INITIAL_DIRECTORY)))); - } else { - displayRoot(); - } - - boolean prefUseRoot = mPreferences.getBoolean(Constants.PREF_USE_ROOT, false); - if (!prefUseRoot) { - Toast.makeText(this, R.string.kitkat_external_storage_warning, Toast.LENGTH_LONG) - .show(); - } - } - - /** - * If a root directory is specified it is added to {@link #mRootsAdapter} otherwise - * all available storage devices/folders from various APIs are inserted into - * {@link #mRootsAdapter}. - */ - @SuppressLint("NewApi") - private void populateRoots() { - ArrayList roots = new ArrayList<>(Arrays.asList(getExternalFilesDirs(null))); - roots.remove(getExternalFilesDir(null)); - - String rootDir = getIntent().getStringExtra(EXTRA_ROOT_DIRECTORY); - if (getIntent().hasExtra(EXTRA_ROOT_DIRECTORY) && !TextUtils.isEmpty(rootDir)) { - roots.add(new File(rootDir)); - } else { - roots.add(Environment.getExternalStorageDirectory()); - roots.add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC)); - roots.add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)); - roots.add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)); - roots.add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)); - roots.add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)); - - // Add paths that might not be accessible to Syncthing. - if (mPreferences.getBoolean("advanced_folder_picker", false)) { - Collections.addAll(roots, Objects.requireNonNull(new File("/storage/").listFiles())); - roots.add(new File("/")); - } - } - // Remove any invalid directories. - Iterator it = roots.iterator(); - while (it.hasNext()) { - File f = it.next(); - if (f == null || !f.exists() || !f.isDirectory()) { - it.remove(); - } - } - - mRootsAdapter.addAll(Sets.newTreeSet(roots)); - } - - @Override - public void onServiceConnected(ComponentName componentName, IBinder iBinder) { - super.onServiceConnected(componentName, iBinder); - SyncthingServiceBinder syncthingServiceBinder = (SyncthingServiceBinder) iBinder; - syncthingServiceBinder.getService().registerOnServiceStateChangeListener(this); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - SyncthingService syncthingService = getService(); - if (syncthingService != null) { - syncthingService.unregisterOnServiceStateChangeListener(this); - } - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - if (mListView.getAdapter() == mRootsAdapter) - return true; - - // Inflate the menu; this adds items to the action bar if it is present. - getMenuInflater().inflate(R.menu.folder_picker, menu); - return true; - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.create_folder: - final EditText et = new EditText(this); - AlertDialog dialog = Util.getAlertDialogBuilder(this) - .setTitle(R.string.create_folder) - .setView(et) - .setPositiveButton(android.R.string.ok, - (dialogInterface, i) -> createFolder(et.getText().toString()) - ) - .setNegativeButton(android.R.string.cancel, null) - .create(); - dialog.setOnShowListener(dialogInterface -> ((InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE)) - .showSoftInput(et, InputMethodManager.SHOW_IMPLICIT)); - dialog.show(); - return true; - case R.id.select: - Intent intent = new Intent() - .putExtra(EXTRA_RESULT_DIRECTORY, Util.formatPath(mLocation.getAbsolutePath())); - setResult(Activity.RESULT_OK, intent); - finish(); - return true; - case android.R.id.home: - finish(); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - /** - * Creates a new folder with the given name and enters it. - */ - private void createFolder(String name) { - File newFolder = new File(mLocation, name); - if (newFolder.mkdir()) { - displayFolder(newFolder); - } else { - Toast.makeText(this, R.string.create_folder_failed, Toast.LENGTH_SHORT).show(); - } - } - - /** - * Refreshes the ListView to show the contents of the folder in {@code }mLocation.peek()}. - */ - private void displayFolder(File folder) { - mLocation = folder; - mFilesAdapter.clear(); - File[] contents = mLocation.listFiles(); - // In case we don't have read access to the folder, just display nothing. - if (contents == null) - contents = new File[]{}; - - Arrays.sort(contents, (f1, f2) -> { - if (f1.isDirectory() && f2.isFile()) - return -1; - if (f1.isFile() && f2.isDirectory()) - return 1; - return f1.getName().compareTo(f2.getName()); - }); - - for (File f : contents) { - mFilesAdapter.add(f); - } - mListView.setAdapter(mFilesAdapter); - } - - @Override - public void onItemClick(AdapterView adapterView, View view, int i, long l) { - @SuppressWarnings("unchecked") - ArrayAdapter adapter = (ArrayAdapter) mListView.getAdapter(); - File f = adapter.getItem(i); - assert f != null; - if (f.isDirectory()) { - displayFolder(f); - invalidateOptions(); - } - } - - private void invalidateOptions() { - invalidateOptionsMenu(); - } - - private static class FileAdapter extends ArrayAdapter { - - public FileAdapter(Context context) { - super(context, R.layout.item_folder_picker); - } - - @Override - @NonNull - public View getView(int position, View convertView, @NonNull ViewGroup parent) { - convertView = super.getView(position, convertView, parent); - TextView title = convertView.findViewById(android.R.id.text1); - File f = getItem(position); - assert f != null; - title.setText(f.getName()); - int textColor = (f.isDirectory()) - ? android.R.color.primary_text_light - : android.R.color.tertiary_text_light; - title.setTextColor(ContextCompat.getColor(getContext(), textColor)); - - return convertView; - } - } - - private static class RootsAdapter extends ArrayAdapter { - - public RootsAdapter(Context context) { - super(context, android.R.layout.simple_list_item_1); - } - - @Override - @NonNull - public View getView(int position, View convertView, @NonNull ViewGroup parent) { - convertView = super.getView(position, convertView, parent); - TextView title = convertView.findViewById(android.R.id.text1); - title.setText(Objects.requireNonNull(getItem(position)).getAbsolutePath()); - return convertView; - } - - public boolean contains(File file) { - for (int i = 0; i < getCount(); i++) { - if (getItem(i).equals(file)) - return true; - } - return false; - } - } - - /** - * Goes up a directory, up to the list of roots if there are multiple roots. - *

- * If we already are in the list of roots, or if we are directly in the only - * root folder, we cancel. - */ - @Override - public void onBackPressed() { - if (!mRootsAdapter.contains(mLocation) && mLocation != null) { - displayFolder(mLocation.getParentFile()); - } else if (mRootsAdapter.contains(mLocation) && mRootsAdapter.getCount() > 1) { - displayRoot(); - } else { - setResult(Activity.RESULT_CANCELED); - finish(); - } - } - - @Override - public void onServiceStateChange(SyncthingService.State currentState) { - if (!isFinishing() && currentState != SyncthingService.State.ACTIVE) { - setResult(Activity.RESULT_CANCELED); - finish(); - } - } - - /** - * Displays a list of all available roots, or if there is only one root, the - * contents of that folder. - */ - private void displayRoot() { - mFilesAdapter.clear(); - if (mRootsAdapter.getCount() == 1) { - displayFolder(mRootsAdapter.getItem(0)); - } else { - mListView.setAdapter(mRootsAdapter); - mLocation = null; - } - invalidateOptions(); - } - -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.kt b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.kt new file mode 100644 index 00000000..2d9d7afc --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.kt @@ -0,0 +1,352 @@ +package com.nutomic.syncthingandroid.activities + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import android.os.Environment +import android.os.IBinder +import android.text.TextUtils +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.AdapterView +import android.widget.AdapterView.OnItemClickListener +import android.widget.ArrayAdapter +import android.widget.EditText +import android.widget.ListView +import android.widget.TextView +import android.widget.Toast +import androidx.core.content.ContextCompat +import com.google.common.collect.Sets +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.SyncthingApp +import com.nutomic.syncthingandroid.service.Constants +import com.nutomic.syncthingandroid.service.SyncthingService +import com.nutomic.syncthingandroid.service.SyncthingService.OnServiceStateChangeListener +import com.nutomic.syncthingandroid.service.SyncthingServiceBinder +import com.nutomic.syncthingandroid.util.Util.formatPath +import com.nutomic.syncthingandroid.util.Util.getAlertDialogBuilder +import java.io.File +import java.util.Arrays +import java.util.Collections +import java.util.Objects + +/** + * Activity that allows selecting a directory in the local file system. + */ +class FolderPickerActivity : SyncthingActivity(), OnItemClickListener, + OnServiceStateChangeListener { + private var mListView: ListView? = null + private var mFilesAdapter: FileAdapter? = null + private var mRootsAdapter: RootsAdapter? = null + + /** + * Location of null means that the list of roots is displayed. + */ + private var mLocation: File? = null + +// @JvmField +// @Inject +// override var mPreferences: SharedPreferences? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + (application as SyncthingApp).component()!!.inject(this) + + setContentView(R.layout.activity_folder_picker) + mListView = findViewById(android.R.id.list) + mListView!!.onItemClickListener = this + mListView!!.setEmptyView(findViewById(android.R.id.empty)) + mFilesAdapter = FileAdapter(this) + mRootsAdapter = RootsAdapter(this) + mListView!!.setAdapter(mFilesAdapter) + + populateRoots() + + if (intent.hasExtra(EXTRA_INITIAL_DIRECTORY)) { + displayFolder( + File( + Objects.requireNonNull( + intent.getStringExtra( + EXTRA_INITIAL_DIRECTORY + ) + ) + ) + ) + } else { + displayRoot() + } + + val prefUseRoot = mPreferences!!.getBoolean(Constants.PREF_USE_ROOT, false) + if (!prefUseRoot) { + Toast.makeText(this, R.string.kitkat_external_storage_warning, Toast.LENGTH_LONG) + .show() + } + } + + /** + * If a root directory is specified it is added to [.mRootsAdapter] otherwise + * all available storage devices/folders from various APIs are inserted into + * [.mRootsAdapter]. + */ + @SuppressLint("NewApi") + private fun populateRoots() { + val roots = ArrayList(listOf(*getExternalFilesDirs(null))) + roots.remove(getExternalFilesDir(null)) + + val rootDir = intent.getStringExtra(EXTRA_ROOT_DIRECTORY) + if (intent.hasExtra(EXTRA_ROOT_DIRECTORY) && !TextUtils.isEmpty(rootDir)) { + roots.add(File(rootDir!!)) + } else { + roots.add(Environment.getExternalStorageDirectory()) + roots.add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC)) + roots.add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)) + roots.add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)) + roots.add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)) + roots.add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)) + + // Add paths that might not be accessible to Syncthing. + if (mPreferences!!.getBoolean("advanced_folder_picker", false)) { + Collections.addAll( + roots, + *Objects.requireNonNull(File("/storage/").listFiles()) + ) + roots.add(File("/")) + } + } + // Remove any invalid directories. + val it = roots.iterator() + while (it.hasNext()) { + val f = it.next() + if (f == null || !f.exists() || !f.isDirectory()) { + it.remove() + } + } + + mRootsAdapter?.addAll(Sets.newTreeSet(roots.filterNotNull())) + } + + override fun onServiceConnected(componentName: ComponentName?, iBinder: IBinder?) { + super.onServiceConnected(componentName, iBinder) + val syncthingServiceBinder = iBinder as SyncthingServiceBinder + syncthingServiceBinder.service!!.registerOnServiceStateChangeListener(this) + } + + override fun onDestroy() { + super.onDestroy() + val syncthingService = service + syncthingService?.unregisterOnServiceStateChangeListener(this) + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + if (mListView!!.adapter === mRootsAdapter) return true + + // Inflate the menu; this adds items to the action bar if it is present. + menuInflater.inflate(R.menu.folder_picker, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.create_folder -> { + val et = EditText(this) + val dialog = getAlertDialogBuilder(this) + .setTitle(R.string.create_folder) + .setView(et) + .setPositiveButton( + android.R.string.ok + ) { _: DialogInterface?, _: Int -> + createFolder( + et.getText().toString() + ) + } + .setNegativeButton(android.R.string.cancel, null) + .create() + dialog.setOnShowListener { _: DialogInterface? -> + (getSystemService( + INPUT_METHOD_SERVICE + ) as InputMethodManager) + .showSoftInput(et, InputMethodManager.SHOW_IMPLICIT) + } + dialog.show() + return true + } + + R.id.select -> { + val intent = Intent() + .putExtra(EXTRA_RESULT_DIRECTORY, formatPath(mLocation!!.absolutePath)) + setResult(RESULT_OK, intent) + finish() + return true + } + + android.R.id.home -> { + finish() + return true + } + + else -> return super.onOptionsItemSelected(item) + } + } + + /** + * Creates a new folder with the given name and enters it. + */ + private fun createFolder(name: String) { + val newFolder = File(mLocation, name) + if (newFolder.mkdir()) { + displayFolder(newFolder) + } else { + Toast.makeText(this, R.string.create_folder_failed, Toast.LENGTH_SHORT).show() + } + } + + /** + * Refreshes the ListView to show the contents of the folder in ``mLocation.peek()}. + */ + private fun displayFolder(folder: File?) { + mLocation = folder + mFilesAdapter!!.clear() + var contents = mLocation!!.listFiles() + // In case we don't have read access to the folder, just display nothing. + if (contents == null) contents = arrayOf() + + Arrays.sort(contents, Comparator { f1: File?, f2: File? -> + if (f1!!.isDirectory() && f2!!.isFile()) return@Comparator -1 + if (f1.isFile() && f2!!.isDirectory()) return@Comparator 1 + f1.getName().compareTo(f2!!.getName()) + }) + + for (f in contents) { + mFilesAdapter!!.add(f) + } + mListView!!.setAdapter(mFilesAdapter) + } + + override fun onItemClick(adapterView: AdapterView<*>?, view: View?, i: Int, l: Long) { + val adapter = mListView!!.adapter as ArrayAdapter<*> + val f: File = checkNotNull(adapter.getItem(i)) as File + if (f.isDirectory()) { + displayFolder(f) + invalidateOptions() + } + } + + private fun invalidateOptions() { + invalidateOptionsMenu() + } + + private class FileAdapter(context: Context) : + ArrayAdapter(context, R.layout.item_folder_picker) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + var convertView = convertView + convertView = super.getView(position, convertView, parent) + val title = convertView.findViewById(android.R.id.text1) + val f: File = checkNotNull(getItem(position)) + title.text = f.getName() + val textColor = if (f.isDirectory()) + android.R.color.primary_text_light + else + android.R.color.tertiary_text_light + title.setTextColor(ContextCompat.getColor(context, textColor)) + + return convertView + } + } + + private class RootsAdapter(context: Context) : + ArrayAdapter(context, android.R.layout.simple_list_item_1) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + var convertView = convertView + convertView = super.getView(position, convertView, parent) + val title = convertView.findViewById(android.R.id.text1) + title.text = Objects.requireNonNull(getItem(position)).absolutePath + return convertView + } + + fun contains(file: File?): Boolean { + for (i in 0.. 1) { + displayRoot() + } else { + setResult(RESULT_CANCELED) + finish() + } + } + + override fun onServiceStateChange(currentState: SyncthingService.State?) { + if (!isFinishing && currentState != SyncthingService.State.ACTIVE) { + setResult(RESULT_CANCELED) + finish() + } + } + + /** + * Displays a list of all available roots, or if there is only one root, the + * contents of that folder. + */ + private fun displayRoot() { + mFilesAdapter!!.clear() + if (mRootsAdapter!!.count == 1) { + displayFolder(mRootsAdapter!!.getItem(0)) + } else { + mListView!!.setAdapter(mRootsAdapter) + mLocation = null + } + invalidateOptions() + } + + companion object { + private const val EXTRA_INITIAL_DIRECTORY = + "com.nutomic.syncthingandroid.activities.FolderPickerActivity.INITIAL_DIRECTORY" + + private const val EXTRA_ROOT_DIRECTORY = + "com.nutomic.syncthingandroid.activities.FolderPickerActivity.ROOT_DIRECTORY" + + const val EXTRA_RESULT_DIRECTORY: String = + "com.nutomic.syncthingandroid.activities.FolderPickerActivity.RESULT_DIRECTORY" + + const val DIRECTORY_REQUEST_CODE: Int = 234 + + fun createIntent( + context: Context?, + initialDirectory: String?, + rootDirectory: String? + ): Intent { + val intent = Intent(context, FolderPickerActivity::class.java) + + if (!TextUtils.isEmpty(initialDirectory)) { + intent.putExtra(EXTRA_INITIAL_DIRECTORY, initialDirectory) + } + + if (!TextUtils.isEmpty(rootDirectory)) { + intent.putExtra(EXTRA_ROOT_DIRECTORY, rootDirectory) + } + + return intent + } + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderTypeDialogActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderTypeDialogActivity.java deleted file mode 100644 index 83900356..00000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderTypeDialogActivity.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.nutomic.syncthingandroid.activities; - -import android.app.Activity; -import android.content.Intent; -import android.os.Bundle; -import android.view.View; -import android.widget.AdapterView; -import android.widget.Button; -import android.widget.Spinner; -import com.nutomic.syncthingandroid.R; -import com.nutomic.syncthingandroid.service.Constants; - -import java.util.Arrays; -import java.util.List; - -public class FolderTypeDialogActivity extends ThemedAppCompatActivity { - - public static final String EXTRA_FOLDER_TYPE = "com.nutomic.syncthinandroid.activities.FolderTypeDialogActivity.FOLDER_TYPE"; - public static final String EXTRA_RESULT_FOLDER_TYPE = "com.nutomic.syncthinandroid.activities.FolderTypeDialogActivity.EXTRA_RESULT_FOLDER_TYPE"; - - private String selectedType; - - private static final List mTypes = Arrays.asList( - Constants.FOLDER_TYPE_SEND_RECEIVE, - Constants.FOLDER_TYPE_SEND_ONLY, - Constants.FOLDER_TYPE_RECEIVE_ONLY - ); - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.fragment_foldertype_dialog); - if (savedInstanceState == null) { - selectedType = getIntent().getStringExtra(EXTRA_FOLDER_TYPE); - } - initiateFinishBtn(); - initiateSpinner(); - } - - private void initiateFinishBtn() { - Button finishBtn = findViewById(R.id.finish_btn); - finishBtn.setOnClickListener(v -> { - saveConfiguration(); - finish(); - }); - } - - private void saveConfiguration() { - Intent intent = new Intent(); - intent.putExtra(EXTRA_RESULT_FOLDER_TYPE, selectedType); - setResult(Activity.RESULT_OK, intent); - } - - private void initiateSpinner() { - Spinner folderTypeSpinner = findViewById(R.id.folderTypeSpinner); - folderTypeSpinner.setSelection(mTypes.indexOf(selectedType)); - folderTypeSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - if (position != mTypes.indexOf(selectedType)) { - selectedType = mTypes.get(position); - } - } - - @Override - public void onNothingSelected(AdapterView parent) { - // This is not allowed. - } - }); - } - - @Override - public void onBackPressed() { - saveConfiguration(); - super.onBackPressed(); - } -} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderTypeDialogActivity.kt b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderTypeDialogActivity.kt new file mode 100644 index 00000000..0d48854a --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderTypeDialogActivity.kt @@ -0,0 +1,78 @@ +package com.nutomic.syncthingandroid.activities + +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.AdapterView +import android.widget.Button +import android.widget.Spinner +import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.service.Constants + +class FolderTypeDialogActivity : ThemedAppCompatActivity() { + private var selectedType: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.fragment_foldertype_dialog) + if (savedInstanceState == null) { + selectedType = intent.getStringExtra(EXTRA_FOLDER_TYPE) + } + initiateFinishBtn() + initiateSpinner() + } + + private fun initiateFinishBtn() { + val finishBtn = findViewById