/* * 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.content.Context; import android.content.Intent; import android.os.Bundle; import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.Menu; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import org.kde.kdeconnect.BackgroundService; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper; import org.kde.kdeconnect.Plugins.BatteryPlugin.BatteryPlugin; import org.kde.kdeconnect.Plugins.BatteryPlugin.DeviceBatteryInfo; import org.kde.kdeconnect.Plugins.Plugin; import org.kde.kdeconnect.UserInterface.List.FailedPluginListItem; import org.kde.kdeconnect.UserInterface.List.ListAdapter; import org.kde.kdeconnect.UserInterface.List.PluginItem; import org.kde.kdeconnect.UserInterface.List.PluginListHeaderItem; 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; import java.util.ArrayList; import java.util.Collection; import java.util.ConcurrentModificationException; import java.util.concurrent.ConcurrentHashMap; /** * Main view. Displays the current device and its plugins */ public class DeviceFragment extends Fragment { private static final String ARG_DEVICE_ID = "deviceId"; private static final String ARG_FROM_DEVICE_LIST = "fromDeviceList"; private static final String TAG = "KDE/DeviceFragment"; private String mDeviceId; private Device device; private MainActivity mActivity; private ArrayList pluginListItems; /** * Top-level ViewBinding for this fragment. * * Host for {@link #pluginListItems}. */ private ActivityDeviceBinding deviceBinding; /** * Not-yet-paired ViewBinding. * * Used to start and retry pairing. */ private ViewPairRequestBinding binding; /** * Cannot-communicate ViewBinding. * * Used when the remote device is unreachable. */ private ViewPairErrorBinding errorBinding; public DeviceFragment() { } public static DeviceFragment newInstance(String deviceId, boolean fromDeviceList) { DeviceFragment frag = new DeviceFragment(); Bundle args = new Bundle(); args.putString(ARG_DEVICE_ID, deviceId); args.putBoolean(ARG_FROM_DEVICE_LIST, fromDeviceList); frag.setArguments(args); return frag; } @Override public void onAttach(@NonNull Context context) { super.onAttach(context); mActivity = ((MainActivity) getActivity()); } @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (getArguments() == null || !getArguments().containsKey(ARG_DEVICE_ID)) { throw new RuntimeException("You must instantiate a new DeviceFragment using DeviceFragment.newInstance()"); } mDeviceId = getArguments().getString(ARG_DEVICE_ID); } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { deviceBinding = ActivityDeviceBinding.inflate(inflater, container, false); // Inner binding for the layout shown when we're not paired yet... binding = deviceBinding.pairRequest; // ...and for when pairing doesn't (or can't) work errorBinding = deviceBinding.pairError; binding.pairButton.setOnClickListener(v -> { binding.pairButton.setVisibility(View.GONE); binding.pairMessage.setText(""); binding.pairVerification.setVisibility(View.VISIBLE); binding.pairVerification.setText(SslHelper.getVerificationKey(SslHelper.certificate, device.certificate)); binding.pairProgress.setVisibility(View.VISIBLE); BackgroundService.RunCommand(mActivity, service -> { device = service.getDevice(mDeviceId); if (device == null) return; device.requestPairing(); }); }); binding.acceptButton.setOnClickListener(v -> { if (device != null) { device.acceptPairing(); binding.pairingButtons.setVisibility(View.GONE); } }); binding.rejectButton.setOnClickListener(v -> { if (device != null) { //Remove listener so buttons don't show for a while before changing the view device.removePluginsChangedListener(pluginsChangedListener); device.removePairingCallback(pairingCallback); device.rejectPairing(); } mActivity.onDeviceSelected(null); }); setHasOptionsMenu(true); BackgroundService.RunCommand(mActivity, service -> { device = service.getDevice(mDeviceId); if (device == null) { Log.e(TAG, "Trying to display a device fragment but the device is not present"); mActivity.onDeviceSelected(null); return; } mActivity.getSupportActionBar().setTitle(device.getName()); device.addPairingCallback(pairingCallback); device.addPluginsChangedListener(pluginsChangedListener); refreshUI(); }); return deviceBinding.getRoot(); } String getDeviceId() { return mDeviceId; } private final Device.PluginsChangedListener pluginsChangedListener = device -> refreshUI(); @Override public void onDestroyView() { BackgroundService.RunCommand(mActivity, service -> { Device device = service.getDevice(mDeviceId); if (device == null) return; device.removePluginsChangedListener(pluginsChangedListener); device.removePairingCallback(pairingCallback); }); super.onDestroyView(); binding = null; } @Override public void onPrepareOptionsMenu(@NonNull Menu menu) { super.onPrepareOptionsMenu(menu); menu.clear(); if (device == null) { return; } //Plugins button list final Collection plugins = device.getLoadedPlugins().values(); for (final Plugin p : plugins) { if (!p.displayInContextMenu()) { continue; } menu.add(p.getActionName()).setOnMenuItemClickListener(item -> { p.startMainActivity(mActivity); return true; }); } menu.add(R.string.device_menu_plugins).setOnMenuItemClickListener(menuItem -> { Intent intent = new Intent(mActivity, PluginSettingsActivity.class); intent.putExtra("deviceId", mDeviceId); startActivity(intent); return true; }); if (device.isReachable()) { menu.add(R.string.encryption_info_title).setOnMenuItemClickListener(menuItem -> { Context context = mActivity; AlertDialog.Builder builder = new AlertDialog.Builder(context); builder.setTitle(context.getResources().getString(R.string.encryption_info_title)); builder.setPositiveButton(context.getResources().getString(R.string.ok), (dialog, id) -> dialog.dismiss()); if (device.certificate == null) { builder.setMessage(R.string.encryption_info_msg_no_ssl); } else { builder.setMessage(context.getResources().getString(R.string.my_device_fingerprint) + "\n" + SslHelper.getCertificateHash(SslHelper.certificate) + "\n\n" + context.getResources().getString(R.string.remote_device_fingerprint) + "\n" + SslHelper.getCertificateHash(device.certificate)); } builder.show(); return true; }); } if (device.isPaired()) { menu.add(R.string.device_menu_unpair).setOnMenuItemClickListener(menuItem -> { //Remove listener so buttons don't show for a while before changing the view device.removePluginsChangedListener(pluginsChangedListener); device.removePairingCallback(pairingCallback); device.unpair(); mActivity.onDeviceSelected(null); return true; }); } } @Override public void onResume() { super.onResume(); getView().setFocusableInTouchMode(true); getView().requestFocus(); getView().setOnKeyListener((v, keyCode, event) -> { if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { boolean fromDeviceList = getArguments().getBoolean(ARG_FROM_DEVICE_LIST, false); // Handle back button so we go to the list of devices in case we came from there if (fromDeviceList) { mActivity.onDeviceSelected(null); return true; } } return false; }); } private void refreshUI() { if (device != null) { //Once in-app, there is no point in keep displaying the notification if any device.hidePairingNotification(); } mActivity.runOnUiThread(new Runnable() { @Override public void run() { if (device == null || binding == null) { return; } if (device.isPairRequestedByPeer()) { binding.pairMessage.setText(R.string.pair_requested); binding.pairVerification.setVisibility(View.VISIBLE); binding.pairVerification.setText(SslHelper.getVerificationKey(SslHelper.certificate, device.certificate)); binding.pairingButtons.setVisibility(View.VISIBLE); binding.pairProgress.setVisibility(View.GONE); binding.pairButton.setVisibility(View.GONE); binding.pairRequestButtons.setVisibility(View.VISIBLE); } else { boolean paired = device.isPaired(); boolean reachable = device.isReachable(); binding.pairingButtons.setVisibility(paired ? View.GONE : View.VISIBLE); errorBinding.errorMessageContainer.setVisibility((paired && !reachable) ? View.VISIBLE : View.GONE); errorBinding.notReachableMessage.setVisibility((paired && !reachable) ? View.VISIBLE : View.GONE); deviceBinding.viewStatusContainer.setVisibility((paired && reachable) ? View.VISIBLE : View.GONE); try { pluginListItems = new ArrayList<>(); if (paired && reachable) { //Plugins button list final Collection plugins = device.getLoadedPlugins().values(); for (final Plugin p : plugins) { if (!p.hasMainActivity(getContext())) continue; if (p.displayInContextMenu()) continue; pluginListItems.add(new PluginItem(p, v -> p.startMainActivity(mActivity))); } DeviceFragment.this.createPluginsList(device.getPluginsWithoutPermissions(), R.string.plugins_need_permission, (plugin) -> { DialogFragment dialog = plugin.getPermissionExplanationDialog(); if (dialog != null) { dialog.show(getChildFragmentManager(), null); } }); DeviceFragment.this.createPluginsList(device.getPluginsWithoutOptionalPermissions(), R.string.plugins_need_optional_permission, (plugin) -> { DialogFragment dialog = plugin.getOptionalPermissionExplanationDialog(); if (dialog != null) { dialog.show(getChildFragmentManager(), null); } }); DeviceFragment.this.displayBatteryInfoIfPossible(); } ListAdapter adapter = new ListAdapter(mActivity, pluginListItems); deviceBinding.buttonsList.setAdapter(adapter); mActivity.invalidateOptionsMenu(); } catch (IllegalStateException e) { //Ignore: The activity was closed while we were trying to update it } catch (ConcurrentModificationException e) { Log.e(TAG, "ConcurrentModificationException"); this.run(); //Try again } } } }); } private final Device.PairingCallback pairingCallback = new Device.PairingCallback() { @Override public void incomingRequest() { refreshUI(); } @Override public void pairingSuccessful() { refreshUI(); } @Override public void pairingFailed(final String error) { mActivity.runOnUiThread(() -> { if (binding == null) return; binding.pairMessage.setText(error); binding.pairVerification.setText(""); binding.pairVerification.setVisibility(View.GONE); binding.pairProgress.setVisibility(View.GONE); binding.pairButton.setVisibility(View.VISIBLE); binding.pairRequestButtons.setVisibility(View.GONE); refreshUI(); }); } @Override public void unpaired() { mActivity.runOnUiThread(() -> { if (binding == null) return; binding.pairMessage.setText(R.string.device_not_paired); binding.pairVerification.setVisibility(View.GONE); binding.pairProgress.setVisibility(View.GONE); binding.pairButton.setVisibility(View.VISIBLE); binding.pairRequestButtons.setVisibility(View.GONE); refreshUI(); }); } }; private void createPluginsList(ConcurrentHashMap plugins, int headerText, FailedPluginListItem.Action action) { if (plugins.isEmpty()) return; pluginListItems.add(new PluginListHeaderItem(headerText)); for (Plugin plugin : plugins.values()) { if (!device.isPluginEnabled(plugin.getPluginKey())) { continue; } pluginListItems.add(new FailedPluginListItem(plugin, action)); } } /** * This method tries to display battery info for the remote device. Includes *
    *
  • Current charge as a percentage
  • *
  • Whether the remote device is low on power
  • *
  • Whether the remote device is currently charging
  • *
*

* This will show a simple message on the view instead if we don't have * accurate info right now. *

*/ private void displayBatteryInfoIfPossible() { boolean canDisplayBatteryInfo = false; BatteryPlugin batteryPlugin = (BatteryPlugin) device.getLoadedPlugins().get( Plugin.getPluginKey(BatteryPlugin.class) ); if (batteryPlugin != null) { DeviceBatteryInfo info = batteryPlugin.getRemoteBatteryInfo(); if (info != null) { canDisplayBatteryInfo = true; Context ctx = deviceBinding.viewBatteryStatus.getContext(); boolean isCharging = info.isCharging(); @StringRes int resId; if (isCharging) { resId = R.string.battery_status_charging_format; } else if (BatteryPlugin.isLowBattery(info)) { resId = R.string.battery_status_low_format; } else { resId = R.string.battery_status_format; } deviceBinding.viewBatteryStatus.setChecked(isCharging); deviceBinding.viewBatteryStatus.setText(ctx.getString(resId, info.getCurrentCharge())); } } if (!canDisplayBatteryInfo) { deviceBinding.viewBatteryStatus.setText(R.string.battery_status_unknown); } } }