diff --git a/res/layout/system_volume_fragment.xml b/res/layout/system_volume_fragment.xml index 2630a117..8d61d3eb 100644 --- a/res/layout/system_volume_fragment.xml +++ b/res/layout/system_volume_fragment.xml @@ -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"> - - + android:paddingVertical="@dimen/activity_vertical_margin" /> diff --git a/src/org/kde/kdeconnect/Backends/BluetoothBackend/BluetoothLinkProvider.kt b/src/org/kde/kdeconnect/Backends/BluetoothBackend/BluetoothLinkProvider.kt index 5e0a081c..fb765df8 100644 --- a/src/org/kde/kdeconnect/Backends/BluetoothBackend/BluetoothLinkProvider.kt +++ b/src/org/kde/kdeconnect/Backends/BluetoothBackend/BluetoothLinkProvider.kt @@ -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.EXTRA_DEVICE) - val activeUuids = intent.getParcelableArrayExtra(BluetoothDevice.EXTRA_UUID) + val device = intent.getParcelableCompat(BluetoothDevice.EXTRA_DEVICE) + val activeUuids = intent.getParcelableArrayCompat(BluetoothDevice.EXTRA_UUID) if (sockets.containsKey(device)) { Log.i("BluetoothLinkProvider", "sockets contains device") return diff --git a/src/org/kde/kdeconnect/Plugins/FindMyPhonePlugin/FindMyPhoneSettingsFragment.java b/src/org/kde/kdeconnect/Plugins/FindMyPhonePlugin/FindMyPhoneSettingsFragment.java index 218ca81e..9a316954 100644 --- a/src/org/kde/kdeconnect/Plugins/FindMyPhonePlugin/FindMyPhoneSettingsFragment.java +++ b/src/org/kde/kdeconnect/Plugins/FindMyPhonePlugin/FindMyPhoneSettingsFragment.java @@ -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() diff --git a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisMediaNotificationReceiver.kt b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisMediaNotificationReceiver.kt index 7d24ee94..eb77b6f2 100644 --- a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisMediaNotificationReceiver.kt +++ b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisMediaNotificationReceiver.kt @@ -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 diff --git a/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java b/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java index 706054d9..3de6bcb4 100644 --- a/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/NotificationsPlugin/NotificationsPlugin.java @@ -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); diff --git a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java index dbc17682..2b837ca9 100644 --- a/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java +++ b/src/org/kde/kdeconnect/Plugins/SharePlugin/SharePlugin.java @@ -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 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()) { diff --git a/src/org/kde/kdeconnect/Plugins/SystemVolumePlugin/SystemVolumeFragment.java b/src/org/kde/kdeconnect/Plugins/SystemVolumePlugin/SystemVolumeFragment.java deleted file mode 100644 index e8e17f23..00000000 --- a/src/org/kde/kdeconnect/Plugins/SystemVolumePlugin/SystemVolumeFragment.java +++ /dev/null @@ -1,179 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2018 Nicolas Fella - * - * 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 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 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 { - - 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)); - } - } -} diff --git a/src/org/kde/kdeconnect/Plugins/SystemVolumePlugin/SystemVolumeFragment.kt b/src/org/kde/kdeconnect/Plugins/SystemVolumePlugin/SystemVolumeFragment.kt new file mode 100644 index 00000000..4ab9238f --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SystemVolumePlugin/SystemVolumeFragment.kt @@ -0,0 +1,162 @@ +/* + * SPDX-FileCopyrightText: 2018 Nicolas Fella + * + * 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(), + 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 = 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(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 + } + } +} diff --git a/src/org/kde/kdeconnect/UserInterface/About/AboutFragment.kt b/src/org/kde/kdeconnect/UserInterface/About/AboutFragment.kt index 8b27abe9..5f7b56bc 100644 --- a/src/org/kde/kdeconnect/UserInterface/About/AboutFragment.kt +++ b/src/org/kde/kdeconnect/UserInterface/About/AboutFragment.kt @@ -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() { 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 - } } \ No newline at end of file diff --git a/src/org/kde/kdeconnect/UserInterface/DeviceFragment.kt b/src/org/kde/kdeconnect/UserInterface/DeviceFragment.kt index 0929dbdb..ed7a5c0c 100644 --- a/src/org/kde/kdeconnect/UserInterface/DeviceFragment.kt +++ b/src/org/kde/kdeconnect/UserInterface/DeviceFragment.kt @@ -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() { 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 = 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 = 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() { } } } - } diff --git a/src/org/kde/kdeconnect/UserInterface/PairingFragment.java b/src/org/kde/kdeconnect/UserInterface/PairingFragment.java deleted file mode 100644 index 59b7f927..00000000 --- a/src/org/kde/kdeconnect/UserInterface/PairingFragment.java +++ /dev/null @@ -1,336 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2014 Albert Vaca Cintora - * - * 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 items = new ArrayList<>(); - - SectionItem connectedSection; - Resources res = getResources(); - - Collection devices = KdeConnect.getInstance().getDevices().values(); - - HashSet 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 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); - } - } - - -} diff --git a/src/org/kde/kdeconnect/UserInterface/PairingFragment.kt b/src/org/kde/kdeconnect/UserInterface/PairingFragment.kt new file mode 100644 index 00000000..c29101d2 --- /dev/null +++ b/src/org/kde/kdeconnect/UserInterface/PairingFragment.kt @@ -0,0 +1,331 @@ +/* + * SPDX-FileCopyrightText: 2014 Albert Vaca Cintora + * + * 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(), 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() + + val connectedSection: SectionItem + val res = resources + + val moreDevices = KdeConnect.getInstance().devices.values + + val seenNames = hashSetOf() + 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 = 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 = 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 + } +} diff --git a/src/org/kde/kdeconnect/base/BaseFragment.kt b/src/org/kde/kdeconnect/base/BaseFragment.kt new file mode 100644 index 00000000..7471bd6f --- /dev/null +++ b/src/org/kde/kdeconnect/base/BaseFragment.kt @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2025 Mash Kyrielight + * + * 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 : 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 + } +} \ No newline at end of file diff --git a/src/org/kde/kdeconnect/extensions/Bundle.kt b/src/org/kde/kdeconnect/extensions/Bundle.kt new file mode 100644 index 00000000..cc3c5667 --- /dev/null +++ b/src/org/kde/kdeconnect/extensions/Bundle.kt @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2025 Mash Kyrielight + * + * 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 Bundle.getParcelableCompat(key: String): T? { + return BundleCompat.getParcelable(this, key, T::class.java) +} + +inline fun Bundle.getParcelableArrayListCompat(key: String): ArrayList? { + return BundleCompat.getParcelableArrayList(this, key, T::class.java) +} + +inline fun Bundle.getParcelableArrayCompat(key: String): Array? { + return BundleCompat.getParcelableArray(this, key, T::class.java) +} \ No newline at end of file diff --git a/src/org/kde/kdeconnect/extensions/Intent.kt b/src/org/kde/kdeconnect/extensions/Intent.kt new file mode 100644 index 00000000..b8894ad6 --- /dev/null +++ b/src/org/kde/kdeconnect/extensions/Intent.kt @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2025 Mash Kyrielight + * + * 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 Intent.getParcelableCompat(key: String): T? { + return IntentCompat.getParcelableExtra(this, key, T::class.java) +} + +inline fun Intent.getParcelableArrayListCompat(key: String): ArrayList? { + return IntentCompat.getParcelableArrayListExtra(this, key, T::class.java) +} + +inline fun Intent.getParcelableArrayCompat(key: String): Array? { + return IntentCompat.getParcelableArrayExtra(this, key, T::class.java) +} \ No newline at end of file