2
0
mirror of https://github.com/KDE/kdeconnect-android synced 2025-08-22 01:51:47 +00:00

Migrate 2 Fragments to Kotlin

This is a rebased version of !522, with the same key changes.

* `PairingFragment` and `SystemVolumeFragment` are now written in Kotlin
* A new `BaseFragment` defines a common 'binding' property for `AboutFragment`,
`DeviceFragment`, `PairingFragment`, and `SystemVolumeFragment`
* Calls in and out of `Intent`s and `Bundle`s for `Parcelable` values now go through `IntentCompat` and `BundleCompat`
This commit is contained in:
Philip Cohn-Cort 2025-05-24 15:52:41 +00:00
parent 08b1a9dce4
commit d5c47a1c4c
15 changed files with 756 additions and 697 deletions

View File

@ -13,6 +13,4 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
android:clipToPadding="false"
android:orientation="vertical"
android:paddingHorizontal="@dimen/activity_horizontal_margin"
android:paddingVertical="@dimen/activity_vertical_margin">
</androidx.recyclerview.widget.RecyclerView>
android:paddingVertical="@dimen/activity_vertical_margin" />

View File

@ -16,6 +16,7 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Network
import android.os.Parcelable
import android.preference.PreferenceManager
import android.util.Base64
import android.util.Log
@ -29,6 +30,8 @@ import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper
import org.kde.kdeconnect.Helpers.ThreadHelper.execute
import org.kde.kdeconnect.NetworkPacket
import org.kde.kdeconnect.UserInterface.SettingsFragment
import org.kde.kdeconnect.extensions.getParcelableArrayCompat
import org.kde.kdeconnect.extensions.getParcelableCompat
import java.io.IOException
import java.io.InputStreamReader
import java.io.Reader
@ -74,7 +77,7 @@ class BluetoothLinkProvider(private val context: Context) : BaseLinkProvider() {
if (!preferences.getBoolean(SettingsFragment.KEY_BLUETOOTH_ENABLED, false)) {
return
}
if (bluetoothAdapter == null || bluetoothAdapter.isEnabled == false) {
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled) {
return
}
Log.i("BluetoothLinkProvider", "onStart called")
@ -297,8 +300,8 @@ class BluetoothLinkProvider(private val context: Context) : BaseLinkProvider() {
val action = intent.action
if (BluetoothDevice.ACTION_UUID == action) {
Log.i("BluetoothLinkProvider", "Action matches")
val device = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
val activeUuids = intent.getParcelableArrayExtra(BluetoothDevice.EXTRA_UUID)
val device = intent.getParcelableCompat<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
val activeUuids = intent.getParcelableArrayCompat<Parcelable>(BluetoothDevice.EXTRA_UUID)
if (sockets.containsKey(device)) {
Log.i("BluetoothLinkProvider", "sockets contains device")
return

View File

@ -15,6 +15,7 @@ import android.os.Bundle;
import android.provider.Settings;
import androidx.annotation.NonNull;
import androidx.core.content.IntentCompat;
import androidx.preference.Preference;
import androidx.preference.PreferenceManager;
@ -85,7 +86,7 @@ public class FindMyPhoneSettingsFragment extends PluginSettingsFragment {
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == REQUEST_CODE_SELECT_RINGTONE && resultCode == Activity.RESULT_OK) {
Uri uri = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI);
Uri uri = IntentCompat.getParcelableExtra(data, RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri.class);
if (uri != null) {
sharedPreferences.edit()

View File

@ -10,6 +10,7 @@ import android.content.Context
import android.content.Intent
import android.util.Log
import org.kde.kdeconnect.KdeConnect
import org.kde.kdeconnect.extensions.getParcelableCompat
/**
* Called when the mpris media notification's buttons are pressed
@ -20,7 +21,7 @@ class MprisMediaNotificationReceiver : BroadcastReceiver() {
if (Intent.ACTION_MEDIA_BUTTON == intent.action) {
// Route these buttons to the media session, which will handle them
val mediaSession = MprisMediaSession.getMediaSession() ?: return
mediaSession.controller.dispatchMediaButtonEvent(intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT))
mediaSession.controller.dispatchMediaButtonEvent(intent.getParcelableCompat(Intent.EXTRA_KEY_EVENT))
} else {
// Second case: buttons on the notification, which we created ourselves
// Get the correct device, the mpris plugin and the mpris player

View File

@ -34,6 +34,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationCompat;
import androidx.core.os.BundleCompat;
import androidx.fragment.app.DialogFragment;
import org.apache.commons.collections4.MultiValuedMap;
@ -358,7 +359,7 @@ public class NotificationsPlugin extends Plugin implements NotificationReceiver.
if (!notification.extras.containsKey(Notification.EXTRA_MESSAGES))
return new Pair<>(null, null);
Parcelable[] ms = notification.extras.getParcelableArray(Notification.EXTRA_MESSAGES);
Parcelable[] ms = BundleCompat.getParcelableArray(notification.extras, Notification.EXTRA_MESSAGES, Parcelable.class);
if (ms == null)
return new Pair<>(null, null);

View File

@ -25,10 +25,12 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.core.content.ContextCompat;
import androidx.core.content.IntentCompat;
import androidx.core.content.LocusIdCompat;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.core.os.BundleCompat;
import androidx.preference.PreferenceManager;
import org.apache.commons.lang3.ArrayUtils;
@ -355,10 +357,10 @@ public class SharePlugin extends Plugin {
Log.i("SharePlugin", "Intent contains streams to share");
ArrayList<Uri> uriList;
if (Intent.ACTION_SEND_MULTIPLE.equals(intent.getAction())) {
uriList = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
uriList = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri.class);
} else {
uriList = new ArrayList<>();
uriList.add(extras.getParcelable(Intent.EXTRA_STREAM));
uriList.add(BundleCompat.getParcelable(extras, Intent.EXTRA_STREAM, Uri.class));
}
uriList.removeAll(Collections.singleton(null));
if (uriList.isEmpty()) {

View File

@ -1,179 +0,0 @@
/*
* SPDX-FileCopyrightText: 2018 Nicolas Fella <nicolas.fella@gmx.de>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SystemVolumePlugin;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Consumer;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.kde.kdeconnect.Helpers.VolumeHelperKt;
import org.kde.kdeconnect.KdeConnect;
import org.kde.kdeconnect.Plugins.MprisPlugin.MprisPlugin;
import org.kde.kdeconnect.Plugins.MprisPlugin.VolumeKeyListener;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.ListItemSystemvolumeBinding;
import org.kde.kdeconnect_tp.databinding.SystemVolumeFragmentBinding;
import java.util.ArrayList;
import java.util.List;
public class SystemVolumeFragment
extends Fragment
implements Sink.UpdateListener, SystemVolumePlugin.SinkListener, VolumeKeyListener {
private SystemVolumePlugin plugin;
private RecyclerSinkAdapter recyclerAdapter;
private boolean tracking;
private final Consumer<Boolean> trackingConsumer = aBoolean -> tracking = aBoolean;
private SystemVolumeFragmentBinding systemVolumeFragmentBinding;
public static SystemVolumeFragment newInstance(String deviceId) {
SystemVolumeFragment systemvolumeFragment = new SystemVolumeFragment();
Bundle arguments = new Bundle();
arguments.putString(MprisPlugin.DEVICE_ID_KEY, deviceId);
systemvolumeFragment.setArguments(arguments);
return systemvolumeFragment;
}
@Nullable
@Override
public View onCreateView(
@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState
) {
if (systemVolumeFragmentBinding == null) {
systemVolumeFragmentBinding = SystemVolumeFragmentBinding.inflate(inflater);
RecyclerView recyclerView = systemVolumeFragmentBinding.audioDevicesRecycler;
int gap = requireContext().getResources().getDimensionPixelSize(R.dimen.activity_vertical_margin);
recyclerView.addItemDecoration(new ItemGapDecoration(gap));
recyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
recyclerAdapter = new RecyclerSinkAdapter();
recyclerView.setAdapter(recyclerAdapter);
}
connectToPlugin(getDeviceId());
return systemVolumeFragmentBinding.getRoot();
}
@Override
public void onDestroyView() {
disconnectFromPlugin(getDeviceId());
super.onDestroyView();
}
@Override
public void updateSink(@NonNull final Sink sink) {
// Don't set progress while the slider is moved
if (!tracking) {
requireActivity().runOnUiThread(() -> recyclerAdapter.notifyDataSetChanged());
}
}
private void connectToPlugin(final String deviceId) {
SystemVolumePlugin plugin = KdeConnect.getInstance().getDevicePlugin(deviceId, SystemVolumePlugin.class);
if (plugin == null) {
return;
}
this.plugin = plugin;
plugin.addSinkListener(SystemVolumeFragment.this);
plugin.requestSinkList();
}
private void disconnectFromPlugin(final String deviceId) {
SystemVolumePlugin plugin = KdeConnect.getInstance().getDevicePlugin(deviceId, SystemVolumePlugin.class);
if (plugin == null) {
return;
}
plugin.removeSinkListener(SystemVolumeFragment.this);
}
@Override
public void sinksChanged() {
for (Sink sink : plugin.getSinks()) {
sink.addListener(SystemVolumeFragment.this);
}
requireActivity().runOnUiThread(() -> {
List<Sink> newSinks = new ArrayList<>(plugin.getSinks());
recyclerAdapter.submitList(newSinks);
});
}
@Override
public void onVolumeUp() {
updateDefaultSinkVolume(5);
}
@Override
public void onVolumeDown() {
updateDefaultSinkVolume(-5);
}
private void updateDefaultSinkVolume(int percent) {
if (plugin == null) return;
Sink defaultSink = SystemVolumeUtilsKt.getDefaultSink(plugin);
if (defaultSink == null) return;
int newVolume = VolumeHelperKt.calculateNewVolume(
defaultSink.getVolume(),
defaultSink.getMaxVolume(),
percent
);
if (defaultSink.getVolume() == newVolume) return;
plugin.sendVolume(defaultSink.getName(), newVolume);
}
private String getDeviceId() {
return requireArguments().getString(MprisPlugin.DEVICE_ID_KEY);
}
private class RecyclerSinkAdapter extends ListAdapter<Sink, SinkItemHolder> {
public RecyclerSinkAdapter() {
super(new SinkItemCallback());
}
@NonNull
@Override
public SinkItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inflater = getLayoutInflater();
ListItemSystemvolumeBinding viewBinding = ListItemSystemvolumeBinding.inflate(inflater, parent, false);
return new SinkItemHolder(viewBinding, plugin, trackingConsumer);
}
@Override
public void onBindViewHolder(@NonNull SinkItemHolder holder, int position) {
holder.bind(getItem(position));
}
}
}

View File

@ -0,0 +1,162 @@
/*
* SPDX-FileCopyrightText: 2018 Nicolas Fella <nicolas.fella@gmx.de>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SystemVolumePlugin
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.util.Consumer
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import org.kde.kdeconnect.Helpers.calculateNewVolume
import org.kde.kdeconnect.KdeConnect
import org.kde.kdeconnect.Plugins.MprisPlugin.MprisPlugin
import org.kde.kdeconnect.Plugins.MprisPlugin.VolumeKeyListener
import org.kde.kdeconnect.Plugins.SystemVolumePlugin.SystemVolumePlugin.SinkListener
import org.kde.kdeconnect.base.BaseFragment
import org.kde.kdeconnect.extensions.setupBottomPadding
import org.kde.kdeconnect_tp.R
import org.kde.kdeconnect_tp.databinding.ListItemSystemvolumeBinding
import org.kde.kdeconnect_tp.databinding.SystemVolumeFragmentBinding
class SystemVolumeFragment : BaseFragment<SystemVolumeFragmentBinding>(),
Sink.UpdateListener, SinkListener, VolumeKeyListener {
private lateinit var plugin: SystemVolumePlugin
private lateinit var recyclerAdapter: RecyclerSinkAdapter
private var tracking = false
private val trackingConsumer = Consumer { aBoolean: Boolean -> tracking = aBoolean }
override fun onInflateBinding(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): SystemVolumeFragmentBinding {
return SystemVolumeFragmentBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerAdapter = RecyclerSinkAdapter()
binding.audioDevicesRecycler.apply {
layoutManager = LinearLayoutManager(requireContext())
addItemDecoration(ItemGapDecoration(resources.getDimensionPixelSize(R.dimen.activity_vertical_margin)))
adapter = recyclerAdapter
setupBottomPadding()
}
connectToPlugin(deviceId)
}
override fun onDestroyView() {
disconnectFromPlugin(deviceId)
super.onDestroyView()
}
@SuppressLint("NotifyDataSetChanged")
override fun updateSink(sink: Sink) {
// Don't set progress while the slider is moved
if (!tracking) {
requireActivity().runOnUiThread {
if (::recyclerAdapter.isInitialized) {
recyclerAdapter.notifyDataSetChanged()
}
}
}
}
private fun connectToPlugin(deviceId: String?) {
val plugin = KdeConnect.getInstance().getDevicePlugin(
deviceId,
SystemVolumePlugin::class.java
)
if (plugin == null) {
return
}
this.plugin = plugin
plugin.addSinkListener(this@SystemVolumeFragment)
plugin.requestSinkList()
}
private fun disconnectFromPlugin(deviceId: String?) {
val plugin = KdeConnect.getInstance().getDevicePlugin(
deviceId,
SystemVolumePlugin::class.java
)
if (plugin == null) {
return
}
plugin.removeSinkListener(this@SystemVolumeFragment)
}
override fun sinksChanged() {
if (!::plugin.isInitialized || !::recyclerAdapter.isInitialized) {
return
}
for (sink in plugin.sinks) {
sink.addListener(this@SystemVolumeFragment)
}
requireActivity().runOnUiThread {
val newSinks: List<Sink> = ArrayList(plugin.sinks)
recyclerAdapter.submitList(newSinks)
}
}
override fun onVolumeUp() {
updateDefaultSinkVolume(5)
}
override fun onVolumeDown() {
updateDefaultSinkVolume(-5)
}
private fun updateDefaultSinkVolume(percent: Int) {
if (!::plugin.isInitialized) {
return
}
val defaultSink = getDefaultSink(plugin) ?: return
val newVolume = calculateNewVolume(
defaultSink.volume,
defaultSink.maxVolume,
percent
)
if (defaultSink.volume == newVolume) return
plugin.sendVolume(defaultSink.name, newVolume)
}
private val deviceId: String?
get() = arguments?.getString(MprisPlugin.DEVICE_ID_KEY)
private inner class RecyclerSinkAdapter : ListAdapter<Sink?, SinkItemHolder>(SinkItemCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SinkItemHolder {
val viewBinding = ListItemSystemvolumeBinding.inflate(layoutInflater, parent, false)
return SinkItemHolder(viewBinding, plugin, trackingConsumer)
}
override fun onBindViewHolder(holder: SinkItemHolder, position: Int) {
holder.bind(getItem(position))
}
}
companion object {
fun newInstance(deviceId: String?): SystemVolumeFragment {
val systemVolumeFragment = SystemVolumeFragment()
val arguments = Bundle()
arguments.putString(MprisPlugin.DEVICE_ID_KEY, deviceId)
systemVolumeFragment.arguments = arguments
return systemVolumeFragment
}
}
}

View File

@ -14,50 +14,56 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import org.kde.kdeconnect.UserInterface.List.ListAdapter
import org.kde.kdeconnect.UserInterface.MainActivity
import org.kde.kdeconnect.base.BaseFragment
import org.kde.kdeconnect.extensions.getParcelableCompat
import org.kde.kdeconnect.extensions.setupBottomPadding
import org.kde.kdeconnect_tp.R
import org.kde.kdeconnect_tp.databinding.FragmentAboutBinding
import androidx.core.net.toUri
class AboutFragment : Fragment() {
private var _binding: FragmentAboutBinding? = null
private val binding get() = _binding!!
private lateinit var aboutData: AboutData
private var tapCount = 0
private var firstTapMillis: Long? = null
class AboutFragment : BaseFragment<FragmentAboutBinding>() {
companion object {
private const val KEY_ABOUT_DATA = "about_data"
@JvmStatic
fun newInstance(aboutData: AboutData): Fragment {
val fragment = AboutFragment()
val args = Bundle(1)
args.putParcelable("ABOUT_DATA", aboutData)
args.putParcelable(KEY_ABOUT_DATA, aboutData)
fragment.arguments = args
return fragment
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
if (activity != null) {
(requireActivity() as MainActivity).supportActionBar?.setTitle(R.string.about)
}
private lateinit var aboutData: AboutData
private var tapCount = 0
private var firstTapMillis: Long? = null
aboutData = requireArguments().getParcelable("ABOUT_DATA")!!
_binding = FragmentAboutBinding.inflate(inflater, container, false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
aboutData = arguments?.getParcelableCompat(KEY_ABOUT_DATA) ?: throw IllegalArgumentException("AboutData is null")
}
updateData()
return binding.root
override fun onInflateBinding(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): FragmentAboutBinding {
return FragmentAboutBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
(activity as? AppCompatActivity)?.supportActionBar?.setTitle(R.string.about)
binding.scrollView.setupBottomPadding()
updateData()
}
@SuppressLint("SetTextI18n")
@ -65,8 +71,8 @@ class AboutFragment : Fragment() {
// Update general info
binding.appName.text = aboutData.name
binding.appIcon.setImageDrawable(this.context?.let { ContextCompat.getDrawable(it, aboutData.icon) })
binding.appVersion.text = this.context?.getString(R.string.version, aboutData.versionName)
binding.appIcon.setImageDrawable(context?.let { ContextCompat.getDrawable(it, aboutData.icon) })
binding.appVersion.text = context?.getString(R.string.version, aboutData.versionName)
// Setup Easter Egg onClickListener
@ -103,7 +109,7 @@ class AboutFragment : Fragment() {
setupInfoButton(aboutData.websiteURL, binding.websiteButton)
// Update authors
binding.authorsList.adapter = ListAdapter(this.requireContext(), aboutData.authors.map { AboutPersonEntryItem(it) }, false)
binding.authorsList.adapter = ListAdapter(requireContext(), aboutData.authors.map { AboutPersonEntryItem(it) }, false)
if (aboutData.authorsFooterText != null) {
binding.authorsFooterText.text = context?.getString(aboutData.authorsFooterText!!)
}
@ -119,8 +125,4 @@ class AboutFragment : Fragment() {
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@ -11,6 +11,8 @@ import android.util.Log
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
@ -30,7 +32,8 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import androidx.fragment.app.Fragment
import androidx.core.view.MenuProvider
import androidx.lifecycle.Lifecycle
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.kde.kdeconnect.BackgroundService
import org.kde.kdeconnect.Device
@ -44,44 +47,15 @@ import org.kde.kdeconnect.Plugins.Plugin
import org.kde.kdeconnect.Plugins.PresenterPlugin.PresenterPlugin
import org.kde.kdeconnect.Plugins.RunCommandPlugin.RunCommandPlugin
import org.kde.kdeconnect.UserInterface.compose.KdeTheme
import org.kde.kdeconnect.base.BaseFragment
import org.kde.kdeconnect.extensions.setupBottomPadding
import org.kde.kdeconnect_tp.R
import org.kde.kdeconnect_tp.databinding.ActivityDeviceBinding
import org.kde.kdeconnect_tp.databinding.ViewPairErrorBinding
import org.kde.kdeconnect_tp.databinding.ViewPairRequestBinding
/**
* Main view. Displays the current device and its plugins
*/
class DeviceFragment : Fragment() {
val deviceId: String by lazy {
arguments?.getString(ARG_DEVICE_ID)
?: throw RuntimeException("You must instantiate a new DeviceFragment using DeviceFragment.newInstance()")
}
private var device: Device? = null
private val mActivity: MainActivity? by lazy { activity as MainActivity? }
/**
* Top-level ViewBinding for this fragment.
*/
private var deviceBinding: ActivityDeviceBinding? = null
private fun requireDeviceBinding() = deviceBinding ?: throw IllegalStateException("deviceBinding is not set")
/**
* Not-yet-paired ViewBinding.
*
* Used to start and retry pairing.
*/
private var pairingBinding: ViewPairRequestBinding? = null
private fun requirePairingBinding() = pairingBinding ?: throw IllegalStateException("binding is not set")
/**
* Cannot-communicate ViewBinding.
*
* Used when the remote device is unreachable.
*/
private var errorBinding: ViewPairErrorBinding? = null
private fun requireErrorBinding() = errorBinding ?: throw IllegalStateException("errorBinding is not set")
class DeviceFragment : BaseFragment<ActivityDeviceBinding>() {
companion object {
private const val ARG_DEVICE_ID = "deviceId"
@ -96,36 +70,128 @@ class DeviceFragment : Fragment() {
return frag
}
}
val deviceId: String by lazy {
arguments?.getString(ARG_DEVICE_ID)
?: throw RuntimeException("You must instantiate a new DeviceFragment using DeviceFragment.newInstance()")
}
private var device: Device? = null
private val mActivity: MainActivity? by lazy { activity as MainActivity? }
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
/**
* Not-yet-paired ViewBinding.
*
* Used to start and retry pairing.
*/
private val pairingBinding get() = binding.pairRequest
/**
* Cannot-communicate ViewBinding.
*
* Used when the remote device is unreachable.
*/
private val errorBinding get() = binding.pairError
override fun onInflateBinding(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
deviceBinding = ActivityDeviceBinding.inflate(inflater, container, false)
val deviceBinding = deviceBinding ?: return null
): ActivityDeviceBinding {
return ActivityDeviceBinding.inflate(inflater, container, false)
}
// Inner binding for the layout shown when we're not paired yet...
pairingBinding = deviceBinding.pairRequest
// ...and for when pairing doesn't (or can't) work
errorBinding = deviceBinding.pairError
private val menuProvider = object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menu.clear()
val device = device ?: return
device = KdeConnect.getInstance().getDevice(deviceId)
requireErrorBinding().errorMessageContainer.setOnRefreshListener {
this.refreshDevicesAction()
//Plugins button list
val plugins: Collection<Plugin> = device.loadedPlugins.values
for (p in plugins) {
if (p.displayInContextMenu()) {
menu.add(p.actionName).setOnMenuItemClickListener {
p.startMainActivity(mActivity!!)
true
}
}
}
val intent = Intent(mActivity, PluginSettingsActivity::class.java)
intent.putExtra("deviceId", deviceId)
menu.add(R.string.device_menu_plugins).setOnMenuItemClickListener {
startActivity(intent)
true
}
if (device.isReachable) {
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setTitle(requireContext().resources.getString(R.string.encryption_info_title))
builder.setPositiveButton(requireContext().resources.getString(R.string.ok)) { dialog, _ ->
dialog.dismiss()
}
builder.setMessage(
"${
requireContext().resources.getString(R.string.my_device_fingerprint)
} \n ${
SslHelper.getCertificateHash(SslHelper.certificate)
} \n\n ${
requireContext().resources.getString(R.string.remote_device_fingerprint)
} \n ${
SslHelper.getCertificateHash(device.certificate)
} \n\n ${
requireContext().resources.getString(R.string.protocol_version)
} ${
device.protocolVersion
}"
)
menu.add(R.string.encryption_info_title).setOnMenuItemClickListener {
builder.show()
true
}
}
if (device.isPaired) {
menu.add(R.string.device_menu_unpair).setOnMenuItemClickListener {
device.apply {
// Remove listener so buttons don't show for an instant before changing the view
removePairingCallback(pairingCallback)
removePluginsChangedListener(pluginsChangedListener)
unpair()
}
mActivity?.onDeviceSelected(null)
true
}
}
if (device.pairStatus == PairingHandler.PairState.Requested) {
menu.add(R.string.cancel_pairing).setOnMenuItemClickListener {
device.cancelPairing()
true
}
}
}
requirePairingBinding().pairButton.setOnClickListener {
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return true
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.deviceView.setupBottomPadding()
errorBinding.errorMessageContainer.setOnRefreshListener {
this.refreshDevicesAction()
}
pairingBinding.pairButton.setOnClickListener {
device?.requestPairing()
refreshUI()
}
requirePairingBinding().acceptButton.setOnClickListener {
pairingBinding.acceptButton.setOnClickListener {
device?.apply {
acceptPairing()
requirePairingBinding().pairingButtons.visibility = View.GONE
pairingBinding.pairingButtons.visibility = View.GONE
}
}
requirePairingBinding().rejectButton.setOnClickListener {
pairingBinding.rejectButton.setOnClickListener {
device?.apply {
// Remove listener so buttons don't show for an instant before changing the view
removePluginsChangedListener(pluginsChangedListener)
@ -134,8 +200,7 @@ class DeviceFragment : Fragment() {
}
mActivity?.onDeviceSelected(null)
}
setHasOptionsMenu(true)
device = KdeConnect.getInstance().getDevice(deviceId)
device?.apply {
mActivity?.supportActionBar?.title = name
addPairingCallback(pairingCallback)
@ -144,104 +209,31 @@ class DeviceFragment : Fragment() {
Log.e(TAG, "Trying to display a device fragment but the device is not present")
mActivity?.onDeviceSelected(null)
}
mActivity?.addMenuProvider(menuProvider, viewLifecycleOwner)
refreshUI()
return deviceBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
deviceBinding?.deviceView?.setupBottomPadding()
}
private fun refreshDevicesAction() {
BackgroundService.ForceRefreshConnections(requireContext())
requireErrorBinding().errorMessageContainer.isRefreshing = true
requireErrorBinding().errorMessageContainer.postDelayed({
errorBinding?.errorMessageContainer?.isRefreshing = false // check for null since the view might be destroyed by now
errorBinding.errorMessageContainer.isRefreshing = true
errorBinding.errorMessageContainer.postDelayed({
if (viewLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
errorBinding.errorMessageContainer.isRefreshing = false // check for null since the view might be destroyed by now
}
}, 1500)
}
private val pluginsChangedListener = PluginsChangedListener { mActivity?.runOnUiThread { refreshUI() } }
override fun onDestroyView() {
device?.apply {
removePluginsChangedListener(pluginsChangedListener)
removePairingCallback(pairingCallback)
}
device = null
pairingBinding = null
errorBinding = null
deviceBinding = null
super.onDestroyView()
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.clear()
val device = device ?: return
//Plugins button list
val plugins: Collection<Plugin> = device.loadedPlugins.values
for (p in plugins) {
if (p.displayInContextMenu()) {
menu.add(p.actionName).setOnMenuItemClickListener {
p.startMainActivity(mActivity!!)
true
}
}
}
val intent = Intent(mActivity, PluginSettingsActivity::class.java)
intent.putExtra("deviceId", deviceId)
menu.add(R.string.device_menu_plugins).setOnMenuItemClickListener {
startActivity(intent)
true
}
if (device.isReachable) {
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setTitle(requireContext().resources.getString(R.string.encryption_info_title))
builder.setPositiveButton(requireContext().resources.getString(R.string.ok)) { dialog, _ ->
dialog.dismiss()
}
builder.setMessage(
"${
requireContext().resources.getString(R.string.my_device_fingerprint)
} \n ${
SslHelper.getCertificateHash(SslHelper.certificate)
} \n\n ${
requireContext().resources.getString(R.string.remote_device_fingerprint)
} \n ${
SslHelper.getCertificateHash(device.certificate)
} \n\n ${
requireContext().resources.getString(R.string.protocol_version)
} ${
device.protocolVersion
}"
)
menu.add(R.string.encryption_info_title).setOnMenuItemClickListener {
builder.show()
true
}
}
if (device.isPaired) {
menu.add(R.string.device_menu_unpair).setOnMenuItemClickListener {
device.apply {
// Remove listener so buttons don't show for an instant before changing the view
removePairingCallback(pairingCallback)
removePluginsChangedListener(pluginsChangedListener)
unpair()
}
mActivity?.onDeviceSelected(null)
true
}
}
if (device.pairStatus == PairingHandler.PairState.Requested) {
menu.add(R.string.cancel_pairing).setOnMenuItemClickListener {
device.cancelPairing()
true
}
}
}
override fun onResume() {
super.onResume()
@ -270,13 +262,13 @@ class DeviceFragment : Fragment() {
when (device.pairStatus) {
PairingHandler.PairState.NotPaired -> {
requireErrorBinding().errorMessageContainer.visibility = View.GONE
requireDeviceBinding().deviceView.visibility = View.GONE
requirePairingBinding().pairingButtons.visibility = View.VISIBLE
requirePairingBinding().pairVerification.visibility = View.GONE
errorBinding.errorMessageContainer.visibility = View.GONE
binding.deviceView.visibility = View.GONE
pairingBinding.pairingButtons.visibility = View.VISIBLE
pairingBinding.pairVerification.visibility = View.GONE
}
PairingHandler.PairState.Requested -> {
with(requirePairingBinding()) {
with(pairingBinding) {
pairButton.visibility = View.GONE
pairMessage.text = getString(R.string.pair_requested)
pairProgress.visibility = View.VISIBLE
@ -285,7 +277,7 @@ class DeviceFragment : Fragment() {
}
}
PairingHandler.PairState.RequestedByPeer -> {
with (requirePairingBinding()) {
with (pairingBinding) {
pairMessage.setText(R.string.pair_requested)
pairVerification.visibility = View.VISIBLE
pairingButtons.visibility = View.VISIBLE
@ -295,25 +287,25 @@ class DeviceFragment : Fragment() {
pairVerification.text = device.verificationKey
pairVerification.visibility = View.VISIBLE
}
requireDeviceBinding().deviceView.visibility = View.GONE
binding.deviceView.visibility = View.GONE
}
PairingHandler.PairState.Paired -> {
requirePairingBinding().pairingButtons.visibility = View.GONE
pairingBinding.pairingButtons.visibility = View.GONE
if (device.isReachable) {
val context = requireContext()
val pluginsWithButtons = device.loadedPlugins.values.filter { it.displayAsButton(context) }
val pluginsNeedPermissions = device.pluginsWithoutPermissions.values.filter { device.isPluginEnabled(it.pluginKey) }
val pluginsNeedOptionalPermissions = device.pluginsWithoutOptionalPermissions.values.filter { device.isPluginEnabled(it.pluginKey) }
requireErrorBinding().errorMessageContainer.visibility = View.GONE
requireDeviceBinding().deviceView.visibility = View.VISIBLE
requireDeviceBinding().deviceViewCompose.apply {
errorBinding.errorMessageContainer.visibility = View.GONE
binding.deviceView.visibility = View.VISIBLE
binding.deviceViewCompose.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent { KdeTheme(context) { PluginList(pluginsWithButtons, pluginsNeedPermissions, pluginsNeedOptionalPermissions) } }
}
displayBatteryInfoIfPossible()
} else {
requireErrorBinding().errorMessageContainer.visibility = View.VISIBLE
requireDeviceBinding().deviceView.visibility = View.GONE
errorBinding.errorMessageContainer.visibility = View.VISIBLE
binding.deviceView.visibility = View.GONE
}
}
}
@ -326,13 +318,13 @@ class DeviceFragment : Fragment() {
}
override fun pairingSuccessful() {
requirePairingBinding().pairMessage.announceForAccessibility(getString(R.string.pair_succeeded))
pairingBinding.pairMessage.announceForAccessibility(getString(R.string.pair_succeeded))
mActivity?.runOnUiThread { refreshUI() }
}
override fun pairingFailed(error: String) {
mActivity?.runOnUiThread {
with(requirePairingBinding()) {
with(pairingBinding) {
pairMessage.text = error
pairProgress.visibility = View.GONE
pairButton.visibility = View.VISIBLE
@ -344,7 +336,7 @@ class DeviceFragment : Fragment() {
override fun unpaired() {
mActivity?.runOnUiThread {
with(requirePairingBinding()) {
with(pairingBinding) {
pairMessage.setText(R.string.device_not_paired)
pairProgress.visibility = View.GONE
pairButton.visibility = View.VISIBLE
@ -501,5 +493,4 @@ class DeviceFragment : Fragment() {
}
}
}
}

View File

@ -1,336 +0,0 @@
/*
* SPDX-FileCopyrightText: 2014 Albert Vaca Cintora <albertvaka@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.UserInterface;
import android.Manifest;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.net.Uri;
import android.os.Bundle;
import android.provider.Settings;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import org.kde.kdeconnect.BackgroundService;
import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.Helpers.TrustedNetworkHelper;
import org.kde.kdeconnect.Helpers.WindowHelper;
import org.kde.kdeconnect.KdeConnect;
import org.kde.kdeconnect.UserInterface.List.ListAdapter;
import org.kde.kdeconnect.UserInterface.List.PairingDeviceItem;
import org.kde.kdeconnect.UserInterface.List.SectionItem;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.DevicesListBinding;
import org.kde.kdeconnect_tp.databinding.PairingExplanationDuplicateNamesBinding;
import org.kde.kdeconnect_tp.databinding.PairingExplanationNotTrustedBinding;
import org.kde.kdeconnect_tp.databinding.PairingExplanationTextBinding;
import org.kde.kdeconnect_tp.databinding.PairingExplanationTextNoNotificationsBinding;
import org.kde.kdeconnect_tp.databinding.PairingExplanationTextNoWifiBinding;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
/**
* The view that the user will see when there are no devices paired, or when you choose "add a new device" from the sidebar.
*/
public class PairingFragment extends Fragment implements PairingDeviceItem.Callback {
private static final int RESULT_PAIRING_SUCCESFUL = Activity.RESULT_FIRST_USER;
private DevicesListBinding devicesListBinding;
private PairingExplanationNotTrustedBinding pairingExplanationNotTrustedBinding;
private PairingExplanationTextBinding pairingExplanationTextBinding;
private PairingExplanationTextNoWifiBinding pairingExplanationTextNoWifiBinding;
private PairingExplanationDuplicateNamesBinding pairingExplanationDuplicateNamesBinding;
private PairingExplanationTextNoNotificationsBinding pairingExplanationTextNoNotificationsBinding;
private MainActivity mActivity;
private boolean listRefreshCalledThisFrame = false;
private TextView headerText;
private TextView noWifiHeader;
private TextView duplicateNamesHeader;
private TextView noNotificationsHeader;
private TextView notTrustedText;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
mActivity.getSupportActionBar().setTitle(R.string.pairing_title);
setHasOptionsMenu(true);
devicesListBinding = DevicesListBinding.inflate(inflater, container, false);
pairingExplanationNotTrustedBinding = PairingExplanationNotTrustedBinding.inflate(inflater);
notTrustedText = pairingExplanationNotTrustedBinding.getRoot();
notTrustedText.setOnClickListener(null);
notTrustedText.setOnLongClickListener(null);
pairingExplanationTextBinding = PairingExplanationTextBinding.inflate(inflater);
headerText = pairingExplanationTextBinding.getRoot();
headerText.setOnClickListener(null);
headerText.setOnLongClickListener(null);
pairingExplanationTextNoWifiBinding = PairingExplanationTextNoWifiBinding.inflate(inflater);
noWifiHeader = pairingExplanationTextNoWifiBinding.getRoot();
noWifiHeader.setOnClickListener(view -> startActivity(new Intent(Settings.ACTION_WIFI_SETTINGS)));
pairingExplanationDuplicateNamesBinding = PairingExplanationDuplicateNamesBinding.inflate(inflater);
duplicateNamesHeader = pairingExplanationDuplicateNamesBinding.getRoot();
pairingExplanationTextNoNotificationsBinding = PairingExplanationTextNoNotificationsBinding.inflate(inflater);
noNotificationsHeader = pairingExplanationTextNoNotificationsBinding.getRoot();
noNotificationsHeader.setOnClickListener(view -> ActivityCompat.requestPermissions(requireActivity(), new String[]{Manifest.permission.POST_NOTIFICATIONS}, MainActivity.RESULT_NOTIFICATIONS_ENABLED));
noNotificationsHeader.setOnLongClickListener(view -> {
Intent intent = new Intent();
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
Uri uri = Uri.fromParts("package", requireContext().getPackageName(), null);
intent.setData(uri);
startActivity(intent);
return true;
});
devicesListBinding.devicesList.addHeaderView(headerText);
devicesListBinding.refreshListLayout.setOnRefreshListener(this::refreshDevicesAction);
return devicesListBinding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
// Configure focus order for Accessibility, for touchpads, and for TV remotes
// (allow focus of items in the device list)
devicesListBinding.devicesList.setItemsCanFocus(true);
WindowHelper.setupBottomPadding(devicesListBinding.devicesList);
}
@Override
public void onDestroyView() {
super.onDestroyView();
devicesListBinding = null;
pairingExplanationNotTrustedBinding = null;
pairingExplanationTextBinding = null;
pairingExplanationTextNoWifiBinding = null;
}
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
mActivity = ((MainActivity) getActivity());
}
private void refreshDevicesAction() {
BackgroundService.ForceRefreshConnections(requireContext());
devicesListBinding.refreshListLayout.setRefreshing(true);
devicesListBinding.refreshListLayout.postDelayed(() -> {
if (devicesListBinding != null) { // the view might be destroyed by now
devicesListBinding.refreshListLayout.setRefreshing(false);
}
}, 1500);
}
private void updateDeviceList() {
if (!isAdded()) {
//Fragment is not attached to an activity. We will crash if we try to do anything here.
return;
}
if (listRefreshCalledThisFrame) {
// This makes sure we don't try to call list.getFirstVisiblePosition()
// twice per frame, because the second time the list hasn't been drawn
// yet and it would always return 0.
return;
}
listRefreshCalledThisFrame = true;
devicesListBinding.devicesList.removeHeaderView(duplicateNamesHeader);
//Check if we're on Wi-Fi/Local network. If we still see a device, don't do anything special
BackgroundService service = BackgroundService.getInstance();
if (service == null) {
updateConnectivityInfoHeader(true);
} else {
service.isConnectedToNonCellularNetwork().observe(this, this::updateConnectivityInfoHeader);
}
try {
final ArrayList<ListAdapter.Item> items = new ArrayList<>();
SectionItem connectedSection;
Resources res = getResources();
Collection<Device> devices = KdeConnect.getInstance().getDevices().values();
HashSet<String> seenNames = new HashSet<>();
for (Device device : devices) {
if (seenNames.contains(device.getName())) {
devicesListBinding.devicesList.addHeaderView(duplicateNamesHeader);
break;
}
seenNames.add(device.getName());
}
connectedSection = new SectionItem(res.getString(R.string.category_connected_devices));
items.add(connectedSection);
for (Device device : devices) {
if (device.isReachable() && device.isPaired()) {
items.add(new PairingDeviceItem(device, PairingFragment.this));
connectedSection.isEmpty = false;
}
}
if (connectedSection.isEmpty) {
items.remove(items.size() - 1); //Remove connected devices section if empty
}
SectionItem availableSection = new SectionItem(res.getString(R.string.category_not_paired_devices));
items.add(availableSection);
for (Device device : devices) {
if (device.isReachable() && !device.isPaired()) {
items.add(new PairingDeviceItem(device, PairingFragment.this));
availableSection.isEmpty = false;
}
}
if (availableSection.isEmpty && !connectedSection.isEmpty) {
items.remove(items.size() - 1); //Remove remembered devices section if empty
}
SectionItem rememberedSection = new SectionItem(res.getString(R.string.category_remembered_devices));
items.add(rememberedSection);
for (Device device : devices) {
if (!device.isReachable() && device.isPaired()) {
items.add(new PairingDeviceItem(device, PairingFragment.this));
rememberedSection.isEmpty = false;
}
}
if (rememberedSection.isEmpty) {
items.remove(items.size() - 1); //Remove remembered devices section if empty
}
//Store current scroll
int index = devicesListBinding.devicesList.getFirstVisiblePosition();
View v = devicesListBinding.devicesList.getChildAt(0);
int top = (v == null) ? 0 : (v.getTop() - devicesListBinding.devicesList.getPaddingTop());
devicesListBinding.devicesList.setAdapter(new ListAdapter(mActivity, items));
//Restore scroll
devicesListBinding.devicesList.setSelectionFromTop(index, top);
} catch (IllegalStateException e) {
//Ignore: The activity was closed while we were trying to update it
} finally {
listRefreshCalledThisFrame = false;
}
}
void updateConnectivityInfoHeader(boolean isConnectedToNonCellularNetwork) {
Collection<Device> devices = KdeConnect.getInstance().getDevices().values();
boolean someDevicesReachable = false;
for (Device device : devices) {
if (device.isReachable()) {
someDevicesReachable = true;
}
}
boolean hasNotificationsPermission = ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED;
devicesListBinding.devicesList.removeHeaderView(headerText);
devicesListBinding.devicesList.removeHeaderView(noWifiHeader);
devicesListBinding.devicesList.removeHeaderView(notTrustedText);
devicesListBinding.devicesList.removeHeaderView(noNotificationsHeader);
if (someDevicesReachable || isConnectedToNonCellularNetwork) {
if (!hasNotificationsPermission) {
devicesListBinding.devicesList.addHeaderView(noNotificationsHeader);
} else if (TrustedNetworkHelper.isTrustedNetwork(getContext())) {
devicesListBinding.devicesList.addHeaderView(headerText);
} else {
devicesListBinding.devicesList.addHeaderView(notTrustedText);
}
} else {
devicesListBinding.devicesList.addHeaderView(noWifiHeader);
}
}
@Override
public void onStart() {
super.onStart();
KdeConnect.getInstance().addDeviceListChangedCallback("PairingFragment", () -> mActivity.runOnUiThread(this::updateDeviceList));
BackgroundService.ForceRefreshConnections(requireContext()); // force a network re-discover
updateDeviceList();
}
@Override
public void onStop() {
KdeConnect.getInstance().removeDeviceListChangedCallback("PairingFragment");
super.onStop();
}
@Override
public void pairingClicked(Device device) {
mActivity.onDeviceSelected(device.getDeviceId(), !device.isPaired() || !device.isReachable());
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
switch (requestCode) {
case RESULT_PAIRING_SUCCESFUL:
if (resultCode == 1) {
String deviceId = data.getStringExtra("deviceId");
mActivity.onDeviceSelected(deviceId);
}
break;
default:
super.onActivityResult(requestCode, resultCode, data);
}
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.pairing, menu);
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
int id = item.getItemId();
if (id == R.id.menu_refresh) {
refreshDevicesAction();
return true;
} else if (id == R.id.menu_custom_device_list) {
startActivity(new Intent(mActivity, CustomDevicesActivity.class));
return true;
} else if (id == R.id.menu_trusted_networks) {
startActivity(new Intent(mActivity, TrustedNetworksActivity.class));
return true;
} else {
return super.onOptionsItemSelected(item);
}
}
}

View File

@ -0,0 +1,331 @@
/*
* SPDX-FileCopyrightText: 2014 Albert Vaca Cintora <albertvaka@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.UserInterface
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.MenuProvider
import androidx.lifecycle.Lifecycle
import org.kde.kdeconnect.BackgroundService.Companion.ForceRefreshConnections
import org.kde.kdeconnect.BackgroundService.Companion.instance
import org.kde.kdeconnect.Device
import org.kde.kdeconnect.Helpers.TrustedNetworkHelper.Companion.isTrustedNetwork
import org.kde.kdeconnect.KdeConnect
import org.kde.kdeconnect.UserInterface.List.ListAdapter
import org.kde.kdeconnect.UserInterface.List.PairingDeviceItem
import org.kde.kdeconnect.UserInterface.List.SectionItem
import org.kde.kdeconnect.base.BaseFragment
import org.kde.kdeconnect.extensions.setupBottomPadding
import org.kde.kdeconnect_tp.R
import org.kde.kdeconnect_tp.databinding.DevicesListBinding
import org.kde.kdeconnect_tp.databinding.PairingExplanationDuplicateNamesBinding
import org.kde.kdeconnect_tp.databinding.PairingExplanationNotTrustedBinding
import org.kde.kdeconnect_tp.databinding.PairingExplanationTextBinding
import org.kde.kdeconnect_tp.databinding.PairingExplanationTextNoNotificationsBinding
import org.kde.kdeconnect_tp.databinding.PairingExplanationTextNoWifiBinding
/**
* The view that the user will see when there are no devices paired, or when you choose "add a new device" from the sidebar.
*/
class PairingFragment : BaseFragment<DevicesListBinding>(), PairingDeviceItem.Callback {
private var _textBinding: PairingExplanationTextBinding? = null
private var _duplicateNamesBinding: PairingExplanationDuplicateNamesBinding? = null
private var _textNoWifiBinding: PairingExplanationTextNoWifiBinding? = null
private var _textNoNotificationsBinding: PairingExplanationTextNoNotificationsBinding? = null
private var _textNotTrustedBinding: PairingExplanationNotTrustedBinding? = null
private val headerText: TextView get() = _textBinding!!.root
private val noWifiHeader: TextView get() = _textNoWifiBinding!!.root
private val duplicateNamesHeader: TextView get() = _duplicateNamesBinding!!.root
private val noNotificationsHeader: TextView get() = _textNoNotificationsBinding!!.root
private val notTrustedText: TextView get() = _textNotTrustedBinding!!.root
private var listRefreshCalledThisFrame = false
private val mainActivity by lazy { activity as MainActivity }
private val menuProvider = object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.pairing, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.menu_refresh -> {
refreshDevicesAction()
true
}
R.id.menu_custom_device_list -> {
startActivity(Intent(mainActivity, CustomDevicesActivity::class.java))
true
}
R.id.menu_trusted_networks -> {
startActivity(Intent(mainActivity, TrustedNetworksActivity::class.java))
true
}
else -> false
}
}
}
override fun onInflateBinding(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): DevicesListBinding {
_textBinding = PairingExplanationTextBinding.inflate(inflater)
_duplicateNamesBinding = PairingExplanationDuplicateNamesBinding.inflate(inflater)
_textNoWifiBinding = PairingExplanationTextNoWifiBinding.inflate(inflater)
_textNoNotificationsBinding = PairingExplanationTextNoNotificationsBinding.inflate(inflater)
_textNotTrustedBinding = PairingExplanationNotTrustedBinding.inflate(inflater)
return DevicesListBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Configure focus order for Accessibility, for touchpads, and for TV remotes
// (allow focus of items in the device list)
binding.devicesList.itemsCanFocus = true
binding.devicesList.setupBottomPadding()
mainActivity.supportActionBar?.setTitle(R.string.pairing_title)
mainActivity.addMenuProvider(menuProvider, viewLifecycleOwner, Lifecycle.State.RESUMED)
notTrustedText.setOnClickListener(null)
notTrustedText.setOnLongClickListener(null)
headerText.setOnClickListener(null)
headerText.setOnLongClickListener(null)
noWifiHeader.setOnClickListener {
startActivity(Intent(Settings.ACTION_WIFI_SETTINGS))
}
noNotificationsHeader.setOnClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.requestPermissions(
requireActivity(),
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
MainActivity.RESULT_NOTIFICATIONS_ENABLED
)
}
}
noNotificationsHeader.setOnLongClickListener {
val intent = Intent()
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
val uri = Uri.fromParts("package", requireContext().packageName, null)
intent.setData(uri)
startActivity(intent)
true
}
binding.devicesList.addHeaderView(headerText)
binding.refreshListLayout.setOnRefreshListener { this.refreshDevicesAction() }
}
override fun onDestroyView() {
super.onDestroyView()
_textBinding = null
_textNoWifiBinding = null
_textNoNotificationsBinding = null
_textNotTrustedBinding = null
}
private fun refreshDevicesAction() {
ForceRefreshConnections(requireContext())
binding.refreshListLayout.isRefreshing = true
binding.refreshListLayout.postDelayed({
if (isResumed && !isDetached) { // the view might be destroyed by now
binding.refreshListLayout.isRefreshing = false
}
}, 1500)
}
private fun updateDeviceList() {
if (!isAdded) {
//Fragment is not attached to an activity. We will crash if we try to do anything here.
return
}
if (listRefreshCalledThisFrame) {
// This makes sure we don't try to call list.getFirstVisiblePosition()
// twice per frame, because the second time the list hasn't been drawn
// yet and it would always return 0.
return
}
listRefreshCalledThisFrame = true
binding.devicesList.removeHeaderView(duplicateNamesHeader)
//Check if we're on Wi-Fi/Local network. If we still see a device, don't do anything special
val service = instance
if (service == null) {
updateConnectivityInfoHeader(true)
} else {
service.isConnectedToNonCellularNetwork.observe(this, ::updateConnectivityInfoHeader)
}
try {
val items = ArrayList<ListAdapter.Item>()
val connectedSection: SectionItem
val res = resources
val moreDevices = KdeConnect.getInstance().devices.values
val seenNames = hashSetOf<String>()
for (device in moreDevices) {
if (seenNames.contains(device.name)) {
binding.devicesList.addHeaderView(duplicateNamesHeader)
break
}
seenNames.add(device.name);
}
connectedSection = SectionItem(res.getString(R.string.category_connected_devices))
items.add(connectedSection)
val devices: Collection<Device> = KdeConnect.getInstance().devices.values
for (device in devices) {
if (device.isReachable && device.isPaired) {
items.add(PairingDeviceItem(device, this@PairingFragment))
connectedSection.isEmpty = false
}
}
if (connectedSection.isEmpty) {
items.removeAt(items.size - 1) //Remove connected devices section if empty
}
val availableSection = SectionItem(res.getString(R.string.category_not_paired_devices))
items.add(availableSection)
for (device in devices) {
if (device.isReachable && !device.isPaired) {
items.add(PairingDeviceItem(device, this@PairingFragment))
availableSection.isEmpty = false
}
}
if (availableSection.isEmpty && !connectedSection.isEmpty) {
items.removeAt(items.size - 1) //Remove remembered devices section if empty
}
val rememberedSection = SectionItem(res.getString(R.string.category_remembered_devices))
items.add(rememberedSection)
for (device in devices) {
if (!device.isReachable && device.isPaired) {
items.add(PairingDeviceItem(device, this@PairingFragment))
rememberedSection.isEmpty = false
}
}
if (rememberedSection.isEmpty) {
items.removeAt(items.size - 1) //Remove remembered devices section if empty
}
//Store current scroll
val index = binding.devicesList.firstVisiblePosition
val v = binding.devicesList.getChildAt(0)
val top = if ((v == null)) 0 else (v.top - binding.devicesList.paddingTop)
binding.devicesList.adapter = ListAdapter(mainActivity, items)
//Restore scroll
binding.devicesList.setSelectionFromTop(index, top)
} catch (e: IllegalStateException) {
//Ignore: The activity was closed while we were trying to update it
} finally {
listRefreshCalledThisFrame = false
}
}
private fun updateConnectivityInfoHeader(isConnectedToNonCellularNetwork: Boolean) {
val devices: Collection<Device> = KdeConnect.getInstance().devices.values
var someDevicesReachable = false
for (device in devices) {
if (device.isReachable) {
someDevicesReachable = true
}
}
val hasNotificationsPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
binding.devicesList.removeHeaderView(headerText)
binding.devicesList.removeHeaderView(noWifiHeader)
binding.devicesList.removeHeaderView(notTrustedText)
binding.devicesList.removeHeaderView(noNotificationsHeader)
if (someDevicesReachable || isConnectedToNonCellularNetwork) {
if (!hasNotificationsPermission) {
binding.devicesList.addHeaderView(noNotificationsHeader)
} else if (isTrustedNetwork(requireContext())) {
binding.devicesList.addHeaderView(headerText)
} else {
binding.devicesList.addHeaderView(notTrustedText)
}
} else {
binding.devicesList.addHeaderView(noWifiHeader)
}
}
override fun onStart() {
super.onStart()
KdeConnect.getInstance().addDeviceListChangedCallback("PairingFragment") {
mainActivity.runOnUiThread { this.updateDeviceList() }
}
ForceRefreshConnections(mainActivity) // force a network re-discover
updateDeviceList()
}
override fun onStop() {
KdeConnect.getInstance().removeDeviceListChangedCallback("PairingFragment")
super.onStop()
}
override fun pairingClicked(device: Device) {
mainActivity.onDeviceSelected(device.deviceId, !device.isPaired || !device.isReachable)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
RESULT_PAIRING_SUCCESFUL -> if (resultCode == 1) {
val deviceId = data?.getStringExtra("deviceId")
mainActivity.onDeviceSelected(deviceId)
}
else -> super.onActivityResult(requestCode, resultCode, data)
}
}
companion object {
private const val RESULT_PAIRING_SUCCESFUL = Activity.RESULT_FIRST_USER
}
}

View File

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2025 Mash Kyrielight <fiepi@live.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.base
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
abstract class BaseFragment<VB: ViewBinding> : Fragment() {
private var _binding: VB? = null
protected val binding get() = _binding!!
abstract fun onInflateBinding(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): VB
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = onInflateBinding(inflater, container, savedInstanceState)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2025 Mash Kyrielight <fiepi@live.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.extensions
import android.os.Bundle
import android.os.Parcelable
import androidx.core.os.BundleCompat
inline fun <reified T> Bundle.getParcelableCompat(key: String): T? {
return BundleCompat.getParcelable(this, key, T::class.java)
}
inline fun <reified T> Bundle.getParcelableArrayListCompat(key: String): ArrayList<T>? {
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
}
inline fun <reified T: Parcelable> Bundle.getParcelableArrayCompat(key: String): Array<Parcelable>? {
return BundleCompat.getParcelableArray(this, key, T::class.java)
}

View File

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2025 Mash Kyrielight <fiepi@live.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.extensions
import android.content.Intent
import android.os.Parcelable
import androidx.core.content.IntentCompat
inline fun <reified T> Intent.getParcelableCompat(key: String): T? {
return IntentCompat.getParcelableExtra(this, key, T::class.java)
}
inline fun <reified T> Intent.getParcelableArrayListCompat(key: String): ArrayList<T>? {
return IntentCompat.getParcelableArrayListExtra(this, key, T::class.java)
}
inline fun <reified T: Parcelable> Intent.getParcelableArrayCompat(key: String): Array<Parcelable>? {
return IntentCompat.getParcelableArrayExtra(this, key, T::class.java)
}