diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a65e870 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,51 @@ +# Handle line endings automatically for files detected as text +# and leave all files detected as binary untouched. +* text=auto + +# +# The above will handle all files NOT found below +# +# These files are text and should be normalized (Convert crlf => lf) +*.md text +*.adoc text +*.textile text +*.mustache text +*.csv text +*.tab text +*.tsv text +*.css text +*.df text +*.htm text +*.html text +*.java text +*.kt text +*.js text +*.json text +*.jsp text +*.jspf text +*.properties text +*.sh text +*.sql text +*.svg text +*.tld text +*.txt text +*.xml text +*.sql text + +#force these files to unix +*.sh text eol=lf +gradlew text eol=lf + +# These files are binary and should be left untouched +# (binary is a macro for -text -diff) +*.class binary +*.dll binary +*.ear binary +*.gif binary +*.ico binary +*.jar binary +*.jpg binary +*.jpeg binary +*.png binary +*.so binary +*.war binary diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a302bd0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Android Instrumented Tests + +on: + push: + tags: + - 'v*.*.*' + +jobs: + test: + runs-on: macOS-latest + strategy: + matrix: + api-level: [21, 23, 26, 29] + target: [default] + arch: [x86] + steps: + - name: checkout + uses: actions/checkout@v2 + + - name: run tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + arch: ${{ matrix.arch }} + profile: Nexus 6 + script: ./gradlew connectedCheck + env: + TEST_KEY: ${{ secrets.TEST_KEY }} \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 23c8f42..b8135cd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,6 +4,17 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' +def getProps(String propName) { + def propsFile = rootProject.file('local.properties') + if (propsFile.exists()) { + def props = new Properties() + props.load(new FileInputStream(propsFile)) + return props[propName] + } else { + return ""; + } +} + android { compileSdkVersion 28 @@ -25,11 +36,29 @@ android { applicationId "tv.remo.android.controller" minSdkVersion 16 targetSdkVersion 28 - versionCode 14 - versionName "0.16.1" + versionCode 15 + versionName "0.17.0" + multiDexEnabled true testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } + + useLibrary 'android.test.runner' + + useLibrary 'android.test.base' + buildTypes { + debug{ + def testKey = System.getenv("TEST_KEY") + if(testKey == null) + testKey = getProps('TEST_KEY') + if(testKey == null) + testKey = "" + + buildConfigField "String", "robot_test_key", "\"$testKey\"" + + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } release { if(System.getenv("KEY_ALIAS_RELEASE")){ signingConfig signingConfigs.release @@ -43,13 +72,27 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'androidx.multidex:multidex:2.0.1' implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation 'androidx.appcompat:appcompat:1.1.0' implementation 'androidx.constraintlayout:constraintlayout:1.1.3' - testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test:runner:1.3.0-alpha02' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0-alpha02' implementation project(':sdk') implementation project(':licensehelper') implementation project(':settingsutil') + + //Test sdks + testImplementation 'junit:junit:4.12' + + // Core library + androidTestImplementation 'androidx.test:core:1.3.0-alpha05' + androidTestImplementation 'androidx.test.ext:junit:1.1.1' + + // AndroidJUnitRunner and JUnit Rules + androidTestImplementation 'androidx.test:runner:1.3.0-alpha05' + androidTestImplementation 'androidx.test:rules:1.3.0-alpha05' + + androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0-alpha05' + def mockito_version = "2.23.4" + testImplementation "org.mockito:mockito-core:$mockito_version" + androidTestImplementation "org.mockito:mockito-android:$mockito_version" } diff --git a/app/src/androidTest/java/tv/remo/android/controller/DemoBotAndroidTest.kt b/app/src/androidTest/java/tv/remo/android/controller/DemoBotAndroidTest.kt new file mode 100644 index 0000000..06300a6 --- /dev/null +++ b/app/src/androidTest/java/tv/remo/android/controller/DemoBotAndroidTest.kt @@ -0,0 +1,181 @@ +package tv.remo.android.controller + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ServiceTestRule +import org.btelman.controlsdk.enums.Operation +import org.btelman.controlsdk.interfaces.ControlSdkServiceWrapper +import org.btelman.controlsdk.models.ComponentHolder +import org.btelman.controlsdk.services.ControlSDKService +import org.btelman.controlsdk.services.ControlSDKServiceConnection +import org.btelman.controlsdk.services.observeAutoCreate +import org.btelman.controlsdk.streaming.factories.VideoRetrieverFactory +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import tv.remo.android.controller.sdk.RemoSettingsUtil +import tv.remo.android.controller.sdk.components.StatusBroadcasterComponent +import tv.remo.android.controller.sdk.components.video.RemoVideoComponent +import tv.remo.android.controller.sdk.utils.ComponentBuilderUtil +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class DemoBotAndroidTest { + @get:Rule + val serviceRule = ServiceTestRule() + private val listenerControllerList = ArrayList>() + private val arrayList = ArrayList>() + + private lateinit var controlSDKServiceApi: ControlSdkServiceWrapper + private val lifecycleOwner: LifecycleOwner = let{ + val owner: LifecycleOwner = + mock(LifecycleOwner::class.java) + val lifecycle = LifecycleRegistry(owner) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + `when`(owner.lifecycle).thenReturn(lifecycle) + return@let owner + } + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun testAndroidStream() { + serviceRule.startService( + Intent(appContext, ControlSDKService::class.java) + ) + val handler = Handler(Looper.getMainLooper()) + RemoSettingsUtil.with(appContext){ + it.apiKey.savePref(BuildConfig.robot_test_key) + it.channelId.savePref("API${Build.VERSION.SDK_INT}") + it.cameraEnabled.savePref(true) + it.microphoneEnabled.savePref(false) + it.cameraResolution.savePref("640x480") + it.ffmpegInputOptions.savePref("-f image2pipe -codec:v mjpeg -i -") + } + + controlSDKServiceApi = ControlSDKServiceConnection.getNewInstance(appContext) + val serviceStatusLatch = CountDownLatch(1) + handler.postDelayed({ + controlSDKServiceApi.getServiceStateObserver().observeAutoCreate(lifecycleOwner){ serviceStatus -> + if(serviceStatus == Operation.OK) + serviceStatusLatch.countDown() + } + controlSDKServiceApi.getServiceBoundObserver().observeAutoCreate(lifecycleOwner){ connected -> + handleListenerAddOrRemove(connected) + if(connected == Operation.OK){ + changeStreamState(Operation.OK) + } + } + controlSDKServiceApi.connectToService() + createComponentHolders() + }, 1000) + serviceStatusLatch.await(1, TimeUnit.MINUTES) //probably too long, but after this it is long dead + Thread.sleep(5*60000) //5 minutes + val serviceStatusCloseLatch = CountDownLatch(1) + handler.post { + controlSDKServiceApi.getServiceStateObserver() + .observeAutoCreate(lifecycleOwner) { serviceStatus -> + if (serviceStatus == Operation.OK) + serviceStatusCloseLatch.countDown() + } + changeStreamState(Operation.NOT_OK) + } + serviceStatusCloseLatch.await(1, TimeUnit.MINUTES) + } + + private fun createComponentHolders() { + RemoSettingsUtil.with(appContext){ settings -> + arrayList.add(ComponentBuilderUtil.createSocketComponent(settings)) + arrayList.addAll(ComponentBuilderUtil.createTTSComponents(settings)) + arrayList.addAll(ComponentBuilderUtil.createStreamingComponents(settings)) + arrayList.addAll(ComponentBuilderUtil.createHardwareComponents(settings)) + listenerControllerList.add(ComponentHolder(StatusBroadcasterComponent::class.java, null)) + injectDemoVideoRetriever(arrayList) + } + } + + /** + * We are injecting our own video component. Need to replace the class with our test class, + * but keep properties + * + * 1. Delete old class holder from the list, cache the holder + * 2. Add our demo holder + * 3. Add old properties to holder + * 4. Add our demo retriever to holder properties + */ + private fun injectDemoVideoRetriever(arrayList: ArrayList>) { + var holder : ComponentHolder<*>? = null + arrayList.reversed().forEach { + if(it.clazz == RemoVideoComponent::class.java){ + //we want to inject a custom video retriever/provider. Remove the old object + holder = it.data?.let { bundle -> + VideoRetrieverFactory.putClassInBundle( + DemoSurfaceVideoComponent::class.java, + bundle + ) + arrayList.remove(it) + it + } + + } + } + + //modify the bundle and put it back in the arrayList. A little ugly but gets the job done + arrayList.add( + ComponentHolder( + DemoVideoComponent::class.java, + (holder?.data ?: Bundle()).also { //it : Bundle + VideoRetrieverFactory.putClassInBundle( + DemoSurfaceVideoComponent::class.java, + it + ) + } + ) + ) + } + + private fun handleListenerAddOrRemove(connected : Operation) { + if(connected == Operation.OK){ + listenerControllerList.forEach { + controlSDKServiceApi?.addListenerOrController(it) + } + } + } + + private fun changeStreamState(desiredState : Operation) { + if(controlSDKServiceApi?.getServiceStateObserver()?.value == desiredState) return //already active + when(desiredState){ + Operation.OK -> { + arrayList.forEach { + controlSDKServiceApi?.attachToLifecycle(it) + } + controlSDKServiceApi?.enable() + } + Operation.LOADING -> {} //do nothing + Operation.NOT_OK -> { + //disable the service + controlSDKServiceApi?.disable() + // remove all components that happen to be left over. We may not know what got added + // if the activity was lost due to the Android system + // Listeners and controllers will still stay, and will not be overridden by the same name + controlSDKServiceApi?.reset() + } + } + } +} diff --git a/app/src/androidTest/java/tv/remo/android/controller/DemoSurfaceVideoComponent.kt b/app/src/androidTest/java/tv/remo/android/controller/DemoSurfaceVideoComponent.kt new file mode 100644 index 0000000..cf5f13a --- /dev/null +++ b/app/src/androidTest/java/tv/remo/android/controller/DemoSurfaceVideoComponent.kt @@ -0,0 +1,37 @@ +package tv.remo.android.controller + +import android.graphics.* +import android.view.Surface +import org.btelman.controlsdk.streaming.models.ImageDataPacket +import org.btelman.controlsdk.streaming.video.retrievers.BaseVideoRetriever +import org.btelman.controlsdk.streaming.video.retrievers.SurfaceTextureVideoRetriever +import org.btelman.controlsdk.streaming.video.retrievers.api16.Camera1SurfaceTextureComponent +import kotlin.math.cos +import kotlin.math.sin + +/** + * Created by Brendon on 3/25/2020. + * + * Run the video demo since we will not be able to use a camera + */ +class DemoSurfaceVideoComponent : BaseVideoRetriever() { + + private var bitmap: Bitmap = Bitmap.createBitmap(640, 480, Bitmap.Config.RGB_565) + private var canvas = Canvas(bitmap) + private var x = 0f + private var t = 0f + + override fun grabImageData(): ImageDataPacket? { + canvas.drawColor(Color.RED) + canvas.drawCircle(x, 240*sin(t/160f)+240, 10f, Paint(Color.BLUE)) + canvas.drawCircle(x, 20f, 10f, Paint(Color.GREEN)) + x += .1f + t += .1f + if(x > 640) + x = 0f + return ImageDataPacket( + bitmap, + ImageFormat.JPEG + ) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/tv/remo/android/controller/DemoVideoComponent.kt b/app/src/androidTest/java/tv/remo/android/controller/DemoVideoComponent.kt new file mode 100644 index 0000000..0dbbd2f --- /dev/null +++ b/app/src/androidTest/java/tv/remo/android/controller/DemoVideoComponent.kt @@ -0,0 +1,11 @@ +package tv.remo.android.controller +import tv.remo.android.controller.sdk.components.video.RemoVideoComponent + +/** + * Created by Brendon on 3/25/2020. + */ +class DemoVideoComponent : RemoVideoComponent() { + override fun setLoopMode() { + shouldAutoUpdateLoop = true + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/tv/remo/android/controller/ExampleInstrumentedTest.kt b/app/src/androidTest/java/tv/remo/android/controller/ExampleInstrumentedTest.kt index 7f6fd2d..8bab480 100644 --- a/app/src/androidTest/java/tv/remo/android/controller/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/tv/remo/android/controller/ExampleInstrumentedTest.kt @@ -1,7 +1,7 @@ package tv.remo.android.controller -import androidx.test.InstrumentationRegistry -import androidx.test.runner.AndroidJUnit4 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import org.junit.Test import org.junit.runner.RunWith @@ -18,7 +18,7 @@ class ExampleInstrumentedTest { @Test fun useAppContext() { // Context of the app under test. - val appContext = InstrumentationRegistry.getTargetContext() + val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("tv.remo.android.controller", appContext.packageName) } } diff --git a/app/src/androidTest/java/tv/remo/android/controller/suite/AndroidTestSuite.java b/app/src/androidTest/java/tv/remo/android/controller/suite/AndroidTestSuite.java new file mode 100644 index 0000000..1450c6b --- /dev/null +++ b/app/src/androidTest/java/tv/remo/android/controller/suite/AndroidTestSuite.java @@ -0,0 +1,27 @@ +/* + * Copyright 2015, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package tv.remo.android.controller.suite; + +import org.junit.runner.RunWith; +import org.junit.runners.Suite; + +/** + * Test suite that runs all tests, unit + instrumentation tests. + */ +@RunWith(Suite.class) +@Suite.SuiteClasses({InstrumentationTestSuite.class}) +public class AndroidTestSuite {} \ No newline at end of file diff --git a/app/src/androidTest/java/tv/remo/android/controller/suite/InstrumentationTestSuite.kt b/app/src/androidTest/java/tv/remo/android/controller/suite/InstrumentationTestSuite.kt new file mode 100644 index 0000000..0bf80a1 --- /dev/null +++ b/app/src/androidTest/java/tv/remo/android/controller/suite/InstrumentationTestSuite.kt @@ -0,0 +1,13 @@ +package tv.remo.android.controller.suite + +import org.junit.runner.RunWith +import org.junit.runners.Suite +import tv.remo.android.controller.DemoBotAndroidTest +import tv.remo.android.controller.ExampleInstrumentedTest + +// Runs all unit tests. +@RunWith(Suite::class) +@Suite.SuiteClasses( + DemoBotAndroidTest::class, + ExampleInstrumentedTest::class) +class InstrumentationTestSuite \ No newline at end of file diff --git a/app/src/main/java/tv/remo/android/controller/RemoApplication.kt b/app/src/main/java/tv/remo/android/controller/RemoApplication.kt index fc6480a..e7fcbc8 100644 --- a/app/src/main/java/tv/remo/android/controller/RemoApplication.kt +++ b/app/src/main/java/tv/remo/android/controller/RemoApplication.kt @@ -1,7 +1,7 @@ package tv.remo.android.controller -import android.app.Application import android.util.Log +import androidx.multidex.MultiDexApplication import org.btelman.controlsdk.services.ControlSDKService import org.btelman.logutil.kotlin.LogLevel import org.btelman.logutil.kotlin.LogUtil @@ -10,7 +10,7 @@ import org.btelman.logutil.kotlin.LogUtilInstance /** * Created by Brendon on 7/28/2019. */ -class RemoApplication : Application() { +class RemoApplication : MultiDexApplication() { private val log = LogUtil("RemoApplication", logID) override fun onCreate() { diff --git a/app/src/main/java/tv/remo/android/controller/WebServerSettingsPage.kt b/app/src/main/java/tv/remo/android/controller/WebServerSettingsPage.kt new file mode 100644 index 0000000..61fcd28 --- /dev/null +++ b/app/src/main/java/tv/remo/android/controller/WebServerSettingsPage.kt @@ -0,0 +1,63 @@ +package tv.remo.android.controller + +import android.content.Context.WIFI_SERVICE +import android.net.wifi.WifiManager +import android.os.Bundle +import android.text.format.Formatter +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.fragment_web_server_settings_page.* +import tv.remo.android.controller.sdk.components.RemoWebServer + + +/** + * A simple [Fragment] subclass. + */ +class WebServerSettingsPage : Fragment() { + + lateinit var server : RemoWebServer + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Inflate the layout for this fragment + return inflater.inflate(R.layout.fragment_web_server_settings_page, container, false) + } + + private val onSettingsUpdated : ()->Unit = { + view?.post { + responseTextView.visibility = View.VISIBLE + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + server = RemoWebServer(context!!, onSettingsUpdated) + val ip: String? = try { + val wm = + context!!.getSystemService(WIFI_SERVICE) as WifiManager? + Formatter.formatIpAddress(wm!!.connectionInfo.ipAddress) + } catch (e: Exception) { + null + } + ipAddrValue.text = if(ip != null && ip != "0.0.0.0"){ + "$ip:8080/config" + } + else{ + "This network does not support IP4 or is not a Wi-Fi network. Web page disabled" + } + if(ip != null) + server.open() + } + + override fun onDestroy() { + super.onDestroy() + try { + server.close() + } catch (e: Exception) { + } + } +} diff --git a/app/src/main/java/tv/remo/android/controller/activities/MainActivity.kt b/app/src/main/java/tv/remo/android/controller/activities/MainActivity.kt index 0fee352..4763252 100644 --- a/app/src/main/java/tv/remo/android/controller/activities/MainActivity.kt +++ b/app/src/main/java/tv/remo/android/controller/activities/MainActivity.kt @@ -57,7 +57,6 @@ class MainActivity : AppCompatActivity(), View.OnClickListener { override fun onDestroy() { super.onDestroy() - handleListenerAddOrRemove(Operation.NOT_OK) controlSDKServiceApi?.disconnectFromService() } @@ -101,6 +100,7 @@ class MainActivity : AppCompatActivity(), View.OnClickListener { audioConnectionStatusView.registerStatusEvents(RemoAudioProcessor::class.java) videoConnectionStatusView.registerStatusEvents(RemoVideoProcessor::class.java) ttsConnectionStatusView.registerStatusEvents(SystemDefaultTTSComponent::class.java) + StatusBroadcasterComponent.sendUpdateBroadcast(applicationContext) } val operationObserver : (Operation) -> Unit = { serviceStatus -> @@ -163,10 +163,12 @@ class MainActivity : AppCompatActivity(), View.OnClickListener { } Operation.LOADING -> {} //do nothing Operation.OK -> { + //disable the service controlSDKServiceApi?.disable() - arrayList.forEach { - controlSDKServiceApi?.detachFromLifecycle(it) - } + // remove all components that happen to be left over. We may not know what got added + // if the activity was lost due to the Android system + // Listeners and controllers will still stay, and will not be overridden by the same name + controlSDKServiceApi?.reset() } null -> powerButton.setTextColor(parseColorForOperation(null)) } diff --git a/app/src/main/java/tv/remo/android/controller/fragments/SettingsConnection.kt b/app/src/main/java/tv/remo/android/controller/fragments/SettingsConnection.kt index 6edecd0..849a52e 100644 --- a/app/src/main/java/tv/remo/android/controller/fragments/SettingsConnection.kt +++ b/app/src/main/java/tv/remo/android/controller/fragments/SettingsConnection.kt @@ -1,4 +1,9 @@ package tv.remo.android.controller.fragments +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.navigation.Navigation import tv.remo.android.controller.R import tv.remo.android.settingsutil.fragments.BasePreferenceFragmentCompat @@ -9,5 +14,37 @@ import tv.remo.android.settingsutil.fragments.BasePreferenceFragmentCompat */ class SettingsConnection : BasePreferenceFragmentCompat( - R.xml.settings_connection -) \ No newline at end of file + R.xml.settings_connection +){ + var refreshNeeded = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onResume() { + super.onResume() + if(refreshNeeded){ + preferenceScreen = null + addPreferencesFromResource(R.xml.settings_connection) + refreshNeeded = false + } + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.connection_menu, menu) + super.onCreateOptionsMenu(menu, inflater) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when(item.itemId){ + R.id.webServerSelectMenuItem -> { + refreshNeeded = true + Navigation.findNavController(view!!) + .navigate(R.id.action_settingsConnection_to_webServerSettingsPage) + } + } + return super.onOptionsItemSelected(item) + } +} \ No newline at end of file diff --git a/app/src/main/java/tv/remo/android/controller/ui/RemoStatusView.kt b/app/src/main/java/tv/remo/android/controller/ui/RemoStatusView.kt index 17e13fe..af3ae9a 100644 --- a/app/src/main/java/tv/remo/android/controller/ui/RemoStatusView.kt +++ b/app/src/main/java/tv/remo/android/controller/ui/RemoStatusView.kt @@ -80,7 +80,7 @@ class RemoStatusView @JvmOverloads constructor( fun registerStatusEvents(statusClassName : Class){ broadcastManager.unregisterReceiver(receiver) val filter = IntentFilter(StatusBroadcasterComponent.ACTION_SERVICE_STATUS) - StatusBroadcasterComponent.generateComponentStatusAction(statusClassName).also { + StatusBroadcasterComponent.generateComponentStatusAction(statusClassName.name).also { filter.addAction(it) log.d { "switching log to $it" } log = RemoApplication.getLogger(this, it) diff --git a/app/src/main/res/layout/fragment_web_server_settings_page.xml b/app/src/main/res/layout/fragment_web_server_settings_page.xml new file mode 100644 index 0000000..27dc92b --- /dev/null +++ b/app/src/main/res/layout/fragment_web_server_settings_page.xml @@ -0,0 +1,48 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/connection_menu.xml b/app/src/main/res/menu/connection_menu.xml new file mode 100644 index 0000000..716c1aa --- /dev/null +++ b/app/src/main/res/menu/connection_menu.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_settings.xml b/app/src/main/res/navigation/nav_settings.xml index 3ba6dfb..b637463 100644 --- a/app/src/main/res/navigation/nav_settings.xml +++ b/app/src/main/res/navigation/nav_settings.xml @@ -38,7 +38,11 @@ + android:label="@string/connectionSettingsTitle" > + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index db3fa6d..6c811f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -108,5 +108,8 @@ FFmpeg additional filters (no spaces!) ffmpeg input options ffmpeg output options + Use the web page that is now running to input the connection settings from a PC. + \nThis is the only page that runs the web server + \nThe device has to be connected to the same Wi-Fi network for this to work diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/sdk/build.gradle b/sdk/build.gradle index 0df4f42..6a13a75 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -46,6 +46,7 @@ dependencies { api 'com.google.code.gson:gson:2.8.5' api project(path: ':licensehelper') implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' + implementation 'org.nanohttpd:nanohttpd:2.2.0' } repositories { mavenCentral() diff --git a/sdk/src/main/java/tv/remo/android/controller/sdk/components/RemoWebServer.kt b/sdk/src/main/java/tv/remo/android/controller/sdk/components/RemoWebServer.kt new file mode 100644 index 0000000..2a600f9 --- /dev/null +++ b/sdk/src/main/java/tv/remo/android/controller/sdk/components/RemoWebServer.kt @@ -0,0 +1,72 @@ +package tv.remo.android.controller.sdk.components + +import android.content.Context +import fi.iki.elonen.NanoHTTPD +import tv.remo.android.controller.sdk.RemoSettingsUtil +import tv.remo.android.controller.sdk.models.StringPref + +class RemoWebServer (val context: Context, val onSettingsUpdated : (()->Unit)? = null){ + private val server = object : NanoHTTPD(8080) { + override fun serve(session: IHTTPSession): Response? { + when(session.uri){ + "/config" -> { + var msg = "

