From 0067ad51b1934c7898d099d3f5b977878386be6e Mon Sep 17 00:00:00 2001 From: Martin Sh Date: Tue, 25 Nov 2025 22:28:51 +0200 Subject: [PATCH] Digitizer plugin Add plugin that allows users to use their devices as pressure-sensitive drawing tablets. * Schemas MR: https://invent.kde.org/network/kdeconnect-meta/-/merge_requests/16 * KDE MR: https://invent.kde.org/network/kdeconnect-kde/-/merge_requests/862 --- AndroidManifest.xml | 12 ++ res/drawable/ic_draw_24dp.xml | 10 + res/drawable/ic_finger_24dp.xml | 10 + res/drawable/ic_open_in_full_24dp.xml | 10 + res/layout/activity_digitizer.xml | 43 ++++ res/menu/menu_digitizer.xml | 17 ++ res/menu/menu_mousepad.xml | 7 + res/values/strings.xml | 27 +++ res/xml/digitizer_preferences.xml | 31 +++ .../DigitizerPlugin/DigitizerActivity.kt | 187 ++++++++++++++++++ .../DigitizerPlugin/DigitizerPlugin.kt | 103 ++++++++++ .../Plugins/DigitizerPlugin/DrawingPadView.kt | 163 +++++++++++++++ .../Plugins/DigitizerPlugin/ToolEvent.kt | 21 ++ .../MousePadPlugin/MousePadActivity.java | 17 ++ 14 files changed, 658 insertions(+) create mode 100644 res/drawable/ic_draw_24dp.xml create mode 100644 res/drawable/ic_finger_24dp.xml create mode 100644 res/drawable/ic_open_in_full_24dp.xml create mode 100644 res/layout/activity_digitizer.xml create mode 100644 res/menu/menu_digitizer.xml create mode 100644 res/xml/digitizer_preferences.xml create mode 100644 src/org/kde/kdeconnect/Plugins/DigitizerPlugin/DigitizerActivity.kt create mode 100644 src/org/kde/kdeconnect/Plugins/DigitizerPlugin/DigitizerPlugin.kt create mode 100644 src/org/kde/kdeconnect/Plugins/DigitizerPlugin/DrawingPadView.kt create mode 100644 src/org/kde/kdeconnect/Plugins/DigitizerPlugin/ToolEvent.kt diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 23542b25..a3401028 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -403,6 +403,18 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted android:value="org.kde.kdeconnect.UserInterface.PluginSettingsActivity" /> + + + + + + diff --git a/res/drawable/ic_finger_24dp.xml b/res/drawable/ic_finger_24dp.xml new file mode 100644 index 00000000..b38cd835 --- /dev/null +++ b/res/drawable/ic_finger_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/res/drawable/ic_open_in_full_24dp.xml b/res/drawable/ic_open_in_full_24dp.xml new file mode 100644 index 00000000..ff1a8dd6 --- /dev/null +++ b/res/drawable/ic_open_in_full_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/res/layout/activity_digitizer.xml b/res/layout/activity_digitizer.xml new file mode 100644 index 00000000..29d23ba9 --- /dev/null +++ b/res/layout/activity_digitizer.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/menu/menu_digitizer.xml b/res/menu/menu_digitizer.xml new file mode 100644 index 00000000..fcf7432d --- /dev/null +++ b/res/menu/menu_digitizer.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/res/menu/menu_mousepad.xml b/res/menu/menu_mousepad.xml index 5f64abe1..d3c9a7a8 100644 --- a/res/menu/menu_mousepad.xml +++ b/res/menu/menu_mousepad.xml @@ -9,6 +9,13 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted + + Periodically report battery status Connectivity report Report network signal strength and status + Drawing tablet + Use your device as a pressure sensitive drawing tablet Filesystem access Allows to browse this device\'s filesystem remotely Clipboard sync @@ -321,6 +323,29 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted Permissions missing: filesystem access No players found Send files + Use as drawing tablet + Draw using finger + digitizer_hide_draw_button + Hide draw button + Completely hides the draw button, making finger drawing impossible. Only enable this if your device supports stylus input. + digitizer_draw_button_side + Side of “Draw with finger” button + digitizer_enable_exit_fullscreen_gesture + Enable fullscreen exit gesture + If enabled, tapping and holding on the drawing pad with your finger will disable fullscreen mode. + + top_left + top_right + bottom_left + bottom_right + + + Top left + Top right + Bottom left + Bottom right + + bottom_left Block notification contents Block notification images @@ -609,4 +634,6 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted Host is invalid. Use a valid hostname, IPv4, or IPv6 Host already exists in the list + Enable fullscreen + diff --git a/res/xml/digitizer_preferences.xml b/res/xml/digitizer_preferences.xml new file mode 100644 index 00000000..3447d020 --- /dev/null +++ b/res/xml/digitizer_preferences.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/org/kde/kdeconnect/Plugins/DigitizerPlugin/DigitizerActivity.kt b/src/org/kde/kdeconnect/Plugins/DigitizerPlugin/DigitizerActivity.kt new file mode 100644 index 00000000..678f3964 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/DigitizerPlugin/DigitizerActivity.kt @@ -0,0 +1,187 @@ +/* + * SPDX-FileCopyrightText: 2025 Martin Sh + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +package org.kde.kdeconnect.Plugins.DigitizerPlugin + +import android.annotation.SuppressLint +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import android.view.Gravity +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.view.View +import android.widget.FrameLayout +import androidx.activity.OnBackPressedCallback +import androidx.activity.addCallback +import androidx.core.content.edit +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.preference.PreferenceManager +import org.kde.kdeconnect.KdeConnect +import org.kde.kdeconnect.UserInterface.PluginSettingsActivity +import org.kde.kdeconnect.base.BaseActivity +import org.kde.kdeconnect.extensions.viewBinding +import org.kde.kdeconnect_tp.R +import org.kde.kdeconnect_tp.databinding.ActivityDigitizerBinding +import kotlin.math.roundToInt + +class DigitizerActivity : BaseActivity(), DrawingPadView.EventListener { + override val binding: ActivityDigitizerBinding by viewBinding(ActivityDigitizerBinding::inflate) + + private lateinit var prefs: SharedPreferences + + private lateinit var fullscreenBackCallback: OnBackPressedCallback + + private lateinit var deviceId: String + + private val plugin: DigitizerPlugin? + get() { + val plugin = KdeConnect.getInstance().getDevicePlugin(deviceId, DigitizerPlugin::class.java) + + if (plugin == null) + finish() + + return plugin + } + + private var prefHideDrawButton: Boolean + get() = prefs.getBoolean(getString(R.string.digitizer_preference_key_hide_draw_button), false) + set(value) = prefs.edit { + putBoolean(getString(R.string.digitizer_preference_key_hide_draw_button), value) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + deviceId = intent.getStringExtra("deviceId")!! + + setSupportActionBar(binding.toolbarLayout.toolbar) + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + supportActionBar!!.setDisplayShowHomeEnabled(true) + + prefs = PreferenceManager.getDefaultSharedPreferences(this) + + binding.drawingPad.eventListener = this + + @SuppressLint("ClickableViewAccessibility") + binding.buttonDraw.setOnTouchListener { _, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + binding.drawingPad.fingerTouchEventsEnabled = true + true + } + MotionEvent.ACTION_UP -> { + binding.drawingPad.fingerTouchEventsEnabled = false + true + } + else -> false + } + } + + fullscreenBackCallback = onBackPressedDispatcher.addCallback(this) { + disableFullscreen() + } + } + + private fun enableFullscreen() { + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + supportActionBar!!.hide() + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + + fullscreenBackCallback.isEnabled = true + } + + private fun disableFullscreen() { + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT + + supportActionBar!!.show() + windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) + + fullscreenBackCallback.isEnabled = false + } + + override fun onSupportNavigateUp(): Boolean { + onBackPressedDispatcher.onBackPressed() + return true + } + + override fun onStart() { + super.onStart() + // During onStart, the views aren't laid out yet. + // We must wait a frame for the view to get laid out before we can query its size. + binding.drawingPad.post { + plugin?.startSession( + binding.drawingPad.width, + binding.drawingPad.height, + (resources.displayMetrics.xdpi * INCHES_TO_MM).roundToInt(), + (resources.displayMetrics.ydpi * INCHES_TO_MM).roundToInt() + ) + } + + if (prefHideDrawButton) { + binding.buttonDraw.visibility = View.GONE + } + + binding.buttonDraw.layoutParams = (binding.buttonDraw.layoutParams as FrameLayout.LayoutParams).also { + @SuppressLint("RtlHardcoded") + when (prefs.getString(getString(R.string.digitizer_preference_key_draw_button_side), "bottom_left")) { + "top_left" -> it.gravity = Gravity.TOP or Gravity.LEFT + "top_right" -> it.gravity = Gravity.TOP or Gravity.RIGHT + "bottom_left" -> it.gravity = Gravity.BOTTOM or Gravity.LEFT + "bottom_right" -> it.gravity = Gravity.BOTTOM or Gravity.RIGHT + } + } + } + + override fun onStop() { + super.onStop() + plugin?.endSession() + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + val inflater = menuInflater + inflater.inflate(R.menu.menu_digitizer, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_fullscreen -> { + enableFullscreen() + true + } + R.id.menu_open_settings -> { + startActivity( + Intent(this, PluginSettingsActivity::class.java) + .putExtra(PluginSettingsActivity.EXTRA_DEVICE_ID, deviceId) + .putExtra(PluginSettingsActivity.EXTRA_PLUGIN_KEY, DigitizerPlugin::class.java.getSimpleName()) + ) + true + } + else -> super.onOptionsItemSelected(item) + } + } + + override fun onToolEvent(event: ToolEvent) { + plugin?.reportEvent(event) + } + + override fun onFingerTouchEvent(touching: Boolean) { + binding.buttonDraw.isEnabled = touching + } + + companion object { + private const val TAG = "DigitizerActivity" + + private const val INCHES_TO_MM = 0.0393701 + } +} \ No newline at end of file diff --git a/src/org/kde/kdeconnect/Plugins/DigitizerPlugin/DigitizerPlugin.kt b/src/org/kde/kdeconnect/Plugins/DigitizerPlugin/DigitizerPlugin.kt new file mode 100644 index 00000000..7d4a544e --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/DigitizerPlugin/DigitizerPlugin.kt @@ -0,0 +1,103 @@ +/* + * SPDX-FileCopyrightText: 2025 Martin Sh + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +package org.kde.kdeconnect.Plugins.DigitizerPlugin + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.util.Log +import androidx.preference.PreferenceManager +import org.kde.kdeconnect.NetworkPacket +import org.kde.kdeconnect.Plugins.Plugin +import org.kde.kdeconnect.Plugins.PluginFactory +import org.kde.kdeconnect.UserInterface.PluginSettingsFragment +import org.kde.kdeconnect_tp.R + +@PluginFactory.LoadablePlugin +class DigitizerPlugin : Plugin() { + override val displayName: String + get() = context.resources.getString(R.string.pref_plugin_digitizer) + + override val description: String + get() = context.resources.getString(R.string.pref_plugin_digitizer_desc) + + override val icon: Int + get() = R.drawable.ic_draw_24dp + + override fun onPacketReceived(np: NetworkPacket): Boolean { + Log.e(TAG, "The drawing tablet plugin should not be able to receive any packets!") + return false + } + + override val actionName: String + get() = context.getString(R.string.use_digitizer) + + override fun displayAsButton(context: Context): Boolean = shouldDisplayAsBigButton(context) + + override fun startMainActivity(parentActivity: Activity) { + parentActivity.startActivity(Intent(parentActivity, DigitizerActivity::class.java).apply { + putExtra("deviceId", device.deviceId) + }) + } + + fun startSession(width: Int, height: Int, resolutionX: Int, resolutionY: Int) { + val np = NetworkPacket(PACKET_TYPE_DIGITIZER_SESSION).apply { + set("action", "start") + set("width", width) + set("height", height) + set("resolutionX", resolutionX) + set("resolutionY", resolutionY) + } + device.sendPacket(np) + } + + fun endSession() { + val np = NetworkPacket(PACKET_TYPE_DIGITIZER_SESSION).apply { + set("action", "end") + } + device.sendPacket(np) + } + + fun reportEvent(event: ToolEvent) { + Log.d(TAG, "reportEvent: $event") + + val np = NetworkPacket(PACKET_TYPE_DIGITIZER).also { packet -> + event.active?.let { packet["active"] = it } + event.touching?.let { packet["touching"] = it } + event.tool?.let { packet["tool"] = it.name } + event.x?.let { packet["x"] = it } + event.y?.let { packet["y"] = it } + event.pressure?.let { packet["pressure"] = it } + } + device.sendPacket(np) + } + + override fun hasSettings(): Boolean = true + override fun getSettingsFragment(activity: Activity): PluginSettingsFragment = + PluginSettingsFragment.newInstance(pluginKey, R.xml.digitizer_preferences) + + override val supportedPacketTypes: Array + get() = arrayOf() + + override val outgoingPacketTypes: Array + get() = arrayOf( + PACKET_TYPE_DIGITIZER_SESSION, + PACKET_TYPE_DIGITIZER, + ) + + companion object { + private const val PACKET_TYPE_DIGITIZER_SESSION = "kdeconnect.digitizer.session" + private const val PACKET_TYPE_DIGITIZER = "kdeconnect.digitizer" + + private const val TAG = "DigitizerPlugin" + + @JvmStatic + fun shouldDisplayAsBigButton(context: Context): Boolean = + context.resources.configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK >= Configuration.SCREENLAYOUT_SIZE_LARGE + } +} \ No newline at end of file diff --git a/src/org/kde/kdeconnect/Plugins/DigitizerPlugin/DrawingPadView.kt b/src/org/kde/kdeconnect/Plugins/DigitizerPlugin/DrawingPadView.kt new file mode 100644 index 00000000..2e890446 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/DigitizerPlugin/DrawingPadView.kt @@ -0,0 +1,163 @@ +/* + * SPDX-FileCopyrightText: 2025 Martin Sh + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +package org.kde.kdeconnect.Plugins.DigitizerPlugin + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.util.AttributeSet +import android.util.TypedValue +import android.view.MotionEvent +import android.view.View +import com.google.android.material.R +import com.google.android.material.color.MaterialColors +import kotlin.math.ceil +import kotlin.math.roundToInt + +class DrawingPadView(context: Context, attrs: AttributeSet?) : View(context, attrs) { + private fun mmToPixels(mm: Float): Float = + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_MM, + mm, + resources.displayMetrics + ) + + val gridSpacing = mmToPixels(5.0f) + val gridOffset = mmToPixels(2.5f) + + val backgroundPaint = Paint().apply { + color = MaterialColors.getColor(this@DrawingPadView, R.attr.colorSurfaceContainer, Color.WHITE) + style = Paint.Style.FILL + } + + val linePaint = Paint().apply { + color = MaterialColors.getColor(this@DrawingPadView, R.attr.colorOutlineVariant, Color.WHITE) + style = Paint.Style.STROKE + strokeWidth = 1.0f + } + + var eventListener: EventListener? = null + var fingerTouchEventsEnabled: Boolean = false + + private fun convertTool(motionEventTool: Int): ToolEvent.Tool = + when (motionEventTool) { + MotionEvent.TOOL_TYPE_ERASER -> ToolEvent.Tool.Rubber + else -> ToolEvent.Tool.Pen + } + + override fun onHoverEvent(event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_HOVER_ENTER -> eventListener?.onToolEvent( + ToolEvent( + active = true, + x = event.x.roundToInt(), + y = event.y.roundToInt() + ) + ) + + MotionEvent.ACTION_HOVER_EXIT -> eventListener?.onToolEvent( + ToolEvent( + active = false, + ) + ) + + MotionEvent.ACTION_HOVER_MOVE -> eventListener?.onToolEvent( + ToolEvent( + tool = convertTool(event.getToolType(0)), + x = event.x.roundToInt(), + y = event.y.roundToInt() + ) + ) + } + return true + } + + private fun findPressure(event: MotionEvent): Double = + if (event.getToolType(0) == MotionEvent.TOOL_TYPE_FINGER) + // We report constant 1.0 pressure for fingers, since most devices can't report + // pressure for fingers (so they report a constant 0.001). + if (fingerTouchEventsEnabled) 1.0 + else 0.0 + else + event.pressure.toDouble() + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + val toolType = event.getToolType(0) + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + eventListener?.onToolEvent( + ToolEvent( + active = true, + touching = toolType != MotionEvent.TOOL_TYPE_FINGER, + tool = convertTool(toolType), + x = event.x.roundToInt(), + y = event.y.roundToInt(), + pressure = findPressure(event) + ) + ) + + if (toolType == MotionEvent.TOOL_TYPE_FINGER) + eventListener?.onFingerTouchEvent(true) + } + + MotionEvent.ACTION_UP -> { + eventListener?.onToolEvent( + ToolEvent( + // If the finger is lifted from the screen, + // we consider the device as "not tracking the tool". + active = toolType != MotionEvent.TOOL_TYPE_FINGER, + touching = false, + ) + ) + + if (toolType == MotionEvent.TOOL_TYPE_FINGER) + eventListener?.onFingerTouchEvent(false) + + // Set this variable to `false` if the user stopped drawing without first letting go + // of the "draw" button. + fingerTouchEventsEnabled = false + } + + MotionEvent.ACTION_MOVE -> eventListener?.onToolEvent( + ToolEvent( + tool = convertTool(toolType), + x = event.x.roundToInt(), + y = event.y.roundToInt(), + pressure = findPressure(event) + ) + ) + } + return true + } + + override fun onDraw(canvas: Canvas) { + canvas.drawPaint(backgroundPaint) + + for (i in 0.. + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +package org.kde.kdeconnect.Plugins.DigitizerPlugin + +data class ToolEvent( + val active: Boolean? = null, + val touching: Boolean? = null, + val tool: Tool? = null, + val x: Int? = null, + val y: Int? = null, + val pressure: Double? = null +) { + enum class Tool { + Pen, + Rubber, + } +} diff --git a/src/org/kde/kdeconnect/Plugins/MousePadPlugin/MousePadActivity.java b/src/org/kde/kdeconnect/Plugins/MousePadPlugin/MousePadActivity.java index 8424eb4b..38d7b40d 100644 --- a/src/org/kde/kdeconnect/Plugins/MousePadPlugin/MousePadActivity.java +++ b/src/org/kde/kdeconnect/Plugins/MousePadPlugin/MousePadActivity.java @@ -28,6 +28,7 @@ import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; import org.kde.kdeconnect.KdeConnect; +import org.kde.kdeconnect.Plugins.DigitizerPlugin.DigitizerPlugin; import org.kde.kdeconnect.UserInterface.PluginSettingsActivity; import org.kde.kdeconnect.base.BaseActivity; import org.kde.kdeconnect_tp.R; @@ -231,8 +232,14 @@ public class MousePadActivity boolean mouseButtonsEnabled = prefs .getBoolean(getString(R.string.mousepad_mouse_buttons_enabled_pref), true); + boolean digitizerEnabled = + KdeConnect.getInstance().getDevicePlugin(deviceId, DigitizerPlugin.class) != null; + menu.findItem(R.id.menu_right_click).setVisible(!mouseButtonsEnabled); menu.findItem(R.id.menu_middle_click).setVisible(!mouseButtonsEnabled); + menu.findItem(R.id.menu_open_digitizer).setVisible( + digitizerEnabled && !DigitizerPlugin.shouldDisplayAsBigButton(this) + ); return true; } @@ -251,6 +258,16 @@ public class MousePadActivity .putExtra(PluginSettingsActivity.EXTRA_DEVICE_ID, deviceId) .putExtra(PluginSettingsActivity.EXTRA_PLUGIN_KEY, MousePadPlugin.class.getSimpleName()); startActivity(intent); + return true; + } else if (id == R.id.menu_open_digitizer) { + DigitizerPlugin plugin = KdeConnect.getInstance().getDevicePlugin(deviceId, DigitizerPlugin.class); + if (plugin == null) { + finish(); + return true; + } + + plugin.startMainActivity(this); + return true; } else if (id == R.id.menu_show_keyboard) { MousePadPlugin plugin = KdeConnect.getInstance().getDevicePlugin(deviceId, MousePadPlugin.class);