diff --git a/app/build.gradle b/app/build.gradle index 45569b5b..b39add0a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -60,6 +60,9 @@ android { textOutput "stdout" explainIssues !project.hasProperty("isCI") } + packagingOptions { + exclude "META-INF/beans.xml" + } } tasks.lint.dependsOn(ktlintCheck) @@ -131,6 +134,13 @@ dependencies { // Material implementation 'com.google.android.material:material:1.4.0' + // Cling (UPnP/DLNA) + implementation "org.fourthline.cling:cling-core:2.1.2" + implementation "org.fourthline.cling:cling-support:2.1.2" + implementation "org.eclipse.jetty:jetty-servlet:8.2.0.v20160908" + implementation "org.eclipse.jetty:jetty-client:8.2.0.v20160908" + implementation "org.slf4j:slf4j-android:1.7.32" + // Tests testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test:runner:1.4.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 100a756a..10327abb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ + + + + + diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt b/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt new file mode 100644 index 00000000..0d0f70a3 --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt @@ -0,0 +1,63 @@ +package me.vanpetegem.accentor.devices + +import androidx.compose.foundation.lazy.rememberLazyListState +import org.fourthline.cling.model.meta.RemoteDevice +import org.fourthline.cling.model.meta.RemoteService +import org.fourthline.cling.model.meta.Service +import org.fourthline.cling.model.types.ServiceType +import org.fourthline.cling.model.types.UDN +import java.lang.Exception + +val PLAYER_SERVICE = ServiceType("schemas-upnp-org", "AVTransport", 1); + +class Device( + protected val clingDevice: RemoteDevice +) { + + val friendlyName: String = clingDevice.details.friendlyName + val displayString: String = clingDevice.displayString + val type: String = clingDevice.type.displayString + val udn: UDN = clingDevice.identity.udn + + val imageURL: String? = clingDevice + .icons + .maxWithOrNull(compareBy({ it.height * it.width }, { it.mimeType.subtype == "png" })) + ?.let { clingDevice.normalizeURI(it.uri).toString() } + + fun isPlayer(): Boolean { + return clingDevice.findServiceTypes().contains(PLAYER_SERVICE) + } + + fun isHydrated(): Boolean { + return playerService()?.hasActions() == true + } + + fun playerService(): RemoteService? { + return clingDevice.findService(PLAYER_SERVICE) + } + + override fun toString(): String { + return "Device($friendlyName, ${clingDevice.findServiceTypes().map { it.type }})" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Device + + if (udn != other.udn) return false + + return true + } + + override fun hashCode(): Int { + return udn.hashCode() + } + + +} + + + + diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt new file mode 100644 index 00000000..d5b7ea55 --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt @@ -0,0 +1,145 @@ +package me.vanpetegem.accentor.devices + + +import android.content.ComponentName +import android.content.ServiceConnection +import android.os.IBinder +import android.util.Log +import androidx.lifecycle.MutableLiveData +import org.fourthline.cling.android.AndroidUpnpService +import org.fourthline.cling.model.action.ActionInvocation +import org.fourthline.cling.model.message.UpnpResponse +import org.fourthline.cling.model.message.header.ServiceTypeHeader +import org.fourthline.cling.model.meta.RemoteDevice +import org.fourthline.cling.model.meta.Service +import org.fourthline.cling.model.types.UDN +import org.fourthline.cling.registry.DefaultRegistryListener +import org.fourthline.cling.registry.Registry +import org.fourthline.cling.support.avtransport.callback.GetDeviceCapabilities +import org.fourthline.cling.support.avtransport.callback.GetMediaInfo +import org.fourthline.cling.support.avtransport.callback.Play +import org.fourthline.cling.support.avtransport.callback.SetAVTransportURI +import org.fourthline.cling.support.avtransport.lastchange.AVTransportVariable +import org.fourthline.cling.support.model.DIDLContent +import org.fourthline.cling.support.model.DIDLObject +import org.fourthline.cling.support.model.DeviceCapabilities +import org.fourthline.cling.support.model.MediaInfo +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DeviceManager @Inject constructor() { + + val playerDevices = MutableLiveData>(emptyMap()) + val selectedDevice = MutableLiveData(null) + + val connection = DeviceServiceConnection() + + private lateinit var upnp: AndroidUpnpService + private val isConnected = MutableLiveData(false) + private val registryListener = DeviceRegistryListener() + private var discovered: Map = emptyMap() + + fun search() { + upnp.controlPoint.search(ServiceTypeHeader(PLAYER_SERVICE)) + } + + fun select(device: Device) { + selectedDevice.postValue(device) + //val url = "http://10.0.0.15:8200/MediaItems/22.mp3" + val url = "https://rien.maertens.io/noot.mp3" + + + val action = SetURI(device, url) + val future = upnp.controlPoint.execute(action) + } + + inner class SetURI(val device: Device, uri: String): SetAVTransportURI(device.playerService(), uri) { + override fun success(invocation: ActionInvocation>?) { + super.success(invocation) + Log.e(TAG, "SetURI invocation succeeded: $invocation") + upnp.controlPoint.execute(Play(device)) + } + override fun failure(invocation: ActionInvocation>?, operation: UpnpResponse?, defaultMsg: String?) { + Log.e(TAG, "SetURI invocation failed: $defaultMsg") + } + } + + inner class Play(val device: Device): org.fourthline.cling.support.avtransport.callback.Play(device.playerService()) { + override fun success(invocation: ActionInvocation>?) { + super.success(invocation) + Log.e(TAG, "Play invocation succeeded: $invocation") + upnp.controlPoint.execute(GetInfo(device)) + } + override fun failure(invocation: ActionInvocation>?, operation: UpnpResponse?, defaultMsg: String?) { + Log.e(TAG, "Play invocation failed: $defaultMsg") + } + + } + + inner class GetInfo(val device: Device): GetMediaInfo(device.playerService()) { + override fun received(invocation: ActionInvocation>?, mediaInfo: MediaInfo?) { + Log.e(TAG, "GetInfo invocation succeeded: $invocation $mediaInfo") + } + + override fun failure(invocation: ActionInvocation>?, operation: UpnpResponse?, defaultMsg: String?) { + Log.e(TAG, "GetInfo invocation failed: $defaultMsg") + } + } + + inner class GetCapabilities(val device: Device): GetDeviceCapabilities(device.playerService()) { + override fun failure(invocation: ActionInvocation>?, operation: UpnpResponse?, defaultMsg: String?) { + Log.e(TAG, "GetCapabilities invocation failed: $defaultMsg") + } + + override fun received(actionInvocation: ActionInvocation>?, caps: DeviceCapabilities?) { + Log.e(TAG, "GetCapabilities invocation succeeded: $actionInvocation $caps") + } + + } + + inner class DeviceServiceConnection() : ServiceConnection { + override fun onServiceConnected(className: ComponentName?, binder: IBinder?) { + upnp = binder!! as AndroidUpnpService + isConnected.value = true + + // clear devices (if any) and collect the known remote devices into a map + discovered = upnp.registry.devices + .filterIsInstance() + .map { it.identity.udn to Device(it) } + .toMap() + + playerDevices.postValue(discovered.filter { it.value.isPlayer() }) + + upnp.registry.addListener(registryListener) + search() + } + + override fun onServiceDisconnected(className: ComponentName?) { + isConnected.value = false + } + } + + private inner class DeviceRegistryListener(): DefaultRegistryListener() { + + override fun remoteDeviceAdded(registry: Registry?, remote: RemoteDevice?) { + val udn = remote!!.identity.udn + val dev = Device(remote) + discovered = discovered + (udn to dev) + Log.i(TAG, "Device added: $dev") + + if (dev.isPlayer()) { + playerDevices.postValue(playerDevices.value!! + (udn to dev)) + Log.i(TAG,"Device added to players: $dev") + } + } + + override fun remoteDeviceRemoved(registry: Registry?, remote: RemoteDevice?) { + val udn = remote!!.identity.udn + Log.i(TAG, "Removing device ${remote.displayString} ($udn)") + playerDevices.postValue(playerDevices.value!! - udn) + } + } +} + +const val TAG: String = "DeviceManagexr" diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt new file mode 100644 index 00000000..f883409c --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt @@ -0,0 +1,20 @@ +package me.vanpetegem.accentor.devices + +import org.fourthline.cling.UpnpServiceConfiguration +import org.fourthline.cling.android.AndroidUpnpServiceConfiguration +import org.fourthline.cling.android.AndroidUpnpServiceImpl +import org.fourthline.cling.binding.xml.ServiceDescriptorBinder +import org.fourthline.cling.binding.xml.UDA10ServiceDescriptorBinderImpl + +class DeviceService: AndroidUpnpServiceImpl() { + override fun createConfiguration(): UpnpServiceConfiguration { + return object: AndroidUpnpServiceConfiguration() { + // This override fixes the XML parser + // See https://github.com/4thline/cling/issues/247 + override fun getServiceDescriptorBinderUDA10(): ServiceDescriptorBinder { + return UDA10ServiceDescriptorBinderImpl() + } + } + } + +} diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt new file mode 100644 index 00000000..a1431cdc --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt @@ -0,0 +1,86 @@ +package me.vanpetegem.accentor.ui.devices + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import me.vanpetegem.accentor.R +import me.vanpetegem.accentor.devices.Device + +@Composable +fun Devices(devicesViewModel: DevicesViewModel = hiltViewModel()) { + val devices: List? by devicesViewModel.devices().observeAsState() + DeviceList(devices ?: emptyList(), selectFn = { devicesViewModel.selectDevice(it) }) +} + +@Composable +fun DeviceList(devices: List, selectFn: (d: Device) -> Unit = {}) { + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { + DeviceCard( + name = stringResource(R.string.local_device), + icon = R.drawable.ic_smartphone_sound, + iconDescription = R.string.local_device_description + ) + Spacer(Modifier.size(8.dp)) + Text( + stringResource(R.string.devices_available), + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.h5 + ) + devices.forEach { device -> + DeviceCard( + name = device.friendlyName, + icon = R.drawable.ic_menu_devices, + onClick = { selectFn(device) } + ) + } + } +} + +@Composable +fun DeviceCard( + name: String, + @StringRes + iconDescription: Int = R.string.device_image, + @DrawableRes + icon: Int = R.drawable.ic_menu_devices, + onClick: () -> Unit = {}) { + Card( + modifier = Modifier.padding(8.dp).fillMaxWidth().clickable(onClick = onClick) + ) { + Row( + modifier = Modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Image( + painter = painterResource(icon), + contentDescription = stringResource(iconDescription), + modifier = Modifier.requiredSize(48.dp) + ) + Column() { + Text( + name, + maxLines = 1, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.subtitle1 + ) + } + } + } +} diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt new file mode 100644 index 00000000..78c39ef8 --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt @@ -0,0 +1,25 @@ +package me.vanpetegem.accentor.ui.devices + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations.map +import dagger.hilt.android.lifecycle.HiltViewModel +import me.vanpetegem.accentor.devices.Device +import me.vanpetegem.accentor.devices.DeviceManager +import javax.inject.Inject + +@HiltViewModel +class DevicesViewModel @Inject constructor( + application: Application, + private val deviceManager: DeviceManager, +) : AndroidViewModel(application) { + + fun devices(): LiveData> = map(deviceManager.playerDevices) { devices -> + devices.values.sortedWith(compareBy { it.friendlyName }) + } + + fun selectDevice(device: Device) { + deviceManager.select(device = device) + } +} diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt index 55ce631b..6f77d6ff 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt @@ -1,8 +1,12 @@ package me.vanpetegem.accentor.ui.main import android.app.Activity +import android.content.ComponentName +import android.content.Context import android.content.Intent +import android.content.ServiceConnection import android.os.Bundle +import android.os.IBinder import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent @@ -49,6 +53,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -73,6 +78,9 @@ import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import me.vanpetegem.accentor.R +import me.vanpetegem.accentor.devices.Device +import me.vanpetegem.accentor.devices.DeviceManager +import me.vanpetegem.accentor.devices.DeviceService import me.vanpetegem.accentor.ui.AccentorTheme import me.vanpetegem.accentor.ui.albums.AlbumGrid import me.vanpetegem.accentor.ui.albums.AlbumToolbar @@ -81,14 +89,27 @@ import me.vanpetegem.accentor.ui.albums.AlbumViewDropdown import me.vanpetegem.accentor.ui.artists.ArtistGrid import me.vanpetegem.accentor.ui.artists.ArtistToolbar import me.vanpetegem.accentor.ui.artists.ArtistView +import me.vanpetegem.accentor.ui.devices.Devices import me.vanpetegem.accentor.ui.home.Home import me.vanpetegem.accentor.ui.login.LoginActivity import me.vanpetegem.accentor.ui.player.PlayerOverlay import me.vanpetegem.accentor.ui.player.PlayerViewModel import me.vanpetegem.accentor.ui.preferences.PreferencesActivity +import org.fourthline.cling.android.AndroidUpnpService +import org.fourthline.cling.android.FixedAndroidLogHandler +import org.fourthline.cling.model.message.header.ServiceTypeHeader +import org.fourthline.cling.model.meta.RemoteDevice +import org.fourthline.cling.model.types.ServiceType +import org.fourthline.cling.model.types.UDN +import org.seamless.util.logging.LoggingUtil +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var deviceManager: DeviceManager + + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { @@ -96,6 +117,12 @@ class MainActivity : ComponentActivity() { Content() } } + + // Fix the logging integration between java.util.logging and Android internal logging + LoggingUtil.resetRootHandler(FixedAndroidLogHandler()) + //Logger.getLogger("org.fourthline.cling").level = Level.FINE + + applicationContext.bindService(Intent(this, DeviceService::class.java), deviceManager.connection, Context.BIND_AUTO_CREATE) } } @@ -154,6 +181,7 @@ fun Content(mainViewModel: MainViewModel = viewModel(), playerViewModel: PlayerV AlbumView(entry.arguments!!.getInt("albumId"), navController, playerViewModel) } } + composable("devices") { Base(navController, mainViewModel, playerViewModel) { Devices() } } } } } @@ -186,6 +214,10 @@ fun Base( navController.navigate("albums") scope.launch { scaffoldState.drawerState.close() } } + DrawerRow(stringResource(R.string.devices), currentNavigation?.destination?.route == "devices", R.drawable.ic_menu_devices) { + navController.navigate("devices") + scope.launch { scaffoldState.drawerState.close() } + } Divider() DrawerRow(stringResource(R.string.preferences), false, R.drawable.ic_menu_preferences) { context.startActivity(Intent(context, PreferencesActivity::class.java)) diff --git a/app/src/main/res/drawable/ic_menu_devices.xml b/app/src/main/res/drawable/ic_menu_devices.xml new file mode 100644 index 00000000..257ef470 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_devices.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_smartphone_sound.xml b/app/src/main/res/drawable/ic_smartphone_sound.xml new file mode 100644 index 00000000..2967f542 --- /dev/null +++ b/app/src/main/res/drawable/ic_smartphone_sound.xml @@ -0,0 +1,8 @@ + + + + \ 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 861c4be3..ecd075ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ Open drawer Navigation icon Image showing artist + Image showing casting device Image showing album Close player Various Artists @@ -62,6 +63,7 @@ Artists Albums Tracks + Devices Go to album Go to %s Search @@ -69,4 +71,8 @@ No albums could be found No albums were released on this day No artists could be found + DeviceView + Play on this device + Sound coming from your device + Stream to devices diff --git a/build.gradle b/build.gradle index 091cea86..f0a8d6b3 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,10 @@ allprojects { repositories { google() mavenCentral() + maven { + allowInsecureProtocol true + url "http://4thline.org/m2/" + } } tasks.withType(JavaCompile) { options.compilerArgs << "-Xlint:deprecation"