Robot settings

\n" + val parms = session.parms + msg += if (parms[RemoSettingsUtil.with(context).apiKey.key] == null) { + buildSettings() + } else { + saveSettings(parms) + onSettingsUpdated?.invoke() + "

Saved!

" + } + return newFixedLengthResponse("$msg\n") + } + else -> { + val msg = "

Hello

\n" + + return newFixedLengthResponse("$msg\n") + } + } + } + } + + fun open(){ + server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false) + System.out.println("\nRunning! Point your browsers to http://localhost:8080/ \n") + } + + fun close(){ + server.stop() + } + + private fun saveSettings(parms: Map) { + RemoSettingsUtil.with(context){ settings -> + trySaveStringSetting(settings.apiKey, parms) + trySaveStringSetting(settings.channelId, parms) + trySaveStringSetting(settings.serverOwner, parms) + } + } + + private fun trySaveStringSetting(pref : StringPref, parms: Map){ + parms[pref.key]?.takeIf { it.isNotEmpty() }?.let { + pref.savePref(it) + } + } + + private fun buildSettings() : String{ + return RemoSettingsUtil.with(context){ + var msg = "
" + msg += getSettingHtml(it.apiKey, "API key", hide = true) + msg += getSettingHtml(it.channelId,"Channel name") + msg += getSettingHtml(it.serverOwner,"Owner name") + msg += "" + msg += "
\n" + return@with msg + } + } + + private fun getSettingHtml(pref : StringPref, label : String = pref.key, hide : Boolean = false) : String{ + val value = if(hide) "" else pref.getPref() + return "\n

$label:

" + } +} \ No newline at end of file diff --git a/sdk/src/main/java/tv/remo/android/controller/sdk/components/StatusBroadcasterComponent.kt b/sdk/src/main/java/tv/remo/android/controller/sdk/components/StatusBroadcasterComponent.kt index f69b675..4d5c68b 100644 --- a/sdk/src/main/java/tv/remo/android/controller/sdk/components/StatusBroadcasterComponent.kt +++ b/sdk/src/main/java/tv/remo/android/controller/sdk/components/StatusBroadcasterComponent.kt @@ -1,7 +1,9 @@ package tv.remo.android.controller.sdk.components +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.os.Bundle import androidx.localbroadcastmanager.content.LocalBroadcastManager import org.btelman.controlsdk.enums.ComponentStatus @@ -14,13 +16,30 @@ import org.btelman.logutil.kotlin.LogUtil * Broadcast the statuses of each component, and some service level events */ class StatusBroadcasterComponent : IListener { - private val log = LogUtil("StatusBroadcasterComponent", ControlSDKService.loggerID) + private val cachedStatus = HashMap() var localBroadcastManager : LocalBroadcastManager? = null + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if(intent?.action == ACTION_UPDATE){ //something wants us to send all events + log.d{ + "status update request received by BroadcastReceiver" + } + //loop through cached statuses and send them + cachedStatus.forEach{ + handleStatusBroadcast(it.key, it.value) + } + } + } + } + override fun onInitializeComponent(applicationContext: Context, bundle: Bundle?) { localBroadcastManager = LocalBroadcastManager.getInstance(applicationContext) + localBroadcastManager?.registerReceiver(receiver, IntentFilter(ACTION_UPDATE)) } override fun onRemoved() { + cachedStatus.clear() + localBroadcastManager?.unregisterReceiver(receiver) localBroadcastManager = null } @@ -37,16 +56,21 @@ class StatusBroadcasterComponent : IListener { } override fun onComponentStatus(clazz: Class<*>, componentStatus: ComponentStatus) { + super.onComponentStatus(clazz, componentStatus) + cachedStatus[clazz.name] = componentStatus //cache it for use if we get a broadcast to update immediately + handleStatusBroadcast(clazz.name, componentStatus) + } + + private fun handleStatusBroadcast(name: String, componentStatus: ComponentStatus) { log.d{ - "STATUS_EVENT $componentStatus from ${clazz.name}" + "STATUS_EVENT $componentStatus from $name sending" } - super.onComponentStatus(clazz, componentStatus) val intent = Intent(ACTION_COMPONENT_STATUS).apply { - putExtra(CLASS_NAME, clazz.name) + putExtra(CLASS_NAME, name) putExtra(STATUS_NAME, componentStatus) } //create an intent that will only contain a single class of statuses - val intentClassLevel = Intent(generateComponentStatusAction(clazz)).apply { + val intentClassLevel = Intent(generateComponentStatusAction(name)).apply { putExtra(STATUS_NAME, componentStatus) } //now send both of them @@ -55,11 +79,24 @@ class StatusBroadcasterComponent : IListener { } companion object{ - fun generateComponentStatusAction(clazz : Class) : String{ - return "${clazz.name}.${ACTION_COMPONENT_STATUS}" + private val log = LogUtil("StatusBroadcasterComponent", ControlSDKService.loggerID) + fun generateComponentStatusAction(clazzName : String) : String{ + return "${clazzName}.${ACTION_COMPONENT_STATUS}" + } + + /** + * Send an update broadcast to trigger this class to send status events again. + * Useful if the activity is destroyed, but not the service + */ + fun sendUpdateBroadcast(context: Context){ + log.d{ + "Request status update" + } + LocalBroadcastManager.getInstance(context).sendBroadcast(Intent(ACTION_UPDATE)) } const val ACTION_SERVICE_STATUS = "control.sdk.ACTION_SERVICE_STATUS" const val ACTION_COMPONENT_STATUS = "ACTION_COMPONENT_STATUS" + const val ACTION_UPDATE = "tv.remo.android.controller.sdk.components.StatusBroadcasterComponent.Refresh" const val CLASS_NAME = "component.class" const val STATUS_NAME = "component.status.name" } diff --git a/sdk/src/main/java/tv/remo/android/controller/sdk/components/video/RemoVideoComponent.kt b/sdk/src/main/java/tv/remo/android/controller/sdk/components/video/RemoVideoComponent.kt index 86f9a69..3435061 100644 --- a/sdk/src/main/java/tv/remo/android/controller/sdk/components/video/RemoVideoComponent.kt +++ b/sdk/src/main/java/tv/remo/android/controller/sdk/components/video/RemoVideoComponent.kt @@ -19,7 +19,7 @@ import tv.remo.android.controller.sdk.utils.ChatUtil /** * Created by Brendon on 10/27/2019. */ -class RemoVideoComponent : VideoComponent(), CommandStreamHandler { +open class RemoVideoComponent : VideoComponent(), CommandStreamHandler { private var commandHandler : StreamCommandHandler? = null override fun enableInternal() {