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 }