Browse Source

refactor: extract screen capture logic into dedicated manager (ARCH-001)

Created ScreenCaptureManager interface and ScreenCaptureManagerImpl to separate
screen capture concerns from ScreenCaptureService, improving architecture:

## New Files:
- ScreenCaptureManager.kt: Interface defining screen capture operations
- ScreenCaptureManagerImpl.kt: Implementation handling MediaProjection lifecycle

## Changes to ScreenCaptureService.kt:
- Remove direct MediaProjection/ImageReader/VirtualDisplay handling
- Use ScreenCaptureManager via dependency injection pattern
- Delegate screen capture operations to manager
- Get screen dimensions from manager instead of storing locally
- Proper manager lifecycle with release() in onDestroy()

## Benefits:
- Single Responsibility Principle: Service focuses on orchestration
- Testability: Screen capture logic can be unit tested in isolation
- Maintainability: Clear separation of concerns
- Reusability: Manager can be used by other components

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
arch-001-screen-capture-manager
Quildra 5 months ago
parent
commit
de27d96789
  1. 138
      app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt
  2. 46
      app/src/main/java/com/quillstudios/pokegoalshelper/capture/ScreenCaptureManager.kt
  3. 186
      app/src/main/java/com/quillstudios/pokegoalshelper/capture/ScreenCaptureManagerImpl.kt

138
app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt

