2
0
mirror of https://github.com/KDE/kdeconnect-android synced 2025-08-22 09:58:08 +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:clipToPadding="false"
android:orientation="vertical" android:orientation="vertical"
android:paddingHorizontal="@dimen/activity_horizontal_margin" android:paddingHorizontal="@dimen/activity_horizontal_margin"
android:paddingVertical="@dimen/activity_vertical_margin"> android:paddingVertical="@dimen/activity_vertical_margin" />
</androidx.recyclerview.widget.RecyclerView>

View File

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

View File

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

View File

@ -10,6 +10,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Log import android.util.Log
import org.kde.kdeconnect.KdeConnect import org.kde.kdeconnect.KdeConnect
import org.kde.kdeconnect.extensions.getParcelableCompat
/** /**
* Called when the mpris media notification's buttons are pressed * Called when the mpris media notification's buttons are pressed
@ -20,7 +21,7 @@ class MprisMediaNotificationReceiver : BroadcastReceiver() {
if (Intent.ACTION_MEDIA_BUTTON == intent.action) { if (Intent.ACTION_MEDIA_BUTTON == intent.action) {
// Route these buttons to the media session, which will handle them // Route these buttons to the media session, which will handle them
val mediaSession = MprisMediaSession.getMediaSession() ?: return val mediaSession = MprisMediaSession.getMediaSession() ?: return
mediaSession.controller.dispatchMediaButtonEvent(intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT)) mediaSession.controller.dispatchMediaButtonEvent(intent.getParcelableCompat(Intent.EXTRA_KEY_EVENT))
} else { } else {
// Second case: buttons on the notification, which we created ourselves // Second case: buttons on the notification, which we created ourselves
// Get the correct device, the mpris plugin and the mpris player // 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.Nullable;
import androidx.annotation.RequiresApi; import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import androidx.core.os.BundleCompat;
import androidx.fragment.app.DialogFragment; import androidx.fragment.app.DialogFragment;
import org.apache.commons.collections4.MultiValuedMap; import org.apache.commons.collections4.MultiValuedMap;
@ -358,7 +359,7 @@ public class NotificationsPlugin extends Plugin implements NotificationReceiver.
if (!notification.extras.containsKey(Notification.EXTRA_MESSAGES)) if (!notification.extras.containsKey(Notification.EXTRA_MESSAGES))
return new Pair<>(null, null); 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) if (ms == null)
return new Pair<>(null, null); return new Pair<>(null, null);

View File

@ -25,10 +25,12 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread; import androidx.annotation.WorkerThread;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.core.content.IntentCompat;
import androidx.core.content.LocusIdCompat; import androidx.core.content.LocusIdCompat;
import androidx.core.content.pm.ShortcutInfoCompat; import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat; import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat; import androidx.core.graphics.drawable.IconCompat;
import androidx.core.os.BundleCompat;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.ArrayUtils;
@ -355,10 +357,10 @@ public class SharePlugin extends Plugin {
Log.i("SharePlugin", "Intent contains streams to share"); Log.i("SharePlugin", "Intent contains streams to share");
ArrayList<Uri> uriList; ArrayList<Uri> uriList;
if (Intent.ACTION_SEND_MULTIPLE.equals(intent.getAction())) { if (Intent.ACTION_SEND_MULTIPLE.equals(intent.getAction())) {
uriList = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); uriList = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri.class);
} else { } else {
uriList = new ArrayList<>(); 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)); uriList.removeAll(Collections.singleton(null));
if (uriList.isEmpty()) { 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.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import org.kde.kdeconnect.UserInterface.List.ListAdapter 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.extensions.setupBottomPadding
import org.kde.kdeconnect_tp.R import org.kde.kdeconnect_tp.R
import org.kde.kdeconnect_tp.databinding.FragmentAboutBinding import org.kde.kdeconnect_tp.databinding.FragmentAboutBinding
import androidx.core.net.toUri import androidx.core.net.toUri
class AboutFragment : Fragment() { class AboutFragment : BaseFragment<FragmentAboutBinding>() {
private var _binding: FragmentAboutBinding? = null
private val binding get() = _binding!!
private lateinit var aboutData: AboutData
private var tapCount = 0
private var firstTapMillis: Long? = null
companion object { companion object {
private const val KEY_ABOUT_DATA = "about_data"
@JvmStatic @JvmStatic
fun newInstance(aboutData: AboutData): Fragment { fun newInstance(aboutData: AboutData): Fragment {
val fragment = AboutFragment() val fragment = AboutFragment()
val args = Bundle(1) val args = Bundle(1)
args.putParcelable("ABOUT_DATA", aboutData) args.putParcelable(KEY_ABOUT_DATA, aboutData)
fragment.arguments = args fragment.arguments = args
return fragment return fragment
} }
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { private lateinit var aboutData: AboutData
if (activity != null) { private var tapCount = 0
(requireActivity() as MainActivity).supportActionBar?.setTitle(R.string.about) private var firstTapMillis: Long? = null
}
aboutData = requireArguments().getParcelable("ABOUT_DATA")!! override fun onCreate(savedInstanceState: Bundle?) {
_binding = FragmentAboutBinding.inflate(inflater, container, false) super.onCreate(savedInstanceState)
aboutData = arguments?.getParcelableCompat(KEY_ABOUT_DATA) ?: throw IllegalArgumentException("AboutData is null")
}
updateData() override fun onInflateBinding(
return binding.root inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): FragmentAboutBinding {
return FragmentAboutBinding.inflate(inflater, container, false)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
(activity as? AppCompatActivity)?.supportActionBar?.setTitle(R.string.about)
binding.scrollView.setupBottomPadding() binding.scrollView.setupBottomPadding()
updateData()
} }
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
@ -65,8 +71,8 @@ class AboutFragment : Fragment() {
// Update general info // Update general info
binding.appName.text = aboutData.name binding.appName.text = aboutData.name
binding.appIcon.setImageDrawable(this.context?.let { ContextCompat.getDrawable(it, aboutData.icon) }) binding.appIcon.setImageDrawable(context?.let { ContextCompat.getDrawable(it, aboutData.icon) })
binding.appVersion.text = this.context?.getString(R.string.version, aboutData.versionName) binding.appVersion.text = context?.getString(R.string.version, aboutData.versionName)
// Setup Easter Egg onClickListener // Setup Easter Egg onClickListener
@ -103,7 +109,7 @@ class AboutFragment : Fragment() {
setupInfoButton(aboutData.websiteURL, binding.websiteButton) setupInfoButton(aboutData.websiteURL, binding.websiteButton)
// Update authors // 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) { if (aboutData.authorsFooterText != null) {
binding.authorsFooterText.text = context?.getString(aboutData.authorsFooterText!!) 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.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.StringRes 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.* 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 com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.kde.kdeconnect.BackgroundService import org.kde.kdeconnect.BackgroundService
import org.kde.kdeconnect.Device 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.PresenterPlugin.PresenterPlugin
import org.kde.kdeconnect.Plugins.RunCommandPlugin.RunCommandPlugin import org.kde.kdeconnect.Plugins.RunCommandPlugin.RunCommandPlugin
import org.kde.kdeconnect.UserInterface.compose.KdeTheme import org.kde.kdeconnect.UserInterface.compose.KdeTheme
import org.kde.kdeconnect.base.BaseFragment
import org.kde.kdeconnect.extensions.setupBottomPadding import org.kde.kdeconnect.extensions.setupBottomPadding
import org.kde.kdeconnect_tp.R import org.kde.kdeconnect_tp.R
import org.kde.kdeconnect_tp.databinding.ActivityDeviceBinding 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 * Main view. Displays the current device and its plugins
*/ */
class DeviceFragment : Fragment() { class DeviceFragment : BaseFragment<ActivityDeviceBinding>() {
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")
companion object { companion object {
private const val ARG_DEVICE_ID = "deviceId" private const val ARG_DEVICE_ID = "deviceId"
@ -97,35 +71,127 @@ class DeviceFragment : Fragment() {
} }
} }
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, 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? }
/**
* 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? savedInstanceState: Bundle?
): View? { ): ActivityDeviceBinding {
deviceBinding = ActivityDeviceBinding.inflate(inflater, container, false) return ActivityDeviceBinding.inflate(inflater, container, false)
val deviceBinding = deviceBinding ?: return null }
// Inner binding for the layout shown when we're not paired yet... private val menuProvider = object : MenuProvider {
pairingBinding = deviceBinding.pairRequest override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
// ...and for when pairing doesn't (or can't) work menu.clear()
errorBinding = deviceBinding.pairError val device = device ?: return
device = KdeConnect.getInstance().getDevice(deviceId) //Plugins button list
val plugins: Collection<Plugin> = device.loadedPlugins.values
requireErrorBinding().errorMessageContainer.setOnRefreshListener { for (p in plugins) {
this.refreshDevicesAction() 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() device?.requestPairing()
refreshUI() refreshUI()
} }
requirePairingBinding().acceptButton.setOnClickListener { pairingBinding.acceptButton.setOnClickListener {
device?.apply { device?.apply {
acceptPairing() acceptPairing()
requirePairingBinding().pairingButtons.visibility = View.GONE pairingBinding.pairingButtons.visibility = View.GONE
} }
} }
requirePairingBinding().rejectButton.setOnClickListener { pairingBinding.rejectButton.setOnClickListener {
device?.apply { device?.apply {
// Remove listener so buttons don't show for an instant before changing the view // Remove listener so buttons don't show for an instant before changing the view
removePluginsChangedListener(pluginsChangedListener) removePluginsChangedListener(pluginsChangedListener)
@ -134,8 +200,7 @@ class DeviceFragment : Fragment() {
} }
mActivity?.onDeviceSelected(null) mActivity?.onDeviceSelected(null)
} }
setHasOptionsMenu(true) device = KdeConnect.getInstance().getDevice(deviceId)
device?.apply { device?.apply {
mActivity?.supportActionBar?.title = name mActivity?.supportActionBar?.title = name
addPairingCallback(pairingCallback) addPairingCallback(pairingCallback)
@ -144,104 +209,31 @@ class DeviceFragment : Fragment() {
Log.e(TAG, "Trying to display a device fragment but the device is not present") Log.e(TAG, "Trying to display a device fragment but the device is not present")
mActivity?.onDeviceSelected(null) mActivity?.onDeviceSelected(null)
} }
mActivity?.addMenuProvider(menuProvider, viewLifecycleOwner)
refreshUI() refreshUI()
return deviceBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
deviceBinding?.deviceView?.setupBottomPadding()
} }
private fun refreshDevicesAction() { private fun refreshDevicesAction() {
BackgroundService.ForceRefreshConnections(requireContext()) BackgroundService.ForceRefreshConnections(requireContext())
requireErrorBinding().errorMessageContainer.isRefreshing = true errorBinding.errorMessageContainer.isRefreshing = true
requireErrorBinding().errorMessageContainer.postDelayed({ errorBinding.errorMessageContainer.postDelayed({
errorBinding?.errorMessageContainer?.isRefreshing = false // check for null since the view might be destroyed by now if (viewLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
errorBinding.errorMessageContainer.isRefreshing = false // check for null since the view might be destroyed by now
}
}, 1500) }, 1500)
} }
private val pluginsChangedListener = PluginsChangedListener { mActivity?.runOnUiThread { refreshUI() } } private val pluginsChangedListener = PluginsChangedListener { mActivity?.runOnUiThread { refreshUI() } }
override fun onDestroyView() { override fun onDestroyView() {
device?.apply { device?.apply {
removePluginsChangedListener(pluginsChangedListener) removePluginsChangedListener(pluginsChangedListener)
removePairingCallback(pairingCallback) removePairingCallback(pairingCallback)
} }
device = null device = null
pairingBinding = null
errorBinding = null
deviceBinding = null
super.onDestroyView() 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() { override fun onResume() {
super.onResume() super.onResume()
@ -270,13 +262,13 @@ class DeviceFragment : Fragment() {
when (device.pairStatus) { when (device.pairStatus) {
PairingHandler.PairState.NotPaired -> { PairingHandler.PairState.NotPaired -> {
requireErrorBinding().errorMessageContainer.visibility = View.GONE errorBinding.errorMessageContainer.visibility = View.GONE
requireDeviceBinding().deviceView.visibility = View.GONE binding.deviceView.visibility = View.GONE
requirePairingBinding().pairingButtons.visibility = View.VISIBLE pairingBinding.pairingButtons.visibility = View.VISIBLE
requirePairingBinding().pairVerification.visibility = View.GONE pairingBinding.pairVerification.visibility = View.GONE
} }
PairingHandler.PairState.Requested -> { PairingHandler.PairState.Requested -> {
with(requirePairingBinding()) { with(pairingBinding) {
pairButton.visibility = View.GONE pairButton.visibility = View.GONE
pairMessage.text = getString(R.string.pair_requested) pairMessage.text = getString(R.string.pair_requested)
pairProgress.visibility = View.VISIBLE pairProgress.visibility = View.VISIBLE
@ -285,7 +277,7 @@ class DeviceFragment : Fragment() {
} }
} }
PairingHandler.PairState.RequestedByPeer -> { PairingHandler.PairState.RequestedByPeer -> {
with (requirePairingBinding()) { with (pairingBinding) {
pairMessage.setText(R.string.pair_requested) pairMessage.setText(R.string.pair_requested)
pairVerification.visibility = View.VISIBLE pairVerification.visibility = View.VISIBLE
pairingButtons.visibility = View.VISIBLE pairingButtons.visibility = View.VISIBLE
@ -295,25 +287,25 @@ class DeviceFragment : Fragment() {
pairVerification.text = device.verificationKey pairVerification.text = device.verificationKey
pairVerification.visibility = View.VISIBLE pairVerification.visibility = View.VISIBLE
} }
requireDeviceBinding().deviceView.visibility = View.GONE binding.deviceView.visibility = View.GONE
} }
PairingHandler.PairState.Paired -> { PairingHandler.PairState.Paired -> {
requirePairingBinding().pairingButtons.visibility = View.GONE pairingBinding.pairingButtons.visibility = View.GONE
if (device.isReachable) { if (device.isReachable) {
val context = requireContext() val context = requireContext()
val pluginsWithButtons = device.loadedPlugins.values.filter { it.displayAsButton(context) } val pluginsWithButtons = device.loadedPlugins.values.filter { it.displayAsButton(context) }
val pluginsNeedPermissions = device.pluginsWithoutPermissions.values.filter { device.isPluginEnabled(it.pluginKey) } val pluginsNeedPermissions = device.pluginsWithoutPermissions.values.filter { device.isPluginEnabled(it.pluginKey) }
val pluginsNeedOptionalPermissions = device.pluginsWithoutOptionalPermissions.values.filter { device.isPluginEnabled(it.pluginKey) } val pluginsNeedOptionalPermissions = device.pluginsWithoutOptionalPermissions.values.filter { device.isPluginEnabled(it.pluginKey) }
requireErrorBinding().errorMessageContainer.visibility = View.GONE errorBinding.errorMessageContainer.visibility = View.GONE
requireDeviceBinding().deviceView.visibility = View.VISIBLE binding.deviceView.visibility = View.VISIBLE
requireDeviceBinding().deviceViewCompose.apply { binding.deviceViewCompose.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent { KdeTheme(context) { PluginList(pluginsWithButtons, pluginsNeedPermissions, pluginsNeedOptionalPermissions) } } setContent { KdeTheme(context) { PluginList(pluginsWithButtons, pluginsNeedPermissions, pluginsNeedOptionalPermissions) } }
} }
displayBatteryInfoIfPossible() displayBatteryInfoIfPossible()
} else { } else {
requireErrorBinding().errorMessageContainer.visibility = View.VISIBLE errorBinding.errorMessageContainer.visibility = View.VISIBLE
requireDeviceBinding().deviceView.visibility = View.GONE binding.deviceView.visibility = View.GONE
} }
} }
} }
@ -326,13 +318,13 @@ class DeviceFragment : Fragment() {
} }
override fun pairingSuccessful() { override fun pairingSuccessful() {
requirePairingBinding().pairMessage.announceForAccessibility(getString(R.string.pair_succeeded)) pairingBinding.pairMessage.announceForAccessibility(getString(R.string.pair_succeeded))
mActivity?.runOnUiThread { refreshUI() } mActivity?.runOnUiThread { refreshUI() }
} }
override fun pairingFailed(error: String) { override fun pairingFailed(error: String) {
mActivity?.runOnUiThread { mActivity?.runOnUiThread {
with(requirePairingBinding()) { with(pairingBinding) {
pairMessage.text = error pairMessage.text = error
pairProgress.visibility = View.GONE pairProgress.visibility = View.GONE
pairButton.visibility = View.VISIBLE pairButton.visibility = View.VISIBLE
@ -344,7 +336,7 @@ class DeviceFragment : Fragment() {
override fun unpaired() { override fun unpaired() {
mActivity?.runOnUiThread { mActivity?.runOnUiThread {
with(requirePairingBinding()) { with(pairingBinding) {
pairMessage.setText(R.string.device_not_paired) pairMessage.setText(R.string.device_not_paired)
pairProgress.visibility = View.GONE pairProgress.visibility = View.GONE
pairButton.visibility = View.VISIBLE 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)
}