diff --git a/catroid/build.gradle b/catroid/build.gradle index 6ec3ce14092..ea81895b8a8 100644 --- a/catroid/build.gradle +++ b/catroid/build.gradle @@ -157,6 +157,7 @@ android { buildConfigField "boolean", "FEATURE_NFC_ENABLED", "true" buildConfigField "boolean", "FEATURE_POCKETMUSIC_ENABLED", "true" buildConfigField "boolean", "FEATURE_RASPI_ENABLED", "true" + buildConfigField "boolean", "FEATURE_MQTT_ENABLED", "true" buildConfigField "boolean", "FEATURE_SCRATCH_CONVERTER_ENABLED", "true" buildConfigField "boolean", "FEATURE_USER_REPORTERS_ENABLED", "true" buildConfigField "boolean", "FEATURE_MULTIPLAYER_VARIABLES_ENABLED", "true" @@ -423,6 +424,9 @@ dependencies { implementation 'com.google.guava:guava:28.2-android' implementation 'com.google.code.gson:gson:2.8.7' + // MQTT + implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5' + implementation 'com.koushikdutta.async:androidasync:2.2.1' implementation 'com.squareup.picasso:picasso:2.71828' implementation 'ar.com.hjg:pngj:2.1.0' diff --git a/catroid/src/androidTest/java/org/catrobat/catroid/uiespresso/ui/activity/SettingsFragmentTest.java b/catroid/src/androidTest/java/org/catrobat/catroid/uiespresso/ui/activity/SettingsFragmentTest.java index ca6c7d435bd..7d10b99e5e0 100644 --- a/catroid/src/androidTest/java/org/catrobat/catroid/uiespresso/ui/activity/SettingsFragmentTest.java +++ b/catroid/src/androidTest/java/org/catrobat/catroid/uiespresso/ui/activity/SettingsFragmentTest.java @@ -85,6 +85,7 @@ import static org.catrobat.catroid.ui.settingsfragments.SettingsFragment.SETTINGS_SHOW_PHIRO_BRICKS_CHECKBOX_PREFERENCE; import static org.catrobat.catroid.ui.settingsfragments.SettingsFragment.SETTINGS_SHOW_PLOT_BRICKS; import static org.catrobat.catroid.ui.settingsfragments.SettingsFragment.SETTINGS_SHOW_RASPI_BRICKS; +import static org.catrobat.catroid.ui.settingsfragments.SettingsFragment.SETTINGS_SHOW_MQTT_BRICKS; import static org.hamcrest.Matchers.hasToString; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.core.AllOf.allOf; @@ -123,7 +124,7 @@ public class SettingsFragmentTest { SETTINGS_CAST_GLOBALLY_ENABLED, SETTINGS_SHOW_AI_SPEECH_RECOGNITION_SENSORS, SETTINGS_SHOW_AI_SPEECH_SYNTHETIZATION_SENSORS, SETTINGS_SHOW_AI_FACE_DETECTION_SENSORS, SETTINGS_SHOW_AI_POSE_DETECTION_SENSORS, - SETTINGS_SHOW_AI_TEXT_RECOGNITION_SENSORS)); + SETTINGS_SHOW_AI_TEXT_RECOGNITION_SENSORS, SETTINGS_SHOW_MQTT_BRICKS)); private Map initialSettings = new HashMap<>(); private Matcher expectedBrowserIntent; @@ -258,6 +259,15 @@ public void rasPiSettingsTest() { checkPreference(R.string.preference_title_enable_raspi_bricks, SETTINGS_SHOW_RASPI_BRICKS); } + @Category({Cat.AppUi.class, Level.Smoke.class, Cat.Gadgets.class}) + @Test + public void mqttSettingsTest() { + onData(PreferenceMatchers.withTitle(R.string.preference_title_enable_mqtt_bricks)) + .perform(click()); + + checkPreference(R.string.preference_title_enable_mqtt_bricks, SETTINGS_SHOW_MQTT_BRICKS); + } + @Category({Cat.AppUi.class, Level.Smoke.class, Cat.Gadgets.class}) @Test public void aiSettingsTest() { diff --git a/catroid/src/main/AndroidManifest.xml b/catroid/src/main/AndroidManifest.xml index 217e6e5a12a..a5c875a0eb6 100644 --- a/catroid/src/main/AndroidManifest.xml +++ b/catroid/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ + + MQTT extension + Allow the app to connect to MQTT brokers + Enable MQTT bricks + Enable bricks and sensors for MQTT + MQTT settings + Broker host + IP address or hostname of the MQTT broker + Broker port + Port number of the MQTT broker (1–65535) + Use TLS + Username + Password + Client ID + Port must be a number between 1 and 65535 + + Set NXT motor to diff --git a/catroid/src/main/res/xml/mqtt_preferences.xml b/catroid/src/main/res/xml/mqtt_preferences.xml new file mode 100644 index 00000000000..4d51c1fb3bd --- /dev/null +++ b/catroid/src/main/res/xml/mqtt_preferences.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/catroid/src/main/res/xml/preferences.xml b/catroid/src/main/res/xml/preferences.xml index 91c6163f3d3..7e91808aaba 100644 --- a/catroid/src/main/res/xml/preferences.xml +++ b/catroid/src/main/res/xml/preferences.xml @@ -104,6 +104,11 @@ android:summary="@string/preference_description_raspi_bricks" android:title="@string/preference_title_enable_raspi_bricks" /> + + ) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.test.mqtt + +import org.eclipse.paho.client.mqttv3.MqttCallback +import org.eclipse.paho.client.mqttv3.MqttClient +import org.eclipse.paho.client.mqttv3.MqttConnectOptions +import org.eclipse.paho.client.mqttv3.MqttException +import org.eclipse.paho.client.mqttv3.MqttMessage +import org.junit.Assert.assertNotNull +import org.junit.Test + +class MqttDependencySanityTest { + + @Test + fun testPahoClientClassesAreAvailable() { + assertNotNull(MqttClient::class.java) + assertNotNull(MqttConnectOptions::class.java) + assertNotNull(MqttMessage::class.java) + assertNotNull(MqttException::class.java) + assertNotNull(MqttCallback::class.java) + } +} diff --git a/catroid/src/test/java/org/catrobat/catroid/test/mqtt/MqttManagerTest.kt b/catroid/src/test/java/org/catrobat/catroid/test/mqtt/MqttManagerTest.kt new file mode 100644 index 00000000000..26aabd8c496 --- /dev/null +++ b/catroid/src/test/java/org/catrobat/catroid/test/mqtt/MqttManagerTest.kt @@ -0,0 +1,261 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.test.mqtt + +import org.catrobat.catroid.devices.mqtt.MqttClientInterface +import org.catrobat.catroid.devices.mqtt.MqttConnectionConfig +import org.catrobat.catroid.devices.mqtt.MqttManager +import org.eclipse.paho.client.mqttv3.MqttCallback +import org.eclipse.paho.client.mqttv3.MqttConnectOptions +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class MqttManagerTest { + + private lateinit var fakeClient: FakeMqttClient + private lateinit var manager: MqttManager + + @Before + fun setUp() { + fakeClient = FakeMqttClient() + manager = MqttManager(fakeClient) + } + + private val defaultConfig = MqttConnectionConfig("localhost", 1883, "client-1", "", "", false) + + // --- Singleton --- + + @Test + fun testMqttManagerInstanceIsNotNull() { + assertNotNull(MqttManager.instance) + } + + @Test + fun testMqttManagerIsSingleton() { + assertSame(MqttManager.instance, MqttManager.instance) + } + + // --- Initial state --- + + @Test + fun testIsNotConnectedInitially() { + fakeClient.connected = false + assertFalse(manager.isConnected) + } + + // --- connect() --- + + @Test + fun testConnectReturnsTrueOnSuccess() { + assertTrue(manager.connect(defaultConfig)) + assertTrue(fakeClient.connectCalled) + } + + @Test + fun testConnectSetsCallbackOnClient() { + manager.connect(defaultConfig) + assertTrue(fakeClient.callbackSet) + } + + @Test + fun testConnectReturnsFalseWhenClientThrows() { + fakeClient.throwOnConnect = true + assertFalse(manager.connect(defaultConfig)) + } + + @Test + fun testIsNotConnectedAfterConnectFailure() { + fakeClient.throwOnConnect = true + manager.connect(defaultConfig) + assertFalse(manager.isConnected) + } + + @Test + fun testCloseIsCalledOnClientWhenConnectThrows() { + fakeClient.throwOnConnect = true + manager.connect(defaultConfig) + assertTrue(fakeClient.closeCalled) + } + + @Test + fun testConnectWhenAlreadyConnectedDoesNotReconnect() { + fakeClient.connected = true + manager.connect(defaultConfig) + assertFalse(fakeClient.connectCalled) + } + + @Test + fun testConnectWhenAlreadyConnectedReturnsTrue() { + fakeClient.connected = true + assertTrue(manager.connect(defaultConfig)) + } + + @Test + fun testConnectWithBlankHostReturnsFalse() { + assertFalse(manager.connect(MqttConnectionConfig(" ", 1883, "client-1", "", "", false))) + } + + @Test + fun testConnectWithBlankHostDoesNotCallClient() { + manager.connect(MqttConnectionConfig(" ", 1883, "client-1", "", "", false)) + assertFalse(fakeClient.connectCalled) + } + + @Test + fun testConnectSucceedsWithEmptyClientId() { + manager.connect(MqttConnectionConfig("localhost", 1883, "", "", "", false)) + assertTrue(fakeClient.connectCalled) + } + + // --- URI building --- + + @Test + fun testBuildServerUriWithoutTlsUsesTcpScheme() { + assertTrue(manager.buildServerUri("localhost", 1883, false).startsWith("tcp://")) + } + + @Test + fun testBuildServerUriWithTlsUsesSslScheme() { + assertTrue(manager.buildServerUri("localhost", 8883, true).startsWith("ssl://")) + } + + @Test + fun testBuildServerUriTcpFullUri() { + assertEquals("tcp://broker.test.com:1883", manager.buildServerUri("broker.test.com", 1883, false)) + } + + @Test + fun testBuildServerUriSslFullUri() { + assertEquals("ssl://broker.test.com:8883", manager.buildServerUri("broker.test.com", 8883, true)) + } + + // --- ConnectOptions building --- + + @Test + fun testBuildConnectOptionsUsesCleanSession() { + assertTrue(manager.buildConnectOptions("", "").isCleanSession) + } + + @Test + fun testBuildConnectOptionsSetsUsernameAndPasswordWhenProvided() { + val options = manager.buildConnectOptions("user", "pass") + assertEquals("user", options.userName) + assertEquals("pass", String(options.password ?: charArrayOf())) + } + + @Test + fun testBuildConnectOptionsDoesNotSetUsernameWhenEmpty() { + assertEquals(null, manager.buildConnectOptions("", "").userName) + } + + @Test + fun testBuildConnectOptionsUsernameOnlyWithEmptyPasswordStillSets() { + val options = manager.buildConnectOptions("user", "") + assertEquals("user", options.userName) + assertEquals("", String(options.password ?: charArrayOf())) + } + + @Test + fun testBuildConnectOptionsDoesNotSetUsernameWhenBlank() { + assertEquals(null, manager.buildConnectOptions(" ", "").userName) + } + + // --- disconnect() --- + + @Test + fun testDisconnectCallsClientDisconnect() { + fakeClient.connected = true + manager.disconnect() + assertTrue(fakeClient.disconnectCalled) + } + + @Test + fun testDisconnectCallsClientClose() { + fakeClient.connected = true + manager.disconnect() + assertTrue(fakeClient.closeCalled) + } + + @Test + fun testDisconnectWhenNotConnectedDoesNotCallClient() { + fakeClient.connected = false + manager.disconnect() + assertFalse(fakeClient.disconnectCalled) + assertFalse(fakeClient.closeCalled) + } + + @Test + fun testIsNotConnectedAfterDisconnect() { + fakeClient.connected = true + manager.disconnect() + assertFalse(manager.isConnected) + } + + @Test + fun testDisconnectTwiceDoesNotCrash() { + fakeClient.connected = true + manager.disconnect() + manager.disconnect() + // no exception = pass + } + + // --- FakeMqttClient --- + + private inner class FakeMqttClient : MqttClientInterface { + var connected = false + var connectCalled = false + var disconnectCalled = false + var closeCalled = false + var callbackSet = false + var throwOnConnect = false + var lastConnectOptions: MqttConnectOptions? = null + + override val isConnected get() = connected + + override fun connect(options: MqttConnectOptions) { + if (throwOnConnect) throw org.eclipse.paho.client.mqttv3.MqttException(0) + connectCalled = true + connected = true + lastConnectOptions = options + } + + override fun disconnect() { + disconnectCalled = true + connected = false + } + + override fun close() { + closeCalled = true + } + + override fun setCallback(callback: MqttCallback) { + callbackSet = true + } + } +} diff --git a/catroid/src/test/java/org/catrobat/catroid/test/mqtt/MqttSettingsTest.kt b/catroid/src/test/java/org/catrobat/catroid/test/mqtt/MqttSettingsTest.kt new file mode 100644 index 00000000000..9f614379580 --- /dev/null +++ b/catroid/src/test/java/org/catrobat/catroid/test/mqtt/MqttSettingsTest.kt @@ -0,0 +1,220 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.test.mqtt + +import android.os.Build +import android.preference.PreferenceManager +import org.catrobat.catroid.ui.settingsfragments.MqttSettingsFragment +import org.catrobat.catroid.ui.settingsfragments.SettingsFragment +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], instrumentedPackages = []) +class MqttSettingsTest { + + private val context get() = RuntimeEnvironment.getApplication() + + @Before + fun setUp() { + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + } + + // --- Default value tests --- + + @Test + fun testMqttEnabledDefaultIsFalse() { + assertFalse(SettingsFragment.isMqttSharedPreferenceEnabled(context)) + } + + @Test + fun testMqttHostDefaultIs192168_0_1() { + assertEquals("192.168.0.1", SettingsFragment.getMqttHost(context)) + } + + @Test + fun testMqttPortDefaultIs1883() { + assertEquals(1883, SettingsFragment.getMqttPort(context)) + } + + @Test + fun testMqttTlsEnabledDefaultIsFalse() { + assertFalse(SettingsFragment.isMqttTlsEnabled(context)) + } + + @Test + fun testMqttUsernameDefaultIsEmpty() { + assertEquals("", SettingsFragment.getMqttUsername(context)) + } + + @Test + fun testMqttPasswordDefaultIsEmpty() { + assertEquals("", SettingsFragment.getMqttPassword(context)) + } + + @Test + fun testMqttClientIdDefaultIsEmpty() { + assertEquals("", SettingsFragment.getMqttClientId(context)) + } + + // --- Persistence tests --- + + @Test + fun testMqttEnabledPersistsAfterWrite() { + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putBoolean(SettingsFragment.SETTINGS_SHOW_MQTT_BRICKS, true).commit() + assertTrue(SettingsFragment.isMqttSharedPreferenceEnabled(context)) + } + + @Test + fun testMqttHostPersistsAfterWrite() { + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(SettingsFragment.MQTT_HOST, "broker.hivemq.com").commit() + assertEquals("broker.hivemq.com", SettingsFragment.getMqttHost(context)) + } + + @Test + fun testMqttPortPersistsAfterWrite() { + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(SettingsFragment.MQTT_PORT, "8883").commit() + assertEquals(8883, SettingsFragment.getMqttPort(context)) + } + + @Test + fun testMqttTlsPersistsAfterWrite() { + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putBoolean(SettingsFragment.MQTT_TLS, true).commit() + assertTrue(SettingsFragment.isMqttTlsEnabled(context)) + } + + @Test + fun testMqttUsernamePersistsAfterWrite() { + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(SettingsFragment.MQTT_USERNAME, "testuser").commit() + assertEquals("testuser", SettingsFragment.getMqttUsername(context)) + } + + @Test + fun testMqttPasswordPersistsAfterWrite() { + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(SettingsFragment.MQTT_PASSWORD, "secret").commit() + assertEquals("secret", SettingsFragment.getMqttPassword(context)) + } + + @Test + fun testMqttClientIdPersistsAfterWrite() { + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(SettingsFragment.MQTT_CLIENT_ID, "device-001").commit() + assertEquals("device-001", SettingsFragment.getMqttClientId(context)) + } + + // --- getMqttPort fallback tests --- + + @Test + fun testMqttPortFallbackOnNonIntegerValue() { + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(SettingsFragment.MQTT_PORT, "abc").commit() + assertEquals(1883, SettingsFragment.getMqttPort(context)) + } + + @Test + fun testMqttPortFallbackOnEmptyValue() { + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(SettingsFragment.MQTT_PORT, "").commit() + assertEquals(1883, SettingsFragment.getMqttPort(context)) + } + + @Test + fun testMqttPortFallbackOnFloatValue() { + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(SettingsFragment.MQTT_PORT, "1883.5").commit() + assertEquals(1883, SettingsFragment.getMqttPort(context)) + } + + // --- Port validation: boundary tests --- + + @Test + fun testPortValidationRejectsZero() { + assertFalse(MqttSettingsFragment.isValidPort("0")) + } + + @Test + fun testPortValidationRejectsNegativeNumber() { + assertFalse(MqttSettingsFragment.isValidPort("-1")) + } + + @Test + fun testPortValidationRejects65536() { + assertFalse(MqttSettingsFragment.isValidPort("65536")) + } + + @Test + fun testPortValidationAcceptsMinBoundary() { + assertTrue(MqttSettingsFragment.isValidPort("1")) + } + + @Test + fun testPortValidationAccepts1883() { + assertTrue(MqttSettingsFragment.isValidPort("1883")) + } + + @Test + fun testPortValidationAcceptsMaxBoundary() { + assertTrue(MqttSettingsFragment.isValidPort("65535")) + } + + // --- Port validation: non-numeric input --- + + @Test + fun testPortValidationRejectsAlphabeticString() { + assertFalse(MqttSettingsFragment.isValidPort("abc")) + } + + @Test + fun testPortValidationRejectsEmptyString() { + assertFalse(MqttSettingsFragment.isValidPort("")) + } + + @Test + fun testPortValidationRejectsPartialNumber() { + assertFalse(MqttSettingsFragment.isValidPort("1883abc")) + } + + @Test + fun testPortValidationRejectsWhitespace() { + assertFalse(MqttSettingsFragment.isValidPort(" 1883 ")) + } + + @Test + fun testPortValidationRejectsFloatString() { + assertFalse(MqttSettingsFragment.isValidPort("1883.5")) + } +}