@ -24,6 +24,8 @@ import android.widget.LinearLayout
import androidx.core.app.NotificationCompat
import com.quillstudios.pokegoalshelper.controllers.DetectionController
import com.quillstudios.pokegoalshelper.ui.EnhancedFloatingFAB
import com.quillstudios.pokegoalshelper.capture.ScreenCaptureManager
import com.quillstudios.pokegoalshelper.capture.ScreenCaptureManagerImpl
import org.opencv.android.Utils
import org.opencv.core.*
import org.opencv.imgproc.Imgproc
@ -104,13 +106,7 @@ class ScreenCaptureService : Service() {
// ONNX YOLO detector instance
private var yoloDetector: YOLOOnnxDetector? = null
private var mediaProjection: MediaProjection? = null
private var virtualDisplay: VirtualDisplay? = null
private var imageReader: ImageReader? = null
private var mediaProjectionManager: MediaProjectionManager? = null
private var screenWidth = 0
private var screenHeight = 0
private var screenDensity = 0
private lateinit var screenCaptureManager: ScreenCaptureManager
private var detectionOverlay: DetectionOverlay? = null
// MVC Components
@ -135,26 +131,14 @@ class ScreenCaptureService : Service() {
}
}
private val mediaProjectionCallback = object : MediaProjection.Callback() {
override fun onStop() {
Log.d(TAG, "MediaProjection stopped")
stopScreenCapture()
}
override fun onCapturedContentResize(width: Int, height: Int) {
Log.d(TAG, "Screen size changed: ${width}x${height}")
}
override fun onCapturedContentVisibilityChanged(isVisible: Boolean) {
Log.d(TAG, "Content visibility changed: $isVisible")
}
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
getScreenMetrics()
mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
// Initialize screen capture manager
screenCaptureManager = ScreenCaptureManagerImpl(this, handler)
screenCaptureManager.setImageCallback { image -> handleCapturedImage(image) }
// Initialize ONNX YOLO detector
yoloDetector = YOLOOnnxDetector(this)
@ -236,22 +220,6 @@ class ScreenCaptureService : Service() {
}
}
private fun getScreenMetrics() {
val displayMetrics = DisplayMetrics()
val windowManager = getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
windowManager.defaultDisplay.getMetrics(displayMetrics)
screenWidth = displayMetrics.widthPixels
screenHeight = displayMetrics.heightPixels
screenDensity = displayMetrics.densityDpi
Log.d(TAG, "Screen metrics: ${screenWidth}x${screenHeight}, density: $screenDensity")
// Get status bar height for coordinate adjustment
val statusBarHeight = getStatusBarHeight()
Log.d(TAG, "Status bar height: ${statusBarHeight}px")
}
private fun getStatusBarHeight(): Int {
var result = 0
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
@ -287,36 +255,9 @@ class ScreenCaptureService : Service() {
Log.d(TAG, "Starting foreground service")
startForeground(NOTIFICATION_ID, notification)
Log.d(TAG, "Getting MediaProjection")
mediaProjection = mediaProjectionManager?.getMediaProjection(Activity.RESULT_OK, resultData)
if (mediaProjection == null) {
Log.e(TAG, "Failed to get MediaProjection")
stopSelf()
return
}
Log.d(TAG, "Registering MediaProjection callback")
mediaProjection?.registerCallback(mediaProjectionCallback, handler)
Log.d(TAG, "Creating ImageReader: ${screenWidth}x${screenHeight}")
imageReader = ImageReader.newInstance(screenWidth, screenHeight, PixelFormat.RGBA_8888, 3) // Increased buffer count
imageReader?.setOnImageAvailableListener(onImageAvailableListener, handler)
Log.d(TAG, "Creating VirtualDisplay")
virtualDisplay = mediaProjection?.createVirtualDisplay(
"ScreenCapture",
screenWidth,
screenHeight,
screenDensity,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader?.surface,
null,
null
)
if (virtualDisplay == null) {
Log.e(TAG, "Failed to create VirtualDisplay")
// Use the screen capture manager to start capture
if (!screenCaptureManager.startCapture(resultData)) {
Log.e(TAG, "Failed to start screen capture via manager")
stopSelf()
return
}
@ -339,35 +280,30 @@ class ScreenCaptureService : Service() {
enhancedFloatingFAB?.hide()
latestImage?.close()
latestImage = null
virtualDisplay?.release()
imageReader?.close()
mediaProjection?.unregisterCallback(mediaProjectionCallback)
mediaProjection?.stop()
virtualDisplay = null
imageReader = null
mediaProjection = null
// Use the screen capture manager to stop capture
screenCaptureManager.stopCapture()
stopForeground(true)
stopSelf()
}
private val onImageAvailableListener = ImageReader.OnImageAvailableListener { reader ->
/**
* Handle captured images from the ScreenCaptureManager
*/
private fun handleCapturedImage(image: Image) {
try {
val image = reader.acquireLatestImage()
if (image != null) {
if (autoProcessing) {
processImage(image)
image.close()
} else {
// Store the latest image for manual processing
latestImage?.close() // Release previous image
latestImage = image
// Don't close the image yet - it will be closed in triggerManualDetection
}
if (autoProcessing) {
processImage(image)
image.close()
} else {
// Store the latest image for manual processing
latestImage?.close() // Release previous image
latestImage = image
// Don't close the image yet - it will be closed in triggerManualDetection
}
} catch (e: Exception) {
Log.e(TAG, "Error in onImageAvailableListener", e)
Log.e(TAG, "Error handling captured image", e)
}
}
@ -384,6 +320,14 @@ class ScreenCaptureService : Service() {
var croppedBitmap: Bitmap? = null
try {
// Get screen dimensions from the manager
val screenDimensions = screenCaptureManager.getScreenDimensions()
if (screenDimensions == null) {
Log.e(TAG, "Screen dimensions not available from manager")
return
}
val (screenWidth, screenHeight) = screenDimensions
val planes = image.planes
val buffer = planes[0].buffer
val pixelStride = planes[0].pixelStride
@ -1161,6 +1105,14 @@ class ScreenCaptureService : Service() {
var mat: Mat? = null
return try {
// Get screen dimensions from the manager
val screenDimensions = screenCaptureManager.getScreenDimensions()
if (screenDimensions == null) {
Log.e(TAG, "Screen dimensions not available from manager")
return null
}
val (screenWidth, screenHeight) = screenDimensions
val planes = image.planes
val buffer = planes[0].buffer
val pixelStride = planes[0].pixelStride
@ -1254,6 +1206,12 @@ class ScreenCaptureService : Service() {
enhancedFloatingFAB?.hide()
detectionController.clearUICallbacks()
yoloDetector?.release()
// Release screen capture manager
if (::screenCaptureManager.isInitialized) {
screenCaptureManager.release()
}
// Proper executor shutdown with timeout
ocrExecutor.shutdown()
try {

46
app/src/main/java/com/quillstudios/pokegoalshelper/capture/ScreenCaptureManager.kt

@ -0,0 +1,46 @@
package com.quillstudios.pokegoalshelper.capture
import android.content.Intent
import android.media.Image
/**
* Interface for managing screen capture functionality.
* Separates screen capture concerns from the main service.
*/
interface ScreenCaptureManager {
/**
* Start screen capture with the provided MediaProjection result data.
* @param resultData The Intent result from MediaProjection permission request
* @return true if capture started successfully, false otherwise
*/
fun startCapture(resultData: Intent): Boolean
/**
* Stop screen capture and clean up resources.
*/
fun stopCapture()
/**
* Set callback to receive captured images.
* @param callback Function to handle captured images
*/
fun setImageCallback(callback: (Image) -> Unit)
/**
* Check if capture is currently active.
* @return true if capturing, false otherwise
*/
fun isCapturing(): Boolean
/**
* Get the current screen dimensions.
* @return Pair of (width, height) or null if not initialized
*/
fun getScreenDimensions(): Pair<Int, Int>?
/**
* Clean up all resources. Should be called when manager is no longer needed.
*/
fun release()
}

186
app/src/main/java/com/quillstudios/pokegoalshelper/capture/ScreenCaptureManagerImpl.kt

@ -0,0 +1,186 @@
package com.quillstudios.pokegoalshelper.capture
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.Image
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Handler
import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
/**
* Implementation of ScreenCaptureManager that handles MediaProjection-based screen capture.
* Extracted from ScreenCaptureService for better separation of concerns.
*/
class ScreenCaptureManagerImpl(
private val context: Context,
private val handler: Handler
) : ScreenCaptureManager {
companion object {
private const val TAG = "ScreenCaptureManager"
private const val BUFFER_COUNT = 3
}
private var mediaProjectionManager: MediaProjectionManager? = null
private var mediaProjection: MediaProjection? = null
private var virtualDisplay: VirtualDisplay? = null
private var imageReader: ImageReader? = null
private var screenWidth = 0
private var screenHeight = 0
private var screenDensity = 0
private var imageCallback: ((Image) -> Unit)? = null
private var isActive = false
init {
mediaProjectionManager = context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
initializeScreenMetrics()
}
private fun initializeScreenMetrics() {
val displayMetrics = DisplayMetrics()
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
windowManager.defaultDisplay.getMetrics(displayMetrics)
screenWidth = displayMetrics.widthPixels
screenHeight = displayMetrics.heightPixels
screenDensity = displayMetrics.densityDpi
Log.d(TAG, "Screen metrics initialized: ${screenWidth}x${screenHeight}, density: $screenDensity")
}
private val mediaProjectionCallback = object : MediaProjection.Callback() {
override fun onStop() {
Log.d(TAG, "MediaProjection stopped")
stopCapture()
}
override fun onCapturedContentResize(width: Int, height: Int) {
Log.d(TAG, "Screen size changed: ${width}x${height}")
}
override fun onCapturedContentVisibilityChanged(isVisible: Boolean) {
Log.d(TAG, "Content visibility changed: $isVisible")
}
}
private val onImageAvailableListener = ImageReader.OnImageAvailableListener { reader ->
try {
val image = reader.acquireLatestImage()
if (image != null) {
imageCallback?.invoke(image)
}
} catch (e: Exception) {
Log.e(TAG, "Error in onImageAvailableListener", e)
}
}
override fun startCapture(resultData: Intent): Boolean {
if (isActive) {
Log.w(TAG, "Capture already active")
return true
}
try {
Log.d(TAG, "Starting screen capture")
mediaProjection = mediaProjectionManager?.getMediaProjection(Activity.RESULT_OK, resultData)
if (mediaProjection == null) {
Log.e(TAG, "Failed to get MediaProjection")
return false
}
Log.d(TAG, "Registering MediaProjection callback")
mediaProjection?.registerCallback(mediaProjectionCallback, handler)
Log.d(TAG, "Creating ImageReader: ${screenWidth}x${screenHeight}")
imageReader = ImageReader.newInstance(screenWidth, screenHeight, PixelFormat.RGBA_8888, BUFFER_COUNT)
imageReader?.setOnImageAvailableListener(onImageAvailableListener, handler)
Log.d(TAG, "Creating VirtualDisplay")
virtualDisplay = mediaProjection?.createVirtualDisplay(
"ScreenCapture",
screenWidth,
screenHeight,
screenDensity,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader?.surface,
null,
null
)
if (virtualDisplay == null) {
Log.e(TAG, "Failed to create VirtualDisplay")
cleanupResources()
return false
}
isActive = true
Log.d(TAG, "Screen capture started successfully")
return true
} catch (e: Exception) {
Log.e(TAG, "Error starting screen capture", e)
cleanupResources()
return false
}
}
override fun stopCapture() {
if (!isActive) {
Log.d(TAG, "Capture not active, nothing to stop")
return
}
Log.d(TAG, "Stopping screen capture")
cleanupResources()
isActive = false
Log.d(TAG, "Screen capture stopped")
}
private fun cleanupResources() {
try {
virtualDisplay?.release()
imageReader?.close()
mediaProjection?.unregisterCallback(mediaProjectionCallback)
mediaProjection?.stop()
} catch (e: Exception) {
Log.e(TAG, "Error during cleanup", e)
} finally {
virtualDisplay = null
imageReader = null
mediaProjection = null
}
}
override fun setImageCallback(callback: (Image) -> Unit) {
this.imageCallback = callback
Log.d(TAG, "Image callback set")
}
override fun isCapturing(): Boolean = isActive
override fun getScreenDimensions(): Pair<Int, Int>? {
return if (screenWidth > 0 && screenHeight > 0) {
Pair(screenWidth, screenHeight)
} else {
null
}
}
override fun release() {
Log.d(TAG, "Releasing ScreenCaptureManager")
stopCapture()
imageCallback = null
mediaProjectionManager = null
}
}
Loading…
Cancel
Save