package com.ahmed.jarvis import android.Manifest import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.location.Location import android.media.projection.MediaProjectionManager import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper import android.provider.AlarmClock import android.provider.MediaStore import android.util.DisplayMetrics import androidx.annotation.NonNull import androidx.core.content.ContextCompat import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import com.google.android.gms.tasks.CancellationTokenSource import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel /** * Native "phone hands" bridge (APP_PLAN §3b / §4B). * * Dart → native: the server→phone device-action verbs (navigate, play_music, * call, take_screenshot, open_camera, open_app, open_url, set_timer, * get_location) plus location-monitoring control. * * Native → Dart: phone-emitted events pushed back over the same channel * (`onLocation` for the new_location event, `onSms` for the sms_in event). * * Runtime permissions are requested on the Dart side (permission_handler) * before each capability is used; native handlers double-check and fail with a * clean error if a permission is missing. Sensitive numbers are NEVER logged. */ class MainActivity : FlutterActivity(), MethodChannel.MethodCallHandler { companion object { private const val CHANNEL = "com.ahmed.jarvis/phone_actions" private const val REQ_SCREENSHOT = 0x0A11 /** Live channel so the manifest SMS receiver can forward to Dart. */ @Volatile var channel: MethodChannel? = null private val main = Handler(Looper.getMainLooper()) /** Forward an incoming SMS to Dart (called by [SmsReceiver]). Drops the * message if the Flutter engine is not alive (documented limitation). */ fun forwardSms(sender: String, body: String, ts: Long) { val ch = channel ?: return main.post { ch.invokeMethod( "onSms", mapOf("sender" to sender, "body" to body, "ts" to ts) ) } } } private var fused: FusedLocationProviderClient? = null private var locationCallback: LocationCallback? = null private var pendingScreenshot: MethodChannel.Result? = null private var shotWidth = 0 private var shotHeight = 0 private var shotDpi = 0 override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) ch.setMethodCallHandler(this) channel = ch } override fun onDestroy() { stopLocationUpdates() channel = null super.onDestroy() } private fun fusedClient(): FusedLocationProviderClient { return fused ?: LocationServices.getFusedLocationProviderClient(this).also { fused = it } } // ------------------------------------------------------------------------- // MethodChannel dispatch // ------------------------------------------------------------------------- override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { try { when (call.method) { "navigate" -> navigate(call, result) "play_music" -> playMusic(call, result) "call" -> placeCall(call, result) "take_screenshot" -> takeScreenshot(result) "open_camera" -> openCamera(call, result) "open_app" -> openApp(call, result) "open_url" -> openUrl(call, result) "set_timer" -> setTimer(call, result) "get_location" -> getLocation(result) "start_location_monitoring" -> startLocationMonitoring(call, result) "stop_location_monitoring" -> { stopLocationUpdates(); result.success(null) } else -> result.notImplemented() } } catch (e: Exception) { result.error("failed", e.message ?: "action failed", null) } } // ------------------------------------------------------------------------- // Intent-based actions // ------------------------------------------------------------------------- private fun launch(intent: Intent, result: MethodChannel.Result, ok: Map? = null) { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) try { startActivity(intent) result.success(ok) } catch (e: ActivityNotFoundException) { result.error("no_app", "No app can handle this action.", null) } } private fun navigate(call: MethodCall, result: MethodChannel.Result) { val lat = call.argument("lat") val lng = call.argument("lng") val address = call.argument("address") val destination = call.argument("destination") val q = when { lat != null && lng != null -> "$lat,$lng" !address.isNullOrBlank() -> address !destination.isNullOrBlank() -> destination else -> null } if (q.isNullOrBlank()) { result.error("bad_args", "No destination provided.", null); return } // Prefer turn-by-turn navigation in Google Maps. val nav = Intent(Intent.ACTION_VIEW, Uri.parse("google.navigation:q=" + Uri.encode(q))) if (nav.resolveActivity(packageManager) != null) { launch(nav, result, mapOf("mode" to "navigation")); return } // Fall back to a geo: search, then a Maps web directions URL. val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=" + Uri.encode(q))) if (geo.resolveActivity(packageManager) != null) { launch(geo, result, mapOf("mode" to "map")); return } val web = Intent( Intent.ACTION_VIEW, Uri.parse("https://www.google.com/maps/dir/?api=1&destination=" + Uri.encode(q)) ) launch(web, result, mapOf("mode" to "web")) } private fun playMusic(call: MethodCall, result: MethodChannel.Result) { val query = call.argument("query")?.trim().orEmpty() if (query.isBlank()) { result.error("bad_args", "No song/query provided.", null); return } // "Play from search" starts playback of the top match. Try YouTube Music // first, then any music app, then a web search as a last resort. val play = Intent(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH).apply { putExtra(MediaStore.EXTRA_MEDIA_FOCUS, "vnd.android.cursor.item/audio") putExtra("query", query) putExtra(android.app.SearchManager.QUERY, query) } val yt = Intent(play).setPackage("com.google.android.apps.youtube.music") if (yt.resolveActivity(packageManager) != null) { launch(yt, result, mapOf("app" to "youtube_music")); return } if (play.resolveActivity(packageManager) != null) { launch(play, result, mapOf("app" to "default_music")); return } val web = Intent( Intent.ACTION_VIEW, Uri.parse("https://music.youtube.com/search?q=" + Uri.encode(query)) ) launch(web, result, mapOf("app" to "web")) } private fun placeCall(call: MethodCall, result: MethodChannel.Result) { val number = call.argument("number")?.trim().orEmpty() if (number.isBlank()) { result.error("bad_args", "No number to call.", null); return } // Direct call when CALL_PHONE is granted; otherwise fall back to the // dialer (pre-filled, one tap) so calling always works. The number is // sensitive — never logged. val uri = Uri.parse("tel:" + Uri.encode(number)) val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED if (granted) { launch(Intent(Intent.ACTION_CALL, uri), result, mapOf("dialed" to true)) } else { launch(Intent(Intent.ACTION_DIAL, uri), result, mapOf("dialed" to false, "prefilled" to true)) } } private fun openCamera(call: MethodCall, result: MethodChannel.Result) { val mode = call.argument("mode") ?: "photo" val action = if (mode == "video") { MediaStore.INTENT_ACTION_VIDEO_CAMERA } else { MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA } val intent = Intent(action) if (mode == "selfie") { // Best-effort front-camera hint (OEM-dependent). intent.putExtra("android.intent.extras.CAMERA_FACING", 1) intent.putExtra("android.intent.extras.LENS_FACING_FRONT", 1) intent.putExtra("android.intent.extra.USE_FRONT_CAMERA", true) } launch(intent, result, mapOf("mode" to mode)) } private fun openApp(call: MethodCall, result: MethodChannel.Result) { val target = call.argument("target")?.trim().orEmpty() if (target.isBlank()) { result.error("bad_args", "No app specified.", null); return } // 1) Treat as a package name. packageManager.getLaunchIntentForPackage(target)?.let { launch(it, result, mapOf("package" to target)); return } // 2) Resolve a human label -> package (case-insensitive, best match). val pkg = resolveLabelToPackage(target) if (pkg != null) { packageManager.getLaunchIntentForPackage(pkg)?.let { launch(it, result, mapOf("package" to pkg)); return } } result.error("no_app", "Couldn't find an app called \"$target\".", null) } private fun resolveLabelToPackage(label: String): String? { val want = label.lowercase() val pm = packageManager var exact: String? = null var partial: String? = null val apps = pm.getInstalledApplications(0) for (app in apps) { val name = pm.getApplicationLabel(app).toString().lowercase() if (name == want) { exact = app.packageName; break } if (partial == null && (name.contains(want) || want.contains(name))) { // Only launchable apps make sense. if (pm.getLaunchIntentForPackage(app.packageName) != null) { partial = app.packageName } } } return exact ?: partial } private fun openUrl(call: MethodCall, result: MethodChannel.Result) { var url = call.argument("url")?.trim().orEmpty() if (url.isBlank()) { result.error("bad_args", "No URL provided.", null); return } if (!url.contains("://")) url = "https://$url" launch(Intent(Intent.ACTION_VIEW, Uri.parse(url)), result, mapOf("url" to url)) } private fun setTimer(call: MethodCall, result: MethodChannel.Result) { val seconds = (call.argument("seconds"))?.toInt() ?: 0 if (seconds <= 0) { result.error("bad_args", "Timer length must be positive.", null); return } val label = call.argument("label") val intent = Intent(AlarmClock.ACTION_SET_TIMER).apply { putExtra(AlarmClock.EXTRA_LENGTH, seconds) putExtra(AlarmClock.EXTRA_SKIP_UI, true) if (!label.isNullOrBlank()) putExtra(AlarmClock.EXTRA_MESSAGE, label) } launch(intent, result, mapOf("seconds" to seconds)) } // ------------------------------------------------------------------------- // Location: one fresh fix + significant-change monitoring // ------------------------------------------------------------------------- private fun hasLocationPermission(): Boolean { return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED } private fun getLocation(result: MethodChannel.Result) { if (!hasLocationPermission()) { result.error("permission_denied", "Location permission not granted.", null); return } val cts = CancellationTokenSource() try { fusedClient().getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, cts.token) .addOnSuccessListener { loc: Location? -> if (loc != null) { result.success(locMap(loc)) } else { // Fall back to the last known fix. fusedClient().lastLocation .addOnSuccessListener { last -> if (last != null) result.success(locMap(last)) else result.error("no_fix", "No location available.", null) } .addOnFailureListener { e -> result.error("no_fix", e.message ?: "No location.", null) } } } .addOnFailureListener { e -> result.error("no_fix", e.message ?: "Location failed.", null) } } catch (e: SecurityException) { result.error("permission_denied", "Location permission not granted.", null) } } private fun locMap(loc: Location): Map = mapOf( "lat" to loc.latitude, "lng" to loc.longitude, "accuracy" to if (loc.hasAccuracy()) loc.accuracy.toDouble() else null ) private fun startLocationMonitoring(call: MethodCall, result: MethodChannel.Result) { if (!hasLocationPermission()) { result.error("permission_denied", "Location permission not granted.", null); return } // Defaults tuned for "significant change": balanced power, ~2 min, 150 m. val intervalMs = (call.argument("intervalMs"))?.toLong() ?: 120_000L val meters = (call.argument("meters"))?.toFloat() ?: 150f stopLocationUpdates() val req = LocationRequest.Builder(Priority.PRIORITY_BALANCED_POWER_ACCURACY, intervalMs) .setMinUpdateDistanceMeters(meters) .setMinUpdateIntervalMillis(intervalMs) .build() val cb = object : LocationCallback() { override fun onLocationResult(res: LocationResult) { val loc = res.lastLocation ?: return channel?.invokeMethod("onLocation", locMap(loc)) } } locationCallback = cb try { fusedClient().requestLocationUpdates(req, cb, Looper.getMainLooper()) result.success(null) } catch (e: SecurityException) { result.error("permission_denied", "Location permission not granted.", null) } } private fun stopLocationUpdates() { locationCallback?.let { fused?.removeLocationUpdates(it) } locationCallback = null } // ------------------------------------------------------------------------- // Screenshot: MediaProjection (per-session consent) via a foreground service // ------------------------------------------------------------------------- private fun takeScreenshot(result: MethodChannel.Result) { if (pendingScreenshot != null) { result.error("busy", "A screenshot is already in progress.", null); return } // Full-screen dimensions for the virtual display. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val bounds = windowManager.maximumWindowMetrics.bounds shotWidth = bounds.width() shotHeight = bounds.height() shotDpi = resources.configuration.densityDpi } else { val dm = DisplayMetrics() @Suppress("DEPRECATION") windowManager.defaultDisplay.getRealMetrics(dm) shotWidth = dm.widthPixels shotHeight = dm.heightPixels shotDpi = dm.densityDpi } pendingScreenshot = result val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager try { startActivityForResult(mpm.createScreenCaptureIntent(), REQ_SCREENSHOT) } catch (e: Exception) { pendingScreenshot = null result.error("failed", e.message ?: "Couldn't start screen capture.", null) } } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == REQ_SCREENSHOT) { val res = pendingScreenshot pendingScreenshot = null if (res == null) return if (resultCode != RESULT_OK || data == null) { res.error("declined", "Screen capture was declined.", null); return } ScreenCaptureService.resultCallback = { ok, pathOrError -> main.post { if (ok) res.success(mapOf("path" to pathOrError)) else res.error("failed", pathOrError, null) } } val svc = Intent(this, ScreenCaptureService::class.java).apply { putExtra(ScreenCaptureService.EXTRA_CODE, resultCode) putExtra(ScreenCaptureService.EXTRA_DATA, data) putExtra(ScreenCaptureService.EXTRA_WIDTH, shotWidth) putExtra(ScreenCaptureService.EXTRA_HEIGHT, shotHeight) putExtra(ScreenCaptureService.EXTRA_DPI, shotDpi) } ContextCompat.startForegroundService(this, svc) return } super.onActivityResult(requestCode, resultCode, data) } }