diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7e7fb19..f82205d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,10 +8,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: - java-version: '11' + java-version: '17' distribution: 'adopt' - name: Build run: chmod +x ./gradlew && ./gradlew --parallel --max-workers=8 clean bundleDebug assembleDebug \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile index 32f2c1c..bb33c82 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -2,7 +2,7 @@ pipeline { agent any tools { - jdk 'Java16' + jdk 'Java17' } stages { @@ -25,14 +25,17 @@ pipeline { script { if (env.BRANCH_NAME == 'master') { echo 'Publishing Bundle to Beta channel (Not allowed to fail)' - androidApkUpload filesPattern: 'app/build/outputs/bundle/release/app-release.aab,wear/build/outputs/bundle/release/wear-release.aab', googleCredentialsId: 'Florianisme', rolloutPercentage: '20', trackName: 'beta' + androidApkUpload filesPattern: 'app/build/outputs/bundle/release/app-release.aab', googleCredentialsId: 'Florianisme', rolloutPercentage: '20', trackName: 'beta' + androidApkUpload filesPattern: 'wear/build/outputs/bundle/release/wear-release.aab', googleCredentialsId: 'Florianisme', rolloutPercentage: '20', trackName: 'wear:beta' } else if (env.BRANCH_NAME == 'release') { echo 'Publishing Bundle to Internal channel (Not allowed to fail)' - androidApkUpload filesPattern: 'app/build/outputs/bundle/release/app-release.aab,wear/build/outputs/bundle/release/wear-release.aab', googleCredentialsId: 'Florianisme', rolloutPercentage: '100', trackName: 'internal' + androidApkUpload filesPattern: 'app/build/outputs/bundle/release/app-release.aab', googleCredentialsId: 'Florianisme', rolloutPercentage: '100', trackName: 'internal' + androidApkUpload filesPattern: 'wear/build/outputs/bundle/release/wear-release.aab', googleCredentialsId: 'Florianisme', rolloutPercentage: '100', trackName: 'wear:internal' } else if (env.BRANCH_NAME == 'develop' || env.BRANCH_NAME.contains('feature')) { echo 'Publishing Bundle to Internal channel (Allowed to fail)' try { - androidApkUpload filesPattern: 'app/build/outputs/bundle/release/app-release.aab,wear/build/outputs/bundle/release/wear-release.aab', googleCredentialsId: 'Florianisme', rolloutPercentage: '100', trackName: 'internal' + androidApkUpload filesPattern: 'app/build/outputs/bundle/release/app-release.aab', googleCredentialsId: 'Florianisme', rolloutPercentage: '100', trackName: 'internal' + androidApkUpload filesPattern: 'wear/build/outputs/bundle/release/wear-release.aab', googleCredentialsId: 'Florianisme', rolloutPercentage: '100', trackName: 'wear:internal' } catch(error) { currentBuild.result = 'SUCCESS' } diff --git a/app/build.gradle b/app/build.gradle index ba422c0..bcc3301 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,7 +3,7 @@ apply from: "$rootProject.projectDir/shared-build.gradle" android { defaultConfig { - versionCode 71 + versionCode 93 wearAppUnbundled true } buildFeatures { @@ -13,22 +13,30 @@ android { dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.google.android.material:material:1.8.0' + implementation 'com.google.android.material:material:1.9.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.navigation:navigation-fragment:2.5.3' - implementation 'androidx.navigation:navigation-ui:2.5.3' + implementation 'androidx.navigation:navigation-fragment:2.7.3' + implementation 'androidx.navigation:navigation-ui:2.7.3' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'org.reactivestreams:reactive-streams:1.0.3' implementation 'io.reactivex.rxjava2:rxjava:2.2.9' implementation 'android.arch.lifecycle:livedata:1.1.1' - implementation "androidx.core:core:1.9.0" + implementation "androidx.core:core:1.12.0" implementation 'androidx.core:core-google-shortcuts:1.1.0' - def room_version = "2.5.0" + def room_version = "2.5.2" implementation "androidx.room:room-runtime:$room_version" annotationProcessor "androidx.room:room-compiler:$room_version" - implementation(name: 'android-ping', ext: 'aar') + // SSH + implementation 'com.hierynomus:sshj:0.31.0' + implementation 'org.bouncycastle:bcprov-jdk15on:1.70' + implementation 'org.bouncycastle:bcpkix-jdk15on:1.70' + + implementation files('libs/android-ping.aar') implementation project(path: ':shared-models') + + // Fix Duplicate class + implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.0")) } \ No newline at end of file diff --git a/app/src/main/java/de/florianisme/wakeonlan/persistence/AppDatabase.java b/app/src/main/java/de/florianisme/wakeonlan/persistence/AppDatabase.java index c43ce16..66f5944 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/persistence/AppDatabase.java +++ b/app/src/main/java/de/florianisme/wakeonlan/persistence/AppDatabase.java @@ -5,7 +5,7 @@ import de.florianisme.wakeonlan.persistence.entities.DeviceEntity; -@Database(entities = {DeviceEntity.class}, version = 3, exportSchema = false) +@Database(entities = {DeviceEntity.class}, version = 5, exportSchema = false) public abstract class AppDatabase extends RoomDatabase { public abstract DeviceDao deviceDao(); } \ No newline at end of file diff --git a/app/src/main/java/de/florianisme/wakeonlan/persistence/DatabaseInstanceManager.java b/app/src/main/java/de/florianisme/wakeonlan/persistence/DatabaseInstanceManager.java index 579a555..c201969 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/persistence/DatabaseInstanceManager.java +++ b/app/src/main/java/de/florianisme/wakeonlan/persistence/DatabaseInstanceManager.java @@ -6,19 +6,23 @@ import de.florianisme.wakeonlan.persistence.migrations.MigrationFrom1To2; import de.florianisme.wakeonlan.persistence.migrations.MigrationFrom2To3; +import de.florianisme.wakeonlan.persistence.migrations.MigrationFrom3To4; +import de.florianisme.wakeonlan.persistence.migrations.MigrationFrom4To5; public class DatabaseInstanceManager { - private static AppDatabase appDatabase; + private static AppDatabase INSTANCE; - public static synchronized AppDatabase getInstance(Context context) { - if (appDatabase == null) { - appDatabase = Room.databaseBuilder(context, AppDatabase.class, "database-name") - .allowMainThreadQueries() - .addMigrations(new MigrationFrom1To2(), new MigrationFrom2To3()) - .build(); + public static synchronized AppDatabase getInstance(final Context context) { + if (INSTANCE == null) { + synchronized (AppDatabase.class) { + INSTANCE = Room.databaseBuilder(context, AppDatabase.class, "database-name") + .allowMainThreadQueries() + .addMigrations(new MigrationFrom1To2(), new MigrationFrom2To3(), new MigrationFrom3To4(), new MigrationFrom4To5()) + .build(); + } } - return appDatabase; + return INSTANCE; } } diff --git a/app/src/main/java/de/florianisme/wakeonlan/persistence/entities/DeviceEntity.java b/app/src/main/java/de/florianisme/wakeonlan/persistence/entities/DeviceEntity.java index 7257362..bfb02ba 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/persistence/entities/DeviceEntity.java +++ b/app/src/main/java/de/florianisme/wakeonlan/persistence/entities/DeviceEntity.java @@ -29,8 +29,27 @@ public class DeviceEntity { @ColumnInfo(name = "secure_on_password") public String secureOnPassword; + @ColumnInfo(name = "enable_remote_shutdown", defaultValue = "0") + public boolean enableRemoteShutdown; + + @ColumnInfo(name = "ssh_address") + public String sshAddress; + + @ColumnInfo(name = "ssh_port") + public Integer sshPort; + + @ColumnInfo(name = "ssh_user") + public String sshUsername; + + @ColumnInfo(name = "ssh_password") + public String sshPassword; + + @ColumnInfo(name = "ssh_command") + public String sshCommand; + @Ignore - public DeviceEntity(int id, String name, String macAddress, String broadcastAddress, int port, String statusIp, String secureOnPassword) { + public DeviceEntity(int id, String name, String macAddress, String broadcastAddress, int port, String statusIp, String secureOnPassword, + boolean enableRemoteShutdown, String sshAddress, Integer sshPort, String sshUsername, String sshPassword, String sshCommand) { this.id = id; this.name = name; this.macAddress = macAddress; @@ -38,6 +57,12 @@ public DeviceEntity(int id, String name, String macAddress, String broadcastAddr this.port = port; this.statusIp = statusIp; this.secureOnPassword = secureOnPassword; + this.enableRemoteShutdown = enableRemoteShutdown; + this.sshAddress = sshAddress; + this.sshPort = sshPort; + this.sshUsername = sshUsername; + this.sshPassword = sshPassword; + this.sshCommand = sshCommand; } diff --git a/app/src/main/java/de/florianisme/wakeonlan/persistence/mapper/DeviceEntityMapper.java b/app/src/main/java/de/florianisme/wakeonlan/persistence/mapper/DeviceEntityMapper.java index 3744839..8ce3365 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/persistence/mapper/DeviceEntityMapper.java +++ b/app/src/main/java/de/florianisme/wakeonlan/persistence/mapper/DeviceEntityMapper.java @@ -10,7 +10,8 @@ public Device entityToModel(DeviceEntity entity) { if (entity == null) { return new Device(); } - return new Device(entity.id, entity.name, entity.macAddress, entity.broadcastAddress, entity.port, entity.statusIp, entity.secureOnPassword); + return new Device(entity.id, entity.name, entity.macAddress, entity.broadcastAddress, entity.port, entity.statusIp, entity.secureOnPassword, + entity.enableRemoteShutdown, entity.sshAddress, entity.sshPort, entity.sshUsername, entity.sshPassword, entity.sshCommand); } @Override @@ -18,6 +19,7 @@ public DeviceEntity modelToEntity(Device model) { if (model == null) { return new DeviceEntity(); } - return new DeviceEntity(model.id, model.name, model.macAddress, model.broadcastAddress, model.port, model.statusIp, model.secureOnPassword); + return new DeviceEntity(model.id, model.name, model.macAddress, model.broadcastAddress, model.port, model.statusIp, model.secureOnPassword, + model.remoteShutdownEnabled, model.sshAddress, model.sshPort, model.sshUsername, model.sshPassword, model.sshCommand); } } diff --git a/app/src/main/java/de/florianisme/wakeonlan/persistence/migrations/MigrationFrom3To4.java b/app/src/main/java/de/florianisme/wakeonlan/persistence/migrations/MigrationFrom3To4.java new file mode 100644 index 0000000..34aceab --- /dev/null +++ b/app/src/main/java/de/florianisme/wakeonlan/persistence/migrations/MigrationFrom3To4.java @@ -0,0 +1,23 @@ +package de.florianisme.wakeonlan.persistence.migrations; + +import androidx.annotation.NonNull; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +public class MigrationFrom3To4 extends Migration { + + public MigrationFrom3To4() { + super(3, 4); + } + + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE 'Devices' ADD COLUMN 'enable_remote_shutdown' INTEGER DEFAULT 0 NOT NULL"); // Booleans are stored as Integer + database.execSQL("ALTER TABLE 'Devices' ADD COLUMN 'ssh_address' TEXT"); + database.execSQL("ALTER TABLE 'Devices' ADD COLUMN 'ssh_port' INTEGER"); + database.execSQL("ALTER TABLE 'Devices' ADD COLUMN 'ssh_user' TEXT"); + database.execSQL("ALTER TABLE 'Devices' ADD COLUMN 'ssh_password' TEXT"); + database.execSQL("ALTER TABLE 'Devices' ADD COLUMN 'ssh_command' TEXT"); + } + +} diff --git a/app/src/main/java/de/florianisme/wakeonlan/persistence/migrations/MigrationFrom4To5.java b/app/src/main/java/de/florianisme/wakeonlan/persistence/migrations/MigrationFrom4To5.java new file mode 100644 index 0000000..2a52be6 --- /dev/null +++ b/app/src/main/java/de/florianisme/wakeonlan/persistence/migrations/MigrationFrom4To5.java @@ -0,0 +1,18 @@ +package de.florianisme.wakeonlan.persistence.migrations; + +import androidx.annotation.NonNull; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +public class MigrationFrom4To5 extends Migration { + + public MigrationFrom4To5() { + super(4, 5); + } + + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + // Do nothing + } + +} diff --git a/app/src/main/java/de/florianisme/wakeonlan/persistence/models/Device.java b/app/src/main/java/de/florianisme/wakeonlan/persistence/models/Device.java index 343e56e..26d0c84 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/persistence/models/Device.java +++ b/app/src/main/java/de/florianisme/wakeonlan/persistence/models/Device.java @@ -16,7 +16,20 @@ public class Device { public String secureOnPassword; - public Device(int id, String name, String macAddress, String broadcastAddress, int port, String statusIp, String secureOnPassword) { + public boolean remoteShutdownEnabled; + + public String sshAddress; + + public Integer sshPort; + + public String sshUsername; + + public String sshPassword; + + public String sshCommand; + + public Device(int id, String name, String macAddress, String broadcastAddress, int port, String statusIp, String secureOnPassword, + boolean remoteShutdownEnabled, String sshAddress, Integer sshPort, String sshUsername, String sshPassword, String sshCommand) { this.id = id; this.name = name; this.macAddress = macAddress; @@ -24,6 +37,12 @@ public Device(int id, String name, String macAddress, String broadcastAddress, i this.port = port; this.statusIp = statusIp; this.secureOnPassword = secureOnPassword; + this.remoteShutdownEnabled = remoteShutdownEnabled; + this.sshAddress = sshAddress; + this.sshPort = sshPort; + this.sshUsername = sshUsername; + this.sshPassword = sshPassword; + this.sshCommand = sshCommand; } diff --git a/app/src/main/java/de/florianisme/wakeonlan/persistence/repository/DeviceRepository.java b/app/src/main/java/de/florianisme/wakeonlan/persistence/repository/DeviceRepository.java index 832da3e..10b1aba 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/persistence/repository/DeviceRepository.java +++ b/app/src/main/java/de/florianisme/wakeonlan/persistence/repository/DeviceRepository.java @@ -58,10 +58,6 @@ public void delete(Device device) { deviceDao.delete(deviceEntityMapper.modelToEntity(device)); } - public void deleteAll() { - deviceDao.deleteAll(); - } - public void replaceAllDevices(Device... devices) { DeviceEntity[] deviceEntities = Arrays.stream(devices) .map(deviceEntityMapper::modelToEntity) diff --git a/app/src/main/java/de/florianisme/wakeonlan/shortcuts/DeviceShortcutMapper.java b/app/src/main/java/de/florianisme/wakeonlan/shortcuts/DeviceShortcutMapper.java index e3204cb..879dcb4 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/shortcuts/DeviceShortcutMapper.java +++ b/app/src/main/java/de/florianisme/wakeonlan/shortcuts/DeviceShortcutMapper.java @@ -24,10 +24,13 @@ public static ShortcutInfoCompat buildShortcut(Device device, Context context) { @NonNull private static Intent buildIntent(Device device, Context context) { Intent wakeDeviceIntent = new Intent(context, WakeDeviceActivity.class); + wakeDeviceIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); wakeDeviceIntent.setAction(Intent.ACTION_VIEW); + Bundle bundle = new Bundle(); bundle.putInt(WakeDeviceActivity.DEVICE_ID_KEY, device.id); wakeDeviceIntent.putExtras(bundle); + return wakeDeviceIntent; } } diff --git a/app/src/main/java/de/florianisme/wakeonlan/shortcuts/DynamicShortcutManager.java b/app/src/main/java/de/florianisme/wakeonlan/shortcuts/DynamicShortcutManager.java index de719b1..e6cd287 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/shortcuts/DynamicShortcutManager.java +++ b/app/src/main/java/de/florianisme/wakeonlan/shortcuts/DynamicShortcutManager.java @@ -11,6 +11,8 @@ public class DynamicShortcutManager { + public static final int SHORTCUT_AMOUNT_LIMIT = 4; + public void updateShortcuts(Context context, List devices) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) { return; @@ -24,6 +26,7 @@ private void publishShortcuts(Context context, List devices) { devices.stream() .sorted((device1, device2) -> Integer.compare(device2.id, device1.id)) .map(device -> DeviceShortcutMapper.buildShortcut(device, context)) + .limit(SHORTCUT_AMOUNT_LIMIT) .forEach(shortcut -> ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)); } diff --git a/app/src/main/java/de/florianisme/wakeonlan/shutdown/ShutdownExecutor.java b/app/src/main/java/de/florianisme/wakeonlan/shutdown/ShutdownExecutor.java new file mode 100644 index 0000000..944a526 --- /dev/null +++ b/app/src/main/java/de/florianisme/wakeonlan/shutdown/ShutdownExecutor.java @@ -0,0 +1,44 @@ +package de.florianisme.wakeonlan.shutdown; + +import android.util.Log; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +import java.security.Security; +import java.util.Optional; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import de.florianisme.wakeonlan.persistence.models.Device; +import de.florianisme.wakeonlan.shutdown.listener.IgnoringShutdownExecutorListener; +import de.florianisme.wakeonlan.shutdown.listener.ShutdownExecutorListener; + +public class ShutdownExecutor { + + private static final Executor executor = Executors.newSingleThreadExecutor(); + + static { + // Override Android's BC implementation with official BC Provider + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME); + Security.insertProviderAt(new BouncyCastleProvider(), 1); + } + + public static void shutdownDevice(Device device, ShutdownExecutorListener shutdownExecutorListener) { + Optional optionalShutdownModel = ShutdownModelFactory.fromDevice(device); + + if (!optionalShutdownModel.isPresent()) { + Log.w(ShutdownExecutor.class.getSimpleName(), "Can not shutdown device. Not all required fields were set"); + shutdownExecutorListener.onGeneralError(new IllegalArgumentException("Can not shutdown device. Not all required fields were set"), null); + return; + } + + ShutdownModel shutdownModel = optionalShutdownModel.get(); + ShutdownRunnable shutdownRunnable = new ShutdownRunnable(shutdownModel, shutdownExecutorListener); + + executor.execute(shutdownRunnable); + } + + public static void shutdownDevice(Device device) { + shutdownDevice(device, new IgnoringShutdownExecutorListener()); + } +} diff --git a/app/src/main/java/de/florianisme/wakeonlan/shutdown/ShutdownModel.java b/app/src/main/java/de/florianisme/wakeonlan/shutdown/ShutdownModel.java new file mode 100644 index 0000000..fa2dc1f --- /dev/null +++ b/app/src/main/java/de/florianisme/wakeonlan/shutdown/ShutdownModel.java @@ -0,0 +1,38 @@ +package de.florianisme.wakeonlan.shutdown; + +public class ShutdownModel { + + private final String sshAddress; + private final int sshPort; + private final String username; + private final String password; + private final String command; + + public ShutdownModel(String sshAddress, int sshPort, String username, String password, String command) { + this.sshAddress = sshAddress; + this.sshPort = sshPort; + this.username = username; + this.password = password; + this.command = command; + } + + public String getSshAddress() { + return sshAddress; + } + + public int getSshPort() { + return sshPort; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public String getCommand() { + return command; + } +} diff --git a/app/src/main/java/de/florianisme/wakeonlan/shutdown/ShutdownModelFactory.java b/app/src/main/java/de/florianisme/wakeonlan/shutdown/ShutdownModelFactory.java new file mode 100644 index 0000000..1393941 --- /dev/null +++ b/app/src/main/java/de/florianisme/wakeonlan/shutdown/ShutdownModelFactory.java @@ -0,0 +1,53 @@ +package de.florianisme.wakeonlan.shutdown; + +import androidx.annotation.Nullable; + +import com.google.common.base.Strings; + +import java.util.Optional; + +import de.florianisme.wakeonlan.persistence.models.Device; + +public class ShutdownModelFactory { + + private static final int DEFAULT_SSH_PORT = 22; + + public static Optional fromDevice(Device device) { + boolean shutdownEnabled = device.remoteShutdownEnabled; + String address = getValueOrFallback(device.sshAddress, device.statusIp); + int port = getSshPortOrFallback(device.sshPort); + String username = getValueOrFallback(device.sshUsername, null); + String password = getValueOrFallback(device.sshPassword, null); + String command = getValueOrFallback(device.sshCommand, null); + + if (allRequiredFieldsSet(shutdownEnabled, address, username, password, command)) { + return Optional.of(new ShutdownModel(address, port, username, password, command)); + } + + return Optional.empty(); + } + + private static boolean allRequiredFieldsSet(boolean shutdownEnabled, String address, String username, String password, String command) { + return shutdownEnabled && address != null && username != null && password != null && command != null; + } + + @Nullable + private static String getValueOrFallback(@Nullable String value, @Nullable String fallback) { + if (!Strings.isNullOrEmpty(value)) { + return value; + } + if (!Strings.isNullOrEmpty(fallback)) { + return fallback; + } + return null; + } + + private static Integer getSshPortOrFallback(@Nullable Integer value) { + if (value != null && value > 0) { + return value; + } + return ShutdownModelFactory.DEFAULT_SSH_PORT; + } + + +} diff --git a/app/src/main/java/de/florianisme/wakeonlan/shutdown/ShutdownRunnable.java b/app/src/main/java/de/florianisme/wakeonlan/shutdown/ShutdownRunnable.java new file mode 100644 index 0000000..3e27060 --- /dev/null +++ b/app/src/main/java/de/florianisme/wakeonlan/shutdown/ShutdownRunnable.java @@ -0,0 +1,82 @@ +package de.florianisme.wakeonlan.shutdown; + +import android.util.Log; + +import com.google.common.base.Throwables; + +import net.schmizz.sshj.SSHClient; +import net.schmizz.sshj.common.LoggerFactory; +import net.schmizz.sshj.common.StreamCopier; +import net.schmizz.sshj.connection.channel.direct.Session; +import net.schmizz.sshj.transport.TransportException; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +import de.florianisme.wakeonlan.shutdown.exception.CommandExecuteException; +import de.florianisme.wakeonlan.shutdown.listener.ShutdownExecutorListener; + +public class ShutdownRunnable implements Runnable { + + private static final int CONNECT_TIMEOUT = 5000; + private static final int EXECUTE_TIMEOUT = 500; + + private final ShutdownModel shutdownModel; + private final ShutdownExecutorListener shutdownExecutorListener; + + public ShutdownRunnable(ShutdownModel shutdownModel, ShutdownExecutorListener shutdownExecutorListener) { + this.shutdownModel = shutdownModel; + this.shutdownExecutorListener = shutdownExecutorListener; + } + + @Override + public void run() { + ByteArrayOutputStream commandOutputStream = new ByteArrayOutputStream(); + + try (SSHClient sshClient = new SSHClient()) { + sshClient.addHostKeyVerifier((hostname, port, key) -> true); + sshClient.setConnectTimeout(CONNECT_TIMEOUT); + sshClient.connect(shutdownModel.getSshAddress(), shutdownModel.getSshPort()); + shutdownExecutorListener.onTargetHostReached(); + + sshClient.authPassword(shutdownModel.getUsername(), shutdownModel.getPassword()); + shutdownExecutorListener.onLoginSuccessful(); + + Session session = sshClient.startSession(); + shutdownExecutorListener.onSessionStartSuccessful(); + + session.allocateDefaultPTY(); + Session.Command exec = session.exec(shutdownModel.getCommand()); + new StreamCopier(exec.getInputStream(), commandOutputStream, LoggerFactory.DEFAULT) + .bufSize(exec.getLocalMaxPacketSize()) + .spawn("stdout"); + + exec.join(EXECUTE_TIMEOUT, TimeUnit.MILLISECONDS); + Integer exitStatus = exec.getExitStatus(); + if (exitStatus != 0) { + throw new CommandExecuteException("Command exited with status code " + exitStatus, exitStatus); + } + + shutdownExecutorListener.onCommandExecuteSuccessful(); + } catch (Exception e) { + if (Throwables.getRootCause(e) instanceof TransportException) { + shutdownExecutorListener.onCommandExecuteSuccessful(); + return; + } + + Log.e(ShutdownRunnable.class.getSimpleName(), "Error during SSH execution", e); + + if (sudoPrompt(commandOutputStream)) { + shutdownExecutorListener.onSudoPromptTriggered(shutdownModel); + return; + } + + shutdownExecutorListener.onGeneralError(e, shutdownModel); + } + } + + private boolean sudoPrompt(ByteArrayOutputStream commandOutputStream) { + return new String(commandOutputStream.toByteArray(), StandardCharsets.UTF_8).contains("[sudo] password for "); + } +} diff --git a/app/src/main/java/de/florianisme/wakeonlan/shutdown/exception/CommandExecuteException.java b/app/src/main/java/de/florianisme/wakeonlan/shutdown/exception/CommandExecuteException.java new file mode 100644 index 0000000..102c0fe --- /dev/null +++ b/app/src/main/java/de/florianisme/wakeonlan/shutdown/exception/CommandExecuteException.java @@ -0,0 +1,15 @@ +package de.florianisme.wakeonlan.shutdown.exception; + +public class CommandExecuteException extends Exception { + + private final Integer exitStatus; + + public CommandExecuteException(String message, Integer exitStatus) { + super(message); + this.exitStatus = exitStatus; + } + + public Integer getExitStatus() { + return exitStatus; + } +} diff --git a/app/src/main/java/de/florianisme/wakeonlan/shutdown/listener/IgnoringShutdownExecutorListener.java b/app/src/main/java/de/florianisme/wakeonlan/shutdown/listener/IgnoringShutdownExecutorListener.java new file mode 100644 index 0000000..9fb2d07 --- /dev/null +++ b/app/src/main/java/de/florianisme/wakeonlan/shutdown/listener/IgnoringShutdownExecutorListener.java @@ -0,0 +1,36 @@ +package de.florianisme.wakeonlan.shutdown.listener; + +import de.florianisme.wakeonlan.shutdown.ShutdownModel; + +public class IgnoringShutdownExecutorListener implements ShutdownExecutorListener { + + @Override + public void onTargetHostReached() { + // Ignore + } + + @Override + public void onLoginSuccessful() { + // Ignore + } + + @Override + public void onSessionStartSuccessful() { + // Ignore + } + + @Override + public void onCommandExecuteSuccessful() { + // Ignore + } + + @Override + public void onSudoPromptTriggered(ShutdownModel shutdownModel) { + // Ignore + } + + @Override + public void onGeneralError(Exception exception, ShutdownModel shutdownModel) { + // Ignore + } +} diff --git a/app/src/main/java/de/florianisme/wakeonlan/shutdown/listener/ShutdownExecutorListener.java b/app/src/main/java/de/florianisme/wakeonlan/shutdown/listener/ShutdownExecutorListener.java new file mode 100644 index 0000000..3c601fe --- /dev/null +++ b/app/src/main/java/de/florianisme/wakeonlan/shutdown/listener/ShutdownExecutorListener.java @@ -0,0 +1,21 @@ +package de.florianisme.wakeonlan.shutdown.listener; + +import androidx.annotation.Nullable; + +import de.florianisme.wakeonlan.shutdown.ShutdownModel; + +public interface ShutdownExecutorListener { + + void onTargetHostReached(); + + void onLoginSuccessful(); + + void onSessionStartSuccessful(); + + void onCommandExecuteSuccessful(); + + void onSudoPromptTriggered(ShutdownModel shutdownModel); + + void onGeneralError(Exception exception, @Nullable ShutdownModel shutdownModel); + +} diff --git a/app/src/main/java/de/florianisme/wakeonlan/shutdown/test/ShutdownCommandTester.java b/app/src/main/java/de/florianisme/wakeonlan/shutdown/test/ShutdownCommandTester.java new file mode 100644 index 0000000..017f417 --- /dev/null +++ b/app/src/main/java/de/florianisme/wakeonlan/shutdown/test/ShutdownCommandTester.java @@ -0,0 +1,19 @@ +package de.florianisme.wakeonlan.shutdown.test; + +import de.florianisme.wakeonlan.persistence.models.Device; +import de.florianisme.wakeonlan.shutdown.ShutdownExecutor; +import de.florianisme.wakeonlan.shutdown.listener.ShutdownExecutorListener; + +public class ShutdownCommandTester { + + private final ShutdownExecutorListener shutdownExecutorListener; + + public ShutdownCommandTester(ShutdownExecutorListener shutdownExecutorListener) { + this.shutdownExecutorListener = shutdownExecutorListener; + } + + public void startShutdownCommandTest(Device device) { + ShutdownExecutor.shutdownDevice(device, shutdownExecutorListener); + } + +} diff --git a/app/src/main/java/de/florianisme/wakeonlan/ui/backup/DataExporter.java b/app/src/main/java/de/florianisme/wakeonlan/ui/backup/DataExporter.java index acaa3b3..388d5fc 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/ui/backup/DataExporter.java +++ b/app/src/main/java/de/florianisme/wakeonlan/ui/backup/DataExporter.java @@ -2,7 +2,6 @@ import android.content.Context; import android.net.Uri; -import android.os.ParcelFileDescriptor; import android.util.Log; import android.widget.Toast; @@ -12,7 +11,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.FileOutputStream; +import java.io.OutputStream; import java.lang.ref.WeakReference; import java.util.List; import java.util.stream.Collectors; @@ -61,12 +60,14 @@ public void onActivityResult(Uri uri) { } private void writeDevicesToFile(Uri uri, byte[] content, Context context) throws Exception { - ParcelFileDescriptor fileDescriptor = context.getContentResolver().openFileDescriptor(uri, FILE_MODE_WRITE); - FileOutputStream fileOutputStream = new FileOutputStream(fileDescriptor.getFileDescriptor()); - fileOutputStream.write(content); + try (OutputStream fileOutputStream = context.getContentResolver().openOutputStream(uri, FILE_MODE_WRITE)) { - fileOutputStream.close(); - fileDescriptor.close(); + if (fileOutputStream == null) { + throw new IllegalStateException("Could not open File for writing"); + } + + fileOutputStream.write(content); + } } } diff --git a/app/src/main/java/de/florianisme/wakeonlan/ui/backup/DataImporter.java b/app/src/main/java/de/florianisme/wakeonlan/ui/backup/DataImporter.java index 9160f07..b9e3585 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/ui/backup/DataImporter.java +++ b/app/src/main/java/de/florianisme/wakeonlan/ui/backup/DataImporter.java @@ -64,9 +64,16 @@ private void replaceDevicesInDatabase(Device[] devices, Context context) { } private byte[] readContentFromFile(Uri uri, Context context) throws IOException { - InputStream inputStream = context.getContentResolver().openInputStream(uri); - byte[] content = ByteStreams.toByteArray(inputStream); - inputStream.close(); + byte[] content; + + try (InputStream inputStream = context.getContentResolver().openInputStream(uri)) { + + if (inputStream == null) { + throw new IllegalStateException("Could not open File for reading"); + } + + content = ByteStreams.toByteArray(inputStream); + } return content; } diff --git a/app/src/main/java/de/florianisme/wakeonlan/ui/backup/model/DeviceBackupModel.java b/app/src/main/java/de/florianisme/wakeonlan/ui/backup/model/DeviceBackupModel.java index 870228d..ef29968 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/ui/backup/model/DeviceBackupModel.java +++ b/app/src/main/java/de/florianisme/wakeonlan/ui/backup/model/DeviceBackupModel.java @@ -43,6 +43,24 @@ to JSON with its obfuscated name ("a", "b", and so on). @JsonAlias("g") public String secureOnPassword; + @JsonProperty("remote_shutdown_enabled") + public boolean remoteShutdownEnabled; + + @JsonProperty("ssh_address") + public String sshAddress; + + @JsonProperty("ssh_port") + public Integer sshPort; + + @JsonProperty("ssh_username") + public String sshUsername; + + @JsonProperty("ssh_password") + public String sshPassword; + + @JsonProperty("ssh_command") + public String sshCommand; + public DeviceBackupModel(Device device) { this.id = device.id; this.name = device.name; @@ -51,10 +69,17 @@ public DeviceBackupModel(Device device) { this.port = device.port; this.statusIp = device.statusIp; this.secureOnPassword = device.secureOnPassword; + this.remoteShutdownEnabled = device.remoteShutdownEnabled; + this.sshAddress = device.sshAddress; + this.sshPort = device.sshPort; + this.sshUsername = device.sshUsername; + this.sshPassword = device.sshPassword; + this.sshCommand = device.sshCommand; } public Device toModel() { - return new Device(this.id, this.name, this.macAddress, this.broadcastAddress, this.port, this.statusIp, this.secureOnPassword); + return new Device(this.id, this.name, this.macAddress, this.broadcastAddress, this.port, this.statusIp, this.secureOnPassword, + this.remoteShutdownEnabled, this.sshAddress, this.sshPort, this.sshUsername, this.sshPassword, this.sshCommand); } @SuppressWarnings("unused") diff --git a/app/src/main/java/de/florianisme/wakeonlan/ui/list/DeviceDiffCallback.java b/app/src/main/java/de/florianisme/wakeonlan/ui/list/DeviceDiffCallback.java index d5290b0..006c59f 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/ui/list/DeviceDiffCallback.java +++ b/app/src/main/java/de/florianisme/wakeonlan/ui/list/DeviceDiffCallback.java @@ -22,10 +22,23 @@ public boolean areContentsTheSame(@NonNull Device oldDevice, @NonNull Device new stringMatches(oldDevice.statusIp, newDevice.statusIp) && stringMatches(oldDevice.macAddress, newDevice.macAddress) && stringMatches(oldDevice.secureOnPassword, newDevice.secureOnPassword) && - oldDevice.port == newDevice.port; + oldDevice.port == newDevice.port && + oldDevice.remoteShutdownEnabled == newDevice.remoteShutdownEnabled && + bothNullOrEqual(oldDevice.sshPort, newDevice.sshPort) && + stringMatches(oldDevice.sshAddress, newDevice.sshAddress) && + stringMatches(oldDevice.sshUsername, newDevice.sshUsername) && + stringMatches(oldDevice.sshPassword, newDevice.sshPassword) && + stringMatches(oldDevice.sshCommand, newDevice.sshCommand); } private boolean stringMatches(@Nullable String oldString, @Nullable String newString) { return Strings.nullToEmpty(oldString).equals(newString); } + + private boolean bothNullOrEqual(@Nullable Integer oldInteger, @Nullable Integer newInteger) { + if (oldInteger == null && newInteger == null) { + return true; + } + return oldInteger != null && oldInteger.equals(newInteger); + } } diff --git a/app/src/main/java/de/florianisme/wakeonlan/ui/list/DeviceListAdapter.java b/app/src/main/java/de/florianisme/wakeonlan/ui/list/DeviceListAdapter.java index ba81818..a80bccb 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/ui/list/DeviceListAdapter.java +++ b/app/src/main/java/de/florianisme/wakeonlan/ui/list/DeviceListAdapter.java @@ -92,6 +92,7 @@ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, final deviceItemViewHolder.setDeviceMacAddress(device.macAddress); deviceItemViewHolder.setOnClickHandler(device); deviceItemViewHolder.setOnEditClickHandler(device); + deviceItemViewHolder.setShutdownVisibilityAndClickHandler(device); deviceItemViewHolder.startDeviceStatusQuery(device); } } diff --git a/app/src/main/java/de/florianisme/wakeonlan/ui/list/viewholder/DeviceItemViewHolder.java b/app/src/main/java/de/florianisme/wakeonlan/ui/list/viewholder/DeviceItemViewHolder.java index e3a418c..5d69b38 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/ui/list/viewholder/DeviceItemViewHolder.java +++ b/app/src/main/java/de/florianisme/wakeonlan/ui/list/viewholder/DeviceItemViewHolder.java @@ -11,6 +11,7 @@ import android.view.animation.Animation; import android.widget.Button; import android.widget.TextView; +import android.widget.Toast; import androidx.appcompat.content.res.AppCompatResources; import androidx.recyclerview.widget.RecyclerView; @@ -18,6 +19,8 @@ import de.florianisme.wakeonlan.R; import de.florianisme.wakeonlan.persistence.models.Device; import de.florianisme.wakeonlan.persistence.models.DeviceStatus; +import de.florianisme.wakeonlan.shutdown.ShutdownExecutor; +import de.florianisme.wakeonlan.shutdown.ShutdownModelFactory; import de.florianisme.wakeonlan.ui.list.DeviceClickedCallback; import de.florianisme.wakeonlan.ui.list.status.DeviceStatusTester; import de.florianisme.wakeonlan.ui.list.status.PingDeviceStatusTester; @@ -32,6 +35,7 @@ public class DeviceItemViewHolder extends RecyclerView.ViewHolder { private final Button editButton; private final Button sendWolButton; + private final Button shutdownButton; private final DeviceClickedCallback deviceClickedCallback; private final DeviceStatusTester deviceStatusTester; @@ -43,6 +47,7 @@ public DeviceItemViewHolder(View view, DeviceClickedCallback deviceClickedCallba editButton = view.findViewById(R.id.edit); sendWolButton = view.findViewById(R.id.send_wol); + shutdownButton = view.findViewById(R.id.shutdown); this.deviceClickedCallback = deviceClickedCallback; this.deviceStatusTester = new PingDeviceStatusTester(); } @@ -74,6 +79,19 @@ public void setOnEditClickHandler(Device device) { }); } + public void setShutdownVisibilityAndClickHandler(Device device) { + boolean shutdownConfigurationValid = ShutdownModelFactory.fromDevice(device).isPresent(); + + shutdownButton.setVisibility(shutdownConfigurationValid ? View.VISIBLE : View.GONE); + + if (shutdownConfigurationValid) { + shutdownButton.setOnClickListener(v -> { + ShutdownExecutor.shutdownDevice(device); + Toast.makeText(v.getContext(), v.getContext().getString(R.string.remote_shutdown_send_command, device.name), Toast.LENGTH_LONG).show(); + }); + } + } + public void startDeviceStatusQuery(Device device) { deviceStatus.clearAnimation(); deviceStatus.setBackground(AppCompatResources.getDrawable(itemView.getContext(), R.drawable.device_status_unknown)); diff --git a/app/src/main/java/de/florianisme/wakeonlan/ui/modify/AddDeviceActivity.java b/app/src/main/java/de/florianisme/wakeonlan/ui/modify/AddDeviceActivity.java index 429b81d..829a747 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/ui/modify/AddDeviceActivity.java +++ b/app/src/main/java/de/florianisme/wakeonlan/ui/modify/AddDeviceActivity.java @@ -1,5 +1,6 @@ package de.florianisme.wakeonlan.ui.modify; +import android.os.Bundle; import android.view.Menu; import android.view.MenuItem; @@ -10,6 +11,12 @@ public class AddDeviceActivity extends ModifyDeviceActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + triggerRemoteShutdownLayoutVisibility(false); + } + @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.add_device_menu, menu); @@ -28,7 +35,12 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { } @Override - protected void persistDevice() { + protected void persistDevice(Device device) { + deviceRepository.insertAll(device); + } + + @Override + protected Device buildDeviceFromInputs() { Device device = new Device(); device.name = getDeviceNameInputText(); device.statusIp = getDeviceStatusIpText(); @@ -36,8 +48,14 @@ protected void persistDevice() { device.broadcastAddress = getDeviceBroadcastAddressText(); device.port = getPort(); device.secureOnPassword = getDeviceSecureOnPassword(); + device.remoteShutdownEnabled = getDeviceRemoteShutdownEnabled(); + device.sshAddress = getDeviceSshAddress(); + device.sshPort = getDeviceSshPort(); + device.sshUsername = getDeviceSshUsername(); + device.sshPassword = getDeviceSshPassword(); + device.sshCommand = getDeviceSshCommand(); - deviceRepository.insertAll(device); + return device; } @Override @@ -45,6 +63,8 @@ protected boolean inputsHaveNotChanged() { // There is no persisted device yet, so we check if any of our inputs are edited return getDeviceNameInputText().isEmpty() && getDeviceMacInputText().isEmpty() && getDeviceBroadcastAddressText().isEmpty() && getDeviceStatusIpText().isEmpty() - && getDeviceSecureOnPassword().isEmpty(); + && getDeviceSecureOnPassword().isEmpty() && !getDeviceRemoteShutdownEnabled() && + getDeviceSshAddress().isEmpty() && getDeviceSshPort() == -1 && getDeviceSshUsername().isEmpty() && + getDeviceSshPassword().isEmpty() && getDeviceSshCommand().isEmpty(); } } diff --git a/app/src/main/java/de/florianisme/wakeonlan/ui/modify/BroadcastHelper.java b/app/src/main/java/de/florianisme/wakeonlan/ui/modify/BroadcastHelper.java index 2f01619..7ca39db 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/ui/modify/BroadcastHelper.java +++ b/app/src/main/java/de/florianisme/wakeonlan/ui/modify/BroadcastHelper.java @@ -1,30 +1,41 @@ package de.florianisme.wakeonlan.ui.modify; -import java.io.IOException; +import com.google.common.collect.Lists; + import java.net.InetAddress; import java.net.InterfaceAddress; import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Collection; +import java.util.Collections; import java.util.Enumeration; +import java.util.List; +import java.util.Objects; import java.util.Optional; public class BroadcastHelper { - public static Optional getBroadcastAddress() throws IOException { - Enumeration networkInterfaces = NetworkInterface.getNetworkInterfaces(); + private static final List INTERFACE_LIST = Lists.newArrayList("wlan", "eth", "tun"); - while (networkInterfaces.hasMoreElements()) { - NetworkInterface singleInterface = networkInterfaces.nextElement(); + public final Optional getBroadcastAddress() { + return Collections.list(getNetworkInterfaces()).stream() + .filter(this::isAllowedInterfaceName) + .map(NetworkInterface::getInterfaceAddresses) + .flatMap(Collection::stream) + .map(InterfaceAddress::getBroadcast) + .filter(Objects::nonNull) + .findFirst(); + } - String interfaceName = singleInterface.getName(); - if (interfaceName.contains("wlan0") || interfaceName.contains("eth0")) { - for (InterfaceAddress interfaceAddress : singleInterface.getInterfaceAddresses()) { - InetAddress broadcastAddress = interfaceAddress.getBroadcast(); - if (broadcastAddress != null) { - return Optional.of(broadcastAddress); - } - } - } + protected Enumeration getNetworkInterfaces() { + try { + return NetworkInterface.getNetworkInterfaces(); + } catch (SocketException e) { + return Collections.emptyEnumeration(); } - return Optional.empty(); + } + + private boolean isAllowedInterfaceName(NetworkInterface networkInterface) { + return INTERFACE_LIST.stream().anyMatch(interfaceName -> networkInterface.getName().startsWith(interfaceName)); } } diff --git a/app/src/main/java/de/florianisme/wakeonlan/ui/modify/EditDeviceActivity.java b/app/src/main/java/de/florianisme/wakeonlan/ui/modify/EditDeviceActivity.java index a8e78ef..d3f043f 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/ui/modify/EditDeviceActivity.java +++ b/app/src/main/java/de/florianisme/wakeonlan/ui/modify/EditDeviceActivity.java @@ -10,6 +10,8 @@ import com.google.common.base.Strings; +import java.util.Objects; + import de.florianisme.wakeonlan.R; import de.florianisme.wakeonlan.persistence.models.Device; @@ -40,15 +42,28 @@ private void populateInputs() { deviceStatusIpInput.setText(device.statusIp); deviceMacInput.setText(device.macAddress); deviceBroadcastInput.setText(device.broadcastAddress); - deviceSecureOnPassword.setText(device.secureOnPassword); if (device.port == 9) { devicePorts.setText("9", false); } else { devicePorts.setText("7", false); } + deviceSecureOnPassword.setText(device.secureOnPassword); + + deviceEnableRemoteShutdown.setChecked(device.remoteShutdownEnabled); + triggerRemoteShutdownLayoutVisibility(device.remoteShutdownEnabled); + deviceSshAddressInput.setText(device.sshAddress); + deviceSshPortInput.setText(getSshPortFallback()); + deviceSshUsernameInput.setText(device.sshUsername); + deviceSshPasswordInput.setText(device.sshPassword); + deviceSshCommandInput.setText(device.sshCommand); } } + @NonNull + private String getSshPortFallback() { + return device.sshPort == null || device.sshPort < 0 ? "" : String.valueOf(device.sshPort); + } + @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.edit_device_menu, menu); @@ -84,19 +99,37 @@ protected boolean inputsHaveNotChanged() { device.macAddress.equals(getDeviceMacInputText()) && device.statusIp.equals(getDeviceStatusIpText()) && device.port == getPort() && - Strings.nullToEmpty(device.secureOnPassword).equals(getDeviceSecureOnPassword()); + Strings.nullToEmpty(device.secureOnPassword).equals(getDeviceSecureOnPassword()) && + device.remoteShutdownEnabled == getDeviceRemoteShutdownEnabled() && + Strings.nullToEmpty(device.sshAddress).equals(getDeviceSshAddress()) && + Objects.equals(device.sshPort == null ? -1 : device.sshPort, getDeviceSshPort()) && + Strings.nullToEmpty(device.sshUsername).equals(getDeviceSshUsername()) && + Strings.nullToEmpty(device.sshPassword).equals(getDeviceSshPassword()) && + Strings.nullToEmpty(device.sshCommand).equals(getDeviceSshCommand()); + } @Override - protected void persistDevice() { + protected void persistDevice(Device device) { + deviceRepository.update(device); + } + + @Override + protected Device buildDeviceFromInputs() { device.name = getDeviceNameInputText(); device.statusIp = getDeviceStatusIpText(); device.macAddress = getDeviceMacInputText(); device.broadcastAddress = getDeviceBroadcastAddressText(); device.port = getPort(); device.secureOnPassword = getDeviceSecureOnPassword(); - - deviceRepository.update(device); + device.remoteShutdownEnabled = getDeviceRemoteShutdownEnabled(); + device.sshAddress = getDeviceSshAddress(); + device.sshPort = getDeviceSshPort(); + device.sshUsername = getDeviceSshUsername(); + device.sshPassword = getDeviceSshPassword(); + device.sshCommand = getDeviceSshCommand(); + + return device; } } diff --git a/app/src/main/java/de/florianisme/wakeonlan/ui/modify/ModifyDeviceActivity.java b/app/src/main/java/de/florianisme/wakeonlan/ui/modify/ModifyDeviceActivity.java index 3ef36f3..5cc3610 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/ui/modify/ModifyDeviceActivity.java +++ b/app/src/main/java/de/florianisme/wakeonlan/ui/modify/ModifyDeviceActivity.java @@ -1,27 +1,53 @@ package de.florianisme.wakeonlan.ui.modify; +import android.content.res.ColorStateList; +import android.graphics.Color; import android.os.Bundle; -import android.util.Log; +import android.view.LayoutInflater; import android.view.View; import android.widget.ArrayAdapter; +import android.widget.Button; import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.SwitchCompat; +import androidx.constraintlayout.widget.ConstraintLayout; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.MaterialAutoCompleteTextView; import com.google.android.material.textfield.TextInputEditText; +import com.google.common.base.Strings; +import com.google.common.base.Throwables; +import com.google.common.collect.Lists; -import java.io.IOException; +import net.schmizz.sshj.connection.ConnectionException; +import net.schmizz.sshj.userauth.UserAuthException; + +import java.net.ConnectException; import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.List; import java.util.Optional; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; import de.florianisme.wakeonlan.R; import de.florianisme.wakeonlan.databinding.ActivityModifyDeviceBinding; +import de.florianisme.wakeonlan.persistence.models.Device; import de.florianisme.wakeonlan.persistence.repository.DeviceRepository; +import de.florianisme.wakeonlan.shutdown.ShutdownModel; +import de.florianisme.wakeonlan.shutdown.exception.CommandExecuteException; +import de.florianisme.wakeonlan.shutdown.listener.ShutdownExecutorListener; +import de.florianisme.wakeonlan.shutdown.test.ShutdownCommandTester; import de.florianisme.wakeonlan.ui.modify.watcher.autocomplete.MacAddressAutocomplete; +import de.florianisme.wakeonlan.ui.modify.watcher.validator.ConditionalInputNotEmptyValidator; import de.florianisme.wakeonlan.ui.modify.watcher.validator.InputNotEmptyValidator; import de.florianisme.wakeonlan.ui.modify.watcher.validator.MacValidator; import de.florianisme.wakeonlan.ui.modify.watcher.validator.SecureOnPasswordValidator; @@ -38,6 +64,14 @@ public abstract class ModifyDeviceActivity extends AppCompatActivity { protected TextInputEditText deviceSecureOnPassword; protected ImageButton broadcastAutofill; protected MaterialAutoCompleteTextView devicePorts; + protected ConstraintLayout deviceRemoteShutdownContainer; + protected SwitchCompat deviceEnableRemoteShutdown; + protected TextInputEditText deviceSshAddressInput; + protected TextInputEditText deviceSshPortInput; + protected TextInputEditText deviceSshUsernameInput; + protected TextInputEditText deviceSshPasswordInput; + protected TextInputEditText deviceSshCommandInput; + protected Button sshTestShutdownButton; @Override protected void onCreate(Bundle savedInstanceState) { @@ -54,6 +88,16 @@ protected void onCreate(Bundle savedInstanceState) { deviceSecureOnPassword = binding.device.deviceSecureOnPassword; broadcastAutofill = binding.device.broadcastAutofill; + deviceRemoteShutdownContainer = binding.device.deviceRemoteShutdownContainer; + deviceEnableRemoteShutdown = binding.device.deviceSwitchRemoteShutdown; + deviceSshAddressInput = binding.device.deviceShutdownAddress; + deviceSshPortInput = binding.device.deviceShutdownPort; + deviceSshUsernameInput = binding.device.deviceShutdownUsername; + deviceSshPasswordInput = binding.device.deviceShutdownPassword; + deviceSshCommandInput = binding.device.deviceShutdownCommand; + + sshTestShutdownButton = binding.device.deviceButtonTestShutdown; + setSupportActionBar(binding.toolbar); getSupportActionBar().setDisplayHomeAsUpEnabled(true); getSupportActionBar().setHomeAsUpIndicator(R.drawable.ic_close); @@ -61,41 +105,64 @@ protected void onCreate(Bundle savedInstanceState) { deviceRepository = DeviceRepository.getInstance(this); addValidators(); addAutofillClickHandler(); + setRemoteDeviceShutdownSwitchListener(); addDevicePortsAdapter(); + setOnTestSshShutdownListenerClickedListener(); } private void addAutofillClickHandler() { - broadcastAutofill.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - try { - Optional broadcastAddress = BroadcastHelper.getBroadcastAddress(); - broadcastAddress.ifPresent(inetAddress -> deviceBroadcastInput.setText(inetAddress.getHostAddress())); - } catch (IOException e) { - Log.e(this.getClass().getName(), "Can not retrieve Broadcast Address", e); - } - } + broadcastAutofill.setOnClickListener(v -> { + Optional broadcastAddress = new BroadcastHelper().getBroadcastAddress(); + broadcastAddress.ifPresent(inetAddress -> deviceBroadcastInput.setText(inetAddress.getHostAddress())); }); } + private void setRemoteDeviceShutdownSwitchListener() { + deviceEnableRemoteShutdown.setOnCheckedChangeListener((buttonView, isChecked) -> triggerRemoteShutdownLayoutVisibility(isChecked)); + } + + protected void triggerRemoteShutdownLayoutVisibility(boolean isEnabled) { + deviceRemoteShutdownContainer.setVisibility(isEnabled ? View.VISIBLE : View.GONE); + } + private void addValidators() { deviceMacInput.addTextChangedListener(new MacValidator(deviceMacInput)); deviceMacInput.addTextChangedListener(new MacAddressAutocomplete()); - deviceNameInput.addTextChangedListener(new InputNotEmptyValidator(deviceNameInput)); - deviceBroadcastInput.addTextChangedListener(new InputNotEmptyValidator(deviceBroadcastInput)); + deviceNameInput.addTextChangedListener(new InputNotEmptyValidator(deviceNameInput, R.string.add_device_error_name_empty)); deviceSecureOnPassword.addTextChangedListener(new SecureOnPasswordValidator(deviceSecureOnPassword)); + + List> remoteShutdownEnabledSupplier = Collections.singletonList(() -> deviceEnableRemoteShutdown.isChecked()); + List> statusIpFallbackAvailable = + Lists.newArrayList(() -> deviceEnableRemoteShutdown.isChecked(), () -> isEmpty(deviceStatusIpInput)); + + deviceSshAddressInput.addTextChangedListener(new ConditionalInputNotEmptyValidator(deviceSshAddressInput, + R.string.add_device_error_ssh_address_empty, statusIpFallbackAvailable)); + deviceSshUsernameInput.addTextChangedListener(new ConditionalInputNotEmptyValidator(deviceSshUsernameInput, + R.string.add_device_error_ssh_username_empty, remoteShutdownEnabledSupplier)); + deviceSshPasswordInput.addTextChangedListener(new ConditionalInputNotEmptyValidator(deviceSshPasswordInput, + R.string.add_device_error_ssh_password_empty, remoteShutdownEnabledSupplier)); + deviceSshCommandInput.addTextChangedListener(new ConditionalInputNotEmptyValidator(deviceSshCommandInput, + R.string.add_device_error_ssh_command_empty, remoteShutdownEnabledSupplier)); } protected boolean assertInputsNotEmptyAndValid() { return deviceMacInput.getError() == null && isNotEmpty(deviceMacInput) && deviceNameInput.getError() == null && isNotEmpty(deviceNameInput) && - deviceBroadcastInput.getError() == null && isNotEmpty(deviceBroadcastInput) && - deviceStatusIpInput.getError() == null && deviceSecureOnPassword.getError() == null; + deviceStatusIpInput.getError() == null && + deviceSecureOnPassword.getError() == null && + deviceSshAddressInput.getError() == null && + deviceSshUsernameInput.getError() == null && + deviceSshPasswordInput.getError() == null && + deviceSshCommandInput.getError() == null; } private boolean isNotEmpty(TextInputEditText inputEditText) { - return inputEditText.getText() != null && inputEditText.getText().length() != 0; + return !isEmpty(inputEditText); + } + + private boolean isEmpty(TextInputEditText inputEditText) { + return inputEditText.getText() == null || inputEditText.getText().length() == 0; } private void addDevicePortsAdapter() { @@ -106,11 +173,11 @@ private void addDevicePortsAdapter() { } protected void checkAndPersistDevice() { + triggerValidators(); if (assertInputsNotEmptyAndValid()) { - persistDevice(); + persistDevice(buildDeviceFromInputs()); finish(); } else { - triggerValidators(); Toast.makeText(this, R.string.add_device_error_save_clicked, Toast.LENGTH_LONG).show(); } } @@ -119,9 +186,150 @@ private void triggerValidators() { deviceNameInput.setText(deviceNameInput.getText()); deviceBroadcastInput.setText(deviceBroadcastInput.getText()); deviceMacInput.setText(deviceMacInput.getText()); + deviceSecureOnPassword.setText(deviceSecureOnPassword.getText()); + deviceSshAddressInput.setText(deviceSshAddressInput.getText()); + deviceSshUsernameInput.setText(deviceSshUsernameInput.getText()); + deviceSshPasswordInput.setText(deviceSshPasswordInput.getText()); + deviceSshCommandInput.setText(deviceSshCommandInput.getText()); } - abstract protected void persistDevice(); + private void setOnTestSshShutdownListenerClickedListener() { + sshTestShutdownButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + triggerValidators(); + if (assertInputsNotEmptyAndValid()) { + Device device = buildDeviceFromInputs(); + + View view = LayoutInflater.from(ModifyDeviceActivity.this).inflate(R.layout.dialog_test_remote_shutdown, null); + AlertDialog dialog = new MaterialAlertDialogBuilder(ModifyDeviceActivity.this) + .setView(view) + .setTitle(R.string.remote_shutdown_send_command_dialog_title) + .setPositiveButton(android.R.string.ok, (dlg, which) -> dlg.dismiss()) + .create(); + + final LinearLayout destinationReachedResult = view.findViewById(R.id.result_destination_reached); + final LinearLayout authorizationResult = view.findViewById(R.id.result_authorization); + final LinearLayout sessionCreatedResult = view.findViewById(R.id.result_session); + final LinearLayout commandExecutedResult = view.findViewById(R.id.result_command_execute); + + final TextView optionalErrorMessage = view.findViewById(R.id.result_optional_error_message); + optionalErrorMessage.setVisibility(View.GONE); + + setInitialDialogTexts(destinationReachedResult, authorizationResult, sessionCreatedResult, commandExecutedResult); + + new ShutdownCommandTester(new ShutdownExecutorListener() { + @Override + public void onTargetHostReached() { + setStepSuccessfullyCompleted(destinationReachedResult, R.string.test_shutdown_successful_destination); + } + + @Override + public void onLoginSuccessful() { + setStepSuccessfullyCompleted(authorizationResult, R.string.test_shutdown_successful_authorization); + } + + @Override + public void onSessionStartSuccessful() { + setStepSuccessfullyCompleted(sessionCreatedResult, R.string.test_shutdown_successful_session); + } + + @Override + public void onCommandExecuteSuccessful() { + setStepSuccessfullyCompleted(commandExecutedResult, R.string.test_shutdown_successful_command_execute); + } + + @Override + public void onSudoPromptTriggered(ShutdownModel shutdownModel) { + runOnUiThread(() -> { + optionalErrorMessage.setVisibility(View.VISIBLE); + optionalErrorMessage.setText(getString(R.string.test_shutdown_error_execution_sudo_prompt, shutdownModel.getCommand())); + }); + } + + @Override + public void onGeneralError(Exception exception, ShutdownModel shutdownModel) { + runOnUiThread(() -> { + optionalErrorMessage.setVisibility(View.VISIBLE); + optionalErrorMessage.setText(getTextByExceptionType(exception, shutdownModel)); + }); + } + + }).startShutdownCommandTest(device); + + dialog.show(); + } + } + + private String getTextByExceptionType(Exception exception, ShutdownModel shutdownModel) { + if (exception instanceof ConnectException) { + return getString(R.string.test_shutdown_error_connect_exception, shutdownModel.getSshAddress(), shutdownModel.getSshPort()); + } else if (exception instanceof UnknownHostException) { + return getString(R.string.test_shutdown_error_unknown_host, shutdownModel.getSshAddress()); + } else if (exception instanceof UserAuthException) { + return getString(R.string.test_shutdown_error_auth_exception, shutdownModel.getUsername(), shutdownModel.getSshAddress()); + } else if (exception instanceof ConnectionException && Throwables.getRootCause(exception) instanceof TimeoutException) { + return getString(R.string.test_shutdown_error_execution_timeout, shutdownModel.getCommand()); + } else if (exception instanceof CommandExecuteException) { + Integer exitStatus = ((CommandExecuteException) exception).getExitStatus(); + String explanationString = getExitCodeExplanationStringRes(exitStatus); + return getString(R.string.test_shutdown_error_execution_exception, shutdownModel.getCommand(), exitStatus, explanationString); + } + + return getString(R.string.test_shutdown_error_unknown_exception, exception.getMessage()); + } + + private String getExitCodeExplanationStringRes(Integer exitStatus) { + switch (exitStatus) { + case 127: + return getString(R.string.execution_error_command_not_found); + case 126: + return getString(R.string.execution_error_command_not_executable); + default: + return getString(R.string.execution_error_unknown); + } + } + + private void runOnUiThread(Runnable runnable) { + ModifyDeviceActivity.this.runOnUiThread(runnable); + } + + private void setStepSuccessfullyCompleted(LinearLayout layout, int stringResourceId) { + runOnUiThread(() -> { + TextView resultMessage = getResultMessageView(layout); + RadioButton resultIndicator = getResultRadioButton(layout); + + resultMessage.setText(stringResourceId); + resultIndicator.setChecked(true); + resultIndicator.setButtonTintList(ColorStateList.valueOf(Color.parseColor("#479c44"))); + }); + } + }); + } + + private void setInitialDialogTexts(LinearLayout destinationReachedResult, LinearLayout authorizationResult, + LinearLayout sessionCreatedResult, LinearLayout commandExecutedResult) { + setTexts(getResultMessageView(destinationReachedResult), R.string.test_shutdown_initial_destination); + setTexts(getResultMessageView(authorizationResult), R.string.test_shutdown_initial_authorization); + setTexts(getResultMessageView(sessionCreatedResult), R.string.test_shutdown_initial_session); + setTexts(getResultMessageView(commandExecutedResult), R.string.test_shutdown_initial_command_execute); + } + + private void setTexts(TextView resultMessageView, int stringResourceId) { + resultMessageView.setText(stringResourceId); + } + + private RadioButton getResultRadioButton(LinearLayout layout) { + return layout.findViewById(R.id.test_shutdown_item_radio); + } + + private TextView getResultMessageView(LinearLayout layout) { + return layout.findViewById(R.id.test_shutdown_item_result_message); + } + + abstract protected void persistDevice(Device device); + + abstract protected Device buildDeviceFromInputs(); abstract protected boolean inputsHaveNotChanged(); @@ -159,6 +367,43 @@ protected String getDeviceSecureOnPassword() { return getInputText(deviceSecureOnPassword); } + protected boolean getDeviceRemoteShutdownEnabled() { + return deviceEnableRemoteShutdown.isChecked(); + } + + @NonNull + protected String getDeviceSshAddress() { + return getInputText(deviceSshAddressInput); + } + + @NonNull + protected Integer getDeviceSshPort() { + try { + String sshPort = getInputText(deviceSshPortInput); + if (Strings.nullToEmpty(sshPort).isEmpty()) { + return -1; + } + return Integer.parseInt(sshPort); + } catch (NumberFormatException e) { + return -1; + } + } + + @NonNull + protected String getDeviceSshUsername() { + return getInputText(deviceSshUsernameInput); + } + + @NonNull + protected String getDeviceSshPassword() { + return getInputText(deviceSshPasswordInput); + } + + @NonNull + protected String getDeviceSshCommand() { + return getInputText(deviceSshCommandInput); + } + @Override public boolean onSupportNavigateUp() { if (inputsHaveNotChanged()) { diff --git a/app/src/main/java/de/florianisme/wakeonlan/ui/modify/watcher/validator/ConditionalInputNotEmptyValidator.java b/app/src/main/java/de/florianisme/wakeonlan/ui/modify/watcher/validator/ConditionalInputNotEmptyValidator.java new file mode 100644 index 0000000..9ffb922 --- /dev/null +++ b/app/src/main/java/de/florianisme/wakeonlan/ui/modify/watcher/validator/ConditionalInputNotEmptyValidator.java @@ -0,0 +1,30 @@ +package de.florianisme.wakeonlan.ui.modify.watcher.validator; + +import android.widget.EditText; + +import java.util.List; +import java.util.function.Supplier; + +public class ConditionalInputNotEmptyValidator extends InputNotEmptyValidator { + + private final List> conditionalSupplierList; + + public ConditionalInputNotEmptyValidator(EditText editTextView, int errorMessageStringId, List> conditionalSupplierList) { + super(editTextView, errorMessageStringId); + this.conditionalSupplierList = conditionalSupplierList; + } + + @Override + public ValidationResult validate(String text) { + if (inputShouldBeValidated()) { + return null == text || text.isEmpty() ? ValidationResult.INVALID : ValidationResult.VALID; + } else { + return ValidationResult.VALID; + } + } + + private boolean inputShouldBeValidated() { + return conditionalSupplierList.stream().allMatch(Supplier::get); + } + +} diff --git a/app/src/main/java/de/florianisme/wakeonlan/ui/modify/watcher/validator/InputNotEmptyValidator.java b/app/src/main/java/de/florianisme/wakeonlan/ui/modify/watcher/validator/InputNotEmptyValidator.java index c727c35..4999089 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/ui/modify/watcher/validator/InputNotEmptyValidator.java +++ b/app/src/main/java/de/florianisme/wakeonlan/ui/modify/watcher/validator/InputNotEmptyValidator.java @@ -2,12 +2,13 @@ import android.widget.EditText; -import de.florianisme.wakeonlan.R; - public class InputNotEmptyValidator extends Validator { - public InputNotEmptyValidator(EditText editTextView) { + private final int errorMessageStringId; + + public InputNotEmptyValidator(EditText editTextView, int errorMessageStringId) { super(editTextView); + this.errorMessageStringId = errorMessageStringId; } @Override @@ -17,7 +18,7 @@ public ValidationResult validate(String text) { @Override int getErrorMessageStringId() { - return R.string.add_device_error_name_empty; + return errorMessageStringId; } } diff --git a/app/src/main/java/de/florianisme/wakeonlan/ui/scan/viewholder/ScanResultViewHolder.java b/app/src/main/java/de/florianisme/wakeonlan/ui/scan/viewholder/ScanResultViewHolder.java index 0500641..3e0fafe 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/ui/scan/viewholder/ScanResultViewHolder.java +++ b/app/src/main/java/de/florianisme/wakeonlan/ui/scan/viewholder/ScanResultViewHolder.java @@ -47,18 +47,15 @@ public void setNameIfPresent(Optional name) { } public void setOnAddClickListener(NetworkScanDevice scanDevice) { - addDevice.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - Context context = view.getContext(); - - Intent intent = new Intent(context, AddNetworkScanDeviceActivity.class); - Bundle bundle = new Bundle(); - bundle.putString(AddNetworkScanDeviceActivity.MACHINE_NAME_KEY, scanDevice.getName().orElse(null)); - bundle.putString(AddNetworkScanDeviceActivity.MACHINE_IP_KEY, scanDevice.getIpAddress()); - intent.putExtras(bundle); - context.startActivity(intent); - } + addDevice.setOnClickListener(view -> { + Context context = view.getContext(); + + Intent intent = new Intent(context, AddNetworkScanDeviceActivity.class); + Bundle bundle = new Bundle(); + bundle.putString(AddNetworkScanDeviceActivity.MACHINE_NAME_KEY, scanDevice.getName().orElse(null)); + bundle.putString(AddNetworkScanDeviceActivity.MACHINE_IP_KEY, scanDevice.getIpAddress()); + intent.putExtras(bundle); + context.startActivity(intent); }); } } diff --git a/app/src/main/java/de/florianisme/wakeonlan/wol/WolSender.java b/app/src/main/java/de/florianisme/wakeonlan/wol/WolSender.java index 2d61aaf..110e3ae 100644 --- a/app/src/main/java/de/florianisme/wakeonlan/wol/WolSender.java +++ b/app/src/main/java/de/florianisme/wakeonlan/wol/WolSender.java @@ -2,12 +2,15 @@ import android.util.Log; +import com.google.common.base.Strings; + import java.net.DatagramPacket; import java.net.DatagramSocket; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import de.florianisme.wakeonlan.persistence.models.Device; +import de.florianisme.wakeonlan.ui.modify.BroadcastHelper; public class WolSender { @@ -18,8 +21,17 @@ public static void sendWolPacket(Device device) { @Override public void run() { + sendPacket(device.broadcastAddress); + new BroadcastHelper().getBroadcastAddress().ifPresent(inetAddress -> sendPacket(inetAddress.getHostAddress())); + } + + private void sendPacket(String broadcastAddress) { + if (Strings.isNullOrEmpty(broadcastAddress)) { + return; + } + try { - DatagramPacket packet = PacketBuilder.buildMagicPacket(device.broadcastAddress, device.macAddress, device.port, device.secureOnPassword); + DatagramPacket packet = PacketBuilder.buildMagicPacket(broadcastAddress, device.macAddress, device.port, device.secureOnPassword); DatagramSocket socket = new DatagramSocket(); socket.send(packet); socket.close(); diff --git a/app/src/main/res/drawable/remote_shutdown_test_result_finished.xml b/app/src/main/res/drawable/remote_shutdown_test_result_finished.xml new file mode 100644 index 0000000..12e2fec --- /dev/null +++ b/app/src/main/res/drawable/remote_shutdown_test_result_finished.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/remote_shutdown_test_result_pending.xml b/app/src/main/res/drawable/remote_shutdown_test_result_pending.xml new file mode 100644 index 0000000..d6405d1 --- /dev/null +++ b/app/src/main/res/drawable/remote_shutdown_test_result_pending.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/remote_shutdown_test_result_selector.xml b/app/src/main/res/drawable/remote_shutdown_test_result_selector.xml new file mode 100644 index 0000000..f835b2e --- /dev/null +++ b/app/src/main/res/drawable/remote_shutdown_test_result_selector.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/content_modify_device.xml b/app/src/main/res/layout/content_modify_device.xml index f3d2edf..0f0d3c5 100644 --- a/app/src/main/res/layout/content_modify_device.xml +++ b/app/src/main/res/layout/content_modify_device.xml @@ -1,124 +1,79 @@ - + android:fillViewport="true"> - - - + android:clipToPadding="false" + android:paddingTop="?attr/actionBarSize" + android:paddingBottom="16dp"> - - - - + android:layout_marginStart="8dp" + android:layout_marginTop="16dp" + android:text="@string/device_title_general" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - - - - - + android:layout_marginStart="8dp" + android:layout_marginTop="8dp" + android:layout_marginEnd="24dp" + android:hint="@string/add_device_name" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/device_title_general"> - + + - - + android:layout_marginStart="8dp" + android:layout_marginTop="16dp" + android:text="@string/device_title_connection" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/device_name_layout" /> - + - - + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@id/device_title_connectivity"> + + + + + + + app:layout_constraintTop_toBottomOf="@id/device_title_status" + app:placeholderText="192.168.0.100"> - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +