Browse Source
- Created LongScreenshotCapture.kt with independent screenshot collection - Extended DetectionUIEvents/DetectionUICallbacks for long screenshot events - Updated DetectionController with long screenshot event handlers - Enhanced EnhancedFloatingFAB with dynamic menu system for long screenshots - Integrated long screenshot system into ScreenCaptureService - Added proper error handling, progress tracking, and cleanup - Works alongside existing MediaProjection without interference - Thread-safe screenshot queue management with proper memory management Key Features: - 📸 LONG SHOT button in floating orb menu - Dynamic menu (CAPTURE/FINISH/CANCEL) in long screenshot mode - Blue orb color indicates long screenshot mode active - Real-time screenshot count tracking - Independent capture system using shared MediaProjection - Complete separation from existing detection functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>feature/pgh-30-long-screenshot-capture
5 changed files with 881 additions and 18 deletions
@ -0,0 +1,519 @@ |
|||
package com.quillstudios.pokegoalshelper.capture |
|||
|
|||
import android.content.Context |
|||
import android.graphics.Bitmap |
|||
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.os.Handler |
|||
import android.os.Looper |
|||
import com.quillstudios.pokegoalshelper.utils.PGHLog |
|||
import kotlinx.coroutines.* |
|||
import java.io.File |
|||
import java.io.FileOutputStream |
|||
import java.util.concurrent.ConcurrentLinkedQueue |
|||
import java.util.concurrent.CountDownLatch |
|||
import java.util.concurrent.TimeUnit |
|||
import java.util.concurrent.atomic.AtomicBoolean |
|||
import java.util.concurrent.atomic.AtomicInteger |
|||
|
|||
/** |
|||
* Long screenshot capture system that works alongside existing MediaProjection setup. |
|||
* |
|||
* Features: |
|||
* - Independent capture system using shared MediaProjection |
|||
* - Manual screenshot collection management |
|||
* - Thread-safe screenshot queue |
|||
* - Proper memory management and cleanup |
|||
* - Error handling and recovery |
|||
* - Image storage and retrieval |
|||
*/ |
|||
class LongScreenshotCapture( |
|||
private val context: Context, |
|||
private val handler: Handler = Handler(Looper.getMainLooper()) |
|||
) |
|||
{ |
|||
companion object |
|||
{ |
|||
private const val TAG = "LongScreenshotCapture" |
|||
private const val BUFFER_COUNT = 2 |
|||
private const val CAPTURE_TIMEOUT_MS = 5000L |
|||
private const val MAX_SCREENSHOTS = 50 |
|||
} |
|||
|
|||
// Core components |
|||
private var mediaProjection: MediaProjection? = null |
|||
private var virtualDisplay: VirtualDisplay? = null |
|||
private var imageReader: ImageReader? = null |
|||
|
|||
// Screen dimensions |
|||
private var screenWidth = 0 |
|||
private var screenHeight = 0 |
|||
private var screenDensity = 0 |
|||
|
|||
// State management |
|||
private val isInitialized = AtomicBoolean(false) |
|||
private val isCapturing = AtomicBoolean(false) |
|||
private val screenshotCount = AtomicInteger(0) |
|||
|
|||
// Screenshot collection |
|||
private val capturedScreenshots = ConcurrentLinkedQueue<CapturedScreenshot>() |
|||
private val captureScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) |
|||
|
|||
// Storage |
|||
private val storageDir: File by lazy { |
|||
File(context.filesDir, "long_screenshots").apply { |
|||
if (!exists()) mkdirs() |
|||
} |
|||
} |
|||
|
|||
// Callbacks |
|||
private var progressCallback: ((count: Int) -> Unit)? = null |
|||
private var errorCallback: ((error: String) -> Unit)? = null |
|||
|
|||
/** |
|||
* Initialize the long screenshot system with existing MediaProjection |
|||
*/ |
|||
fun initialize(mediaProjection: MediaProjection, screenWidth: Int, screenHeight: Int, screenDensity: Int): Boolean |
|||
{ |
|||
if (isInitialized.get()) |
|||
{ |
|||
PGHLog.w(TAG, "Long screenshot capture already initialized") |
|||
return true |
|||
} |
|||
|
|||
try |
|||
{ |
|||
PGHLog.i(TAG, "🔧 Initializing long screenshot capture system") |
|||
|
|||
this.mediaProjection = mediaProjection |
|||
this.screenWidth = screenWidth |
|||
this.screenHeight = screenHeight |
|||
this.screenDensity = screenDensity |
|||
|
|||
// Create dedicated ImageReader for long screenshots |
|||
imageReader = ImageReader.newInstance(screenWidth, screenHeight, PixelFormat.RGBA_8888, BUFFER_COUNT) |
|||
imageReader?.setOnImageAvailableListener(onImageAvailableListener, handler) |
|||
|
|||
// Create dedicated VirtualDisplay for long screenshots |
|||
virtualDisplay = mediaProjection.createVirtualDisplay( |
|||
"LongScreenshotCapture", |
|||
screenWidth, |
|||
screenHeight, |
|||
screenDensity, |
|||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, |
|||
imageReader?.surface, |
|||
null, |
|||
handler |
|||
) |
|||
|
|||
if (virtualDisplay == null) |
|||
{ |
|||
PGHLog.e(TAG, "❌ Failed to create VirtualDisplay for long screenshots") |
|||
cleanup() |
|||
return false |
|||
} |
|||
|
|||
isInitialized.set(true) |
|||
PGHLog.i(TAG, "✅ Long screenshot capture system initialized successfully") |
|||
return true |
|||
|
|||
} |
|||
catch (e: Exception) |
|||
{ |
|||
PGHLog.e(TAG, "❌ Error initializing long screenshot capture", e) |
|||
cleanup() |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Start long screenshot collection session |
|||
*/ |
|||
fun startCollection(): Boolean |
|||
{ |
|||
if (!isInitialized.get()) |
|||
{ |
|||
PGHLog.e(TAG, "❌ Cannot start collection - not initialized") |
|||
return false |
|||
} |
|||
|
|||
if (isCapturing.get()) |
|||
{ |
|||
PGHLog.w(TAG, "⚠️ Collection already in progress") |
|||
return true |
|||
} |
|||
|
|||
try |
|||
{ |
|||
PGHLog.i(TAG, "🚀 Starting long screenshot collection") |
|||
|
|||
// Clear any previous screenshots |
|||
clearStoredScreenshots() |
|||
capturedScreenshots.clear() |
|||
screenshotCount.set(0) |
|||
|
|||
isCapturing.set(true) |
|||
PGHLog.i(TAG, "✅ Long screenshot collection started") |
|||
|
|||
return true |
|||
|
|||
} |
|||
catch (e: Exception) |
|||
{ |
|||
PGHLog.e(TAG, "❌ Error starting collection", e) |
|||
errorCallback?.invoke("Failed to start collection: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Capture a single frame manually (on-demand) |
|||
*/ |
|||
fun captureFrame(): Boolean |
|||
{ |
|||
if (!isCapturing.get()) |
|||
{ |
|||
PGHLog.w(TAG, "⚠️ Cannot capture - collection not active") |
|||
return false |
|||
} |
|||
|
|||
if (screenshotCount.get() >= MAX_SCREENSHOTS) |
|||
{ |
|||
PGHLog.w(TAG, "⚠️ Maximum screenshots reached ($MAX_SCREENSHOTS)") |
|||
errorCallback?.invoke("Maximum screenshots reached") |
|||
return false |
|||
} |
|||
|
|||
return try |
|||
{ |
|||
PGHLog.d(TAG, "📸 Triggering manual frame capture") |
|||
|
|||
// The capture will be handled by onImageAvailableListener |
|||
// We just need to trigger it by accessing the ImageReader |
|||
val latch = CountDownLatch(1) |
|||
var captureSuccess = false |
|||
|
|||
handler.post { |
|||
try |
|||
{ |
|||
// Force a capture by accessing the latest image |
|||
imageReader?.acquireLatestImage()?.let { image -> |
|||
// This will be processed by onImageAvailableListener |
|||
image.close() |
|||
captureSuccess = true |
|||
} |
|||
} |
|||
catch (e: Exception) |
|||
{ |
|||
PGHLog.e(TAG, "Error triggering frame capture", e) |
|||
} |
|||
finally |
|||
{ |
|||
latch.countDown() |
|||
} |
|||
} |
|||
|
|||
// Wait for capture to complete |
|||
val completed = latch.await(CAPTURE_TIMEOUT_MS, TimeUnit.MILLISECONDS) |
|||
|
|||
if (!completed) |
|||
{ |
|||
PGHLog.e(TAG, "⏱️ Frame capture timed out") |
|||
errorCallback?.invoke("Capture timed out") |
|||
false |
|||
} |
|||
else |
|||
{ |
|||
captureSuccess |
|||
} |
|||
|
|||
} |
|||
catch (e: Exception) |
|||
{ |
|||
PGHLog.e(TAG, "❌ Error capturing frame", e) |
|||
errorCallback?.invoke("Capture failed: ${e.message}") |
|||
false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Stop collection and return captured screenshots |
|||
*/ |
|||
fun finishCollection(): List<CapturedScreenshot> |
|||
{ |
|||
if (!isCapturing.get()) |
|||
{ |
|||
PGHLog.w(TAG, "⚠️ No collection in progress") |
|||
return emptyList() |
|||
} |
|||
|
|||
try |
|||
{ |
|||
PGHLog.i(TAG, "🏁 Finishing long screenshot collection") |
|||
|
|||
isCapturing.set(false) |
|||
|
|||
val screenshots = capturedScreenshots.toList() |
|||
PGHLog.i(TAG, "✅ Collection finished with ${screenshots.size} screenshots") |
|||
|
|||
return screenshots |
|||
|
|||
} |
|||
catch (e: Exception) |
|||
{ |
|||
PGHLog.e(TAG, "❌ Error finishing collection", e) |
|||
errorCallback?.invoke("Failed to finish collection: ${e.message}") |
|||
return emptyList() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Cancel current collection |
|||
*/ |
|||
fun cancelCollection() |
|||
{ |
|||
if (!isCapturing.get()) |
|||
{ |
|||
PGHLog.w(TAG, "⚠️ No collection to cancel") |
|||
return |
|||
} |
|||
|
|||
try |
|||
{ |
|||
PGHLog.i(TAG, "❌ Canceling long screenshot collection") |
|||
|
|||
isCapturing.set(false) |
|||
|
|||
// Clear captured screenshots |
|||
capturedScreenshots.clear() |
|||
screenshotCount.set(0) |
|||
clearStoredScreenshots() |
|||
|
|||
PGHLog.i(TAG, "✅ Collection canceled") |
|||
|
|||
} |
|||
catch (e: Exception) |
|||
{ |
|||
PGHLog.e(TAG, "❌ Error canceling collection", e) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Get current screenshot count |
|||
*/ |
|||
fun getScreenshotCount(): Int = screenshotCount.get() |
|||
|
|||
/** |
|||
* Check if collection is active |
|||
*/ |
|||
fun isCollectionActive(): Boolean = isCapturing.get() |
|||
|
|||
/** |
|||
* Set progress callback |
|||
*/ |
|||
fun setProgressCallback(callback: (count: Int) -> Unit) |
|||
{ |
|||
this.progressCallback = callback |
|||
} |
|||
|
|||
/** |
|||
* Set error callback |
|||
*/ |
|||
fun setErrorCallback(callback: (error: String) -> Unit) |
|||
{ |
|||
this.errorCallback = callback |
|||
} |
|||
|
|||
private val onImageAvailableListener = ImageReader.OnImageAvailableListener { reader -> |
|||
if (!isCapturing.get()) return@OnImageAvailableListener |
|||
|
|||
try |
|||
{ |
|||
val image = reader.acquireLatestImage() |
|||
if (image != null) |
|||
{ |
|||
captureScope.launch { |
|||
processImageAsync(image) |
|||
} |
|||
} |
|||
} |
|||
catch (e: Exception) |
|||
{ |
|||
PGHLog.e(TAG, "❌ Error in onImageAvailableListener", e) |
|||
errorCallback?.invoke("Image capture failed: ${e.message}") |
|||
} |
|||
} |
|||
|
|||
private suspend fun processImageAsync(image: Image) = withContext(Dispatchers.IO) |
|||
{ |
|||
try |
|||
{ |
|||
val timestamp = System.currentTimeMillis() |
|||
val filename = "screenshot_${timestamp}.png" |
|||
val file = File(storageDir, filename) |
|||
|
|||
// Convert image to bitmap and save |
|||
val bitmap = convertImageToBitmap(image) |
|||
if (bitmap != null) |
|||
{ |
|||
saveBitmapToFile(bitmap, file) |
|||
|
|||
val screenshot = CapturedScreenshot( |
|||
id = timestamp, |
|||
filename = filename, |
|||
filePath = file.absolutePath, |
|||
timestamp = timestamp, |
|||
width = screenWidth, |
|||
height = screenHeight |
|||
) |
|||
|
|||
capturedScreenshots.offer(screenshot) |
|||
val count = screenshotCount.incrementAndGet() |
|||
|
|||
PGHLog.i(TAG, "📸 Screenshot #$count captured: $filename") |
|||
|
|||
// Notify progress on main thread |
|||
handler.post { |
|||
progressCallback?.invoke(count) |
|||
} |
|||
|
|||
bitmap.recycle() |
|||
} |
|||
else |
|||
{ |
|||
PGHLog.e(TAG, "❌ Failed to convert image to bitmap") |
|||
errorCallback?.invoke("Failed to process screenshot") |
|||
} |
|||
|
|||
} |
|||
catch (e: Exception) |
|||
{ |
|||
PGHLog.e(TAG, "❌ Error processing image", e) |
|||
errorCallback?.invoke("Failed to save screenshot: ${e.message}") |
|||
} |
|||
finally |
|||
{ |
|||
image.close() |
|||
} |
|||
} |
|||
|
|||
private fun convertImageToBitmap(image: Image): Bitmap? |
|||
{ |
|||
return try |
|||
{ |
|||
val planes = image.planes |
|||
val buffer = planes[0].buffer |
|||
val pixelStride = planes[0].pixelStride |
|||
val rowStride = planes[0].rowStride |
|||
val rowPadding = rowStride - pixelStride * screenWidth |
|||
|
|||
val bitmap = Bitmap.createBitmap( |
|||
screenWidth + rowPadding / pixelStride, |
|||
screenHeight, |
|||
Bitmap.Config.ARGB_8888 |
|||
) |
|||
|
|||
bitmap.copyPixelsFromBuffer(buffer) |
|||
|
|||
// Crop if there's padding |
|||
if (rowPadding == 0) |
|||
{ |
|||
bitmap |
|||
} |
|||
else |
|||
{ |
|||
val croppedBitmap = Bitmap.createBitmap(bitmap, 0, 0, screenWidth, screenHeight) |
|||
bitmap.recycle() |
|||
croppedBitmap |
|||
} |
|||
|
|||
} |
|||
catch (e: Exception) |
|||
{ |
|||
PGHLog.e(TAG, "❌ Error converting image to bitmap", e) |
|||
null |
|||
} |
|||
} |
|||
|
|||
private fun saveBitmapToFile(bitmap: Bitmap, file: File) |
|||
{ |
|||
FileOutputStream(file).use { stream -> |
|||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) |
|||
} |
|||
} |
|||
|
|||
private fun clearStoredScreenshots() |
|||
{ |
|||
try |
|||
{ |
|||
storageDir.listFiles()?.forEach { file -> |
|||
if (file.name.startsWith("screenshot_") && file.name.endsWith(".png")) |
|||
{ |
|||
file.delete() |
|||
} |
|||
} |
|||
} |
|||
catch (e: Exception) |
|||
{ |
|||
PGHLog.e(TAG, "❌ Error clearing stored screenshots", e) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Clean up resources |
|||
*/ |
|||
fun cleanup() |
|||
{ |
|||
try |
|||
{ |
|||
PGHLog.i(TAG, "🧹 Cleaning up long screenshot capture") |
|||
|
|||
isCapturing.set(false) |
|||
isInitialized.set(false) |
|||
|
|||
// Cancel any ongoing coroutines |
|||
captureScope.cancel() |
|||
|
|||
// Clean up capture resources |
|||
virtualDisplay?.release() |
|||
imageReader?.close() |
|||
|
|||
// Clear collections |
|||
capturedScreenshots.clear() |
|||
screenshotCount.set(0) |
|||
|
|||
// Clear stored files |
|||
clearStoredScreenshots() |
|||
|
|||
// Clear references |
|||
virtualDisplay = null |
|||
imageReader = null |
|||
mediaProjection = null |
|||
progressCallback = null |
|||
errorCallback = null |
|||
|
|||
PGHLog.i(TAG, "✅ Long screenshot capture cleaned up") |
|||
|
|||
} |
|||
catch (e: Exception) |
|||
{ |
|||
PGHLog.e(TAG, "❌ Error during cleanup", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Data class representing a captured screenshot |
|||
*/ |
|||
data class CapturedScreenshot( |
|||
val id: Long, |
|||
val filename: String, |
|||
val filePath: String, |
|||
val timestamp: Long, |
|||
val width: Int, |
|||
val height: Int |
|||
) |
|||
Loading…
Reference in new issue