From e49601b80a9f7286b64703dbea6f0f87c3cd2fe4 Mon Sep 17 00:00:00 2001 From: d050150 Date: Wed, 17 Sep 2025 16:38:52 +0200 Subject: [PATCH 01/13] update build files --- app/build.gradle | 32 +++++++++++------------ app/src/main/res/layout/main_activity.xml | 6 ++--- build.gradle | 2 +- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index ab80d52..65e9409 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,16 +1,16 @@ apply plugin: 'com.android.application' android { - compileSdk = 35 + compileSdk = 36 namespace = 'com.sshdaemon' defaultConfig { applicationId "com.daemon.ssh" minSdkVersion 26 - targetSdkVersion 35 - versionCode 50 - versionName "2.1.32" + targetSdkVersion 36 + versionCode 51 + versionName "2.1.33" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true } @@ -52,13 +52,11 @@ android { useJUnitPlatform() } } - lintOptions { - checkReleaseBuilds = false - abortOnError = false - } lint { baseline = file('lint-baseline.xml') + abortOnError false + checkReleaseBuilds false } tasks.withType(JavaCompile).configureEach { @@ -67,24 +65,24 @@ android { } ext { - sshdVersion = '2.15.0' + sshdVersion = '2.16.0' } dependencies { - api 'com.google.android.material:material:1.12.0' + api 'com.google.android.material:material:1.13.0' implementation "org.apache.sshd:sshd-core:${sshdVersion}" implementation "org.apache.sshd:sshd-sftp:${sshdVersion}" implementation "org.apache.sshd:sshd-contrib:${sshdVersion}" - implementation "org.slf4j:slf4j-api:2.0.16" - implementation "org.slf4j:slf4j-log4j12:2.0.16" - implementation "org.bouncycastle:bcpkix-jdk15to18:1.80" + implementation "org.slf4j:slf4j-api:2.0.17" + implementation "org.slf4j:slf4j-log4j12:2.0.17" + implementation "org.bouncycastle:bcpkix-jdk15to18:1.82" implementation "net.i2p.crypto:eddsa:0.3.0" - testImplementation "org.junit.jupiter:junit-jupiter-api:5.12.0" - testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.12.0" - testImplementation "org.junit.jupiter:junit-jupiter-params:5.12.0" - testImplementation "org.junit.platform:junit-platform-launcher:1.12.0" + testImplementation "org.junit.jupiter:junit-jupiter-api:5.13.4" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.13.4" + testImplementation "org.junit.jupiter:junit-jupiter-params:5.13.4" + testImplementation "org.junit.platform:junit-platform-launcher:1.13.4" testImplementation "org.hamcrest:hamcrest-all:1.3" testImplementation "org.mockito:mockito-core:5.14.1" diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml index f09dd94..ee560f4 100644 --- a/app/src/main/res/layout/main_activity.xml +++ b/app/src/main/res/layout/main_activity.xml @@ -23,8 +23,7 @@ android:clipChildren="false" android:clipToPadding="false" android:orientation="vertical" - android:padding="24dp" - android:paddingTop="16dp"> + android:padding="12dp"> Date: Wed, 17 Sep 2025 17:20:38 +0200 Subject: [PATCH 02/13] update interface view --- .../main/java/com/sshdaemon/MainActivity.java | 52 +++++++++++++++++-- app/src/main/res/layout/main_activity.xml | 38 ++++++++++++++ app/src/main/res/values/strings.xml | 1 + 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/sshdaemon/MainActivity.java b/app/src/main/java/com/sshdaemon/MainActivity.java index e5d0d79..23da8cd 100644 --- a/app/src/main/java/com/sshdaemon/MainActivity.java +++ b/app/src/main/java/com/sshdaemon/MainActivity.java @@ -53,6 +53,8 @@ import com.sshdaemon.sshd.SshDaemon; import com.sshdaemon.sshd.SshFingerprint; +import java.util.ArrayList; +import java.util.List; import java.util.Map; @@ -121,6 +123,7 @@ private void enableViews(boolean enable) { } else { button.setImageResource(R.drawable.pause_black_24dp); } + updateServerStatusDisplay(enable); } private void setPasswordGroupVisibility(int visibility) { @@ -174,8 +177,50 @@ private boolean isStarted() { return started; } - private void updateViews() { - enableViews(!isStarted()); + private void updateServerStatusDisplay(boolean enable) { + androidx.cardview.widget.CardView statusCard = findViewById(R.id.server_status_card); + TextView statusSummary = findViewById(R.id.server_status_summary); + + if (!enable) { + String statusText = getServerStatusText(); + statusSummary.setText(statusText); + statusCard.setVisibility(View.VISIBLE); + } else { + statusCard.setVisibility(View.GONE); + } + } + + private String getServerStatusText() { + if (selectedInterface == null) { + // "All interfaces" is selected + var activeInterfaces = getActiveNetworkInterfaces(); + if (activeInterfaces.isEmpty()) { + return "Server active on all available interfaces"; + } else { + int count = activeInterfaces.size(); + String interfaceList = String.join(", ", activeInterfaces); + return "Server active on: " + interfaceList + " (" + count + " interface" + (count > 1 ? "s" : "") + ")"; + } + } else { + // Specific interface is selected + return "Server active on: " + selectedInterface; + } + } + + private List getActiveNetworkInterfaces() { + // Get the current list of interfaces from NetworkChangeReceiver + // Since we can't directly access it, we'll get it from the Spinner adapter + var networkInterfaceSpinner = (Spinner) findViewById(R.id.network_interface_spinner); + var adapter = (ArrayAdapter) networkInterfaceSpinner.getAdapter(); + + List activeInterfaces = new ArrayList<>(); + if (adapter != null && adapter.getCount() > 1) { + // Skip the first item which is "all interfaces" + for (int i = 1; i < adapter.getCount(); i++) { + activeInterfaces.add(adapter.getItem(i)); + } + } + return activeInterfaces; } private void storeValues(String selectedInterface, String port, String user, boolean passwordAuthenticationEnabled, boolean readOnly, String sftpRootPath) { @@ -231,7 +276,6 @@ protected void onDestroy() { protected void onResume() { super.onResume(); restoreValues(); - updateViews(); } @Override @@ -269,7 +313,6 @@ protected void onCreate(Bundle savedInstanceState) { setFingerPrints(getFingerPrints()); generateClicked(null); restoreValues(); - updateViews(); } public void setSelectedInterface(String selectedInterface) { @@ -292,7 +335,6 @@ public void generateClicked(View view) { public void passwordSwitchClicked(View passwordAuthenticationEnabled) { var passwordSwitch = (SwitchMaterial) passwordAuthenticationEnabled; enablePasswordAuthentication(true, !passwordSwitch.isActivated()); - updateViews(); } public void startStopClicked(View view) { diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml index ee560f4..e5d97f6 100644 --- a/app/src/main/res/layout/main_activity.xml +++ b/app/src/main/res/layout/main_activity.xml @@ -45,6 +45,44 @@ android:textColor="@android:color/holo_blue_dark" android:spinnerMode="dropdown" /> + + + + + + + + + + + + Starts/Stops server Shows key based authentication enabled/disabled + Server Status \ No newline at end of file From 1902b27b6974bd599024019191e2e5636878b461 Mon Sep 17 00:00:00 2001 From: d050150 Date: Wed, 17 Sep 2025 17:37:17 +0200 Subject: [PATCH 03/13] fix update views --- app/src/main/java/com/sshdaemon/MainActivity.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/com/sshdaemon/MainActivity.java b/app/src/main/java/com/sshdaemon/MainActivity.java index 23da8cd..61202e9 100644 --- a/app/src/main/java/com/sshdaemon/MainActivity.java +++ b/app/src/main/java/com/sshdaemon/MainActivity.java @@ -177,6 +177,10 @@ private boolean isStarted() { return started; } + private void updateViews() { + enableViews(!isStarted()); + } + private void updateServerStatusDisplay(boolean enable) { androidx.cardview.widget.CardView statusCard = findViewById(R.id.server_status_card); TextView statusSummary = findViewById(R.id.server_status_summary); @@ -276,6 +280,7 @@ protected void onDestroy() { protected void onResume() { super.onResume(); restoreValues(); + updateViews(); } @Override @@ -313,6 +318,7 @@ protected void onCreate(Bundle savedInstanceState) { setFingerPrints(getFingerPrints()); generateClicked(null); restoreValues(); + updateViews(); } public void setSelectedInterface(String selectedInterface) { From fe2f5a94ecd9e7cded5e00ae2b4b03316e4889b8 Mon Sep 17 00:00:00 2001 From: d050150 Date: Thu, 18 Sep 2025 10:05:25 +0200 Subject: [PATCH 04/13] fix styling --- app/src/main/res/layout/main_activity.xml | 192 +++++++++++++++------- app/src/main/res/values/styles.xml | 11 -- 2 files changed, 136 insertions(+), 67 deletions(-) diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml index e5d97f6..84c28a7 100644 --- a/app/src/main/res/layout/main_activity.xml +++ b/app/src/main/res/layout/main_activity.xml @@ -25,25 +25,45 @@ android:orientation="vertical" android:padding="12dp"> - + android:layout_margin="4dp" + app:cardBackgroundColor="@color/colorCardView" + app:cardCornerRadius="8dp" + app:cardElevation="2dp"> + + + + - + - + app:cardBackgroundColor="@color/colorCardView" + app:cardCornerRadius="8dp" + app:cardElevation="2dp"> + + + + + android:textSize="11sp" /> - + app:cardBackgroundColor="@color/colorCardView" + app:cardCornerRadius="8dp" + app:cardElevation="2dp"> - - + android:layout_margin="8dp" + android:hint="@string/port_label_text" + style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox" + app:boxBackgroundColor="@android:color/transparent" + app:boxStrokeColor="@android:color/transparent" + app:boxCornerRadiusTopStart="0dp" + app:boxCornerRadiusTopEnd="0dp" + app:boxCornerRadiusBottomStart="0dp" + app:boxCornerRadiusBottomEnd="0dp"> + + + + - + app:cardBackgroundColor="@color/colorCardView" + app:cardCornerRadius="8dp" + app:cardElevation="2dp"> - - - - + + + + + + + + app:cardBackgroundColor="@color/colorCardView" + app:cardCornerRadius="8dp" + app:cardElevation="2dp"> - - + style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox" + app:boxBackgroundColor="@android:color/transparent" + app:boxStrokeColor="@android:color/transparent" + app:boxCornerRadiusTopStart="0dp" + app:boxCornerRadiusTopEnd="0dp" + app:boxCornerRadiusBottomStart="0dp" + app:boxCornerRadiusBottomEnd="0dp" + app:passwordToggleDrawable="@drawable/show_password_selector" + app:passwordToggleEnabled="true"> + + + + + - + + + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index bf85330..228f58b 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -6,15 +6,4 @@ @color/colorAccent - - \ No newline at end of file From 5b2c9cd7547e81fec5653d66d6d33cada9082784 Mon Sep 17 00:00:00 2001 From: d050150 Date: Thu, 18 Sep 2025 10:11:33 +0200 Subject: [PATCH 05/13] fix font size --- app/src/main/res/layout/main_activity.xml | 60 ++++++++++++----------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml index 84c28a7..56d27ab 100644 --- a/app/src/main/res/layout/main_activity.xml +++ b/app/src/main/res/layout/main_activity.xml @@ -57,11 +57,12 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="8dp" - android:text="@string/select_network_interface" android:background="@android:color/transparent" android:popupBackground="@color/colorAccent" + android:spinnerMode="dropdown" + android:text="@string/select_network_interface" android:textColor="@android:color/holo_blue_dark" - android:spinnerMode="dropdown" /> + android:textSize="11sp" /> @@ -85,10 +86,10 @@ android:id="@+id/server_status_title" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginBottom="6dp" android:text="@string/server_status" android:textSize="11sp" - android:textStyle="bold" - android:layout_marginBottom="6dp" /> + android:textStyle="bold" /> + app:boxCornerRadiusTopEnd="0dp" + app:boxCornerRadiusTopStart="0dp" + app:boxStrokeColor="@android:color/transparent"> + android:text="@string/default_port_value" + android:textSize="11sp" /> @@ -144,27 +146,28 @@ + app:boxCornerRadiusTopEnd="0dp" + app:boxCornerRadiusTopStart="0dp" + app:boxStrokeColor="@android:color/transparent"> + android:text="@string/default_user_value" + android:textSize="11sp" /> @@ -179,17 +182,17 @@ @@ -197,11 +200,12 @@ android:id="@+id/password_value" android:layout_width="match_parent" android:layout_height="wrap_content" + android:background="@android:color/transparent" android:hint="@string/default_password_value" android:inputType="textPassword" - android:background="@android:color/transparent" android:maxLines="1" - android:text="@string/default_password_value" /> + android:text="@string/default_password_value" + android:textSize="11sp" /> @@ -275,13 +279,13 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="8dp" - android:text="@string/sftp_root_path" android:background="@android:color/transparent" - android:textColor="@android:color/holo_blue_dark" /> + android:text="@string/sftp_root_path" + android:textColor="@android:color/holo_blue_dark" + android:textSize="11sp" /> - From cd8ba21ea1789d9f095231ce36a575dd95f2ad68 Mon Sep 17 00:00:00 2001 From: d050150 Date: Thu, 18 Sep 2025 10:16:01 +0200 Subject: [PATCH 06/13] clean styling --- .../main/java/com/sshdaemon/MainActivity.java | 4 ++-- .../sshdaemon/net/NetworkChangeReceiver.java | 6 +++--- app/src/main/res/layout/main_activity.xml | 2 ++ .../main/res/layout/spinner_dropdown_item.xml | 9 +++++++++ app/src/main/res/layout/spinner_item.xml | 8 ++++++++ app/src/main/res/values/styles.xml | 19 ++++++++++++++++++- 6 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 app/src/main/res/layout/spinner_dropdown_item.xml create mode 100644 app/src/main/res/layout/spinner_item.xml diff --git a/app/src/main/java/com/sshdaemon/MainActivity.java b/app/src/main/java/com/sshdaemon/MainActivity.java index 61202e9..b2968ef 100644 --- a/app/src/main/java/com/sshdaemon/MainActivity.java +++ b/app/src/main/java/com/sshdaemon/MainActivity.java @@ -68,8 +68,8 @@ private String getValue(EditText t) { private void createSpinnerAdapter(Spinner sftpRootPaths) { if (isNull(sftpRootPaths.getSelectedItem())) { - var adapter = new ArrayAdapter<>(MainActivity.this, android.R.layout.simple_spinner_item, getAllStorageLocations(this)); - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + var adapter = new ArrayAdapter<>(MainActivity.this, R.layout.spinner_item, getAllStorageLocations(this)); + adapter.setDropDownViewResource(R.layout.spinner_dropdown_item); sftpRootPaths.setAdapter(adapter); } } diff --git a/app/src/main/java/com/sshdaemon/net/NetworkChangeReceiver.java b/app/src/main/java/com/sshdaemon/net/NetworkChangeReceiver.java index d5ffe74..5473d21 100644 --- a/app/src/main/java/com/sshdaemon/net/NetworkChangeReceiver.java +++ b/app/src/main/java/com/sshdaemon/net/NetworkChangeReceiver.java @@ -68,8 +68,8 @@ private void setAdapter() { adapter.addAll(interfaces); adapter.notifyDataSetChanged(); } else { - adapter = new ArrayAdapter<>(activity, android.R.layout.simple_spinner_item, interfaces); - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + adapter = new ArrayAdapter<>(activity, com.sshdaemon.R.layout.spinner_item, interfaces); + adapter.setDropDownViewResource(com.sshdaemon.R.layout.spinner_dropdown_item); networkInterfaces.setAdapter(adapter); } }); @@ -160,4 +160,4 @@ public void onLost(@NonNull android.net.Network network) { logger.info("Network lost. Updating network interfaces."); setAdapter(); } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml index 56d27ab..2f15f7f 100644 --- a/app/src/main/res/layout/main_activity.xml +++ b/app/src/main/res/layout/main_activity.xml @@ -248,6 +248,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/read_only" + android:textSize="11sp" app:useMaterialThemeColors="true" /> + diff --git a/app/src/main/res/layout/spinner_item.xml b/app/src/main/res/layout/spinner_item.xml new file mode 100644 index 0000000..6df7ead --- /dev/null +++ b/app/src/main/res/layout/spinner_item.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 228f58b..2824176 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -6,4 +6,21 @@ @color/colorAccent - \ No newline at end of file + + + + + From 7f3286f4294a470c7eb9a798122b37d90b7c618c Mon Sep 17 00:00:00 2001 From: d050150 Date: Thu, 18 Sep 2025 10:26:58 +0200 Subject: [PATCH 07/13] refactor main activity --- .../main/java/com/sshdaemon/MainActivity.java | 496 +++++++++++------- 1 file changed, 310 insertions(+), 186 deletions(-) diff --git a/app/src/main/java/com/sshdaemon/MainActivity.java b/app/src/main/java/com/sshdaemon/MainActivity.java index b2968ef..5da5d3d 100644 --- a/app/src/main/java/com/sshdaemon/MainActivity.java +++ b/app/src/main/java/com/sshdaemon/MainActivity.java @@ -57,167 +57,153 @@ import java.util.List; import java.util.Map; - public class MainActivity extends AppCompatActivity { private String selectedInterface; + private ViewHolder views; + + // ViewHolder pattern to cache view references + private static class ViewHolder { + final Spinner networkInterfaceSpinner; + final EditText portValue; + final EditText userValue; + final EditText passwordValue; + final Spinner sftpPaths; + final View generate; + final SwitchMaterial passwordAuthenticationEnabled; + final SwitchMaterial readonly; + final ImageView keyBasedAuthentication; + final FloatingActionButton startStopAction; + final androidx.cardview.widget.CardView serverStatusCard; + final TextView serverStatusSummary; + final LinearLayout serverFingerprints; + final View passwordLayout; + final View userLayout; + + ViewHolder(MainActivity activity) { + networkInterfaceSpinner = activity.findViewById(R.id.network_interface_spinner); + portValue = activity.findViewById(R.id.port_value); + userValue = activity.findViewById(R.id.user_value); + passwordValue = activity.findViewById(R.id.password_value); + sftpPaths = activity.findViewById(R.id.sftp_paths); + generate = activity.findViewById(R.id.generate); + passwordAuthenticationEnabled = activity.findViewById(R.id.password_authentication_enabled); + readonly = activity.findViewById(R.id.readonly_switch); + keyBasedAuthentication = activity.findViewById(R.id.key_based_authentication); + startStopAction = activity.findViewById(R.id.start_stop_action); + serverStatusCard = activity.findViewById(R.id.server_status_card); + serverStatusSummary = activity.findViewById(R.id.server_status_summary); + serverFingerprints = activity.findViewById(R.id.server_fingerprints); + passwordLayout = activity.findViewById(R.id.password_layout); + userLayout = activity.findViewById(R.id.user_layout); + } + } - private String getValue(EditText t) { - return t.getText().toString().isEmpty() ? t.getHint().toString() : t.getText().toString(); + // UI State Management + private void enableViews(boolean enable) { + setupBasicViewStates(enable); + configureSpinnerVisibility(); + configureAuthenticationViews(enable); + updateActionButton(enable); + updateServerStatusDisplay(enable); } - private void createSpinnerAdapter(Spinner sftpRootPaths) { - if (isNull(sftpRootPaths.getSelectedItem())) { - var adapter = new ArrayAdapter<>(MainActivity.this, R.layout.spinner_item, getAllStorageLocations(this)); - adapter.setDropDownViewResource(R.layout.spinner_dropdown_item); - sftpRootPaths.setAdapter(adapter); - } + private void setupBasicViewStates(boolean enable) { + views.networkInterfaceSpinner.setEnabled(enable); + views.portValue.setEnabled(enable); + views.userValue.setEnabled(enable); + views.passwordValue.setEnabled(enable); + views.sftpPaths.setEnabled(enable); + views.generate.setClickable(enable); + views.readonly.setEnabled(enable); } - private void enableViews(boolean enable) { - var selectedInterface = findViewById(R.id.network_interface_spinner); - var port = findViewById(R.id.port_value); - var user = findViewById(R.id.user_value); - var password = findViewById(R.id.password_value); - var sftpRootPaths = (Spinner) findViewById(R.id.sftp_paths); - var generate = findViewById(R.id.generate); - var passwordAuthenticationEnabled = (SwitchMaterial) findViewById(R.id.password_authentication_enabled); - var readonly = findViewById(R.id.readonly_switch); - var imageView = (ImageView) findViewById(R.id.key_based_authentication); - - selectedInterface.setEnabled(enable); - port.setEnabled(enable); - user.setEnabled(enable); - password.setEnabled(enable); - sftpRootPaths.setEnabled(enable); - generate.setClickable(enable); - readonly.setEnabled(enable); - - if (hasMultipleStorageLocations(this)) { - sftpRootPaths.setVisibility(View.VISIBLE); - } else { - sftpRootPaths.setVisibility(View.GONE); - } + private void configureSpinnerVisibility() { + int visibility = hasMultipleStorageLocations(this) ? View.VISIBLE : View.GONE; + views.sftpPaths.setVisibility(visibility); + createSpinnerAdapter(views.sftpPaths); + } - createSpinnerAdapter(sftpRootPaths); + private void configureAuthenticationViews(boolean enable) { + boolean hasPublicKey = publicKeyAuthenticationExists(); + int keyIcon = hasPublicKey ? R.drawable.key_black_24dp : R.drawable.key_off_black_24dp; + views.keyBasedAuthentication.setImageResource(keyIcon); - if (publicKeyAuthenticationExists()) { - imageView.setImageResource(R.drawable.key_black_24dp); - if (passwordAuthenticationEnabled.isChecked()) { - setPasswordGroupVisibility(View.VISIBLE); - enablePasswordAuthentication(enable, true); - } else { - setPasswordGroupVisibility(View.GONE); - enablePasswordAuthentication(enable, false); - } + if (hasPublicKey) { + handlePublicKeyAuthentication(enable); } else { - imageView.setImageResource(R.drawable.key_off_black_24dp); - setPasswordGroupVisibility(View.VISIBLE); - enablePasswordAuthentication(enable && publicKeyAuthenticationExists(), true); + handleNoPublicKeyAuthentication(enable); } + } - var view = findViewById(R.id.start_stop_action); - var button = (FloatingActionButton) view; - if (enable) { - button.setImageResource(R.drawable.play_arrow_black_24dp); + private void handlePublicKeyAuthentication(boolean enable) { + if (views.passwordAuthenticationEnabled.isChecked()) { + setPasswordGroupVisibility(View.VISIBLE); + enablePasswordAuthentication(enable, true); } else { - button.setImageResource(R.drawable.pause_black_24dp); + setPasswordGroupVisibility(View.GONE); + enablePasswordAuthentication(enable, false); } - updateServerStatusDisplay(enable); } - private void setPasswordGroupVisibility(int visibility) { - var generate = findViewById(R.id.generate); - var passwordLayout = findViewById(R.id.password_layout); - var userLayout = findViewById(R.id.user_layout); - userLayout.setVisibility(visibility); - passwordLayout.setVisibility(visibility); - generate.setVisibility(visibility); + private void handleNoPublicKeyAuthentication(boolean enable) { + setPasswordGroupVisibility(View.VISIBLE); + enablePasswordAuthentication(enable && publicKeyAuthenticationExists(), true); } - private void enablePasswordAuthentication(boolean enabled, boolean activated) { - var passwordAuthenticationEnabled = (SwitchMaterial) findViewById(R.id.password_authentication_enabled); - passwordAuthenticationEnabled.setEnabled(enabled); - passwordAuthenticationEnabled.setChecked(activated); - passwordAuthenticationEnabled.setActivated(activated); - } - - private void setFingerPrints(Map fingerPrints) { - - LinearLayout fingerPrintsLayout = findViewById(R.id.server_fingerprints); - - fingerPrintsLayout.removeAllViews(); - - var interfacesText = new TextView(this); - interfacesText.setEllipsize(END); - interfacesText.setSingleLine(); - interfacesText.setMaxLines(1); - interfacesText.setTextSize(11); - interfacesText.setText(R.string.fingerprints_label_text); - interfacesText.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT)); - interfacesText.setTypeface(null, Typeface.BOLD); - - fingerPrintsLayout.addView(interfacesText, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT)); - - for (Map.Entry e : fingerPrints.entrySet()) { - var textView = createTextView(this, "(" + e.getKey() + ") " + e.getValue()); - fingerPrintsLayout.addView(textView, - new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT)); - } + private void updateActionButton(boolean enable) { + int iconResource = enable ? R.drawable.play_arrow_black_24dp : R.drawable.pause_black_24dp; + views.startStopAction.setImageResource(iconResource); } - private boolean isStarted() { - var am = (ActivityManager) this.getSystemService(ACTIVITY_SERVICE); - @SuppressWarnings("deprecation") - var runningServices = am.getRunningServices(1); - var started = false; - if (!runningServices.isEmpty() && runningServices.get(0).service.flattenToString().contains(SSH_DAEMON)) { - started = runningServices.get(0).started; - } - return started; + // Authentication Management + private void setPasswordGroupVisibility(int visibility) { + views.userLayout.setVisibility(visibility); + views.passwordLayout.setVisibility(visibility); + views.generate.setVisibility(visibility); } - private void updateViews() { - enableViews(!isStarted()); + private void enablePasswordAuthentication(boolean enabled, boolean activated) { + views.passwordAuthenticationEnabled.setEnabled(enabled); + views.passwordAuthenticationEnabled.setChecked(activated); + views.passwordAuthenticationEnabled.setActivated(activated); } + // Server Status Management private void updateServerStatusDisplay(boolean enable) { - androidx.cardview.widget.CardView statusCard = findViewById(R.id.server_status_card); - TextView statusSummary = findViewById(R.id.server_status_summary); - if (!enable) { String statusText = getServerStatusText(); - statusSummary.setText(statusText); - statusCard.setVisibility(View.VISIBLE); + views.serverStatusSummary.setText(statusText); + views.serverStatusCard.setVisibility(View.VISIBLE); } else { - statusCard.setVisibility(View.GONE); + views.serverStatusCard.setVisibility(View.GONE); } } private String getServerStatusText() { if (selectedInterface == null) { - // "All interfaces" is selected - var activeInterfaces = getActiveNetworkInterfaces(); - if (activeInterfaces.isEmpty()) { - return "Server active on all available interfaces"; - } else { - int count = activeInterfaces.size(); - String interfaceList = String.join(", ", activeInterfaces); - return "Server active on: " + interfaceList + " (" + count + " interface" + (count > 1 ? "s" : "") + ")"; - } + return buildAllInterfacesStatusText(); } else { - // Specific interface is selected return "Server active on: " + selectedInterface; } } - private List getActiveNetworkInterfaces() { - // Get the current list of interfaces from NetworkChangeReceiver - // Since we can't directly access it, we'll get it from the Spinner adapter - var networkInterfaceSpinner = (Spinner) findViewById(R.id.network_interface_spinner); - var adapter = (ArrayAdapter) networkInterfaceSpinner.getAdapter(); + private String buildAllInterfacesStatusText() { + var activeInterfaces = getActiveNetworkInterfaces(); + if (activeInterfaces.isEmpty()) { + return "Server active on all available interfaces"; + } else { + int count = activeInterfaces.size(); + String interfaceList = String.join(", ", activeInterfaces); + String plural = count > 1 ? "s" : ""; + return "Server active on: " + interfaceList + " (" + count + " interface" + plural + ")"; + } + } + private List getActiveNetworkInterfaces() { + var adapter = (ArrayAdapter) views.networkInterfaceSpinner.getAdapter(); List activeInterfaces = new ArrayList<>(); + if (adapter != null && adapter.getCount() > 1) { // Skip the first item which is "all interfaces" for (int i = 1; i < adapter.getCount(); i++) { @@ -227,8 +213,62 @@ private List getActiveNetworkInterfaces() { return activeInterfaces; } - private void storeValues(String selectedInterface, String port, String user, boolean passwordAuthenticationEnabled, boolean readOnly, String sftpRootPath) { - var editor = this.getPreferences(Context.MODE_PRIVATE).edit(); + // Fingerprint Management + private void setFingerPrints(Map fingerPrints) { + views.serverFingerprints.removeAllViews(); + addFingerprintHeader(); + addFingerprintEntries(fingerPrints); + } + + private void addFingerprintHeader() { + var headerText = createFingerprintHeaderTextView(); + var layoutParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ); + views.serverFingerprints.addView(headerText, layoutParams); + } + + private TextView createFingerprintHeaderTextView() { + var textView = new TextView(this); + textView.setEllipsize(END); + textView.setSingleLine(); + textView.setMaxLines(1); + textView.setTextSize(11); + textView.setText(R.string.fingerprints_label_text); + textView.setLayoutParams(new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + )); + textView.setTypeface(null, Typeface.BOLD); + return textView; + } + + private void addFingerprintEntries(Map fingerPrints) { + var layoutParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ); + + for (Map.Entry entry : fingerPrints.entrySet()) { + var textView = createTextView(this, "(" + entry.getKey() + ") " + entry.getValue()); + views.serverFingerprints.addView(textView, layoutParams); + } + } + + // Spinner Management + private void createSpinnerAdapter(Spinner sftpRootPaths) { + if (isNull(sftpRootPaths.getSelectedItem())) { + var adapter = new ArrayAdapter<>(this, R.layout.spinner_item, getAllStorageLocations(this)); + adapter.setDropDownViewResource(R.layout.spinner_dropdown_item); + sftpRootPaths.setAdapter(adapter); + } + } + + // Preferences Management + private void storeValues(String selectedInterface, String port, String user, + boolean passwordAuthenticationEnabled, boolean readOnly, String sftpRootPath) { + var editor = getPreferences(Context.MODE_PRIVATE).edit(); editor.putString(getString(R.string.select_network_interface), selectedInterface); editor.putString(getString(R.string.default_port_value), port); @@ -241,101 +281,162 @@ private void storeValues(String selectedInterface, String port, String user, boo } private void restoreValues() { - var preferences = this.getPreferences(Context.MODE_PRIVATE); - var networkInterfaceSpinner = (Spinner) findViewById(R.id.network_interface_spinner); - var port = (TextView) findViewById(R.id.port_value); - var user = (TextView) findViewById(R.id.user_value); - var passwordAuthenticationEnabled = (SwitchMaterial) findViewById(R.id.password_authentication_enabled); - var readonly = (SwitchMaterial) findViewById(R.id.readonly_switch); - var sftpRootPath = (Spinner) findViewById(R.id.sftp_paths); + var preferences = getPreferences(Context.MODE_PRIVATE); - this.setSelectedInterface(preferences.getString(getString(R.string.select_network_interface), null)); - ArrayAdapter adapter = (ArrayAdapter) networkInterfaceSpinner.getAdapter(); + restoreNetworkInterface(preferences); + restoreTextFields(preferences); + restoreSwitches(preferences); + restoreSftpPath(preferences); + } - var position = adapter.getPosition(this.selectedInterface); + private void restoreNetworkInterface(android.content.SharedPreferences preferences) { + setSelectedInterface(preferences.getString(getString(R.string.select_network_interface), null)); + var adapter = (ArrayAdapter) views.networkInterfaceSpinner.getAdapter(); + var position = adapter.getPosition(selectedInterface); if (position >= 0) { - networkInterfaceSpinner.setSelection(position); + views.networkInterfaceSpinner.setSelection(position); } + } + + private void restoreTextFields(android.content.SharedPreferences preferences) { + var defaultPort = getString(R.string.default_port_value); + var defaultUser = getString(R.string.default_user_value); - port.setText(preferences.getString(getString(R.string.default_port_value), getString(R.string.default_port_value))); - user.setText(preferences.getString(getString(R.string.default_user_value), getString(R.string.default_user_value))); - passwordAuthenticationEnabled.setChecked(preferences.getBoolean(getString(R.string.password_authentication_enabled), true)); - readonly.setChecked(preferences.getBoolean(getString(R.string.read_only), false)); - createSpinnerAdapter(sftpRootPath); - position = ((ArrayAdapter) sftpRootPath.getAdapter()).getPosition(preferences.getString(getString(R.string.sftp_root_path), "/")); - sftpRootPath.setSelection(position); + views.portValue.setText(preferences.getString(getString(R.string.default_port_value), defaultPort)); + views.userValue.setText(preferences.getString(getString(R.string.default_user_value), defaultUser)); } - @Override - protected void onDestroy() { - super.onDestroy(); - NotificationManager notificationManager = getSystemService(NotificationManager.class); - if (!isNull(notificationManager)) { - notificationManager.cancel(NOTIFICATION_ID); + private void restoreSwitches(android.content.SharedPreferences preferences) { + views.passwordAuthenticationEnabled.setChecked( + preferences.getBoolean(getString(R.string.password_authentication_enabled), true) + ); + views.readonly.setChecked( + preferences.getBoolean(getString(R.string.read_only), false) + ); + } + + private void restoreSftpPath(android.content.SharedPreferences preferences) { + createSpinnerAdapter(views.sftpPaths); + var savedPath = preferences.getString(getString(R.string.sftp_root_path), "/"); + var adapter = (ArrayAdapter) views.sftpPaths.getAdapter(); + var position = adapter.getPosition(savedPath); + views.sftpPaths.setSelection(position); + } + + // Permission Management + private void setupPermissions() { + setupStoragePermissions(); + setupNotificationPermissions(); + } + + private void setupStoragePermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) { + startActivity(new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)); + } else if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1); } } - @Override - protected void onResume() { - super.onResume(); - restoreValues(); - updateViews(); + private void setupNotificationPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + && !getSystemService(NotificationManager.class).areNotificationsEnabled()) { + ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.POST_NOTIFICATIONS}, 1); + } } + // Service Status Management + private boolean isStarted() { + var am = (ActivityManager) getSystemService(ACTIVITY_SERVICE); + @SuppressWarnings("deprecation") + var runningServices = am.getRunningServices(1); + var started = false; + if (!runningServices.isEmpty() && runningServices.get(0).service.flattenToString().contains(SSH_DAEMON)) { + started = runningServices.get(0).started; + } + return started; + } + + private void updateViews() { + enableViews(!isStarted()); + } + + // Utility Methods + private String getValue(EditText editText) { + return editText.getText().toString().isEmpty() ? + editText.getHint().toString() : + editText.getText().toString(); + } + + // Lifecycle Methods @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main_activity); - var networkInterfaceSpinner = (Spinner) findViewById(R.id.network_interface_spinner); + views = new ViewHolder(this); + setupNetworkChangeReceiver(); + setupPermissions(); + setupWindowInsets(); + initializeApp(); + } + private void setupNetworkChangeReceiver() { var connectivityManager = getSystemService(ConnectivityManager.class); - connectivityManager.registerDefaultNetworkCallback(new NetworkChangeReceiver(networkInterfaceSpinner, - this.getSystemService(ConnectivityManager.class), - this)); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) { - startActivity(new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)); - } else if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this, - new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1); - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !getSystemService(NotificationManager.class).areNotificationsEnabled()) { - ActivityCompat.requestPermissions(this, - new String[]{Manifest.permission.POST_NOTIFICATIONS}, 1); - } + connectivityManager.registerDefaultNetworkCallback( + new NetworkChangeReceiver(views.networkInterfaceSpinner, connectivityManager, this) + ); + } + private void setupWindowInsets() { WindowCompat.setDecorFitsSystemWindows(getWindow(), false); - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.root_layout), (view, insets) -> { var systemBarsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()); - view.setPadding(systemBarsInsets.left, systemBarsInsets.top, systemBarsInsets.right, systemBarsInsets.bottom); + view.setPadding(systemBarsInsets.left, systemBarsInsets.top, + systemBarsInsets.right, systemBarsInsets.bottom); return insets; }); + } + private void initializeApp() { setFingerPrints(getFingerPrints()); generateClicked(null); restoreValues(); updateViews(); } + @Override + protected void onResume() { + super.onResume(); + restoreValues(); + updateViews(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + var notificationManager = getSystemService(NotificationManager.class); + if (!isNull(notificationManager)) { + notificationManager.cancel(NOTIFICATION_ID); + } + } + + // Public Interface Methods public void setSelectedInterface(String selectedInterface) { this.selectedInterface = selectedInterface; } + // Click Handlers public void keyClicked(View view) { var text = publicKeyAuthenticationExists() ? getResources().getString(R.string.ssh_public_key_exists) : getResources().getString(R.string.ssh_public_key_doesnt_exists); - Toast.makeText(this, text, Toast.LENGTH_SHORT).show(); } public void generateClicked(View view) { - TextInputEditText password = findViewById(R.id.password_value); - password.setText(getRandomString(6)); + var passwordField = (TextInputEditText) views.passwordValue; + passwordField.setText(getRandomString(6)); } public void passwordSwitchClicked(View passwordAuthenticationEnabled) { @@ -345,24 +446,47 @@ public void passwordSwitchClicked(View passwordAuthenticationEnabled) { public void startStopClicked(View view) { if (isStarted()) { - enableViews(true); - stopService(); + handleStopService(); } else { - enableViews(false); - setFingerPrints(getFingerPrints()); - final var port = getValue(findViewById(R.id.port_value)); - final var user = getValue(findViewById(R.id.user_value)); - final var password = getValue(findViewById(R.id.password_value)); - final var sftpRootPath = ((Spinner) findViewById(R.id.sftp_paths)).getSelectedItem().toString(); - final var passwordAuthenticationEnabled = ((SwitchMaterial) findViewById(R.id.password_authentication_enabled)).isChecked(); - final var readOnly = ((SwitchMaterial) findViewById(R.id.readonly_switch)).isChecked(); - storeValues(this.selectedInterface, port, user, passwordAuthenticationEnabled, readOnly, sftpRootPath); - - startService(Integer.parseInt(port), user, password, sftpRootPath, passwordAuthenticationEnabled, readOnly); + handleStartService(); } } - public void startService(int port, String user, String password, String sftpRootPath, boolean passwordAuthenticationEnabled, boolean readOnly) { + private void handleStopService() { + enableViews(true); + stopService(); + } + + private void handleStartService() { + enableViews(false); + setFingerPrints(getFingerPrints()); + + var serviceParams = collectServiceParameters(); + storeValues(selectedInterface, serviceParams.port, serviceParams.user, + serviceParams.passwordAuthEnabled, serviceParams.readOnly, serviceParams.sftpRootPath); + + startService(Integer.parseInt(serviceParams.port), serviceParams.user, serviceParams.password, + serviceParams.sftpRootPath, serviceParams.passwordAuthEnabled, serviceParams.readOnly); + } + + private ServiceParameters collectServiceParameters() { + var port = getValue(views.portValue); + var user = getValue(views.userValue); + var password = getValue(views.passwordValue); + var sftpRootPath = views.sftpPaths.getSelectedItem().toString(); + var passwordAuthEnabled = views.passwordAuthenticationEnabled.isChecked(); + var readOnly = views.readonly.isChecked(); + + return new ServiceParameters(port, user, password, sftpRootPath, passwordAuthEnabled, readOnly); + } + + private record ServiceParameters(String port, String user, String password, String sftpRootPath, + boolean passwordAuthEnabled, boolean readOnly) { + } + + // Service Management + public void startService(int port, String user, String password, String sftpRootPath, + boolean passwordAuthenticationEnabled, boolean readOnly) { var sshDaemonIntent = new Intent(this, SshDaemon.class); sshDaemonIntent.putExtra(INTERFACE, selectedInterface); sshDaemonIntent.putExtra(PORT, port); From da82c87b4fb7f3dc4ef3d793c4ab8a227fc2a964 Mon Sep 17 00:00:00 2001 From: d050150 Date: Thu, 18 Sep 2025 10:34:35 +0200 Subject: [PATCH 08/13] Fix build files, remove deprecated stuff --- app/build.gradle | 4 ++-- app/lint-baseline.xml | 21 ++++++------------- .../java/com/sshdaemon/sshd/SshDaemon.java | 4 ++-- build.gradle | 4 +--- 4 files changed, 11 insertions(+), 22 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 65e9409..694a182 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -55,8 +55,8 @@ android { lint { baseline = file('lint-baseline.xml') - abortOnError false - checkReleaseBuilds false + abortOnError = false + checkReleaseBuilds = false } tasks.withType(JavaCompile).configureEach { diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index ddaf276..cbe0de4 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,22 +1,13 @@ - + - - - - - + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" + id="ScopedStorage" + message="The Google Play store has a policy that limits usage of MANAGE_EXTERNAL_STORAGE"> + diff --git a/app/src/main/java/com/sshdaemon/sshd/SshDaemon.java b/app/src/main/java/com/sshdaemon/sshd/SshDaemon.java index a52eebe..d110cab 100644 --- a/app/src/main/java/com/sshdaemon/sshd/SshDaemon.java +++ b/app/src/main/java/com/sshdaemon/sshd/SshDaemon.java @@ -255,7 +255,7 @@ public void onDestroy() { var pendingIntent = PendingIntent.getActivity(getApplicationContext(), 0, notificationIntent, FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); updateNotification("SSH Server Stopped", pendingIntent); - stopForeground(true); + stopForeground(STOP_FOREGROUND_REMOVE); } } catch (IOException e) { logger.error("Failed to stop SSH daemon", e); @@ -265,7 +265,7 @@ public void onDestroy() { @Override public void onTaskRemoved(Intent rootIntent) { super.onTaskRemoved(rootIntent); - stopForeground(true); + stopForeground(STOP_FOREGROUND_REMOVE); } @Nullable diff --git a/build.gradle b/build.gradle index a5af9c8..6c76391 100644 --- a/build.gradle +++ b/build.gradle @@ -11,8 +11,6 @@ buildscript { } } -apply plugin: 'android-reporting' - allprojects { repositories { mavenCentral() @@ -22,4 +20,4 @@ allprojects { tasks.register('clean', Delete) { delete rootProject.layout.buildDirectory -} \ No newline at end of file +} From 4c7f25e1141018f01d8dbe55544ac35000da2f1e Mon Sep 17 00:00:00 2001 From: d050150 Date: Thu, 18 Sep 2025 10:36:38 +0200 Subject: [PATCH 09/13] upgrade test dependencies --- app/build.gradle | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 694a182..2bb864c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -85,9 +85,9 @@ dependencies { testImplementation "org.junit.platform:junit-platform-launcher:1.13.4" testImplementation "org.hamcrest:hamcrest-all:1.3" - testImplementation "org.mockito:mockito-core:5.14.1" - androidTestImplementation "androidx.test:core:1.6.1" - androidTestImplementation "androidx.test.ext:junit:1.2.1" - androidTestImplementation "androidx.test:runner:1.6.2" - androidTestImplementation "androidx.test.espresso:espresso-core:3.6.1" + testImplementation "org.mockito:mockito-core:5.19.0" + androidTestImplementation "androidx.test:core:1.7.0" + androidTestImplementation "androidx.test.ext:junit:1.3.0" + androidTestImplementation "androidx.test:runner:1.7.0" + androidTestImplementation "androidx.test.espresso:espresso-core:3.7.0" } \ No newline at end of file From f56145072d9d837e8ec3c54e832f50b531c5ca9d Mon Sep 17 00:00:00 2001 From: d050150 Date: Thu, 18 Sep 2025 10:50:53 +0200 Subject: [PATCH 10/13] Fix deprecated method --- app/src/main/java/com/sshdaemon/MainActivity.java | 11 +---------- app/src/main/java/com/sshdaemon/sshd/SshDaemon.java | 10 +++++++++- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/sshdaemon/MainActivity.java b/app/src/main/java/com/sshdaemon/MainActivity.java index 5da5d3d..8928d2a 100644 --- a/app/src/main/java/com/sshdaemon/MainActivity.java +++ b/app/src/main/java/com/sshdaemon/MainActivity.java @@ -8,7 +8,6 @@ import static com.sshdaemon.sshd.SshDaemon.PORT; import static com.sshdaemon.sshd.SshDaemon.READ_ONLY; import static com.sshdaemon.sshd.SshDaemon.SFTP_ROOT_PATH; -import static com.sshdaemon.sshd.SshDaemon.SSH_DAEMON; import static com.sshdaemon.sshd.SshDaemon.USER; import static com.sshdaemon.sshd.SshDaemon.getFingerPrints; import static com.sshdaemon.sshd.SshDaemon.publicKeyAuthenticationExists; @@ -19,7 +18,6 @@ import static java.util.Objects.isNull; import android.Manifest; -import android.app.ActivityManager; import android.app.NotificationManager; import android.content.Context; import android.content.Intent; @@ -347,14 +345,7 @@ private void setupNotificationPermissions() { // Service Status Management private boolean isStarted() { - var am = (ActivityManager) getSystemService(ACTIVITY_SERVICE); - @SuppressWarnings("deprecation") - var runningServices = am.getRunningServices(1); - var started = false; - if (!runningServices.isEmpty() && runningServices.get(0).service.flattenToString().contains(SSH_DAEMON)) { - started = runningServices.get(0).started; - } - return started; + return SshDaemon.isRunning(); } private void updateViews() { diff --git a/app/src/main/java/com/sshdaemon/sshd/SshDaemon.java b/app/src/main/java/com/sshdaemon/sshd/SshDaemon.java index d110cab..5e37ab4 100644 --- a/app/src/main/java/com/sshdaemon/sshd/SshDaemon.java +++ b/app/src/main/java/com/sshdaemon/sshd/SshDaemon.java @@ -68,6 +68,8 @@ public class SshDaemon extends Service { private static final int THREAD_POOL_SIZE = 10; private static final int DEFAULT_PORT = 8022; + private static volatile boolean isServiceRunning = false; + static { Security.removeProvider("BC"); if (SecurityUtils.isRegistrationCompleted()) { @@ -103,6 +105,10 @@ public static boolean publicKeyAuthenticationExists() { return authenticator.loadKeysFromPath(authorizedKeyPath); } + public static boolean isRunning() { + return isServiceRunning; + } + public static Map getFingerPrints() { var result = new HashMap(); try { @@ -224,6 +230,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { init(interfaceName, port, user, password, sftpRootPath, passwordAuthEnabled, readOnly); sshd.start(); + isServiceRunning = true; logger.info("SSH daemon started on port {}", port); updateNotification("SSH Server Running on port " + port, pendingIntent); } catch (IOException e) { @@ -247,6 +254,7 @@ private void updateNotification(String status, PendingIntent pendingIntent) { @Override public void onDestroy() { super.onDestroy(); + isServiceRunning = false; try { if (sshd != null && sshd.isStarted()) { sshd.stop(); @@ -273,4 +281,4 @@ public void onTaskRemoved(Intent rootIntent) { public IBinder onBind(Intent intent) { return null; } -} \ No newline at end of file +} From 3d5b8e7080ed69b5b9435f2021c781fc8bd36f5b Mon Sep 17 00:00:00 2001 From: d050150 Date: Thu, 18 Sep 2025 10:57:50 +0200 Subject: [PATCH 11/13] SshDaemon test --- .../com/sshdaemon/sshd/SshDaemonTest.java | 322 +++++++++++++++++- 1 file changed, 314 insertions(+), 8 deletions(-) diff --git a/app/src/test/java/com/sshdaemon/sshd/SshDaemonTest.java b/app/src/test/java/com/sshdaemon/sshd/SshDaemonTest.java index c3af457..0eab302 100644 --- a/app/src/test/java/com/sshdaemon/sshd/SshDaemonTest.java +++ b/app/src/test/java/com/sshdaemon/sshd/SshDaemonTest.java @@ -1,23 +1,329 @@ package com.sshdaemon.sshd; import static com.sshdaemon.sshd.SshDaemon.getFingerPrints; +import static com.sshdaemon.sshd.SshDaemon.isRunning; +import static com.sshdaemon.sshd.SshDaemon.publicKeyAuthenticationExists; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.Is.is; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.lang.reflect.Field; + class SshDaemonTest { + @BeforeEach + void setUp() { + // Reset the static isServiceRunning flag before each test + resetServiceRunningFlag(); + } + + @Nested + @DisplayName("Service Status Tests") + class ServiceStatusTests { + + @Test + @DisplayName("Should return false when service is not running initially") + void testIsRunningInitiallyFalse() { + assertFalse(isRunning(), "Service should not be running initially"); + } + + @Test + @DisplayName("Should track service running state correctly") + void testServiceRunningStateTracking() { + // Initially false + assertFalse(isRunning()); + + // Set to true (simulating service start) + setServiceRunningFlag(true); + assertTrue(isRunning(), "Service should be running after start"); + + // Set to false (simulating service stop) + setServiceRunningFlag(false); + assertFalse(isRunning(), "Service should not be running after stop"); + } + + @Test + @DisplayName("Should handle concurrent access to service status") + void testConcurrentServiceStatusAccess() throws InterruptedException { + final int numThreads = 10; + final Thread[] threads = new Thread[numThreads]; + final boolean[] results = new boolean[numThreads]; + + // Set service as running + setServiceRunningFlag(true); + + // Create threads that check service status + for (int i = 0; i < numThreads; i++) { + final int index = i; + threads[i] = new Thread(() -> results[index] = isRunning()); + } + + // Start all threads + for (Thread thread : threads) { + thread.start(); + } + + // Wait for all threads to complete + for (Thread thread : threads) { + thread.join(); + } + + // All threads should see the same result + for (boolean result : results) { + assertTrue(result, "All threads should see service as running"); + } + } + + @Test + @DisplayName("Should handle rapid state changes") + void testRapidStateChanges() { + for (int i = 0; i < 100; i++) { + setServiceRunningFlag(true); + assertTrue(isRunning(), "Service should be running on rapid change iteration " + i); + + setServiceRunningFlag(false); + assertFalse(isRunning(), "Service should be stopped on rapid change iteration " + i); + } + } + } + + @Nested + @DisplayName("Fingerprint Tests") + class FingerprintTests { + + @Test + @DisplayName("Should load fingerprints successfully") + void testLoadKeys() { + var fingerPrints = getFingerPrints(); + assertThat(fingerPrints.containsKey(SshFingerprint.DIGESTS.MD5), Matchers.is(true)); + assertThat(fingerPrints.containsKey(SshFingerprint.DIGESTS.SHA256), Matchers.is(true)); + assertThat(fingerPrints.get(SshFingerprint.DIGESTS.MD5), is(not(nullValue()))); + assertThat(fingerPrints.get(SshFingerprint.DIGESTS.SHA256), is(not(nullValue()))); + } + + @Test + @DisplayName("Should return non-null fingerprints map") + void testFingerprintsNotNull() { + var fingerPrints = getFingerPrints(); + assertNotNull(fingerPrints, "Fingerprints map should not be null"); + } + + @Test + @DisplayName("Should handle fingerprint generation with different service states") + void testFingerprintsWithServiceState() { + // Test when service is not running + setServiceRunningFlag(false); + var fingerprintsWhenStopped = getFingerPrints(); + assertNotNull(fingerprintsWhenStopped, "Fingerprints should be available when service is stopped"); + + // Test when service is running + setServiceRunningFlag(true); + var fingerprintsWhenRunning = getFingerPrints(); + assertNotNull(fingerprintsWhenRunning, "Fingerprints should be available when service is running"); + + // Fingerprints should be the same regardless of service state + assertEquals(fingerprintsWhenStopped.size(), fingerprintsWhenRunning.size(), + "Fingerprints should be consistent regardless of service state"); + } + } + + @Nested + @DisplayName("Public Key Authentication Tests") + class PublicKeyAuthTests { + + @Test + @DisplayName("Should handle public key authentication check") + void testPublicKeyAuthenticationExists() { + // This test will depend on the actual file system state + // Just ensure it doesn't throw an exception + assertDoesNotThrow(() -> publicKeyAuthenticationExists(), + "Public key authentication check should not throw exception"); + } + + @Test + @DisplayName("Should return boolean for public key authentication") + void testPublicKeyAuthenticationReturnsBoolean() { + boolean result = publicKeyAuthenticationExists(); + // Should return either true or false, not throw exception + assertTrue(result == true || result == false, + "Public key authentication should return boolean"); + } + + @Test + @DisplayName("Should be consistent across multiple calls") + void testPublicKeyAuthenticationConsistency() { + boolean result1 = publicKeyAuthenticationExists(); + boolean result2 = publicKeyAuthenticationExists(); + assertEquals(result1, result2, + "Public key authentication result should be consistent"); + } + } + + @Nested + @DisplayName("Constructor Tests") + class ConstructorTests { + + @Test + @DisplayName("Should create SshDaemon with default constructor") + void testDefaultConstructor() { + assertDoesNotThrow(() -> new SshDaemon(), + "Default constructor should not throw exception"); + } + + @Test + @DisplayName("Should validate port range in constructor") + void testPortValidation() { + // Test valid ports + assertDoesNotThrow(() -> new SshDaemon("127.0.0.1", 1024, "user", "pass", "/tmp", true, false), + "Port 1024 should be valid"); + assertDoesNotThrow(() -> new SshDaemon("127.0.0.1", 65535, "user", "pass", "/tmp", true, false), + "Port 65535 should be valid"); + + // Test invalid ports + assertThrows(IllegalArgumentException.class, + () -> new SshDaemon("127.0.0.1", 1023, "user", "pass", "/tmp", true, false), + "Port 1023 should be invalid"); + assertThrows(IllegalArgumentException.class, + () -> new SshDaemon("127.0.0.1", 65536, "user", "pass", "/tmp", true, false), + "Port 65536 should be invalid"); + assertThrows(IllegalArgumentException.class, + () -> new SshDaemon("127.0.0.1", 0, "user", "pass", "/tmp", true, false), + "Port 0 should be invalid"); + assertThrows(IllegalArgumentException.class, + () -> new SshDaemon("127.0.0.1", -1, "user", "pass", "/tmp", true, false), + "Negative port should be invalid"); + } + + @Test + @DisplayName("Should validate SFTP root path in constructor") + void testSftpRootPathValidation() { + // Test with non-existent path + assertThrows(IllegalArgumentException.class, + () -> new SshDaemon("127.0.0.1", 2222, "user", "pass", "/definitely/does/not/exist", true, false), + "Non-existent SFTP root path should throw exception"); + } + } + + @Nested + @DisplayName("Service Lifecycle Tests") + class ServiceLifecycleTests { + + @Test + @DisplayName("Should create service instances without errors") + void testServiceCreation() { + SshDaemon daemon1 = assertDoesNotThrow(() -> new SshDaemon(), + "Creating SshDaemon should not throw exception"); + assertNotNull(daemon1, "SshDaemon instance should not be null"); + + SshDaemon daemon2 = assertDoesNotThrow(() -> new SshDaemon(), + "Creating multiple SshDaemon instances should not throw exception"); + assertNotNull(daemon2, "Second SshDaemon instance should not be null"); + } + + @Test + @DisplayName("Should handle service state changes correctly") + void testServiceStateManagement() { + // Test initial state + assertFalse(isRunning(), "Service should not be running initially"); + + // Simulate service start + setServiceRunningFlag(true); + assertTrue(isRunning(), "Service should be running after start"); + + // Simulate service stop + setServiceRunningFlag(false); + assertFalse(isRunning(), "Service should not be running after stop"); + } + } + + @Nested + @DisplayName("Integration Tests") + class IntegrationTests { + + @Test + @DisplayName("Should maintain service state consistency") + void testServiceStateConsistency() { + // Initial state + assertFalse(isRunning()); + + // Simulate service lifecycle + setServiceRunningFlag(true); // Service starts + assertTrue(isRunning()); + + // Get fingerprints while service is running + var fingerprints = getFingerPrints(); + assertNotNull(fingerprints); + + setServiceRunningFlag(false); // Service stops + assertFalse(isRunning()); + + // Fingerprints should still be available + var fingerprintsAfterStop = getFingerPrints(); + assertNotNull(fingerprintsAfterStop); + } + + @Test + @DisplayName("Should handle multiple service state changes") + void testMultipleServiceStateChanges() { + for (int i = 0; i < 5; i++) { + setServiceRunningFlag(true); + assertTrue(isRunning(), "Service should be running on iteration " + i); + + // Check that other methods still work + var fingerprints = getFingerPrints(); + assertNotNull(fingerprints, "Fingerprints should be available on iteration " + i); + + boolean authExists = publicKeyAuthenticationExists(); + assertTrue(authExists == true || authExists == false, + "Auth check should return boolean on iteration " + i); + + setServiceRunningFlag(false); + assertFalse(isRunning(), "Service should be stopped on iteration " + i); + } + } + + @Test + @DisplayName("Should handle service state with static method interactions") + void testServiceStateWithStaticMethods() { + // Test all static methods work when service is stopped + setServiceRunningFlag(false); + assertFalse(isRunning()); + assertNotNull(getFingerPrints()); + assertDoesNotThrow(() -> publicKeyAuthenticationExists()); + + // Test all static methods work when service is running + setServiceRunningFlag(true); + assertTrue(isRunning()); + assertNotNull(getFingerPrints()); + assertDoesNotThrow(() -> publicKeyAuthenticationExists()); + } + } + + // Helper methods for testing + private void resetServiceRunningFlag() { + setServiceRunningFlag(false); + } - @Test - void testLoadKeys() { - var fingerPrints = getFingerPrints(); - assertThat(fingerPrints.containsKey(SshFingerprint.DIGESTS.MD5), Matchers.is(true)); - assertThat(fingerPrints.containsKey(SshFingerprint.DIGESTS.SHA256), Matchers.is(true)); - assertThat(fingerPrints.get(SshFingerprint.DIGESTS.MD5), is(not(nullValue()))); - assertThat(fingerPrints.get(SshFingerprint.DIGESTS.SHA256), is(not(nullValue()))); + private void setServiceRunningFlag(boolean value) { + try { + Field field = SshDaemon.class.getDeclaredField("isServiceRunning"); + field.setAccessible(true); + field.setBoolean(null, value); + } catch (Exception e) { + throw new RuntimeException("Failed to set service running flag", e); + } } -} \ No newline at end of file +} From b9ccc25762049d9768fbfe69db22e1d2e7a327ee Mon Sep 17 00:00:00 2001 From: d050150 Date: Thu, 18 Sep 2025 11:13:00 +0200 Subject: [PATCH 12/13] add more tests --- .../sshd/SshPublicKeyAuthenticator.java | 21 +- .../sshd/SshPublicKeyAuthenticatorTest.java | 355 +++++++++++++++--- 2 files changed, 312 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/sshdaemon/sshd/SshPublicKeyAuthenticator.java b/app/src/main/java/com/sshdaemon/sshd/SshPublicKeyAuthenticator.java index 33feb8f..854d32d 100644 --- a/app/src/main/java/com/sshdaemon/sshd/SshPublicKeyAuthenticator.java +++ b/app/src/main/java/com/sshdaemon/sshd/SshPublicKeyAuthenticator.java @@ -52,12 +52,14 @@ private static byte[] readElement(DataInputStream dataInput) throws IOException protected static PublicKey readKey(String key) throws Exception { if (isNull(key) || key.trim().isEmpty()) { - throw new IllegalArgumentException("Key string is empty or null"); + LOGGER.error("Key string is empty or null"); + return null; } String[] parts = key.trim().split("\\s+"); if (parts.length < 2) { - throw new IllegalArgumentException("Invalid key format: expected at least type and key"); + LOGGER.error("Invalid key format: expected at least type and key"); + return null; } String keyType = parts[0]; @@ -65,13 +67,15 @@ protected static PublicKey readKey(String key) throws Exception { try { decodedKey = Base64.getDecoder().decode(parts[1]); } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Invalid Base64 encoding in key", e); + LOGGER.error("Invalid Base64 encoding in key", e); + return null; } try (DataInputStream dataInputStream = new DataInputStream(new ByteArrayInputStream(decodedKey))) { String pubKeyFormat = new String(readElement(dataInputStream)); if (!pubKeyFormat.equals(keyType)) { - throw new IllegalArgumentException("Key type mismatch: expected " + keyType + ", got " + pubKeyFormat); + LOGGER.error("Key type mismatch: expected {}, got {}", keyType, pubKeyFormat); + return null; } switch (pubKeyFormat) { @@ -88,7 +92,8 @@ protected static PublicKey readKey(String key) throws Exception { return new EdDSAPublicKey(new EdDSAPublicKeySpec(params.getEncoded(), EdDSANamedCurveTable.ED_25519_CURVE_SPEC)); default: - throw new UnknownPublicKeyFormatException(pubKeyFormat); + LOGGER.error(pubKeyFormat); + return null; } } } @@ -98,6 +103,7 @@ Set getAuthorizedKeys() { } public boolean loadKeysFromPath(String authorizedKeysPath) { + authorizedKeys.clear(); if (isNull(authorizedKeysPath)) { LOGGER.error("Authorized keys path is null"); return false; @@ -110,7 +116,7 @@ public boolean loadKeysFromPath(String authorizedKeysPath) { } LOGGER.debug("Loading authorized keys from {}", authorizedKeysPath); - authorizedKeys.clear(); + try (BufferedReader reader = new BufferedReader(new FileReader(file))) { String line; @@ -121,6 +127,9 @@ public boolean loadKeysFromPath(String authorizedKeysPath) { } try { PublicKey key = readKey(line); + if (isNull(key)) { + continue; + } if (authorizedKeys.add(key)) { LOGGER.debug("Added authorized key: type={}", key.getAlgorithm()); } else { diff --git a/app/src/test/java/com/sshdaemon/sshd/SshPublicKeyAuthenticatorTest.java b/app/src/test/java/com/sshdaemon/sshd/SshPublicKeyAuthenticatorTest.java index 4426a0f..b448a85 100644 --- a/app/src/test/java/com/sshdaemon/sshd/SshPublicKeyAuthenticatorTest.java +++ b/app/src/test/java/com/sshdaemon/sshd/SshPublicKeyAuthenticatorTest.java @@ -3,12 +3,23 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.security.KeyFactory; import java.security.interfaces.RSAPublicKey; @@ -16,77 +27,305 @@ class SshPublicKeyAuthenticatorTest { - private final SshPublicKeyAuthenticator sshPublicKeyAuthenticator = new SshPublicKeyAuthenticator(); + private SshPublicKeyAuthenticator sshPublicKeyAuthenticator; - @Test - void testLoadRSAKey() throws Exception { - var rsaPublicKey = (RSAPublicKey) SshPublicKeyAuthenticator.readKey("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCrkf5RHFcmmnPFxfOVsVOCdDVfs04dZg+/n808/NEdyOPuyAde4UIvZbzKEjW9brtEvOHCFfxZuXa0TbTIUau9p+4gWTGXIONcarwJ7LtNUlWfJiWYmIWVgyNnpzVftcW3mi8gRGxPbCJM2yVeB7gv452wvWPDe9TFdpgbwhLBqVIRG6EBHC0VBXX8qKNCbFoclYbiXa5DfwMkxYwN2yyKaSu75e0H4FP4BehaqQ6SfBIThqQRVdcx9J9Du3GzTi4ArN0timPAQ+X17pWxgEQ3qNbj49Lnteu+NSmb0PawcrP+Ykd7oy82kXm/hRM6cLjS1GOTsXpGDFf0NevAW8b3 D050150@WDFL34195932A"); - assertThat(rsaPublicKey.getPublicExponent(), is(new BigInteger("65537"))); - assertThat(rsaPublicKey.getModulus(), is(new BigInteger("21658742190318166967712730864679652658650859121969481181201380769435852715982079838796135745206268981260737877360141273622280512537469661232310601414632396577736750997307043633989350470146139654498683603607823966490835477269345553397205866827412911445557084380501015516582017566897110095005407768881980022943053565933828297090533987425102831869390057642704253755269803136323388759627399370507151238064778399477125470941103468997107204954580888346976963732529191611522789249471940599415587667163136427455499142265843852906870573003016543761403915579728832278943756709241709719567708592405407294409003276217649490282231"))); + @BeforeEach + void setUp() { + sshPublicKeyAuthenticator = new SshPublicKeyAuthenticator(); } - @Test - void testLoadED25519Key() throws Exception { - var publicKey = SshPublicKeyAuthenticator.readKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGJ0j5BztROLdZYHf8cpJsJr9jd8gCRUfm6oe9k3Bhh0 @quantenzitrone:matrix.org"); - assertThat(publicKey.getFormat(), is("X.509")); - assertThat(publicKey.getEncoded(), is(new byte[]{48, 42, 48, 5, 6, 3, 43, 101, 112, 3, 33, 0, 98, 116, -113, -112, 115, -75, 19, -117, 117, -106, 7, 127, -57, 41, 38, -62, 107, -10, 55, 124, -128, 36, 84, 126, 110, -88, 123, -39, 55, 6, 24, 116})); + @Nested + @DisplayName("Key Loading Tests") + class KeyLoadingTests { + + @Test + @DisplayName("Should load RSA key correctly") + void testLoadRSAKey() throws Exception { + var rsaPublicKey = (RSAPublicKey) SshPublicKeyAuthenticator.readKey("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCrkf5RHFcmmnPFxfOVsVOCdDVfs04dZg+/n808/NEdyOPuyAde4UIvZbzKEjW9brtEvOHCFfxZuXa0TbTIUau9p+4gWTGXIONcarwJ7LtNUlWfJiWYmIWVgyNnpzVftcW3mi8gRGxPbCJM2yVeB7gv452wvWPDe9TFdpgbwhLBqVIRG6EBHC0VBXX8qKNCbFoclYbiXa5DfwMkxYwN2yyKaSu75e0H4FP4BehaqQ6SfBIThqQRVdcx9J9Du3GzTi4ArN0timPAQ+X17pWxgEQ3qNbj49Lnteu+NSmb0PawcrP+Ykd7oy82kXm/hRM6cLjS1GOTsXpGDFf0NevAW8b3 D050150@WDFL34195932A"); + assertThat(rsaPublicKey.getPublicExponent(), is(new BigInteger("65537"))); + assertThat(rsaPublicKey.getModulus(), is(new BigInteger("21658742190318166967712730864679652658650859121969481181201380769435852715982079838796135745206268981260737877360141273622280512537469661232310601414632396577736750997307043633989350470146139654498683603607823966490835477269345553397205866827412911445557084380501015516582017566897110095005407768881980022943053565933828297090533987425102831869390057642704253755269803136323388759627399370507151238064778399477125470941103468997107204954580888346976963732529191611522789249471940599415587667163136427455499142265843852906870573003016543761403915579728832278943756709241709719567708592405407294409003276217649490282231"))); + } + + @Test + @DisplayName("Should load ED25519 key correctly") + void testLoadED25519Key() throws Exception { + var publicKey = SshPublicKeyAuthenticator.readKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGJ0j5BztROLdZYHf8cpJsJr9jd8gCRUfm6oe9k3Bhh0 @quantenzitrone:matrix.org"); + assertThat(publicKey.getFormat(), is("X.509")); + assertThat(publicKey.getEncoded(), is(new byte[]{48, 42, 48, 5, 6, 3, 43, 101, 112, 3, 33, 0, 98, 116, -113, -112, 115, -75, 19, -117, 117, -106, 7, 127, -57, 41, 38, -62, 107, -10, 55, 124, -128, 36, 84, 126, 110, -88, 123, -39, 55, 6, 24, 116})); + } + + @Test + @DisplayName("Should load keys from file path") + void testLoadKeyFromFilePath() throws Exception { + var resourceDirectory = Paths.get("src", "test", "resources"); + var absolutePath = resourceDirectory.toFile().getAbsolutePath() + "/authorized_keys"; + assertTrue(sshPublicKeyAuthenticator.loadKeysFromPath(absolutePath)); + var authorizedKeys = sshPublicKeyAuthenticator.getAuthorizedKeys(); + assertThat(authorizedKeys.size(), is(3)); + + var exponent = new BigInteger("65537"); + var firstKey = new RSAPublicKeySpec(new BigInteger("21658742190318166967712730864679652658650859121969481181201380769435852715982079838796135745206268981260737877360141273622280512537469661232310601414632396577736750997307043633989350470146139654498683603607823966490835477269345553397205866827412911445557084380501015516582017566897110095005407768881980022943053565933828297090533987425102831869390057642704253755269803136323388759627399370507151238064778399477125470941103468997107204954580888346976963732529191611522789249471940599415587667163136427455499142265843852906870573003016543761403915579728832278943756709241709719567708592405407294409003276217649490282231"), exponent); + var secondKey = new RSAPublicKeySpec(new BigInteger("784057767550419878369497798651827476361889178814477573346641631234935186314436782078536135459192115951182846131604763290106347820711735919818466318881966145658619187844260657686465054388415729621256835109072466122751680324465523571218314239699133938274929722422531435916124040593004728158303703638151544229751515190620733194729793182402256827874540802963173001942073095959874409030457157131068008004452131416339302414300154381574660775550756346290523471370004641759457082400056951523140192837676235596014868691294116723696798672826048372197524626777597698825985438359440849049188507660150181938186465442057503356737043772475149570597456464086884083587865261320028768112850655672995490391301788008160746624607620536612729945152345637233101657918767620370276196646289217228948026575176667526067692435995447599542086540447642569281636925038610129227622664311974763550767950513197666055242104878427773497759504733742315824234665981633069516518731571901751350961661458615098275390788530389016622885595867572513280042783815166138155280065655067579686735407066393737630455891385113394433769584091954362175665925155421775122038568449049307648037245144977590866670732267987944408567171290527382426393459497954986775620782288149569534834718157"), exponent); + var keyFactory = KeyFactory.getInstance("RSA"); + var ed25519Key = SshPublicKeyAuthenticator.readKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGJ0j5BztROLdZYHf8cpJsJr9jd8gCRUfm6oe9k3Bhh0 @quantenzitrone:matrix.org"); + assertThat(authorizedKeys, containsInAnyOrder( + keyFactory.generatePublic(firstKey), + keyFactory.generatePublic(secondKey), + ed25519Key)); + } + + @Test + @DisplayName("Should handle DSA key loading failure") + void testLoadDSAKeyFromFilePath() { + var resourceDirectory = Paths.get("src", "test", "resources"); + var absolutePath = resourceDirectory.toFile().getAbsolutePath() + "/id_dsa.pub"; + assertFalse(sshPublicKeyAuthenticator.loadKeysFromPath(absolutePath)); + + var authorizedKeys = sshPublicKeyAuthenticator.getAuthorizedKeys(); + assertThat(authorizedKeys.isEmpty(), is(true)); + } + + @Test + @DisplayName("Should handle invalid key content") + void testLoadInvalidKeyContent() throws Exception { + var resourceDirectory = Paths.get("src", "test", "resources"); + var absolutePath = resourceDirectory.toFile().getAbsolutePath() + "/invalid_authorized_keys"; + assertFalse(sshPublicKeyAuthenticator.loadKeysFromPath(absolutePath)); + var authorizedKeys = sshPublicKeyAuthenticator.getAuthorizedKeys(); + assertThat(authorizedKeys.isEmpty(), is(true)); + } + + @Test + @DisplayName("Should handle non-existent file path") + void testLoadFromNonExistentPath() { + String nonExistentPath = "/path/that/does/not/exist/authorized_keys"; + assertFalse(sshPublicKeyAuthenticator.loadKeysFromPath(nonExistentPath)); + var authorizedKeys = sshPublicKeyAuthenticator.getAuthorizedKeys(); + assertThat(authorizedKeys, is(empty())); + } + + @Test + @DisplayName("Should handle null file path") + void testLoadFromNullPath() { + assertFalse(sshPublicKeyAuthenticator.loadKeysFromPath(null)); + var authorizedKeys = sshPublicKeyAuthenticator.getAuthorizedKeys(); + assertThat(authorizedKeys, is(empty())); + } + + @Test + @DisplayName("Should handle empty file path") + void testLoadFromEmptyPath() { + assertFalse(sshPublicKeyAuthenticator.loadKeysFromPath("")); + var authorizedKeys = sshPublicKeyAuthenticator.getAuthorizedKeys(); + assertThat(authorizedKeys, is(empty())); + } + } + + @Nested + @DisplayName("Authentication Tests") + class AuthenticationTests { + + @Test + @DisplayName("Should authenticate with different key types") + void testAuthenticationWithDifferentKeys() throws Exception { + var resourceDirectory = Paths.get("src", "test", "resources"); + var absolutePath = resourceDirectory.toFile().getAbsolutePath() + "/authorized_keys"; + assertTrue(sshPublicKeyAuthenticator.loadKeysFromPath(absolutePath)); + + var exponent = new BigInteger("65537"); + var firstKey = new RSAPublicKeySpec(new BigInteger("21658742190318166967712730864679652658650859121969481181201380769435852715982079838796135745206268981260737877360141273622280512537469661232310601414632396577736750997307043633989350470146139654498683603607823966490835477269345553397205866827412911445557084380501015516582017566897110095005407768881980022943053565933828297090533987425102831869390057642704253755269803136323388759627399370507151238064778399477125470941103468997107204954580888346976963732529191611522789249471940599415587667163136427455499142265843852906870573003016543761403915579728832278943756709241709719567708592405407294409003276217649490282231"), exponent); + var secondKey = new RSAPublicKeySpec(new BigInteger("784057767550419878369497798651827476361889178814477573346641631234935186314436782078536135459192115951182846131604763290106347820711735919818466318881966145658619187844260657686465054388415729621256835109072466122751680324465523571218314239699133938274929722422531435916124040593004728158303703638151544229751515190620733194729793182402256827874540802963173001942073095959874409030457157131068008004452131416339302414300154381574660775550756346290523471370004641759457082400056951523140192837676235596014868691294116723696798672826048372197524626777597698825985438359440849049188507660150181938186465442057503356737043772475149570597456464086884083587865261320028768112850655672995490391301788008160746624607620536612729945152345637233101657918767620370276196646289217228948026575176667526067692435995447599542086540447642569281636925038610129227622664311974763550767950513197666055242104878427773497759504733742315824234665981633069516518731571901751350961661458615098275390788530389016622885595867572513280042783815166138155280065655067579686735407066393737630455891385113394433769584091954362175665925155421775122038568449049307648037245144977590866670732267987944408567171290527382426393459497954986775620782288149569534834718157"), exponent); + var unauthorizedKey = new RSAPublicKeySpec(new BigInteger("666057767550419878369497798651827476361889178814477573346641631234935186314436782078536135459192115951182846131604763290106347820711735919818466318881966145658619187844260657686465054388415729621256835109072466122751680324465523571218314239699133938274929722422531435916124040593004728158303703638151544229751515190620733194729793182402256827874540802963173001942073095959874409030457157131068008004452131416339302414300154381574660775550756346290523471370004641759457082400056951523140192837676235596014868691294116723696798672826048372197524626777597698825985438359440849049188507660150181938186465442057503356737043772475149570597456464086884083587865261320028768112850655672995490391301788008160746624607620536612729945152345637233101657918767620370276196646289217228948026575176667526067692435995447599542086540447642569281636925038610129227622664311974763550767950513197666055242104878427773497759504733742315824234665981633069516518731571901751350961661458615098275390788530389016622885595867572513280042783815166138155280065655067579686735407066393737630455891385113394433769584091954362175665925155421775122038568449049307648037245144977590866670732267987944408567171290527382426393459497954986775620782288149569534834718666"), exponent); + var keyFactory = KeyFactory.getInstance("RSA"); + + var ed25519Key = SshPublicKeyAuthenticator.readKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGJ0j5BztROLdZYHf8cpJsJr9jd8gCRUfm6oe9k3Bhh0 @quantenzitrone:matrix.org"); + + // Test authorized keys + assertThat(sshPublicKeyAuthenticator.authenticate(null, keyFactory.generatePublic(firstKey), null), is(true)); + assertThat(sshPublicKeyAuthenticator.authenticate(null, keyFactory.generatePublic(secondKey), null), is(true)); + assertThat(sshPublicKeyAuthenticator.authenticate(null, ed25519Key, null), is(true)); + + // Test unauthorized key + assertThat(sshPublicKeyAuthenticator.authenticate(null, keyFactory.generatePublic(unauthorizedKey), null), is(false)); + } + + @Test + @DisplayName("Should handle authentication with null key") + void testAuthenticationWithNullKey() { + var resourceDirectory = Paths.get("src", "test", "resources"); + var absolutePath = resourceDirectory.toFile().getAbsolutePath() + "/authorized_keys"; + assertTrue(sshPublicKeyAuthenticator.loadKeysFromPath(absolutePath)); + + assertThat(sshPublicKeyAuthenticator.authenticate(null, null, null), is(false)); + } + + @Test + @DisplayName("Should handle session parameter correctly") + void testAuthenticationWithSession() throws Exception { + var resourceDirectory = Paths.get("src", "test", "resources"); + var absolutePath = resourceDirectory.toFile().getAbsolutePath() + "/authorized_keys"; + assertTrue(sshPublicKeyAuthenticator.loadKeysFromPath(absolutePath)); + + var exponent = new BigInteger("65537"); + var firstKey = new RSAPublicKeySpec(new BigInteger("21658742190318166967712730864679652658650859121969481181201380769435852715982079838796135745206268981260737877360141273622280512537469661232310601414632396577736750997307043633989350470146139654498683603607823966490835477269345553397205866827412911445557084380501015516582017566897110095005407768881980022943053565933828297090533987425102831869390057642704253755269803136323388759627399370507151238064778399477125470941103468997107204954580888346976963732529191611522789249471940599415587667163136427455499142265843852906870573003016543761403915579728832278943756709241709719567708592405407294409003276217649490282231"), exponent); + var keyFactory = KeyFactory.getInstance("RSA"); + + // Test with null session (which is what we pass in other tests) + assertThat(sshPublicKeyAuthenticator.authenticate(null, keyFactory.generatePublic(firstKey), null), is(true)); + } } - @Test - void testLoadKeyFromFilePath() throws Exception { - var resourceDirectory = Paths.get("src", "test", "resources"); - var absolutePath = resourceDirectory.toFile().getAbsolutePath() + "/authorized_keys"; - assertTrue(sshPublicKeyAuthenticator.loadKeysFromPath(absolutePath)); - var authorizedKeys = sshPublicKeyAuthenticator.getAuthorizedKeys(); - assertThat(authorizedKeys.size(), is(3)); - - var exponent = new BigInteger("65537"); - var firstKey = new RSAPublicKeySpec(new BigInteger("21658742190318166967712730864679652658650859121969481181201380769435852715982079838796135745206268981260737877360141273622280512537469661232310601414632396577736750997307043633989350470146139654498683603607823966490835477269345553397205866827412911445557084380501015516582017566897110095005407768881980022943053565933828297090533987425102831869390057642704253755269803136323388759627399370507151238064778399477125470941103468997107204954580888346976963732529191611522789249471940599415587667163136427455499142265843852906870573003016543761403915579728832278943756709241709719567708592405407294409003276217649490282231"), exponent); - var secondKey = new RSAPublicKeySpec(new BigInteger("784057767550419878369497798651827476361889178814477573346641631234935186314436782078536135459192115951182846131604763290106347820711735919818466318881966145658619187844260657686465054388415729621256835109072466122751680324465523571218314239699133938274929722422531435916124040593004728158303703638151544229751515190620733194729793182402256827874540802963173001942073095959874409030457157131068008004452131416339302414300154381574660775550756346290523471370004641759457082400056951523140192837676235596014868691294116723696798672826048372197524626777597698825985438359440849049188507660150181938186465442057503356737043772475149570597456464086884083587865261320028768112850655672995490391301788008160746624607620536612729945152345637233101657918767620370276196646289217228948026575176667526067692435995447599542086540447642569281636925038610129227622664311974763550767950513197666055242104878427773497759504733742315824234665981633069516518731571901751350961661458615098275390788530389016622885595867572513280042783815166138155280065655067579686735407066393737630455891385113394433769584091954362175665925155421775122038568449049307648037245144977590866670732267987944408567171290527382426393459497954986775620782288149569534834718157"), exponent); - var keyFactory = KeyFactory.getInstance("RSA"); - var ed25519Key = SshPublicKeyAuthenticator.readKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGJ0j5BztROLdZYHf8cpJsJr9jd8gCRUfm6oe9k3Bhh0 @quantenzitrone:matrix.org"); - assertThat(authorizedKeys, containsInAnyOrder( - keyFactory.generatePublic(firstKey), - keyFactory.generatePublic(secondKey), - ed25519Key)); + @Nested + @DisplayName("Key Management Tests") + class KeyManagementTests { + + @Test + @DisplayName("Should return empty list initially") + void testInitialKeyList() { + var keys = sshPublicKeyAuthenticator.getAuthorizedKeys(); + assertThat(keys, is(empty())); + } + + @Test + @DisplayName("Should maintain key list after loading") + void testKeyListAfterLoading() { + var resourceDirectory = Paths.get("src", "test", "resources"); + var absolutePath = resourceDirectory.toFile().getAbsolutePath() + "/authorized_keys"; + + assertTrue(sshPublicKeyAuthenticator.loadKeysFromPath(absolutePath)); + var keys = sshPublicKeyAuthenticator.getAuthorizedKeys(); + assertThat(keys, hasSize(3)); + assertNotNull(keys); + } + + @Test + @DisplayName("Should clear keys on failed load") + void testKeysClearedOnFailedLoad() { + // First load valid keys + var resourceDirectory = Paths.get("src", "test", "resources"); + var absolutePath = resourceDirectory.toFile().getAbsolutePath() + "/authorized_keys"; + assertTrue(sshPublicKeyAuthenticator.loadKeysFromPath(absolutePath)); + assertThat(sshPublicKeyAuthenticator.getAuthorizedKeys(), hasSize(3)); + + // Then try to load invalid keys + var invalidPath = resourceDirectory.toFile().getAbsolutePath() + "/invalid_authorized_keys"; + assertFalse(sshPublicKeyAuthenticator.loadKeysFromPath(invalidPath)); + assertThat(sshPublicKeyAuthenticator.getAuthorizedKeys(), is(empty())); + } } - @Test - void testLoadDSAKeyFromFilePath() { - var resourceDirectory = Paths.get("src", "test", "resources"); - var absolutePath = resourceDirectory.toFile().getAbsolutePath() + "/id_dsa.pub"; - assertFalse(sshPublicKeyAuthenticator.loadKeysFromPath(absolutePath)); + @Nested + @DisplayName("Key Format Tests") + class KeyFormatTests { + + @Test + @DisplayName("Should handle malformed key strings") + void testMalformedKeyStrings() { + assertDoesNotThrow(() -> SshPublicKeyAuthenticator.readKey("invalid-key-format")); + assertDoesNotThrow(() -> SshPublicKeyAuthenticator.readKey("ssh-rsa")); + assertDoesNotThrow(() -> SshPublicKeyAuthenticator.readKey("ssh-rsa invalid-base64")); + assertDoesNotThrow(() -> SshPublicKeyAuthenticator.readKey("")); + } - var authorizedKeys = sshPublicKeyAuthenticator.getAuthorizedKeys(); - assertThat(authorizedKeys.isEmpty(), is(true)); + @Test + @DisplayName("Should handle keys with comments") + void testKeysWithComments() { + String keyWithComment = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCrkf5RHFcmmnPFxfOVsVOCdDVfs04dZg+/n808/NEdyOPuyAde4UIvZbzKEjW9brtEvOHCFfxZuXa0TbTIUau9p+4gWTGXIONcarwJ7LtNUlWfJiWYmIWVgyNnpzVftcW3mi8gRGxPbCJM2yVeB7gv452wvWPDe9TFdpgbwhLBqVIRG6EBHC0VBXX8qKNCbFoclYbiXa5DfwMkxYwN2yyKaSu75e0H4FP4BehaqQ6SfBIThqQRVdcx9J9Du3GzTi4ArN0timPAQ+X17pWxgEQ3qNbj49Lnteu+NSmb0PawcrP+Ykd7oy82kXm/hRM6cLjS1GOTsXpGDFf0NevAW8b3 user@hostname"; + + assertDoesNotThrow(() -> SshPublicKeyAuthenticator.readKey(keyWithComment)); + } } - @Test - void testAuthenticationWithDifferentKeys() throws Exception { - var resourceDirectory = Paths.get("src", "test", "resources"); - var absolutePath = resourceDirectory.toFile().getAbsolutePath() + "/authorized_keys"; - assertTrue(sshPublicKeyAuthenticator.loadKeysFromPath(absolutePath)); + @Nested + @DisplayName("File Operations Tests") + class FileOperationsTests { + + @Test + @DisplayName("Should handle temporary files correctly") + void testTemporaryFileHandling(@TempDir Path tempDir) throws IOException { + // Create a temporary authorized_keys file + Path tempKeyFile = tempDir.resolve("temp_authorized_keys"); + String keyContent = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCrkf5RHFcmmnPFxfOVsVOCdDVfs04dZg+/n808/NEdyOPuyAde4UIvZbzKEjW9brtEvOHCFfxZuXa0TbTIUau9p+4gWTGXIONcarwJ7LtNUlWfJiWYmIWVgyNnpzVftcW3mi8gRGxPbCJM2yVeB7gv452wvWPDe9TFdpgbwhLBqVIRG6EBHC0VBXX8qKNCbFoclYbiXa5DfwMkxYwN2yyKaSu75e0H4FP4BehaqQ6SfBIThqQRVdcx9J9Du3GzTi4ArN0timPAQ+X17pWxgEQ3qNbj49Lnteu+NSmb0PawcrP+Ykd7oy82kXm/hRM6cLjS1GOTsXpGDFf0NevAW8b3 test@example.com\n"; + Files.write(tempKeyFile, keyContent.getBytes()); - var exponent = new BigInteger("65537"); - var firstKey = new RSAPublicKeySpec(new BigInteger("21658742190318166967712730864679652658650859121969481181201380769435852715982079838796135745206268981260737877360141273622280512537469661232310601414632396577736750997307043633989350470146139654498683603607823966490835477269345553397205866827412911445557084380501015516582017566897110095005407768881980022943053565933828297090533987425102831869390057642704253755269803136323388759627399370507151238064778399477125470941103468997107204954580888346976963732529191611522789249471940599415587667163136427455499142265843852906870573003016543761403915579728832278943756709241709719567708592405407294409003276217649490282231"), exponent); - var secondKey = new RSAPublicKeySpec(new BigInteger("784057767550419878369497798651827476361889178814477573346641631234935186314436782078536135459192115951182846131604763290106347820711735919818466318881966145658619187844260657686465054388415729621256835109072466122751680324465523571218314239699133938274929722422531435916124040593004728158303703638151544229751515190620733194729793182402256827874540802963173001942073095959874409030457157131068008004452131416339302414300154381574660775550756346290523471370004641759457082400056951523140192837676235596014868691294116723696798672826048372197524626777597698825985438359440849049188507660150181938186465442057503356737043772475149570597456464086884083587865261320028768112850655672995490391301788008160746624607620536612729945152345637233101657918767620370276196646289217228948026575176667526067692435995447599542086540447642569281636925038610129227622664311974763550767950513197666055242104878427773497759504733742315824234665981633069516518731571901751350961661458615098275390788530389016622885595867572513280042783815166138155280065655067579686735407066393737630455891385113394433769584091954362175665925155421775122038568449049307648037245144977590866670732267987944408567171290527382426393459497954986775620782288149569534834718157"), exponent); - var thirdKey = new RSAPublicKeySpec(new BigInteger("801927917580758757979472556844251061760106383062627255825844583234477172790822215181804115531524750167110626578753641854839364115183295134199855565705144171967726133540156054269874189394350513031624322275630868353550082435946449319622372805788192807194970449569969122740243642313771155978930558037041288538032621167596518567745650173750903408431586471621670551481448236654798300138271016665710707996714088857315862552285448835022674484517837235751299196272185180808003335278208338200082797063402819475274804059251132953473839761384934730254695012273980048687260755946494082294536504365854421042209747262215825867288895826525289860017356087972232374369407427514550781201588459581132834605833136171102931440301693064676022315949585452254976583732273279685241548589989552958501585967916294792046822598474933776631974472781228885380669240991739694442254830793044605848889551686184197217760849351416181286067575078914864978102172921573494121943905751707671961364840577714192853806567108935276748552313084033867404574544169457199433681276796221829523529342719064559112634681882823253467088788554399120329634693450227027912364246515879972958896483379084082357407915577538153708816300929136449401980058929992848710222259418850185043358239451"), exponent); - var keyFactory = KeyFactory.getInstance("RSA"); + assertTrue(sshPublicKeyAuthenticator.loadKeysFromPath(tempKeyFile.toString())); + assertThat(sshPublicKeyAuthenticator.getAuthorizedKeys(), hasSize(1)); + } - var ed25519Key = SshPublicKeyAuthenticator.readKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGJ0j5BztROLdZYHf8cpJsJr9jd8gCRUfm6oe9k3Bhh0 @quantenzitrone:matrix.org"); + @Test + @DisplayName("Should handle empty files") + void testEmptyFile(@TempDir Path tempDir) throws IOException { + Path emptyKeyFile = tempDir.resolve("empty_authorized_keys"); + Files.write(emptyKeyFile, "".getBytes()); - assertThat(sshPublicKeyAuthenticator.authenticate(null, keyFactory.generatePublic(firstKey), null), is(true)); - assertThat(sshPublicKeyAuthenticator.authenticate(null, keyFactory.generatePublic(secondKey), null), is(true)); - assertThat(sshPublicKeyAuthenticator.authenticate(null, keyFactory.generatePublic(thirdKey), null), is(false)); - assertThat(sshPublicKeyAuthenticator.authenticate(null, ed25519Key, null), is(true)); + assertFalse(sshPublicKeyAuthenticator.loadKeysFromPath(emptyKeyFile.toString())); + assertThat(sshPublicKeyAuthenticator.getAuthorizedKeys(), is(empty())); + } + + @Test + @DisplayName("Should handle files with only whitespace") + void testWhitespaceOnlyFile(@TempDir Path tempDir) throws IOException { + Path whitespaceKeyFile = tempDir.resolve("whitespace_authorized_keys"); + Files.write(whitespaceKeyFile, " \n\t\n ".getBytes()); + + assertFalse(sshPublicKeyAuthenticator.loadKeysFromPath(whitespaceKeyFile.toString())); + assertThat(sshPublicKeyAuthenticator.getAuthorizedKeys(), is(empty())); + } + + @Test + @DisplayName("Should handle mixed valid and invalid keys") + void testMixedValidInvalidKeys(@TempDir Path tempDir) throws IOException { + Path mixedKeyFile = tempDir.resolve("mixed_authorized_keys"); + String mixedContent = """ + ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCrkf5RHFcmmnPFxfOVsVOCdDVfs04dZg+/n808/NEdyOPuyAde4UIvZbzKEjW9brtEvOHCFfxZuXa0TbTIUau9p+4gWTGXIONcarwJ7LtNUlWfJiWYmIWVgyNnpzVftcW3mi8gRGxPbCJM2yVeB7gv452wvWPDe9TFdpgbwhLBqVIRG6EBHC0VBXX8qKNCbFoclYbiXa5DfwMkxYwN2yyKaSu75e0H4FP4BehaqQ6SfBIThqQRVdcx9J9Du3GzTi4ArN0timPAQ+X17pWxgEQ3qNbj49Lnteu+NSmb0PawcrP+Ykd7oy82kXm/hRM6cLjS1GOTsXpGDFf0NevAW8b3 valid@example.com + invalid-key-line + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGJ0j5BztROLdZYHf8cpJsJr9jd8gCRUfm6oe9k3Bhh0 @quantenzitrone:matrix.org + another-invalid-line + """; + Files.write(mixedKeyFile, mixedContent.getBytes()); + + assertTrue(sshPublicKeyAuthenticator.loadKeysFromPath(mixedKeyFile.toString())); + // Should load only the valid keys + assertThat(sshPublicKeyAuthenticator.getAuthorizedKeys(), hasSize(2)); + } } - @Test - void testLoadInvalidKeyContent() throws Exception { - var resourceDirectory = Paths.get("src", "test", "resources"); - var absolutePath = resourceDirectory.toFile().getAbsolutePath() + "/invalid_authorized_keys"; - assertFalse(sshPublicKeyAuthenticator.loadKeysFromPath(absolutePath)); - var authorizedKeys = sshPublicKeyAuthenticator.getAuthorizedKeys(); - assertThat(authorizedKeys.isEmpty(), is(true)); + @Nested + @DisplayName("Error Handling Tests") + class ErrorHandlingTests { + + @Test + @DisplayName("Should handle corrupted key files gracefully") + void testCorruptedKeyFile() { + var resourceDirectory = Paths.get("src", "test", "resources"); + var absolutePath = resourceDirectory.toFile().getAbsolutePath() + "/invalid_authorized_keys"; + + // Should not throw exception, just return false + assertDoesNotThrow(() -> sshPublicKeyAuthenticator.loadKeysFromPath(absolutePath)); + assertFalse(sshPublicKeyAuthenticator.loadKeysFromPath(absolutePath)); + } + + @Test + @DisplayName("Should handle directory instead of file") + void testDirectoryPath() { + var resourceDirectory = Paths.get("src", "test", "resources"); + var directoryPath = resourceDirectory.toFile().getAbsolutePath(); + + assertFalse(sshPublicKeyAuthenticator.loadKeysFromPath(directoryPath)); + assertThat(sshPublicKeyAuthenticator.getAuthorizedKeys(), is(empty())); + } + + @Test + @DisplayName("Should handle very long file paths") + void testVeryLongPath() { + StringBuilder longPath = new StringBuilder("/"); + for (int i = 0; i < 1000; i++) { + longPath.append("very_long_directory_name_").append(i).append("/"); + } + longPath.append("authorized_keys"); + + assertFalse(sshPublicKeyAuthenticator.loadKeysFromPath(longPath.toString())); + assertThat(sshPublicKeyAuthenticator.getAuthorizedKeys(), is(empty())); + } } -} \ No newline at end of file +} From a555b16e0a40dde6493f8fd94f8cfa1edd45a529 Mon Sep 17 00:00:00 2001 From: d050150 Date: Thu, 18 Sep 2025 11:33:53 +0200 Subject: [PATCH 13/13] more tests --- app/build.gradle | 3 +- .../net/NetworkChangeReceiverTest.java | 288 ++++++++++++++++-- 2 files changed, 261 insertions(+), 30 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2bb864c..5075d2f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -86,8 +86,9 @@ dependencies { testImplementation "org.hamcrest:hamcrest-all:1.3" testImplementation "org.mockito:mockito-core:5.19.0" + testImplementation "org.mockito:mockito-junit-jupiter:5.2.0" androidTestImplementation "androidx.test:core:1.7.0" androidTestImplementation "androidx.test.ext:junit:1.3.0" androidTestImplementation "androidx.test:runner:1.7.0" androidTestImplementation "androidx.test.espresso:espresso-core:3.7.0" -} \ No newline at end of file +} diff --git a/app/src/test/java/com/sshdaemon/net/NetworkChangeReceiverTest.java b/app/src/test/java/com/sshdaemon/net/NetworkChangeReceiverTest.java index 54b45b6..be1c78d 100644 --- a/app/src/test/java/com/sshdaemon/net/NetworkChangeReceiverTest.java +++ b/app/src/test/java/com/sshdaemon/net/NetworkChangeReceiverTest.java @@ -1,69 +1,299 @@ package com.sshdaemon.net; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.times; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import android.content.Context; import android.net.ConnectivityManager; import android.net.Network; import android.net.NetworkCapabilities; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; import android.widget.Spinner; import com.sshdaemon.MainActivity; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Enumeration; +import java.util.List; + +@ExtendWith(MockitoExtension.class) class NetworkChangeReceiverTest { - private final Spinner spinner = mock(Spinner.class); - private final MainActivity mainActivity = mock(MainActivity.class); + @Mock + private Spinner mockSpinner; + + @Mock + private MainActivity mockActivity; + + @Mock + private ConnectivityManager mockConnectivityManager; + + @Mock + private Network mockNetwork; + + @Mock + private NetworkCapabilities mockNetworkCapabilities; + + @Mock + private ArrayAdapter mockAdapter; + private NetworkChangeReceiver networkChangeReceiver; - private ConnectivityManager connectivityManager; @BeforeEach void setup() { - connectivityManager = mock(ConnectivityManager.class); + // Setup default behavior for runOnUiThread to execute immediately doAnswer(invocation -> { Runnable runnable = invocation.getArgument(0); runnable.run(); return null; - }).when(mainActivity).runOnUiThread(any(Runnable.class)); - networkChangeReceiver = new NetworkChangeReceiver(spinner, connectivityManager, mainActivity); - reset(spinner); - reset(mainActivity); + }).when(mockActivity).runOnUiThread(any(Runnable.class)); + + // Create the receiver under test + networkChangeReceiver = new NetworkChangeReceiver(mockSpinner, mockConnectivityManager, mockActivity); + } + + @Test + void testConstructorSetsUpSpinnerListener() { + // Verify that the spinner's item selected listener is set + verify(mockSpinner).setOnItemSelectedListener(any(AdapterView.OnItemSelectedListener.class)); + } + + @Test + void testOnAvailableUpdatesNetworkInterfaces() { + // Given: Network connectivity is available + setupNetworkConnectivity(true); + when(mockSpinner.getAdapter()).thenReturn(mockAdapter); + + // When: Network becomes available + networkChangeReceiver.onAvailable(mockNetwork); + + // Then: UI thread is used to update the adapter + verify(mockActivity, atLeastOnce()).runOnUiThread(any(Runnable.class)); + verify(mockAdapter).clear(); + verify(mockAdapter).addAll(any(List.class)); + verify(mockAdapter).notifyDataSetChanged(); + } + + @Test + void testOnLostUpdatesNetworkInterfaces() { + // Given: Network connectivity is lost + setupNetworkConnectivity(false); + when(mockSpinner.getAdapter()).thenReturn(mockAdapter); + + // When: Network is lost + networkChangeReceiver.onLost(mockNetwork); + + // Then: UI thread is used to update the adapter + verify(mockActivity, atLeastOnce()).runOnUiThread(any(Runnable.class)); + verify(mockAdapter).clear(); + verify(mockAdapter).addAll(any(List.class)); + verify(mockAdapter).notifyDataSetChanged(); + } + + @Test + void testGetInterfacesReturnsEmptyListWhenNoConnectivity() { + // Given: No network connectivity + setupNetworkConnectivity(false); + + // When: Getting interfaces + List interfaces = networkChangeReceiver.getInterfaces(); + + // Then: Empty list is returned + assertTrue(interfaces.isEmpty()); + } + + @Test + void testGetInterfacesReturnsDefaultWhenConnectivityButNoInterfaces() { + // Given: Network connectivity is available but no valid interfaces + setupNetworkConnectivity(true); + + try (MockedStatic mockedNetworkInterface = mockStatic(NetworkInterface.class)) { + @SuppressWarnings("unchecked") + Enumeration emptyEnumeration = mock(Enumeration.class); + when(emptyEnumeration.hasMoreElements()).thenReturn(false); + + mockedNetworkInterface.when(NetworkInterface::getNetworkInterfaces) + .thenReturn(emptyEnumeration); + + // When: Getting interfaces + List interfaces = networkChangeReceiver.getInterfaces(); + + // Then: Only default "all interfaces" option is returned + assertEquals(1, interfaces.size()); + assertEquals("all interfaces", interfaces.get(0)); + } + } + + @Test + void testGetInterfacesHandlesSocketException() { + // Given: Network connectivity is available but NetworkInterface throws exception + setupNetworkConnectivity(true); + + try (MockedStatic mockedNetworkInterface = mockStatic(NetworkInterface.class)) { + mockedNetworkInterface.when(NetworkInterface::getNetworkInterfaces) + .thenThrow(new SocketException("Test exception")); + + // When: Getting interfaces + List interfaces = networkChangeReceiver.getInterfaces(); + + // Then: Only default "all interfaces" option is returned + assertEquals(1, interfaces.size()); + assertEquals("all interfaces", interfaces.get(0)); + } + } + + @Test + void testGetInterfacesHandlesGeneralException() { + // Given: Network connectivity is available but NetworkInterface throws general exception + setupNetworkConnectivity(true); + + try (MockedStatic mockedNetworkInterface = mockStatic(NetworkInterface.class)) { + mockedNetworkInterface.when(NetworkInterface::getNetworkInterfaces) + .thenThrow(new RuntimeException("Unexpected error")); + + // When: Getting interfaces + List interfaces = networkChangeReceiver.getInterfaces(); + + // Then: Only default "all interfaces" option is returned + assertEquals(1, interfaces.size()); + assertEquals("all interfaces", interfaces.get(0)); + } } @Test - void testNoConnectivity() { - networkChangeReceiver.onLost(mock(Network.class)); - verify(spinner, times(0)).addView(any(), any()); + void testSetAdapterCreatesNewAdapterWhenNoneExists() { + // Given: No existing adapter + when(mockSpinner.getAdapter()).thenReturn(null); + setupNetworkConnectivity(true); + + // When: Network becomes available (triggers setAdapter) + networkChangeReceiver.onAvailable(mockNetwork); + + // Then: New adapter is created and set (allow multiple calls since constructor also triggers setAdapter) + ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockActivity, atLeastOnce()).runOnUiThread(runnableCaptor.capture()); + + // Execute the runnable to verify adapter creation + runnableCaptor.getValue().run(); + verify(mockSpinner, atLeastOnce()).setAdapter(any(ArrayAdapter.class)); } @Test - void testSpinnerIsUpdated() { - var network = mock(Network.class); - var networkCapabilities = mock(NetworkCapabilities.class); - when(networkCapabilities.hasTransport(anyInt())).thenReturn(true); - when(connectivityManager.getActiveNetwork()).thenReturn(network); - when(connectivityManager.getNetworkCapabilities(network)).thenReturn(networkCapabilities); - when(mainActivity.getSystemService(Context.CONNECTIVITY_SERVICE)).thenReturn(connectivityManager); - networkChangeReceiver.onAvailable(mock(Network.class)); + void testSetAdapterHandlesExceptionGracefully() { + // Given: Spinner adapter setup throws an exception + when(mockSpinner.getAdapter()).thenThrow(new RuntimeException("Test exception")); + setupNetworkConnectivity(true); - final var interfaces = networkChangeReceiver.getInterfaces(); + // When: Network becomes available (triggers setAdapter) + // Then: Exception is caught and doesn't propagate (test doesn't fail) + assertDoesNotThrow(() -> networkChangeReceiver.onAvailable(mockNetwork)); + } - var runnable = ArgumentCaptor.forClass(Runnable.class); + @Test + void testConnectivityCheckWithWifi() { + // Given: WiFi network is available + when(mockConnectivityManager.getActiveNetwork()).thenReturn(mockNetwork); + when(mockConnectivityManager.getNetworkCapabilities(mockNetwork)).thenReturn(mockNetworkCapabilities); + when(mockNetworkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)).thenReturn(true); + + try (MockedStatic mockedNetworkInterface = mockStatic(NetworkInterface.class)) { + @SuppressWarnings("unchecked") + Enumeration emptyEnumeration = mock(Enumeration.class); + when(emptyEnumeration.hasMoreElements()).thenReturn(false); + mockedNetworkInterface.when(NetworkInterface::getNetworkInterfaces).thenReturn(emptyEnumeration); - assertFalse(interfaces.isEmpty()); + // When: Getting interfaces + List interfaces = networkChangeReceiver.getInterfaces(); + + // Then: Connectivity is detected and default interface is returned + assertEquals(1, interfaces.size()); + assertEquals("all interfaces", interfaces.get(0)); + } + } + + @Test + void testConnectivityCheckWithCellular() { + // Given: Cellular network is available + when(mockConnectivityManager.getActiveNetwork()).thenReturn(mockNetwork); + when(mockConnectivityManager.getNetworkCapabilities(mockNetwork)).thenReturn(mockNetworkCapabilities); + when(mockNetworkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)).thenReturn(false); + when(mockNetworkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)).thenReturn(true); + + try (MockedStatic mockedNetworkInterface = mockStatic(NetworkInterface.class)) { + @SuppressWarnings("unchecked") + Enumeration emptyEnumeration = mock(Enumeration.class); + when(emptyEnumeration.hasMoreElements()).thenReturn(false); + mockedNetworkInterface.when(NetworkInterface::getNetworkInterfaces).thenReturn(emptyEnumeration); + + // When: Getting interfaces + List interfaces = networkChangeReceiver.getInterfaces(); + + // Then: Connectivity is detected and default interface is returned + assertEquals(1, interfaces.size()); + assertEquals("all interfaces", interfaces.get(0)); + } + } + + @Test + void testConnectivityCheckWithEthernet() { + // Given: Ethernet network is available + when(mockConnectivityManager.getActiveNetwork()).thenReturn(mockNetwork); + when(mockConnectivityManager.getNetworkCapabilities(mockNetwork)).thenReturn(mockNetworkCapabilities); + when(mockNetworkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)).thenReturn(false); + when(mockNetworkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)).thenReturn(false); + when(mockNetworkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)).thenReturn(true); + + try (MockedStatic mockedNetworkInterface = mockStatic(NetworkInterface.class)) { + @SuppressWarnings("unchecked") + Enumeration emptyEnumeration = mock(Enumeration.class); + when(emptyEnumeration.hasMoreElements()).thenReturn(false); + mockedNetworkInterface.when(NetworkInterface::getNetworkInterfaces).thenReturn(emptyEnumeration); + + // When: Getting interfaces + List interfaces = networkChangeReceiver.getInterfaces(); + + // Then: Connectivity is detected and default interface is returned + assertEquals(1, interfaces.size()); + assertEquals("all interfaces", interfaces.get(0)); + } + } + + @Test + void testConnectivityCheckHandlesException() { + // Given: ConnectivityManager throws exception + when(mockConnectivityManager.getActiveNetwork()).thenThrow(new RuntimeException("Test exception")); + + // When: Getting interfaces + List interfaces = networkChangeReceiver.getInterfaces(); + + // Then: Empty list is returned (no connectivity detected) + assertTrue(interfaces.isEmpty()); + } - verify(mainActivity, times(1)).runOnUiThread(runnable.capture()); + private void setupNetworkConnectivity(boolean hasConnectivity) { + if (hasConnectivity) { + when(mockConnectivityManager.getActiveNetwork()).thenReturn(mockNetwork); + when(mockConnectivityManager.getNetworkCapabilities(mockNetwork)).thenReturn(mockNetworkCapabilities); + when(mockNetworkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)).thenReturn(true); + } else { + when(mockConnectivityManager.getActiveNetwork()).thenReturn(null); + } } -} \ No newline at end of file +}