Warning, /network/kdeconnect-android/src/org/kde/kdeconnect/UserInterface/DeviceFragment.kt is written in an unsupported language. File is not indexed.

0001 /*
0002  * SPDX-FileCopyrightText: 2014 Albert Vaca Cintora <albertvaka@gmail.com>
0003  *
0004  * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0005  */
0006 package org.kde.kdeconnect.UserInterface
0007 
0008 import android.content.Intent
0009 import android.os.Bundle
0010 import android.util.Log
0011 import android.view.KeyEvent
0012 import android.view.LayoutInflater
0013 import android.view.Menu
0014 import android.view.View
0015 import android.view.ViewGroup
0016 import androidx.annotation.StringRes
0017 import androidx.annotation.UiThread
0018 import androidx.compose.foundation.clickable
0019 import androidx.compose.foundation.layout.*
0020 import androidx.compose.material3.*
0021 import androidx.compose.runtime.Composable
0022 import androidx.compose.ui.Modifier
0023 import androidx.compose.ui.platform.LocalContext
0024 import androidx.compose.ui.platform.ViewCompositionStrategy
0025 import androidx.compose.ui.res.painterResource
0026 import androidx.compose.ui.text.style.TextOverflow
0027 import androidx.compose.ui.tooling.preview.Preview
0028 import androidx.compose.ui.unit.*
0029 import androidx.fragment.app.Fragment
0030 import com.google.accompanist.themeadapter.material3.Mdc3Theme
0031 import com.google.android.material.dialog.MaterialAlertDialogBuilder
0032 import org.kde.kdeconnect.BackgroundService
0033 import org.kde.kdeconnect.Device
0034 import org.kde.kdeconnect.Device.PluginsChangedListener
0035 import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper
0036 import org.kde.kdeconnect.KdeConnect
0037 import org.kde.kdeconnect.PairingHandler
0038 import org.kde.kdeconnect.Plugins.BatteryPlugin.BatteryPlugin
0039 import org.kde.kdeconnect.Plugins.MprisPlugin.MprisPlugin
0040 import org.kde.kdeconnect.Plugins.Plugin
0041 import org.kde.kdeconnect.Plugins.PresenterPlugin.PresenterPlugin
0042 import org.kde.kdeconnect.Plugins.RunCommandPlugin.RunCommandPlugin
0043 import org.kde.kdeconnect_tp.R
0044 import org.kde.kdeconnect_tp.databinding.ActivityDeviceBinding
0045 import org.kde.kdeconnect_tp.databinding.ViewPairErrorBinding
0046 import org.kde.kdeconnect_tp.databinding.ViewPairRequestBinding
0047 
0048 /**
0049  * Main view. Displays the current device and its plugins
0050  */
0051 class DeviceFragment : Fragment() {
0052     val deviceId: String by lazy {
0053         arguments?.getString(ARG_DEVICE_ID)
0054             ?: throw RuntimeException("You must instantiate a new DeviceFragment using DeviceFragment.newInstance()")
0055     }
0056     private var device: Device? = null
0057     private val mActivity: MainActivity? by lazy { activity as MainActivity? }
0058 
0059     /**
0060      * Top-level ViewBinding for this fragment.
0061      */
0062     private var deviceBinding: ActivityDeviceBinding? = null
0063     private fun requireDeviceBinding() = deviceBinding ?: throw IllegalStateException("deviceBinding is not set")
0064 
0065     /**
0066      * Not-yet-paired ViewBinding.
0067      *
0068      * Used to start and retry pairing.
0069      */
0070     private var pairingBinding: ViewPairRequestBinding? = null
0071     private fun requirePairingBinding() = pairingBinding ?: throw IllegalStateException("binding is not set")
0072 
0073     /**
0074      * Cannot-communicate ViewBinding.
0075      *
0076      * Used when the remote device is unreachable.
0077      */
0078     private var errorBinding: ViewPairErrorBinding? = null
0079     private fun requireErrorBinding() = errorBinding ?: throw IllegalStateException("errorBinding is not set")
0080 
0081     companion object {
0082         private const val ARG_DEVICE_ID = "deviceId"
0083         private const val ARG_FROM_DEVICE_LIST = "fromDeviceList"
0084         private const val TAG = "KDE/DeviceFragment"
0085         fun newInstance(deviceId: String?, fromDeviceList: Boolean): DeviceFragment {
0086             val frag = DeviceFragment()
0087             val args = Bundle()
0088             args.putString(ARG_DEVICE_ID, deviceId)
0089             args.putBoolean(ARG_FROM_DEVICE_LIST, fromDeviceList)
0090             frag.arguments = args
0091             return frag
0092         }
0093     }
0094 
0095     override fun onCreateView(
0096         inflater: LayoutInflater, container: ViewGroup?,
0097         savedInstanceState: Bundle?
0098     ): View? {
0099         deviceBinding = ActivityDeviceBinding.inflate(inflater, container, false)
0100         val deviceBinding = deviceBinding ?: return null
0101 
0102         // Inner binding for the layout shown when we're not paired yet...
0103         pairingBinding = deviceBinding.pairRequest
0104         // ...and for when pairing doesn't (or can't) work
0105         errorBinding = deviceBinding.pairError
0106 
0107         device = KdeConnect.getInstance().getDevice(deviceId)
0108 
0109         requireErrorBinding().errorMessageContainer.setOnRefreshListener {
0110             this.refreshDevicesAction()
0111         }
0112 
0113         requirePairingBinding().pairButton.setOnClickListener {
0114             with(requirePairingBinding()) {
0115                 pairButton.visibility = View.GONE
0116                 pairMessage.text = null
0117                 pairVerification.visibility = View.VISIBLE
0118                 pairVerification.text = SslHelper.getVerificationKey(SslHelper.certificate, device?.certificate)
0119                 pairProgress.visibility = View.VISIBLE
0120             }
0121             device?.requestPairing()
0122             mActivity?.invalidateOptionsMenu()
0123         }
0124         requirePairingBinding().acceptButton.setOnClickListener {
0125             device?.apply {
0126                 acceptPairing()
0127                 requirePairingBinding().pairingButtons.visibility = View.GONE
0128             }
0129         }
0130         requirePairingBinding().rejectButton.setOnClickListener {
0131             device?.apply {
0132                 // Remove listener so buttons don't show for an instant before changing the view
0133                 removePluginsChangedListener(pluginsChangedListener)
0134                 removePairingCallback(pairingCallback)
0135                 cancelPairing()
0136             }
0137             mActivity?.onDeviceSelected(null)
0138         }
0139         setHasOptionsMenu(true)
0140 
0141         device?.apply {
0142             mActivity?.supportActionBar?.title = name
0143             addPairingCallback(pairingCallback)
0144             addPluginsChangedListener(pluginsChangedListener)
0145         } ?: run { // device is null
0146             Log.e(TAG, "Trying to display a device fragment but the device is not present")
0147             mActivity?.onDeviceSelected(null)
0148         }
0149 
0150         refreshUI()
0151 
0152         return deviceBinding.root
0153     }
0154 
0155     private fun refreshDevicesAction() {
0156         BackgroundService.ForceRefreshConnections(requireContext())
0157         requireErrorBinding().errorMessageContainer.isRefreshing = true
0158         requireErrorBinding().errorMessageContainer.postDelayed({
0159             errorBinding?.errorMessageContainer?.isRefreshing = false // check for null since the view might be destroyed by now
0160         }, 1500)
0161     }
0162 
0163     private val pluginsChangedListener = PluginsChangedListener { mActivity?.runOnUiThread { refreshUI() } }
0164     override fun onDestroyView() {
0165         device?.apply {
0166             removePluginsChangedListener(pluginsChangedListener)
0167             removePairingCallback(pairingCallback)
0168         }
0169         device = null
0170         pairingBinding = null
0171         errorBinding = null
0172         deviceBinding = null
0173         super.onDestroyView()
0174     }
0175 
0176     override fun onPrepareOptionsMenu(menu: Menu) {
0177         super.onPrepareOptionsMenu(menu)
0178         menu.clear()
0179         val device = device ?: return
0180 
0181         //Plugins button list
0182         val plugins: Collection<Plugin> = device.loadedPlugins.values
0183         for (p in plugins) {
0184             if (p.displayInContextMenu()) {
0185                 menu.add(p.actionName).setOnMenuItemClickListener {
0186                     p.startMainActivity(mActivity)
0187                     true
0188                 }
0189             }
0190         }
0191         val intent = Intent(mActivity, PluginSettingsActivity::class.java)
0192         intent.putExtra("deviceId", deviceId)
0193         menu.add(R.string.device_menu_plugins).setOnMenuItemClickListener {
0194             startActivity(intent)
0195             true
0196         }
0197         if (device.isReachable) {
0198             val builder = MaterialAlertDialogBuilder(requireContext())
0199             builder.setTitle(requireContext().resources.getString(R.string.encryption_info_title))
0200             builder.setPositiveButton(requireContext().resources.getString(R.string.ok)) { dialog, _ ->
0201                 dialog.dismiss()
0202             }
0203             if (device.certificate == null) {
0204                 builder.setMessage(R.string.encryption_info_msg_no_ssl)
0205             } else {
0206                 builder.setMessage(
0207                     "${
0208                         requireContext().resources.getString(R.string.my_device_fingerprint)
0209                     } \n ${
0210                         SslHelper.getCertificateHash(SslHelper.certificate)
0211                     } \n\n ${
0212                         requireContext().resources.getString(R.string.remote_device_fingerprint)
0213                     } \n ${
0214                         SslHelper.getCertificateHash(device.certificate)
0215                     }"
0216                 )
0217             }
0218             menu.add(R.string.encryption_info_title).setOnMenuItemClickListener {
0219                 builder.show()
0220                 true
0221             }
0222         }
0223         if (device.isPaired) {
0224             menu.add(R.string.device_menu_unpair).setOnMenuItemClickListener {
0225                 device.apply {
0226                     // Remove listener so buttons don't show for an instant before changing the view
0227                     removePairingCallback(pairingCallback)
0228                     removePluginsChangedListener(pluginsChangedListener)
0229                     unpair()
0230                 }
0231                 mActivity?.onDeviceSelected(null)
0232                 true
0233             }
0234         }
0235         if (device.isPairRequested) {
0236             menu.add(R.string.cancel_pairing).setOnMenuItemClickListener {
0237                 device.cancelPairing()
0238                 true
0239             }
0240         }
0241     }
0242 
0243     override fun onResume() {
0244         super.onResume()
0245         with(requireView()) {
0246             isFocusableInTouchMode = true
0247             requestFocus()
0248             setOnKeyListener { _, keyCode, event ->
0249                 if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
0250                     val fromDeviceList = requireArguments().getBoolean(ARG_FROM_DEVICE_LIST, false)
0251                     // Handle back button, so we go to the list of devices in case we came from there
0252                     if (fromDeviceList) {
0253                         mActivity?.onDeviceSelected(null)
0254                         return@setOnKeyListener true
0255                     }
0256                 }
0257                 false
0258             }
0259         }
0260     }
0261 
0262     @UiThread
0263     private fun refreshUI() {
0264         val device = device ?: return
0265         //Once in-app, there is no point in keep displaying the notification if any
0266         device.hidePairingNotification()
0267 
0268         if (device.isPairRequestedByPeer) {
0269             with (requirePairingBinding()) {
0270                 pairMessage.setText(R.string.pair_requested)
0271                 pairVerification.visibility = View.VISIBLE
0272                 pairVerification.text = SslHelper.getVerificationKey(SslHelper.certificate, device.certificate)
0273                 pairingButtons.visibility = View.VISIBLE
0274                 pairProgress.visibility = View.GONE
0275                 pairButton.visibility = View.GONE
0276                 pairRequestButtons.visibility = View.VISIBLE
0277             }
0278             requireDeviceBinding().deviceView.visibility = View.GONE
0279         } else {
0280             if (device.isPaired) {
0281                 requirePairingBinding().pairingButtons.visibility = View.GONE
0282                 if (device.isReachable) {
0283                     requireErrorBinding().errorMessageContainer.visibility = View.GONE
0284                     requireDeviceBinding().deviceView.visibility = View.VISIBLE
0285                     requireDeviceBinding().deviceViewCompose.apply {
0286                         setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
0287                         setContent { Mdc3Theme { PluginList(device) } }
0288                     }
0289                     displayBatteryInfoIfPossible()
0290                 } else {
0291                     requireErrorBinding().errorMessageContainer.visibility = View.VISIBLE
0292                     requireDeviceBinding().deviceView.visibility = View.GONE
0293                 }
0294             } else {
0295                 requireErrorBinding().errorMessageContainer.visibility = View.GONE
0296                 requireDeviceBinding().deviceView.visibility = View.GONE
0297                 requirePairingBinding().pairingButtons.visibility = View.VISIBLE
0298             }
0299             mActivity?.invalidateOptionsMenu()
0300         }
0301     }
0302 
0303     private val pairingCallback: PairingHandler.PairingCallback = object : PairingHandler.PairingCallback {
0304         override fun incomingPairRequest() {
0305             mActivity?.runOnUiThread { refreshUI() }
0306         }
0307 
0308         override fun pairingSuccessful() {
0309             mActivity?.runOnUiThread { refreshUI() }
0310         }
0311 
0312         override fun pairingFailed(error: String) {
0313             mActivity?.runOnUiThread {
0314                 with(requirePairingBinding()) {
0315                     pairMessage.text = error
0316                     pairVerification.text = null
0317                     pairVerification.visibility = View.GONE
0318                     pairProgress.visibility = View.GONE
0319                     pairButton.visibility = View.VISIBLE
0320                     pairRequestButtons.visibility = View.GONE
0321                 }
0322                 refreshUI()
0323             }
0324         }
0325 
0326         override fun unpaired() {
0327             mActivity?.runOnUiThread {
0328                 with(requirePairingBinding()) {
0329                     pairMessage.setText(R.string.device_not_paired)
0330                     pairVerification.visibility = View.GONE
0331                     pairProgress.visibility = View.GONE
0332                     pairButton.visibility = View.VISIBLE
0333                     pairRequestButtons.visibility = View.GONE
0334                 }
0335                 refreshUI()
0336             }
0337         }
0338     }
0339 
0340 
0341     /**
0342      * This method tries to display battery info for the remote device. Includes
0343      *
0344      *  * Current charge as a percentage
0345      *  * Whether the remote device is low on power
0346      *  * Whether the remote device is currently charging
0347      *
0348      */
0349     private fun displayBatteryInfoIfPossible() {
0350         val batteryPlugin = device?.loadedPlugins?.get(Plugin.getPluginKey(BatteryPlugin::class.java)) as BatteryPlugin?
0351 
0352         val info = batteryPlugin?.remoteBatteryInfo
0353         if (info != null) {
0354 
0355             @StringRes
0356             val resId = when {
0357                 info.isCharging -> R.string.battery_status_charging_format
0358                 BatteryPlugin.isLowBattery(info) -> R.string.battery_status_low_format
0359                 else -> R.string.battery_status_format
0360             }
0361 
0362             mActivity?.supportActionBar?.subtitle = mActivity?.getString(resId, info.currentCharge)
0363         } else {
0364             mActivity?.supportActionBar?.subtitle = null
0365         }
0366     }
0367 
0368     override fun onDetach() {
0369         super.onDetach()
0370         mActivity?.supportActionBar?.subtitle = null
0371     }
0372 
0373     @Composable
0374     @Preview
0375     fun PreviewCompose() {
0376         val plugins = listOf(MprisPlugin(), RunCommandPlugin(), PresenterPlugin())
0377         plugins.forEach { it.setContext(LocalContext.current, null) }
0378         PluginButtons(plugins.iterator(), 2)
0379     }
0380 
0381     @OptIn(ExperimentalMaterial3Api::class)
0382     @Composable
0383     fun PluginButton(plugin : Plugin, modifier: Modifier) {
0384         Card(
0385             shape = MaterialTheme.shapes.medium,
0386             modifier = modifier,
0387             onClick = { plugin.startMainActivity(mActivity) }
0388         ) {
0389             Column(
0390                 verticalArrangement = Arrangement.spacedBy(10.dp),
0391                 modifier = Modifier.padding(horizontal=16.dp, vertical=10.dp)
0392             ) {
0393                 Icon(
0394                     painter = painterResource(plugin.icon),
0395                     modifier = Modifier.padding(top = 12.dp),
0396                     contentDescription = null
0397                 )
0398                 Text(
0399                     text = plugin.actionName,
0400                     maxLines = 2,
0401                     minLines = 2,
0402                     fontSize = 18.sp,
0403                     overflow = TextOverflow.Ellipsis
0404                 )
0405             }
0406         }
0407     }
0408 
0409     @Composable
0410     fun PluginButtons(plugins: Iterator<Plugin>, numColumns: Int) {
0411         Column(modifier = Modifier.padding(horizontal = 16.dp)) {
0412             while (plugins.hasNext()) {
0413                 Row(
0414                     modifier = Modifier
0415                         .fillMaxWidth()
0416                         .padding(bottom = 8.dp),
0417                     horizontalArrangement = Arrangement.spacedBy(8.dp)
0418                 ) {
0419                     repeat(numColumns) {
0420                         if (plugins.hasNext()) {
0421                             PluginButton(
0422                                 plugin = plugins.next(),
0423                                 modifier = Modifier.weight(1f)
0424                             )
0425                         } else {
0426                             Spacer(modifier = Modifier.weight(1f))
0427                         }
0428                     }
0429                 }
0430             }
0431         }
0432     }
0433 
0434     @Composable
0435     fun PluginsWithoutPermissions(title : String, plugins: Collection<Plugin>, action : (plugin: Plugin) -> Unit) {
0436         Text(
0437             text = title,
0438             modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
0439         )
0440         plugins.forEach { plugin ->
0441             Text(
0442                 text = plugin.displayName,
0443                 modifier = Modifier
0444                     .fillMaxWidth()
0445                     .clickable { action(plugin) }
0446                     .padding(start = 28.dp, end = 16.dp, top = 12.dp, bottom = 12.dp)
0447             )
0448         }
0449     }
0450 
0451     @Composable
0452     fun PluginList(device : Device) {
0453 
0454         val context = requireContext()
0455 
0456         val pluginsWithButtons = device.loadedPlugins.values.filter { it.displayAsButton(context) }.iterator()
0457         val pluginsNeedPermissions = device.pluginsWithoutPermissions.values.filter { device.isPluginEnabled(it.pluginKey) }
0458         val pluginsNeedOptionalPermissions = device.pluginsWithoutOptionalPermissions.values.filter { device.isPluginEnabled(it.pluginKey) }
0459 
0460         Surface {
0461             Column(modifier = Modifier.padding(top = 16.dp)) {
0462 
0463                 val numColumns = resources.getInteger(R.integer.plugins_columns)
0464                 PluginButtons(pluginsWithButtons, numColumns)
0465 
0466                 Spacer(modifier = Modifier.padding(vertical=6.dp))
0467 
0468                 if (pluginsNeedPermissions.isNotEmpty()) {
0469                     PluginsWithoutPermissions(
0470                         title = getString(R.string.plugins_need_permission),
0471                         plugins = pluginsNeedPermissions,
0472                         action = { it.permissionExplanationDialog.show(childFragmentManager,null) }
0473                     )
0474                     Spacer(modifier = Modifier.padding(vertical=2.dp))
0475                 }
0476 
0477                 if (pluginsNeedOptionalPermissions.isNotEmpty()) {
0478                     PluginsWithoutPermissions(
0479                         title = getString(R.string.plugins_need_optional_permission),
0480                         plugins = pluginsNeedOptionalPermissions,
0481                         action = { it.optionalPermissionExplanationDialog.show(childFragmentManager,null) }
0482                     )
0483                 }
0484             }
0485         }
0486     }
0487 
0488 }