diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..4a53bee
--- /dev/null
+++ b/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..751f643
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index a2cc883..639c779 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -4,11 +4,9 @@
-
-
-
+
-
+
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 0f86676..ee3814b 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,4 +1,10 @@
+
+
+
+
+
+
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index 1712de1..8f77b5b 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,6 +1,6 @@
plugins {
id 'com.android.application'
- id 'kotlin-android'
+ id 'org.jetbrains.kotlin.android'
id 'com.google.gms.google-services'
}
@@ -16,47 +16,38 @@ android {
}
buildTypes {
- debug {
- debuggable true
- }
+ debug { debuggable true }
release {
- // Enables code shrinking, obfuscation, and optimization for only
- // your project's release build type.
- //minifyEnabled true
-
- // Enables resource shrinking, which is performed by the
- // Android Gradle plugin.
- //shrinkResources true
-
- // Includes the default ProGuard rules files that are packaged with
- // the Android Gradle plugin. To learn more, go to the section about
- // R8 configuration files.
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.debug
-
- //debuggable true
}
}
+
+ // AGP 8.x expects JDK 17 toolchain
compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = '1.8'
+ jvmTarget = '17'
}
+
namespace 'com.meshcentral.agent'
}
dependencies {
- //noinspection GradleDependency
- implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+ // Kotlin stdlib (pin explicitly since we removed ext.kotlin_version)
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.10"
+
implementation 'androidx.core:core-ktx:1.12.0'
- //implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
- implementation 'com.budiyev.android:code-scanner:2.1.0'
+
+ // Replaced old JCenter artifact with Maven Central one
+ implementation "io.github.yuriy-budiyev:code-scanner:2.3.2"
+
implementation 'com.karumi:dexter:6.2.2'
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.madgag.spongycastle:bcpkix-jdk15on:1.58.0.0'
@@ -68,9 +59,4 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'com.google.firebase:firebase-installations-ktx:17.2.0'
- //implementation 'androidx.work:work-runtime-ktx:2.9.0'
- //implementation 'org.webrtc:google-webrtc:1.0.32006'
- //testImplementation 'junit:junit:4.13.1'
- //androidTestImplementation 'androidx.test.ext:junit:1.1.2'
- //androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e618687..f289533 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -17,6 +17,8 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/meshcentral/agent/MainActivity.kt b/app/src/main/java/com/meshcentral/agent/MainActivity.kt
index aca1448..19e9dc7 100644
--- a/app/src/main/java/com/meshcentral/agent/MainActivity.kt
+++ b/app/src/main/java/com/meshcentral/agent/MainActivity.kt
@@ -33,6 +33,12 @@ import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import com.google.firebase.messaging.FirebaseMessaging
+import com.meshcentral.agent.annotation.AnnotationConsent
+import com.meshcentral.agent.annotation.AnnotationController
+import com.meshcentral.agent.annotation.AnnotationFeature
+import com.meshcentral.agent.annotation.AnnotationFeature.stopAnnotations
+import com.meshcentral.agent.annotation.AnnotationPrefs
+import com.meshcentral.agent.annotation.AnnotationServiceBus
import org.json.JSONObject
import org.spongycastle.asn1.x500.X500Name
import org.spongycastle.cert.X509v3CertificateBuilder
@@ -81,6 +87,8 @@ var pendingActivities : ArrayList = ArrayList {
processConsoleMessage(json.getString("value"), json.getString("sessionid"), json)
}
- "tunnel" -> {
+ "tunnel" -> {
/*
{"action":"msg",
"type":"tunnel",
@@ -511,6 +514,23 @@ class MeshAgent(parent: MainActivity, host: String, certHash: String, devGroupId
parent.refreshInfo()
}
}
+ "annotation" -> {
+ val op = json.optString("op", "")
+
+ // Allow probe (and start/style/remove if you want) without ScreenCaptureService.
+ val opsAllowedWithoutCapture = setOf("probe", "start", "style", "remove")
+
+ if (!opsAllowedWithoutCapture.contains(op)) {
+ if (g_ScreenCaptureService == null) return
+ }
+
+ if (op == "start") {
+ AnnotationConsent.requestEnableWithConsent(parent)
+ }
+
+ val ack = AnnotationBridge.handleFromServer(parent, json)
+ ack?.let { _webSocket?.send(it.toString().toByteArray().toByteString()) }
+ }
else -> {
// Unknown command, ignore it.
println("Unhandled action: $action")
@@ -532,6 +552,21 @@ class MeshAgent(parent: MainActivity, host: String, certHash: String, devGroupId
if (_webSocket != null) { _webSocket?.send(r.toString().toByteArray().toByteString()) }
}
+ // Send Annotation Capability
+ fun sendAnnotationCaps() {
+ try {
+ val hasPerm = if (Build.VERSION.SDK_INT >= 23)
+ Settings.canDrawOverlays(parent)
+ else true
+
+ val r = JSONObject()
+ r.put("action", "annotationcaps")
+ r.put("annotation", true) // feature supported
+ r.put("annotationPermission", if (hasPerm) "granted" else "denied")
+ _webSocket?.send(r.toString().toByteArray().toByteString())
+ } catch (_: Exception) { /* ignore */ }
+ }
+
// Send 2FA authentication URL and approval/reject back
fun send2faAuth(url: Uri, approved: Boolean) {
val r = JSONObject()
@@ -1033,4 +1068,8 @@ class MeshAgent(parent: MainActivity, host: String, certHash: String, devGroupId
if (_webSocket != null) { _webSocket?.send(json.toString().toByteArray().toByteString()) }
}
+ fun sendJson(obj: JSONObject) {
+ _webSocket?.send(obj.toString().toByteArray().toByteString())
+ }
+
}
diff --git a/app/src/main/java/com/meshcentral/agent/MeshTunnel.kt b/app/src/main/java/com/meshcentral/agent/MeshTunnel.kt
index 793494e..492460e 100644
--- a/app/src/main/java/com/meshcentral/agent/MeshTunnel.kt
+++ b/app/src/main/java/com/meshcentral/agent/MeshTunnel.kt
@@ -11,6 +11,10 @@ import android.os.CountDownTimer
import android.os.Environment
import android.provider.MediaStore
import android.util.Base64
+import android.widget.Toast
+import com.meshcentral.agent.annotation.AnnotationConsent
+import com.meshcentral.agent.annotation.AnnotationController
+import com.meshcentral.agent.annotation.AnnotationServiceBus
import okhttp3.*
import okio.ByteString
import okio.ByteString.Companion.toByteString
@@ -152,6 +156,15 @@ class MeshTunnel(parent: MeshAgent, url: String, serverData: JSONObject) : WebSo
connectionTimer?.cancel()
connectionTimer = null
}
+ if ((usage == 2) && (g_ScreenCaptureService != null)) {
+ g_ScreenCaptureService!!.onTunnelDisconnected()
+ }
+
+ if (usage == 2) {
+ AnnotationServiceBus.clear()
+ AnnotationController.hide(parent.parent)
+ }
+
// Remove the tunnel from the parent's list
parent.removeTunnel(this) // Notify the parent that this tunnel is done
@@ -234,6 +247,10 @@ class MeshTunnel(parent: MeshAgent, url: String, serverData: JSONObject) : WebSo
}
// Send the display size
updateDesktopDisplaySize()
+
+ if (g_autoConsent && g_ScreenCaptureService != null) {
+ g_ScreenCaptureService!!.requestImmediateFrame()
+ }
}
}
} else {
@@ -524,10 +541,14 @@ class MeshTunnel(parent: MeshAgent, url: String, serverData: JSONObject) : WebSo
eventArgs.put(fileUploadSize)
parent.logServerEventEx(105, eventArgs, "Upload: \"${fileUploadName}}\", Size: $fileUploadSize", serverData);
}
+ "annotation" -> {
+ handleAnnotation(json)
+ }
else -> {
// Unknown command, ignore it.
println("Unhandled action: $action, $jsonStr")
}
+
}
}
@@ -845,4 +866,136 @@ class MeshTunnel(parent: MeshAgent, url: String, serverData: JSONObject) : WebSo
}
*/
+ // ----- Annotation control over the desktop tunnel -----
+
+ private fun sendAnnoStatus(status: String, reason: String? = null) {
+ val o = JSONObject()
+ o.put("type", "annotation")
+ o.put("status", status)
+ if (reason != null) o.put("reason", reason)
+ sendCtrlResponse(o)
+ }
+
+ private fun colorFromHex(hex: String?): Int? {
+ if (hex.isNullOrEmpty()) return null
+ // Accept "#RRGGBB" or "#AARRGGBB"
+ return try { android.graphics.Color.parseColor(hex) } catch (_: Exception) { null }
+ }
+
+ private fun handleAnnotation(json: JSONObject) {
+ // Only valid during remote desktop session
+ if (usage != 2) { sendAnnoStatus("error", "no_kvm"); return }
+
+ val cmd = json.optString("cmd", "")
+ when (cmd) {
+ "start" -> {
+ // Ask for consent/permission if needed, then show overlay
+ AnnotationConsent.requestEnableWithConsent(parent.parent)
+ // Give service a moment to come up, then report status
+ parent.parent.mainLooper.let { looper ->
+ android.os.Handler(looper).postDelayed({
+ if (AnnotationServiceBus.isActive()) {
+ sendAnnoStatus("started")
+ } else {
+ // Not active yet (user may still be in system Settings)
+ sendAnnoStatus("pending_permission")
+ }
+ }, 350)
+ }
+ }
+ "stop" -> {
+ AnnotationServiceBus.clear()
+ AnnotationController.hide(parent.parent)
+ sendAnnoStatus("stopped")
+ }
+ "clear" -> {
+ AnnotationServiceBus.clear()
+ sendAnnoStatus("cleared")
+ }
+ "style" -> {
+ val color = colorFromHex(json.optString("color"))
+ val width = json.optDouble("width", Double.NaN)
+ if (color != null) {
+ val w = if (width.isNaN()) 6f else width.toFloat()
+ AnnotationServiceBus.setStyle(
+ com.meshcentral.agent.annotation.DrawStyle(color, w)
+ )
+ sendAnnoStatus("ok")
+ } else {
+ sendAnnoStatus("error", "bad_color")
+ }
+ }
+ "draw" -> {
+ val ttlMs = json.optLong("ttlMs", 0L).coerceAtLeast(0L)
+ when (json.optString("shape", "")) {
+ "rect" -> {
+ val x = json.optDouble("x", Double.NaN)
+ val y = json.optDouble("y", Double.NaN)
+ val w = json.optDouble("w", Double.NaN)
+ val h = json.optDouble("h", Double.NaN)
+ if (x.isNaN() || y.isNaN() || w.isNaN() || h.isNaN()) { sendAnnoStatus("error","bad_args"); return }
+ AnnotationServiceBus.drawRect(
+ x.toFloat(), y.toFloat(), w.toFloat(), h.toFloat(), ttlMs
+ )
+ sendAnnoStatus("ok")
+ }
+ "circle" -> {
+ val x = json.optDouble("x", Double.NaN)
+ val y = json.optDouble("y", Double.NaN)
+ val r = json.optDouble("r", Double.NaN)
+ if (x.isNaN() || y.isNaN() || r.isNaN()) { sendAnnoStatus("error","bad_args"); return }
+ AnnotationServiceBus.drawCircle(
+ x.toFloat(), y.toFloat(), r.toFloat(), ttlMs
+ )
+ sendAnnoStatus("ok")
+ }
+ "arrow" -> {
+ val x1 = json.optDouble("x1", Double.NaN)
+ val y1 = json.optDouble("y1", Double.NaN)
+ val x2 = json.optDouble("x2", Double.NaN)
+ val y2 = json.optDouble("y2", Double.NaN)
+ if (x1.isNaN() || y1.isNaN() || x2.isNaN() || y2.isNaN()) { sendAnnoStatus("error","bad_args"); return }
+ AnnotationServiceBus.drawArrow(
+ x1.toFloat(), y1.toFloat(), x2.toFloat(), y2.toFloat(), ttlMs
+ )
+ sendAnnoStatus("ok")
+ }
+ "path" -> {
+ val arr = json.optJSONArray("points") ?: run { sendAnnoStatus("error","bad_args"); return }
+ val pts = buildList {
+ for (i in 0 until arr.length()) {
+ val p = arr.optJSONArray(i) ?: continue
+ if (p.length() >= 2) add(p.getDouble(0).toFloat() to p.getDouble(1).toFloat())
+ }
+ }
+ if (pts.size < 2) { sendAnnoStatus("error","bad_args"); return }
+ AnnotationServiceBus.drawPath(pts, ttlMs)
+ sendAnnoStatus("ok")
+ }
+ else -> sendAnnoStatus("error","bad_shape")
+ }
+ }
+ "strokeStart" -> {
+ val x = json.optDouble("x", Double.NaN)
+ val y = json.optDouble("y", Double.NaN)
+ if (x.isNaN() || y.isNaN()) { sendAnnoStatus("error","bad_args"); return }
+ AnnotationServiceBus.strokeStart(x.toFloat(), y.toFloat())
+ }
+ "strokeMove" -> {
+ val x = json.optDouble("x", Double.NaN)
+ val y = json.optDouble("y", Double.NaN)
+ if (x.isNaN() || y.isNaN()) { /* ignore bad move */ return }
+ AnnotationServiceBus.strokeMove(x.toFloat(), y.toFloat())
+ }
+ "strokeEnd" -> {
+ val ttlMs = json.optLong("ttlMs", 0L).coerceAtLeast(0L)
+ AnnotationServiceBus.strokeEnd(ttlMs)
+ sendAnnoStatus("ok")
+ }
+ else -> {
+ sendAnnoStatus("error", "unknown_cmd")
+ }
+ }
+ }
+
}
\ No newline at end of file
diff --git a/app/src/main/java/com/meshcentral/agent/NotificationUtils.kt b/app/src/main/java/com/meshcentral/agent/NotificationUtils.kt
index 73209ee..6ead6c8 100644
--- a/app/src/main/java/com/meshcentral/agent/NotificationUtils.kt
+++ b/app/src/main/java/com/meshcentral/agent/NotificationUtils.kt
@@ -1,11 +1,13 @@
package com.meshcentral.agent
-import android.R
+
import android.annotation.TargetApi
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
+import android.os.CountDownTimer
+import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.util.Pair
@@ -13,9 +15,12 @@ object NotificationUtils {
const val NOTIFICATION_ID = 1337
private const val NOTIFICATION_CHANNEL_ID = "com.meshcentral.agent.app"
private const val NOTIFICATION_CHANNEL_NAME = "com.meshcentral.agent.app"
- fun getNotification(context: Context): Pair {
+
+ private var notificationBlinkTimer: CountDownTimer? = null
+
+ fun getNotification(context: Context, isBlinking: Boolean = false): Pair {
NotificationUtils.createNotificationChannel(context)
- val notification: Notification = NotificationUtils.createNotification(context)
+ val notification: Notification = NotificationUtils.createNotification(context, isBlinking)
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(NotificationUtils.NOTIFICATION_ID, notification)
return Pair(NotificationUtils.NOTIFICATION_ID, notification)
@@ -25,9 +30,9 @@ object NotificationUtils {
private fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
- NotificationUtils.NOTIFICATION_CHANNEL_ID,
- NotificationUtils.NOTIFICATION_CHANNEL_NAME,
- NotificationManager.IMPORTANCE_LOW
+ NotificationUtils.NOTIFICATION_CHANNEL_ID,
+ NotificationUtils.NOTIFICATION_CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_LOW
)
channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -35,9 +40,16 @@ object NotificationUtils {
}
}
- private fun createNotification(context: Context): Notification {
+ private fun createNotification(context: Context, isBlinking: Boolean = false): Notification {
val builder = NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_ID)
- builder.setSmallIcon(com.meshcentral.agent.R.drawable.ic_camera)
+
+ if (isBlinking) {
+ builder.setSmallIcon(com.meshcentral.agent.R.drawable.ic_camera)
+ builder.setColor(0xFFFF0000.toInt()) // Red when blinking
+ } else {
+ builder.setSmallIcon(com.meshcentral.agent.R.drawable.ic_camera)
+ }
+
builder.setContentTitle(context.getString(com.meshcentral.agent.R.string.meshcentral))
builder.setContentText(context.getString(com.meshcentral.agent.R.string.displaysharing))
builder.setOngoing(true)
@@ -46,4 +58,72 @@ object NotificationUtils {
builder.setShowWhen(true)
return builder.build()
}
-}
+
+
+ fun startNotificationBlink(context: Context) {
+ val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
+ mainHandler.post {
+ // Show toast once when blink starts - ONLY if setting is enabled
+ if (g_autoConsentNotification) {
+ Toast.makeText(context, "Remote View Connected", Toast.LENGTH_SHORT).show()
+ }
+
+ val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ var blinkCount = 0
+ notificationBlinkTimer?.cancel()
+
+ notificationBlinkTimer = object : CountDownTimer(2000, 250) { // 8 blinks over 2 seconds
+ override fun onTick(millisUntilFinished: Long) {
+ blinkCount++
+
+ // Alternate between canceling and re-posting notification
+ // This forces the system to redraw the status bar area
+ notificationManager.cancel(NOTIFICATION_ID)
+
+ // Wait 50ms then repost (creates visible blink effect)
+ mainHandler.postDelayed({
+ val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
+ builder.setSmallIcon(com.meshcentral.agent.R.drawable.ic_camera)
+ builder.setContentTitle(context.getString(com.meshcentral.agent.R.string.meshcentral))
+ builder.setContentText(context.getString(com.meshcentral.agent.R.string.displaysharing))
+ builder.setOngoing(true)
+ builder.setCategory(Notification.CATEGORY_SERVICE)
+ builder.priority = NotificationCompat.PRIORITY_LOW
+ builder.setShowWhen(true)
+
+ // Alternate color
+ if (blinkCount % 2 == 1) {
+ builder.setColor(0xFFFF0000.toInt())
+ }
+
+ notificationManager.notify(NOTIFICATION_ID, builder.build())
+ }, 50)
+ }
+
+ override fun onFinish() {
+ // Ensure notification is back to normal
+ mainHandler.postDelayed({
+ val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
+ builder.setSmallIcon(com.meshcentral.agent.R.drawable.ic_camera)
+ builder.setContentTitle(context.getString(com.meshcentral.agent.R.string.meshcentral))
+ builder.setContentText(context.getString(com.meshcentral.agent.R.string.displaysharing))
+ builder.setOngoing(true)
+ builder.setCategory(Notification.CATEGORY_SERVICE)
+ builder.priority = NotificationCompat.PRIORITY_LOW
+ builder.setShowWhen(true)
+ notificationManager.notify(NOTIFICATION_ID, builder.build())
+
+ notificationBlinkTimer = null
+ }, 100)
+ }
+ }
+ notificationBlinkTimer?.start()
+ }
+ }
+
+ // Stop any ongoing blink
+ fun stopNotificationBlink() {
+ notificationBlinkTimer?.cancel()
+ notificationBlinkTimer = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/meshcentral/agent/ScreenCaptureService.kt b/app/src/main/java/com/meshcentral/agent/ScreenCaptureService.kt
index e27cc29..2664a08 100644
--- a/app/src/main/java/com/meshcentral/agent/ScreenCaptureService.kt
+++ b/app/src/main/java/com/meshcentral/agent/ScreenCaptureService.kt
@@ -28,12 +28,13 @@ import androidx.core.util.Pair
import okio.ByteString
import okio.ByteString.Companion.toByteString
import java.io.*
+import kotlin.concurrent.thread
class ScreenCaptureService : Service() {
private var mMediaProjection: MediaProjection? = null
- private var mImageReader: ImageReader? = null
- private var mHandler: Handler? = null
+ var mImageReader: ImageReader? = null
+ var mHandler: Handler? = null
private var mDisplay: Display? = null
private var mVirtualDisplay: VirtualDisplay? = null
private var mDensity = 0
@@ -53,6 +54,13 @@ class ScreenCaptureService : Service() {
private var oldcrcs : IntArray? = null
private var newcrcs : IntArray? = null
+ var forceFullFrame = false
+ private var notificationBlinkTimer: CountDownTimer? = null
+ @Volatile private var projectionActive = false
+
+ fun isProjectionActive(): Boolean = projectionActive
+
+
private inner class ImageAvailableListener : OnImageAvailableListener {
override fun onImageAvailable(reader: ImageReader) {
@@ -61,118 +69,140 @@ class ScreenCaptureService : Service() {
return
}
- var bitmap: Bitmap? = null
- var image: android.media.Image? = null
if (mImageReader == null) return
+ // Drain available image to keep capture flowing
try {
- image = mImageReader!!.acquireLatestImage()
- // Skip this image if null or websocket push-back is high
- if ((image != null) && (checkDesktopTunnelPushback() < 65535) && (meshAgent?.tunnels?.getOrNull(0) != null)) {
- val planes: Array = image.getPlanes()
- val buffer = planes[0].buffer
- val pixelStride = planes[0].pixelStride
- val rowStride = planes[0].rowStride
- val rowPadding = rowStride - pixelStride * mWidth
-
- // Create the bitmap
- bitmap = Bitmap.createBitmap(mWidth + rowPadding / pixelStride, mHeight, Bitmap.Config.ARGB_8888)
- bitmap!!.copyPixelsFromBuffer(buffer)
-
- // Resize the bitmap if needed
- if (g_desktop_scalingLevel != 1024) {
- val newWidth = (mWidth * g_desktop_scalingLevel) / 1024
- val newHeight = (mHeight * g_desktop_scalingLevel) / 1024
- bitmap = getResizedBitmap(bitmap, newWidth, newHeight)
- }
-
- // Setup or update the CRC buffer and tile information.
- val wt = (bitmap!!.width / 64)
- val ht = (bitmap.height / 64)
- if ((tilesFullWide != wt) || (tilesFullHigh != ht)) {
- tilesWide = wt;
- tilesHigh = ht;
- tilesFullWide = tilesWide
- tilesFullHigh = tilesHigh
- tilesRemainingWidth = (bitmap.width % 64);
- tilesRemainingHeight = (bitmap.height % 64);
- if (tilesRemainingWidth != 0) { tilesWide++; }
- if (tilesRemainingHeight != 0) { tilesHigh++; }
- tilesCount = (tilesWide * tilesHigh);
- oldcrcs = IntArray(tilesCount); // 64 x 64 tiles
- newcrcs = IntArray(tilesCount); // 64 x 64 tiles
- //println("New tile count: $tilesCount")
- }
+ var image = mImageReader!!.acquireLatestImage()
+ if (image != null) {
+ processImage(image)
+ image.close()
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ }
+ private val activeThreads = java.util.concurrent.atomic.AtomicInteger(0)
+ fun processImage(image: android.media.Image) {
+ var bitmap: Bitmap? = null
+ try {
+ val hasTunnels = (meshAgent?.tunnels?.size ?: 0) > 0
+ val shouldSend = hasTunnels && (checkDesktopTunnelPushback() < 65535)
+
+ val planes: Array = image.getPlanes()
+ val buffer = planes[0].buffer
+ val pixelStride = planes[0].pixelStride
+ val rowStride = planes[0].rowStride
+ val rowPadding = rowStride - pixelStride * mWidth
+
+ // Create the bitmap
+ bitmap = Bitmap.createBitmap(mWidth + rowPadding / pixelStride, mHeight, Bitmap.Config.ARGB_8888)
+ bitmap.copyPixelsFromBuffer(buffer)
+
+ // Resize the bitmap if needed
+ if (g_desktop_scalingLevel != 1024) {
+ val newWidth = (mWidth * g_desktop_scalingLevel) / 1024
+ val newHeight = (mHeight * g_desktop_scalingLevel) / 1024
+ bitmap = getResizedBitmap(bitmap, newWidth, newHeight)
+ }
- // Compute all tile CRC's
- computeAllCRCs(bitmap);
+ // Setup or update the CRC buffer and tile information.
+ val wt = (bitmap!!.width / 64)
+ val ht = (bitmap.height / 64)
+ if ((tilesFullWide != wt) || (tilesFullHigh != ht)) {
+ tilesWide = wt;
+ tilesHigh = ht;
+ tilesFullWide = tilesWide
+ tilesFullHigh = tilesHigh
+ tilesRemainingWidth = (bitmap.width % 64);
+ tilesRemainingHeight = (bitmap.height % 64);
+ if (tilesRemainingWidth != 0) { tilesWide++; }
+ if (tilesRemainingHeight != 0) { tilesHigh++; }
+ tilesCount = (tilesWide * tilesHigh);
+ oldcrcs = IntArray(tilesCount);
+ newcrcs = IntArray(tilesCount);
+ }
- // Compute how many tiles have changed
- var changedTiles : Int = 0;
- for (i in 0 until tilesCount) { if (oldcrcs!![i] != newcrcs!![i]) { changedTiles++; } }
- if (changedTiles > 0) {
- // If 85% of the all tiles have changed, send the entire screen
- if ((changedTiles * 100) >= (tilesCount * 85))
- {
- sendEntireImage(bitmap)
- for (i in 0 until tilesCount) { oldcrcs!![i] = newcrcs!![i]; }
+ // Compute all tile CRC's
+ computeAllCRCs(bitmap);
+
+ // Compute how many tiles have changed
+ var changedTiles : Int = 0;
+ for (i in 0 until tilesCount) { if (oldcrcs!![i] != newcrcs!![i]) { changedTiles++; } }
+
+ if (changedTiles > 0 && shouldSend) {
+ if (forceFullFrame || (changedTiles * 100) >= (tilesCount * 85))
+ {
+ val bitmapCopy = bitmap.copy(bitmap.config, false)
+ if (bitmapCopy != null) {
+ activeThreads.incrementAndGet()
+ thread {
+ sendEntireImage(bitmapCopy)
+ bitmapCopy.recycle()
+ val remaining = activeThreads.decrementAndGet()
}
- else
+ }
+ for (i in 0 until tilesCount) { oldcrcs!![i] = newcrcs!![i]; }
+ forceFullFrame = false
+ }
+ else
+ {
+ val bitmapCopy = bitmap.copy(bitmap.config, false)
+
+ // Collect tile data to send
+ data class TileToSend(val x: Int, val y: Int, val w: Int)
+ val tilesToSend = mutableListOf()
+
+ var sendx : Int = -1;
+ var sendy : Int = 0;
+ var sendw : Int = 0;
+ for (i in 0 until tilesHigh)
+ {
+ for (j in 0 until tilesWide)
{
- // Send all changed tiles
- // This version has horizontal & vertical optimization, JPEG as wide as possible then as high as possible
- var sendx : Int = -1;
- var sendy : Int = 0;
- var sendw : Int = 0;
- for (i in 0 until tilesHigh)
+ val tileNumber : Int = (i * tilesWide) + j;
+ if (oldcrcs!![tileNumber] != newcrcs!![tileNumber])
+ {
+ oldcrcs!![tileNumber] = newcrcs!![tileNumber];
+ if (sendx == -1) { sendx = j; sendy = i; sendw = 1; } else { sendw += 1; }
+ }
+ else
{
- for (j in 0 until tilesWide)
- {
- val tileNumber : Int = (i * tilesWide) + j;
- if (oldcrcs!![tileNumber] != newcrcs!![tileNumber])
- {
- oldcrcs!![tileNumber] = newcrcs!![tileNumber];
- if (sendx == -1) { sendx = j; sendy = i; sendw = 1; } else { sendw += 1; }
- }
- else
- {
- if (sendx != -1) { sendSubBitmapRow(bitmap, sendx, sendy, sendw); sendx = -1; }
- }
+ if (sendx != -1) {
+ tilesToSend.add(TileToSend(sendx, sendy, sendw))
+ sendx = -1;
}
- if (sendx != -1) { sendSubBitmapRow(bitmap, sendx, sendy, sendw); sendx = -1; }
}
- if (sendx != -1) { sendSubBitmapRow(bitmap, sendx, sendy, sendw); sendx = -1; }
+ }
+ if (sendx != -1) {
+ tilesToSend.add(TileToSend(sendx, sendy, sendw))
+ sendx = -1;
+ }
+ }
+ if (sendx != -1) {
+ tilesToSend.add(TileToSend(sendx, sendy, sendw))
+ }
+ if (bitmapCopy != null) {
+ // Send tiles in background thread
+ thread {
+ for (tile in tilesToSend) {
+ sendSubBitmapRow(bitmapCopy, tile.x, tile.y, tile.w)
+ }
+ bitmapCopy.recycle()
}
}
}
- } catch (e: Exception) {
- e.printStackTrace()
}
- if (bitmap != null) { bitmap.recycle() }
- if (image != null) { image.close() }
+ } finally {
+ bitmap?.recycle()
}
}
private fun sendSubBitmapRow(bm: Bitmap, x : Int, y : Int, w : Int) {
- var h : Int = (y + 1)
- var exit : Boolean = false
- while (h < tilesHigh) {
- // Check if the row is all different
- for (xx in x until (x + w)) {
- val tileNumber = (h * tilesWide) + xx;
- if (oldcrcs!![tileNumber] == newcrcs!![tileNumber]) { exit = true; break; }
- }
- // If all different set the CRC's to the same, otherwise exit.
- if (!exit) {
- for (xx in x until (x + w)) {
- val tileNumber : Int = (h * tilesWide) + xx;
- oldcrcs!![tileNumber] = newcrcs!![tileNumber];
- }
- } else break;
- h++
- }
- h -= y
- sendSubImage(bm, x * 64, y * 64, w * 64, h * 64);
+ // Just send the single row of tiles without accessing CRCs
+ // The vertical optimization was causing race conditions
+ sendSubImage(bm, x * 64, y * 64, w * 64, 64);
}
private fun Adler32(n : Int, state: Int) : Int {
@@ -405,6 +435,7 @@ class ScreenCaptureService : Service() {
g_ScreenCaptureService = this
updateTunnelDisplaySize()
sendAgentConsole("Started display sharing")
+ projectionActive = true
}
}
}
@@ -416,9 +447,13 @@ class ScreenCaptureService : Service() {
}
private fun stopProjection() {
+ if (!projectionActive) return
+ NotificationUtils.stopNotificationBlink()
if (mHandler != null) {
mHandler!!.post {
if (mMediaProjection != null) {
+ try { mMediaProjection?.stop() } catch (_: Exception) {}
+ projectionActive = false
mMediaProjection!!.stop()
g_ScreenCaptureService = null
sendAgentConsole("Stopped display sharing")
@@ -437,7 +472,7 @@ class ScreenCaptureService : Service() {
updateTunnelDisplaySize()
// Start capture reader
- mImageReader = ImageReader.newInstance(mWidth, mHeight, PixelFormat.RGBA_8888, 2)
+ mImageReader = ImageReader.newInstance(mWidth, mHeight, PixelFormat.RGBA_8888, 5)
// Register media projection stop callback
mMediaProjection!!.registerCallback(this.MediaProjectionStopCallback(), mHandler)
mVirtualDisplay = mMediaProjection!!.createVirtualDisplay(ScreenCaptureService.Companion.SCREENCAP_NAME, mWidth, mHeight,
@@ -478,7 +513,9 @@ class ScreenCaptureService : Service() {
}
private val virtualDisplayFlags: Int
- private get() = DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY or DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC
+ private get() = DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY or
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC or
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
}
fun updateTunnelDisplaySize() {
@@ -532,4 +569,63 @@ class ScreenCaptureService : Service() {
}
}
}
+
+
+
+ fun requestImmediateFrame() {
+ forceFullFrame = true
+ NotificationUtils.startNotificationBlink(this)
+
+ mHandler?.post {
+ try {
+ // First, try to get any existing image
+ val existingImage = mImageReader?.acquireLatestImage()
+ if (existingImage != null) {
+ processImage(existingImage)
+ existingImage.close()
+ return@post
+ }
+ // No existing image - force VirtualDisplay to generate a new frame
+ // Request a new frame by briefly pausing and resuming the virtual display
+ // This forces Android to render a fresh frame
+ mVirtualDisplay?.surface?.let { surface ->
+ // The virtual display should automatically generate frames
+ // Set a small delay to allow the system to render
+ mHandler?.postDelayed({
+ val newImage = mImageReader?.acquireLatestImage()
+ if (newImage != null) {
+ processImage(newImage)
+ newImage.close()
+ } else {
+ println("No frame available after delay")
+ }
+ }, 100) // Wait 100ms for frame to be rendered
+ }
+ } catch (e: Exception) {
+ println("requestImmediateFrame - Failed to request frame: ${e.message}")
+ }
+ }
+ }
+
+ fun onTunnelDisconnected() {
+ // Only reset state in autoconsent mode where service persists between connections
+ if (!g_autoConsent) {
+ println("Tunnel disconnected (normal mode), no state reset needed")
+ return
+ }
+
+ println("Tunnel disconnected (autoconsent mode), resetting state for next connection")
+ forceFullFrame = false
+
+ // Reset tile state to force full frame on next connect
+ tilesFullWide = 0
+ tilesFullHigh = 0
+
+ // Clear CRCs to ensure fresh comparison
+ if (oldcrcs != null) {
+ for (i in 0 until oldcrcs!!.size) {
+ oldcrcs!![i] = 0
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/meshcentral/agent/SettingsFragment.kt b/app/src/main/java/com/meshcentral/agent/SettingsFragment.kt
index e88166c..819347f 100644
--- a/app/src/main/java/com/meshcentral/agent/SettingsFragment.kt
+++ b/app/src/main/java/com/meshcentral/agent/SettingsFragment.kt
@@ -1,11 +1,13 @@
package com.meshcentral.agent
+import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.PreferenceManager
-class SettingsFragment : PreferenceFragmentCompat() {
+class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedPreferenceChangeListener {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.root_preferences, rootKey)
@@ -13,8 +15,39 @@ class SettingsFragment : PreferenceFragmentCompat() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- settingsFragment = this;
- visibleScreen = 5;
+ settingsFragment = this
+ visibleScreen = 5
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // Register listener when fragment becomes visible
+ PreferenceManager.getDefaultSharedPreferences(requireContext())
+ .registerOnSharedPreferenceChangeListener(this)
+ }
+
+ override fun onPause() {
+ super.onPause()
+ // Unregister listener when fragment is hidden
+ PreferenceManager.getDefaultSharedPreferences(requireContext())
+ .unregisterOnSharedPreferenceChangeListener(this)
+ }
+
+ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
+ // This is called automatically whenever ANY preference changes
+ when (key) {
+ "pref_autoconnect",
+ "pref_autoconsent",
+ "pref_annotation_auto" -> {
+ // These settings need full settingsChanged() processing
+ g_mainActivity?.settingsChanged()
+ }
+ "pref_autoconsentnotifcation" -> {
+ // This one just needs the global variable updated
+ g_autoConsentNotification = sharedPreferences?.getBoolean(key, true) ?: true
+ }
+
+ }
}
override fun onDestroy() {
diff --git a/app/src/main/java/com/meshcentral/agent/annotation/AnnotationBridge.kt b/app/src/main/java/com/meshcentral/agent/annotation/AnnotationBridge.kt
new file mode 100644
index 0000000..12516f9
--- /dev/null
+++ b/app/src/main/java/com/meshcentral/agent/annotation/AnnotationBridge.kt
@@ -0,0 +1,186 @@
+package com.meshcentral.agent.annotation
+
+import android.app.Activity
+import android.content.Context
+import android.os.Build
+import android.provider.Settings
+import android.util.DisplayMetrics
+import org.json.JSONObject
+
+/**
+ * Translates server "annotation" JSON into local overlay actions.
+ * Supports normalized coords (0..1) when msg["norm"] == true.
+ */
+object AnnotationBridge {
+ private fun ok(op: String) = JSONObject().put("action","annotationAck").put("op",op).put("ok",true)
+ private fun err(op: String, m:String) =
+ JSONObject().put("action","annotationAck").put("op",op).put("ok",false).put("error",m)
+ private fun hasOverlayPermission(ctx: Context): Boolean {
+ return if (Build.VERSION.SDK_INT >= 23) Settings.canDrawOverlays(ctx) else true
+ }
+ fun handleFromServer(activity: Activity, msg: JSONObject): JSONObject? {
+ val op = msg.optString("op", "")
+ val norm = msg.optBoolean("norm", false)
+ val (W, H) = screenSize(activity) // overlay matches screen
+
+ android.util.Log.d("AnnotationBridge", "msg=" + msg.toString())
+
+
+ fun fx(k: String) = msg.getDouble(k).toFloat().let { if (norm) it * W else it }
+ fun fy(k: String) = msg.getDouble(k).toFloat().let { if (norm) it * H else it }
+ fun fdim(k: String) = msg.getDouble(k).toFloat().let { if (norm) it * (if (k == "w" || k == "x2" || k == "r") W else H) else it }
+ fun fttl() = msg.optLong("ttlMs", 0L)
+
+
+
+ return try {
+ when (op) {
+ // lifecycle ---------------------------------------------------
+ "start" -> {
+ // Tell the browser whether we *currently* have overlay permission.
+ val perm = if (Build.VERSION.SDK_INT >= 23)
+ Settings.canDrawOverlays(activity)
+ else true
+
+ return JSONObject().apply {
+ put("action", "annotationAck")
+ put("op", "start")
+ put("ok", true)
+ put("supported", true)
+ put("permission", if (perm) "granted" else "denied")
+ // if you have nodeid available in the incoming msg, echo it:
+ msg.optString("nodeid", null)?.let { put("nodeid", it) }
+ msg.optJSONArray("nodeids")?.optString(0)?.let { put("nodeid", it) }
+ }
+ }
+
+ "stop" -> { AnnotationController.hide(activity); ok(op) }
+ "clear" -> { AnnotationServiceBus.clear(); ok(op) }
+
+ // style -------------------------------------------------------
+ "style" -> {
+ val colorAny = msg.opt("color")
+ val color = when (colorAny) {
+ is Number -> colorAny.toInt()
+ is String -> parseColor(colorAny)
+ else -> null
+ }
+ val width = if (msg.has("width")) msg.optDouble("width", 6.0).toFloat() else null
+ AnnotationServiceBus.setStyle(
+ DrawStyle(
+ color = color ?: DrawStyle().color,
+ widthPx = width ?: DrawStyle().widthPx
+ )
+ )
+ ok(op)
+ }
+
+ // streamed pen -----------------------------------------------
+ "strokeStart" -> { AnnotationServiceBus.strokeStart(fx("x"), fy("y")); ok(op) }
+ "strokeMove" -> { AnnotationServiceBus.strokeMove(fx("x"), fy("y")); ok(op) }
+ "strokeEnd" -> { AnnotationServiceBus.strokeEnd(fttl()); ok(op) }
+
+ // batched path -----------------------------------------------
+ "path" -> {
+ val pts = msg.getJSONArray("points")
+ val list = ArrayList>(pts.length())
+ for (i in 0 until pts.length()) {
+ val p = pts.getJSONArray(i)
+ val x = p.getDouble(0).toFloat()
+ val y = p.getDouble(1).toFloat()
+ list += if (norm) (x * W) to (y * H) else x to y
+ }
+ AnnotationServiceBus.drawPath(list, fttl())
+ ok(op)
+ }
+
+ // shapes ------------------------------------------------------
+ "rect" -> {
+ AnnotationServiceBus.drawRect(
+ fx("x"), fy("y"),
+ fdim("w"), fdim("h"),
+ fttl()
+ )
+ ok(op)
+ }
+ "circle" -> {
+ val cx = fx("cx"); val cy = fy("cy")
+ val r = fdim("r")
+ AnnotationServiceBus.drawCircle(cx, cy, r, fttl())
+ ok(op)
+ }
+ "arrow" -> {
+ AnnotationServiceBus.drawArrow(
+ fx("x"), fy("y"), fx("x2"), fy("y2"),
+ fttl()
+ )
+ ok(op)
+ }
+
+ // housekeeping -----------------------------------------------
+ "remove" -> {
+ val id = msg.optString("id", "")
+ if (id.isNotEmpty()) {
+ // send remove as a DrawCmd
+ AnnotationServiceBus.post(DrawCmd("remove", id))
+ ok(op)
+ } else err(op, "Missing id")
+ }
+
+ "probe" -> {
+ // Try to preserve whatever ID the server/browser sent down.
+ // Browser code accepts either "nodeid" or we can pick from "nodeids[0]".
+ val nodeIdFromSingle = msg.optString("nodeid", null)
+ val nodeIdFromArray = msg.optJSONArray("nodeids")?.optString(0)
+ val nodeId = nodeIdFromSingle ?: nodeIdFromArray
+
+ val perm = if (hasOverlayPermission(activity)) "granted" else "denied"
+
+ // If this agent includes the Annotation feature, it's supported.
+ val supported = true
+
+ return JSONObject().apply {
+ put("action", "annotationAck")
+ if (nodeId != null) put("nodeid", nodeId)
+ put("supported", supported)
+ put("permission", perm) // "granted" | "denied"
+ }
+ }
+
+ else -> err(op, "Unknown op")
+ }
+ } catch (t: Throwable) {
+ err(op, t.message ?: "error")
+ }
+ }
+
+ private fun parseColor(s: String): Int? = try {
+ android.graphics.Color.parseColor(s) // #RRGGBB / #AARRGGBB
+ } catch (_: Exception) { null }
+
+ // For future Emulator detection.
+ private fun isEmulator(): Boolean {
+ return (Build.FINGERPRINT.startsWith("generic")
+ || Build.FINGERPRINT.startsWith("unknown")
+ || Build.MODEL.contains("google_sdk")
+ || Build.MODEL.contains("Emulator")
+ || Build.MODEL.contains("Android SDK built for x86")
+ || Build.MANUFACTURER.contains("Genymotion")
+ || Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")
+ || "google_sdk" == Build.PRODUCT)
+ }
+
+ // Get current screen size in pixels (overlay is MATCH_PARENT)
+ private fun screenSize(activity: Activity): Pair {
+ return if (Build.VERSION.SDK_INT >= 30) {
+ val wm = activity.windowManager
+ val b = wm.currentWindowMetrics.bounds
+ b.width().toFloat() to b.height().toFloat()
+ } else {
+ val dm = DisplayMetrics()
+ @Suppress("DEPRECATION")
+ activity.windowManager.defaultDisplay.getRealMetrics(dm)
+ dm.widthPixels.toFloat() to dm.heightPixels.toFloat()
+ }
+ }
+}
diff --git a/app/src/main/java/com/meshcentral/agent/annotation/AnnotationConsent.kt b/app/src/main/java/com/meshcentral/agent/annotation/AnnotationConsent.kt
new file mode 100644
index 0000000..8e9f181
--- /dev/null
+++ b/app/src/main/java/com/meshcentral/agent/annotation/AnnotationConsent.kt
@@ -0,0 +1,71 @@
+package com.meshcentral.agent.annotation
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.os.Build
+import android.provider.Settings
+import android.widget.Toast
+
+object AnnotationConsent {
+
+ /**
+ * Entry-point to enable annotations with client consent:
+ * - If auto is ON and permission is granted -> enable immediately.
+ * - Else show a dialog with Allow once / Always allow / Deny.
+ * - If permission is missing at the end, flow will route to system Settings.
+ */
+ fun requestEnableWithConsent(activity: Activity) {
+ // ALWAYS run on UI thread since we might show dialogs
+ activity.runOnUiThread {
+ val ctx = activity
+
+ val hasPermission = if (Build.VERSION.SDK_INT >= 23) Settings.canDrawOverlays(ctx) else true
+ val autoEnabled = AnnotationPrefs.isAutoEnabled(ctx)
+
+ // Fast path: if permission already granted, just start overlay
+ if (hasPermission) {
+ if (autoEnabled) {
+ // Auto-enabled, start immediately
+ AnnotationController.show(ctx)
+ } else {
+ // Ask once, but since permission exists, just confirm and start
+ AlertDialog.Builder(activity)
+ .setTitle("Remote Annotation Request")
+ .setMessage("A remote user is requesting to annotate over your screen to help guide you.")
+ .setNegativeButton("Deny", null)
+ .setNeutralButton("OK") { _, _ ->
+ AnnotationController.show(ctx)
+ }
+ .setPositiveButton("Always allow") { _, _ ->
+ AnnotationPrefs.setAutoEnabled(ctx, true)
+ AnnotationController.show(ctx)
+ }
+ .show()
+ }
+ return@runOnUiThread
+ }
+
+ // Permission NOT granted - need to go to settings
+ if (!autoEnabled) {
+ AlertDialog.Builder(activity)
+ .setTitle("Remote Annotation Request")
+ .setMessage("A remote user is requesting to annotate over your screen to help guide you.\n\nYou'll need to enable 'Display over other apps' permission.")
+ .setNegativeButton("Deny", null)
+ .setNeutralButton("Allow") { _, _ ->
+ Toast.makeText(ctx, "Please enable 'Display over other apps'", Toast.LENGTH_LONG).show()
+ AnnotationController.ensurePermissionAndShow(activity, showRationale = false)
+ }
+ .setPositiveButton("Always allow") { _, _ ->
+ AnnotationPrefs.setAutoEnabled(ctx, true)
+ Toast.makeText(ctx, "Please enable 'Display over other apps'", Toast.LENGTH_LONG).show()
+ AnnotationController.ensurePermissionAndShow(activity, showRationale = false)
+ }
+ .show()
+ } else {
+ // Auto is ON but permission missing → go straight to settings
+ Toast.makeText(ctx, "Please enable 'Display over other apps'", Toast.LENGTH_LONG).show()
+ AnnotationController.ensurePermissionAndShow(activity, showRationale = false)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/meshcentral/agent/annotation/AnnotationController.kt b/app/src/main/java/com/meshcentral/agent/annotation/AnnotationController.kt
new file mode 100644
index 0000000..ead9ac7
--- /dev/null
+++ b/app/src/main/java/com/meshcentral/agent/annotation/AnnotationController.kt
@@ -0,0 +1,138 @@
+package com.meshcentral.agent.annotation
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.provider.Settings
+import com.meshcentral.agent.meshAgent
+
+object AnnotationController {
+ private const val REQ_OVERLAY = 8801
+
+ /**
+ * Ensures overlay permission and starts the overlay.
+ *
+ * @param activity host activity (used to launch Settings)
+ * @param showRationale whether to show a friendly dialog before jumping to Settings (default: true)
+ * @param title optional custom title for the rationale dialog
+ * @param message optional custom message for the rationale dialog
+ * @param positiveLabel positive button label (default: "Open Settings")
+ * @param negativeLabel negative button label (default: "Cancel")
+ */
+ @JvmStatic
+ fun ensurePermissionAndShow(
+ activity: Activity,
+ showRationale: Boolean = true,
+ title: String = "Allow on-screen annotations",
+ message: String = "To show guidance draw during remote support, allow 'Draw over other apps'. You can turn it off anytime.",
+ positiveLabel: String = "Open Settings",
+ negativeLabel: String = "Cancel"
+ ) {
+ // Pre-M: nothing to ask
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || hasOverlayPermission(activity)) {
+ show(activity)
+ return
+ }
+
+ if (!showRationale) {
+ openOverlaySettings(activity)
+ return
+ }
+
+ AlertDialog.Builder(activity)
+ .setTitle(title)
+ .setMessage(message)
+ .setPositiveButton(positiveLabel) { _, _ -> openOverlaySettings(activity) }
+ .setNegativeButton(negativeLabel, null)
+ .show()
+ }
+
+ @JvmStatic
+ fun onActivityResult(activity: Activity, requestCode: Int) {
+ if (requestCode == REQ_OVERLAY) {
+ // Give Android a moment to process the permission change
+ Handler(Looper.getMainLooper()).postDelayed({
+ try { meshAgent?.sendAnnotationCaps() } catch (_: Exception) {}
+ if (hasOverlayPermission(activity)) {
+ show(activity)
+ } else {
+ // Permission still not granted
+ android.widget.Toast.makeText(
+ activity,
+ "Overlay permission not granted",
+ android.widget.Toast.LENGTH_SHORT
+ ).show()
+ }
+ }, 200) // Small delay to ensure permission is registered
+ }
+ }
+
+ @JvmStatic
+ fun show(ctx: Context) {
+ if (!hasOverlayPermission(ctx)) {
+ android.util.Log.w("AnnotationController", "show() called but permission not granted")
+ return
+ }
+
+ if (!AnnotationServiceBus.isActive()) {
+ android.util.Log.d("AnnotationController", "Starting AnnotationOverlayService")
+ val i = Intent(ctx, AnnotationOverlayService::class.java)
+ try {
+ if (Build.VERSION.SDK_INT >= 26) {
+ ctx.startForegroundService(i)
+ } else {
+ ctx.startService(i)
+ }
+
+ // Send started event to server
+ meshAgent?.sendJson(org.json.JSONObject().apply {
+ put("action", "annotationAck")
+ put("op", "event")
+ put("event", "started")
+ })
+
+ if (ctx is Activity) {
+ ctx.invalidateOptionsMenu()
+ }
+ } catch (e: Exception) {
+ android.util.Log.e("AnnotationController", "Failed to start service: ${e.message}")
+ }
+ } else {
+ android.util.Log.d("AnnotationController", "Service already active")
+ }
+ }
+
+ @JvmStatic
+ fun hide(ctx: Context) {
+ android.util.Log.d("AnnotationController", "Stopping AnnotationOverlayService")
+ ctx.stopService(Intent(ctx, AnnotationOverlayService::class.java))
+ meshAgent?.sendJson(org.json.JSONObject().apply {
+ put("action", "annotationAck")
+ put("op", "event")
+ put("event", "stopped")
+ })
+
+ if (ctx is Activity) {
+ ctx.invalidateOptionsMenu()
+ }
+ }
+
+ private fun openOverlaySettings(activity: Activity) {
+ val intent = Intent(
+ Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
+ Uri.parse("package:${activity.packageName}")
+ )
+ activity.startActivityForResult(intent, REQ_OVERLAY)
+ }
+
+ private fun hasOverlayPermission(ctx: Context): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ Settings.canDrawOverlays(ctx)
+ } else true
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/meshcentral/agent/annotation/AnnotationFeature.kt b/app/src/main/java/com/meshcentral/agent/annotation/AnnotationFeature.kt
new file mode 100644
index 0000000..7eb1368
--- /dev/null
+++ b/app/src/main/java/com/meshcentral/agent/annotation/AnnotationFeature.kt
@@ -0,0 +1,33 @@
+package com.meshcentral.agent.annotation
+
+import android.content.Context
+import android.os.Build
+import android.provider.Settings
+
+object AnnotationFeature {
+
+ fun onScreenShareStarted(ctx: Context) {
+ // Do NOT auto-start unless the user opted in AND permission exists.
+ if (AnnotationPrefs.isAutoEnabled(ctx) && hasOverlayPermission(ctx)) {
+ AnnotationController.show(ctx)
+ }
+ }
+
+ fun onScreenShareStopped(ctx: Context) {
+ // Always hide when sharing ends.
+ AnnotationController.hide(ctx)
+ }
+
+ fun stopAnnotations(ctx: Context) {
+ AnnotationController.hide(ctx)
+ }
+
+ fun capabilities(ctx: Context): Map = mapOf(
+ "overlay" to "supported",
+ "permission" to if (hasOverlayPermission(ctx)) "granted" else "denied",
+ "auto" to if (AnnotationPrefs.isAutoEnabled(ctx)) "on" else "off"
+ )
+
+ private fun hasOverlayPermission(ctx: Context): Boolean =
+ if (Build.VERSION.SDK_INT >= 23) Settings.canDrawOverlays(ctx) else true
+}
diff --git a/app/src/main/java/com/meshcentral/agent/annotation/AnnotationOverlayService.kt b/app/src/main/java/com/meshcentral/agent/annotation/AnnotationOverlayService.kt
new file mode 100644
index 0000000..5336d49
--- /dev/null
+++ b/app/src/main/java/com/meshcentral/agent/annotation/AnnotationOverlayService.kt
@@ -0,0 +1,125 @@
+package com.meshcentral.agent.annotation
+
+import android.app.*
+import android.content.Context
+import android.content.Intent
+import android.graphics.PixelFormat
+import android.os.Build
+import android.os.IBinder
+import android.view.WindowManager
+import androidx.core.app.NotificationCompat
+import com.meshcentral.agent.R
+import android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO
+import com.meshcentral.agent.meshAgent
+
+class AnnotationOverlayService : Service() {
+ private lateinit var wm: WindowManager
+ private lateinit var overlay: DrawingOverlayView
+ private lateinit var lp: WindowManager.LayoutParams
+ private var foregroundStarted = false
+
+
+
+ override fun onCreate() {
+ super.onCreate()
+ AnnotationServiceBus.attach(this)
+
+ wm = getSystemService(Context.WINDOW_SERVICE) as WindowManager
+ overlay = DrawingOverlayView(this)
+
+ overlay.apply {
+ isClickable = false
+ isFocusable = false
+ isFocusableInTouchMode = false
+ importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
+ }
+
+ val type = if (Build.VERSION.SDK_INT >= 26)
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
+ else @Suppress("DEPRECATION")
+ WindowManager.LayoutParams.TYPE_PHONE
+
+ lp = WindowManager.LayoutParams(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.MATCH_PARENT,
+ type,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
+ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, // ✅ pass-through
+ PixelFormat.TRANSLUCENT
+ )
+ }
+
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ // 1) Foreground ASAP here (some OEMs require this location)
+ if (!foregroundStarted) {
+ startForeground(NOTIF_ID, buildNotif())
+ foregroundStarted = true
+ }
+
+ // 2) Now attach the overlay; catching any race-y issues is okay
+ try {
+ if (overlay.windowToken == null) {
+ wm.addView(overlay, lp)
+ }
+ } catch (_: Exception) { /* ignore duplicate add */ }
+
+ return START_NOT_STICKY
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+
+ // Notify server that annotations stopped
+ try {
+ try {
+ meshAgent?.sendJson(org.json.JSONObject().apply {
+ put("action", "annotationAck")
+ put("op", "event")
+ put("event", "stopped")
+ })
+ } catch (e: Exception) {
+ android.util.Log.e("AnnotationOverlayService", "Failed to send stop event: ${e.message}")
+ }
+ } catch (e: Exception) {
+ // Log but don't crash
+ }
+
+ AnnotationServiceBus.detach(this)
+ try { wm.removeView(overlay) } catch (_: Exception) {}
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ fun applyCommand(cmd: DrawCmd) = overlay.applyCommand(cmd)
+ fun setStyle(style: DrawStyle) = overlay.setStyle(style)
+ fun clear() = overlay.clear()
+ fun removeById(id: String) = overlay.removeById(id)
+
+ private fun buildNotif(): Notification {
+ val channelId = "annotations"
+ if (Build.VERSION.SDK_INT >= 26) {
+ val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ if (mgr.getNotificationChannel(channelId) == null) {
+ mgr.createNotificationChannel(
+ NotificationChannel(channelId, "Remote annotations", NotificationManager.IMPORTANCE_LOW)
+ )
+ }
+ }
+ val stopPI = PendingIntent.getBroadcast(
+ this, 1, Intent(this, StopAnnotationReceiver::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
+ )
+ return NotificationCompat.Builder(this, channelId)
+ .setSmallIcon(R.drawable.ic_cloud)
+ .setContentTitle("Remote annotations active")
+ .setContentText("Tap Stop to hide annotations")
+ .addAction(0, "Stop", stopPI)
+ .setOngoing(true)
+ .build()
+ }
+
+ companion object { const val NOTIF_ID = 911001 }
+}
+
diff --git a/app/src/main/java/com/meshcentral/agent/annotation/AnnotationPrefs.kt b/app/src/main/java/com/meshcentral/agent/annotation/AnnotationPrefs.kt
new file mode 100644
index 0000000..8f0c0ee
--- /dev/null
+++ b/app/src/main/java/com/meshcentral/agent/annotation/AnnotationPrefs.kt
@@ -0,0 +1,21 @@
+package com.meshcentral.agent.annotation
+
+import android.content.Context
+import androidx.preference.PreferenceManager
+import com.meshcentral.agent.meshAgent
+
+object AnnotationPrefs {
+ private const val KEY_AUTO = "pref_annotation_auto"
+
+ fun isAutoEnabled(ctx: Context): Boolean =
+ PreferenceManager.getDefaultSharedPreferences(ctx).getBoolean(KEY_AUTO, false)
+
+ fun setAutoEnabled(ctx: Context, enabled: Boolean) {
+ PreferenceManager.getDefaultSharedPreferences(ctx)
+ .edit()
+ .putBoolean(KEY_AUTO, enabled)
+ .apply()
+ try { meshAgent?.sendAnnotationCaps() } catch (_: Exception) {}
+ }
+}
+
diff --git a/app/src/main/java/com/meshcentral/agent/annotation/AnnotationServiceBus.kt b/app/src/main/java/com/meshcentral/agent/annotation/AnnotationServiceBus.kt
new file mode 100644
index 0000000..d36c165
--- /dev/null
+++ b/app/src/main/java/com/meshcentral/agent/annotation/AnnotationServiceBus.kt
@@ -0,0 +1,143 @@
+package com.meshcentral.agent.annotation
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import java.lang.ref.WeakReference
+import java.util.concurrent.atomic.AtomicLong
+
+object AnnotationServiceBus {
+ private var serviceRef: WeakReference? = null
+
+ private val main = Handler(Looper.getMainLooper())
+ private val idGen = AtomicLong(1)
+ private var activeStrokeId: String? = null
+
+ private var cachedStyle: DrawStyle? = null
+
+ internal fun attach(service: AnnotationOverlayService) {
+ serviceRef = WeakReference(service)
+ // Apply cached style immediately when service attaches
+ cachedStyle?.let { style ->
+ main.post {
+ serviceRef?.get()?.setStyle(style)
+ }
+ }
+ }
+
+ internal fun detach(service: AnnotationOverlayService) {
+ serviceRef?.get()?.takeIf { it == service }?.let { serviceRef = null }
+ activeStrokeId = null
+ cachedStyle = null // Clear cache when service stops
+ }
+
+ fun isActive(): Boolean = serviceRef?.get() != null
+
+ fun post(cmd: DrawCmd) {
+ main.post {
+ serviceRef?.get()?.applyCommand(cmd)
+ }
+ }
+
+ fun clear() {
+ post(DrawCmd(type = "clear", strokeId = "all"))
+ activeStrokeId = null
+ }
+
+ fun remove(id: String) = post(DrawCmd(type = "remove", strokeId = id))
+
+ fun setStyle(style: DrawStyle) {
+ // Always cache the style
+ cachedStyle = style
+
+ // Apply to service if it exists
+ post(
+ DrawCmd(
+ type = "style",
+ strokeId = "style",
+ color = style.color,
+ width = style.widthPx
+ )
+ )
+ }
+
+ private fun newId(prefix: String = "s"): String = "$prefix${idGen.getAndIncrement()}"
+
+ private fun scheduleTtl(id: String, ttlMs: Long) {
+ if (ttlMs <= 0) return
+ main.postDelayed({
+ post(DrawCmd(type = "remove", strokeId = id))
+ if (activeStrokeId == id) activeStrokeId = null
+ }, ttlMs)
+ }
+
+ // ---------- Streaming pen ----------
+ fun strokeStart(x: Float, y: Float) {
+ val id = newId("stroke_")
+ activeStrokeId = id
+ post(DrawCmd(type = "begin", strokeId = id, x = x, y = y))
+ }
+
+ fun strokeMove(x: Float, y: Float) {
+ val id = activeStrokeId ?: return
+ post(DrawCmd(type = "move", strokeId = id, x = x, y = y))
+ }
+
+ fun strokeEnd(ttlMs: Long = 0L) {
+ val id = activeStrokeId ?: return
+ post(DrawCmd(type = "end", strokeId = id, ttlMs = ttlMs))
+ scheduleTtl(id, ttlMs)
+ activeStrokeId = null
+ }
+
+ // ---------- Batched path ----------
+ fun drawPath(points: List>, ttlMs: Long = 0L) {
+ if (points.size < 2) return
+ val id = newId("path_")
+ post(DrawCmd(type = "begin", strokeId = id, x = points.first().first, y = points.first().second))
+ for (i in 1 until points.size) {
+ val (px, py) = points[i]
+ post(DrawCmd(type = "move", strokeId = id, x = px, y = py))
+ }
+ post(DrawCmd(type = "end", strokeId = id, ttlMs = ttlMs))
+ scheduleTtl(id, ttlMs)
+ }
+
+ // ---------- Shapes ----------
+ fun drawRect(x: Float, y: Float, w: Float, h: Float, ttlMs: Long = 0L) {
+ val id = newId("rect_")
+ post(DrawCmd(type = "rect", strokeId = id, x = x, y = y, x2 = w, y2 = h, ttlMs = ttlMs))
+ scheduleTtl(id, ttlMs)
+ }
+
+ fun drawCircle(cx: Float, cy: Float, r: Float, ttlMs: Long = 0L) {
+ val id = newId("circle_")
+ post(DrawCmd(type = "circle", strokeId = id, x = cx, y = cy, r = r, ttlMs = ttlMs))
+ scheduleTtl(id, ttlMs)
+ }
+
+ fun drawArrow(x1: Float, y1: Float, x2: Float, y2: Float, ttlMs: Long = 0L) {
+ val id = newId("arrow_")
+ post(DrawCmd(type = "arrow", strokeId = id, x = x1, y = y1, x2 = x2, y2 = y2, ttlMs = ttlMs))
+ scheduleTtl(id, ttlMs)
+ }
+
+ // ---------- Demo ----------
+ fun demoCircle(context: Context, cx: Float, cy: Float, r: Float) {
+ val id = newId("demo_")
+ post(DrawCmd(type = "begin", strokeId = id, x = cx + r, y = cy))
+ val segments = 32
+ for (i in 1..segments) {
+ val theta = (2.0 * Math.PI * i / segments).toFloat()
+ post(
+ DrawCmd(
+ type = "move",
+ strokeId = id,
+ x = cx + r * kotlin.math.cos(theta),
+ y = cy + r * kotlin.math.sin(theta)
+ )
+ )
+ }
+ post(DrawCmd(type = "end", strokeId = id))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/meshcentral/agent/annotation/DrawingOverlayView.kt b/app/src/main/java/com/meshcentral/agent/annotation/DrawingOverlayView.kt
new file mode 100644
index 0000000..da28395
--- /dev/null
+++ b/app/src/main/java/com/meshcentral/agent/annotation/DrawingOverlayView.kt
@@ -0,0 +1,143 @@
+package com.meshcentral.agent.annotation
+
+import android.content.Context
+import android.graphics.*
+import android.view.View
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+
+
+class DrawingOverlayView(ctx: Context) : View(ctx) {
+ private val livePaths = ConcurrentHashMap() // in-progress strokes
+ private val finalized = ConcurrentHashMap>() // finished shapes keyed by id
+
+ // Current style used for new strokes/shapes
+ private val currentPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 6f
+ strokeJoin = Paint.Join.ROUND
+ strokeCap = Paint.Cap.ROUND
+ color = Color.RED
+ }
+
+ override fun onDraw(c: Canvas) {
+ super.onDraw(c)
+ // draw saved shapes
+ for ((_, pair) in finalized) c.drawPath(pair.first, pair.second)
+ // draw active stroke(s) with current paint
+ for (p in livePaths.values) c.drawPath(p, currentPaint)
+ }
+
+ fun setStyle(style: DrawStyle) {
+ currentPaint.color = style.color
+ currentPaint.strokeWidth = style.widthPx
+ invalidate()
+ }
+
+ fun clear() {
+ livePaths.clear()
+ finalized.clear()
+ invalidate()
+ }
+
+ fun removeById(id: String) {
+ livePaths.remove(id)
+ finalized.remove(id)
+ invalidate()
+ }
+
+ fun applyCommand(cmd: DrawCmd) {
+ when (cmd.type) {
+ // ---------- streaming stroke ----------
+ "begin" -> {
+ val x = cmd.x ?: return
+ val y = cmd.y ?: return
+ val p = Path().apply { moveTo(x, y) }
+ livePaths[cmd.strokeId] = p
+ }
+ "move" -> {
+ val x = cmd.x ?: return
+ val y = cmd.y ?: return
+ livePaths[cmd.strokeId]?.lineTo(x, y)
+ }
+ "end" -> {
+ val p = livePaths.remove(cmd.strokeId) ?: return
+ finalized[cmd.strokeId] = p to snapshotPaint(cmd)
+ }
+
+ // ---------- one-shot shapes ----------
+ "rect" -> {
+ val x = cmd.x ?: return
+ val y = cmd.y ?: return
+ val w = cmd.x2 ?: return
+ val h = cmd.y2 ?: return
+ val path = Path().apply {
+ addRect(RectF(x, y, x + w, y + h), Path.Direction.CW)
+ }
+ finalized[cmd.strokeId] = path to snapshotPaint(cmd)
+ }
+ "circle" -> {
+ val cx = cmd.x ?: return
+ val cy = cmd.y ?: return
+ val r = cmd.r ?: return
+ val path = Path().apply { addCircle(cx, cy, r, Path.Direction.CW) }
+ finalized[cmd.strokeId] = path to snapshotPaint(cmd)
+ }
+ "arrow" -> {
+ val x1 = cmd.x ?: return
+ val y1 = cmd.y ?: return
+ val x2 = cmd.x2 ?: return
+ val y2 = cmd.y2 ?: return
+ finalized[cmd.strokeId] = buildArrowPath(x1, y1, x2, y2) to snapshotPaint(cmd)
+ }
+
+ // ---------- housekeeping ----------
+ "remove" -> removeById(cmd.strokeId)
+ "clear" -> clear()
+
+ // ---------- style update (unified DrawCmd path) ----------
+ "style" -> {
+ cmd.color?.let { currentPaint.color = it }
+ cmd.width?.let { currentPaint.strokeWidth = it }
+ }
+ }
+ invalidate()
+ }
+
+ private fun snapshotPaint(cmd: DrawCmd): Paint {
+ return Paint(currentPaint).apply {
+ cmd.color?.let { color = it }
+ cmd.width?.let { strokeWidth = it }
+ }
+ }
+
+ private fun buildArrowPath(x1: Float, y1: Float, x2: Float, y2: Float): Path {
+ val p = Path()
+ // shaft
+ p.moveTo(x1, y1)
+ p.lineTo(x2, y2)
+
+ // head size scales with stroke width a bit
+ val w = currentPaint.strokeWidth
+ val headLen = 6f * w.coerceAtLeast(2f) // length of the head “sides”
+ val headWidth = 3f * w.coerceAtLeast(2f)
+
+ val angle = atan2((y2 - y1), (x2 - x1))
+ val a1 = angle + Math.toRadians(150.0).toFloat() // 150° from shaft
+ val a2 = angle - Math.toRadians(150.0).toFloat()
+
+ val hx1 = x2 + headLen * cos(a1)
+ val hy1 = y2 + headLen * sin(a1)
+ val hx2 = x2 + headLen * cos(a2)
+ val hy2 = y2 + headLen * sin(a2)
+
+ p.moveTo(x2, y2)
+ p.lineTo(hx1, hy1)
+ p.moveTo(x2, y2)
+ p.lineTo(hx2, hy2)
+
+ return p
+ }
+}
diff --git a/app/src/main/java/com/meshcentral/agent/annotation/StopAnnotationReceiver.kt b/app/src/main/java/com/meshcentral/agent/annotation/StopAnnotationReceiver.kt
new file mode 100644
index 0000000..6b42554
--- /dev/null
+++ b/app/src/main/java/com/meshcentral/agent/annotation/StopAnnotationReceiver.kt
@@ -0,0 +1,11 @@
+package com.meshcentral.agent.annotation
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+
+class StopAnnotationReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ context.stopService(Intent(context, AnnotationOverlayService::class.java))
+ }
+}
diff --git a/app/src/main/java/com/meshcentral/agent/annotation/Types.kt b/app/src/main/java/com/meshcentral/agent/annotation/Types.kt
new file mode 100644
index 0000000..f9eb063
--- /dev/null
+++ b/app/src/main/java/com/meshcentral/agent/annotation/Types.kt
@@ -0,0 +1,21 @@
+package com.meshcentral.agent.annotation
+
+import android.graphics.Color
+
+data class DrawCmd(
+ val type: String, // "begin","move","end","rect","circle","arrow","style","clear","remove"
+ val strokeId: String, // logical id (e.g. "stroke_42", "circle_5", "all" for clear)
+ val x: Float? = null, // begin/move, rect.x, arrow.x1, circle.cx
+ val y: Float? = null, // begin/move, rect.y, arrow.y1, circle.cy
+ val x2: Float? = null, // rect.w (or x2 if you prefer), arrow.x2
+ val y2: Float? = null, // rect.h (or y2), arrow.y2
+ val r: Float? = null, // circle radius
+ val color: Int? = null, // style.color (ARGB)
+ val width: Float? = null, // style.widthPx
+ val ttlMs: Long? = null // auto-clear per item
+)
+
+data class DrawStyle(
+ val color: Int = Color.RED,
+ val widthPx: Float = 6f
+)
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml
index cd98730..7b15794 100644
--- a/app/src/main/res/menu/menu_main.xml
+++ b/app/src/main/res/menu/menu_main.xml
@@ -27,6 +27,11 @@
android:orderInCategory="104"
android:title="@string/stopsharescreen"
app:showAsAction="never" />
+
- MeshCentral
Share Screen
Stop Screen Sharing
+ Stop Annotations
Display is being shared
default
Accept
@@ -59,8 +60,14 @@
Automatic Consent
Automatically give consent to remote agent
Always ask for consent when remote agent connects
+ Auto Consent Notify
+ Notify on connect after auto consent
+ Do not notify on connect after auto consent
Setup to: %1$s?
Clear server setup?
Server Pairing Link
Invalid Server Pairing Linbk
+ Automatic Annotations
+ Turn on on-screen hints without asking when requested
+ Ask me before enabling on-screen hints
\ No newline at end of file
diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml
index 50f85ef..d108f12 100644
--- a/app/src/main/res/xml/root_preferences.xml
+++ b/app/src/main/res/xml/root_preferences.xml
@@ -17,6 +17,19 @@
app:summaryOff="@string/always_ask_for_consent_when_remote_agent_connects"
app:defaultValue="false"
app:useSimpleSummaryProvider="true" />
-
+
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 27b203c..bc59958 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,27 +1,4 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
-buildscript {
- ext.kotlin_version = '1.9.10'
- repositories {
- google()
- jcenter()
- }
- dependencies {
- classpath 'com.android.tools.build:gradle:8.1.2'
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
- classpath 'com.google.gms:google-services:4.4.0'
-
- // NOTE: Do not place your application dependencies here; they belong
- // in the individual module build.gradle files
- }
-}
-
-allprojects {
- repositories {
- google()
- jcenter()
- }
-}
-
-task clean(type: Delete) {
+// Root build file – no repositories here; all repos are in settings.gradle
+tasks.register("clean", Delete) {
delete rootProject.buildDir
}
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
index 1e7070a..ae4ad5a 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,2 +1,29 @@
-include ':app'
-rootProject.name = "MeshCentral Agent"
\ No newline at end of file
+// settings.gradle (Groovy)
+
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+ // You can declare plugin versions here so module build.gradle can be versionless
+ plugins {
+ id "com.android.application" version "8.1.2"
+ id "org.jetbrains.kotlin.android" version "1.9.10"
+ id "com.google.gms.google-services" version "4.4.0"
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven { url "https://jitpack.io" }
+ // no jcenter()
+ }
+}
+
+rootProject.name = "MeshCentral Agent"
+include(":app")
+