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