Skip to content

Commit 89294b5

Browse files
Refactor MediaProjection to use Foreground Service
This commit refactors the screenshot functionality to use a Foreground Service (`ScreenCaptureService`) for MediaProjection, which is required for Android 14 and later. Key changes: - Created `ScreenCaptureService.kt` to handle MediaProjection, screen capture, and saving the screenshot. This service runs in the foreground. - Added `FOREGROUND_SERVICE` and `FOREGROUND_SERVICE_MEDIA_PROJECTION` permissions to `AndroidManifest.xml`. - Declared `ScreenCaptureService` in `AndroidManifest.xml`. - Modified `MainActivity.kt`: - Updated MediaProjection-related class variables. - Changed the `ActivityResultLauncher` for MediaProjection to start `ScreenCaptureService` with the projection data. - Removed `takeScreenshot` and `saveScreenshot` methods as this logic is now in `ScreenCaptureService`. - Removed MediaProjection cleanup code from `onDestroy` as it's handled by the service. - The `requestMediaProjectionPermission` method in `MainActivity.kt` now solely initiates the permission request. - Screenshots are still saved to `/storage/emulated/0/Android/data/com.google.ai.sample/files/Pictures/Screenshots/`.
1 parent 8ac18f5 commit 89294b5

3 files changed

Lines changed: 212 additions & 91 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
<!-- Foreground service permission for background operation -->
1818
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
19+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
1920

2021
<!-- Notification permission for Android 13+ (API 33+) -->
2122
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
@@ -68,6 +69,10 @@
6869
android:name="android.support.FILE_PROVIDER_PATHS"
6970
android:resource="@xml/file_paths" />
7071
</provider>
72+
<service
73+
android:name=".ScreenCaptureService"
74+
android:exported="false"
75+
android:foregroundServiceType="mediaProjection" />
7176
</application>
7277
</manifest>
7378

app/src/main/kotlin/com/google/ai/sample/MainActivity.kt

Lines changed: 16 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,10 @@ class MainActivity : ComponentActivity() {
115115
private var showPermissionRationaleDialog by mutableStateOf(false)
116116
private var permissionRequestCount by mutableStateOf(0)
117117

118-
// Nach Zeile 98 hinzufügen:
118+
// Zeilen 99-104 ersetzen durch:
119119
// MediaProjection
120-
private var mediaProjection: MediaProjection? = null
121120
private lateinit var mediaProjectionManager: MediaProjectionManager
122-
private val MEDIA_PROJECTION_REQUEST_CODE = 1001
123121
private lateinit var mediaProjectionLauncher: ActivityResultLauncher<Intent>
124-
private var virtualDisplay: VirtualDisplay? = null
125-
private var imageReader: ImageReader? = null
126122

127123
private lateinit var navController: NavHostController
128124

@@ -155,86 +151,13 @@ class MainActivity : ComponentActivity() {
155151
}
156152
}
157153

158-
// Nach Zeile 920 (vor companion object) hinzufügen:
154+
// Zeilen 921-996 ersetzen durch:
159155
private fun requestMediaProjectionPermission() {
160156
Log.d(TAG, "Requesting MediaProjection permission")
161157
val intent = mediaProjectionManager.createScreenCaptureIntent()
162158
mediaProjectionLauncher.launch(intent)
163159
}
164160

165-
private fun takeScreenshot() {
166-
Log.d(TAG, "Taking screenshot")
167-
168-
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
169-
val displayMetrics = DisplayMetrics()
170-
windowManager.defaultDisplay.getMetrics(displayMetrics)
171-
172-
val width = displayMetrics.widthPixels
173-
val height = displayMetrics.heightPixels
174-
val density = displayMetrics.densityDpi
175-
176-
imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 1)
177-
178-
virtualDisplay = mediaProjection?.createVirtualDisplay(
179-
"ScreenCapture",
180-
width, height, density,
181-
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
182-
imageReader!!.surface, null, null
183-
)
184-
185-
imageReader!!.setOnImageAvailableListener({ reader ->
186-
val image = reader.acquireLatestImage()
187-
if (image != null) {
188-
val planes = image.planes
189-
val buffer = planes[0].buffer
190-
val pixelStride = planes[0].pixelStride
191-
val rowStride = planes[0].rowStride
192-
val rowPadding = rowStride - pixelStride * width
193-
194-
val bitmap = Bitmap.createBitmap(
195-
width + rowPadding / pixelStride,
196-
height,
197-
Bitmap.Config.ARGB_8888
198-
)
199-
bitmap.copyPixelsFromBuffer(buffer)
200-
201-
// Save screenshot
202-
saveScreenshot(bitmap)
203-
204-
image.close()
205-
virtualDisplay?.release()
206-
imageReader?.close()
207-
}
208-
}, Handler(Looper.getMainLooper()))
209-
}
210-
211-
private fun saveScreenshot(bitmap: Bitmap) {
212-
try {
213-
val picturesDir = File(getExternalFilesDir(Environment.DIRECTORY_PICTURES), "Screenshots")
214-
if (!picturesDir.exists()) {
215-
picturesDir.mkdirs()
216-
}
217-
218-
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
219-
val file = File(picturesDir, "screenshot_$timestamp.png")
220-
221-
val outputStream = FileOutputStream(file)
222-
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
223-
outputStream.flush()
224-
outputStream.close()
225-
226-
Log.i(TAG, "Screenshot saved to: ${file.absolutePath}")
227-
Toast.makeText(
228-
this,
229-
"Screenshot saved to: Android/data/com.google.ai.sample/files/Pictures/Screenshots/",
230-
Toast.LENGTH_LONG
231-
).show()
232-
} catch (e: Exception) {
233-
Log.e(TAG, "Failed to save screenshot", e)
234-
Toast.makeText(this, "Failed to save screenshot: ${e.message}", Toast.LENGTH_LONG).show()
235-
}
236-
}
237-
238161
// START: Added for Accessibility Service Status
239162
private val _isAccessibilityServiceEnabled = MutableStateFlow(false)
240163
val isAccessibilityServiceEnabledFlow: StateFlow<Boolean> = _isAccessibilityServiceEnabled.asStateFlow()
@@ -464,7 +387,7 @@ class MainActivity : ComponentActivity() {
464387
}
465388
rootView.viewTreeObserver.addOnGlobalLayoutListener(onGlobalLayoutListener)
466389

467-
// Nach Zeile 261 (vor setContent) hinzufügen:
390+
// Zeilen 262-283 ersetzen durch:
468391
// Initialize MediaProjectionManager
469392
mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
470393

@@ -474,11 +397,19 @@ class MainActivity : ComponentActivity() {
474397
) { result ->
475398
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
476399
Log.i(TAG, "MediaProjection permission granted")
477-
mediaProjection = mediaProjectionManager.getMediaProjection(result.resultCode, result.data!!)
478-
// Take screenshot after permission granted
479-
Handler(Looper.getMainLooper()).postDelayed({
480-
takeScreenshot()
481-
}, 500) // Small delay to ensure everything is ready
400+
401+
// Start the foreground service with the result
402+
val serviceIntent = Intent(this, ScreenCaptureService::class.java).apply {
403+
action = ScreenCaptureService.ACTION_START_CAPTURE
404+
putExtra(ScreenCaptureService.EXTRA_RESULT_CODE, result.resultCode)
405+
putExtra(ScreenCaptureService.EXTRA_RESULT_DATA, result.data)
406+
}
407+
408+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
409+
startForegroundService(serviceIntent)
410+
} else {
411+
startService(serviceIntent)
412+
}
482413
} else {
483414
Log.w(TAG, "MediaProjection permission denied")
484415
Toast.makeText(this, "Screen capture permission denied", Toast.LENGTH_SHORT).show()
@@ -1005,12 +936,6 @@ class MainActivity : ComponentActivity() {
1005936
Log.w(TAG, "onDestroy: trialStatusReceiver was not registered or already unregistered.", e)
1006937
}
1007938

1008-
// Nach Zeile 906 (in onDestroy, vor billingClient cleanup) hinzufügen:
1009-
// Clean up MediaProjection resources
1010-
virtualDisplay?.release()
1011-
imageReader?.close()
1012-
mediaProjection?.stop()
1013-
1014939
if (::billingClient.isInitialized && billingClient.isReady) {
1015940
Log.d(TAG, "onDestroy: BillingClient is initialized and ready. Ending connection.")
1016941
billingClient.endConnection()
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package com.google.ai.sample
2+
3+
import android.app.Notification
4+
import android.app.NotificationChannel
5+
import android.app.NotificationManager
6+
import android.app.Service
7+
import android.content.Context
8+
import android.content.Intent
9+
import android.content.pm.ServiceInfo
10+
import android.graphics.Bitmap
11+
import android.graphics.PixelFormat
12+
import android.hardware.display.DisplayManager
13+
import android.hardware.display.VirtualDisplay
14+
import android.media.ImageReader
15+
import android.media.projection.MediaProjection
16+
import android.media.projection.MediaProjectionManager
17+
import android.os.Build
18+
import android.os.Environment
19+
import android.os.Handler
20+
import android.os.IBinder
21+
import android.os.Looper
22+
import android.util.DisplayMetrics
23+
import android.util.Log
24+
import android.view.WindowManager
25+
import android.widget.Toast
26+
import androidx.core.app.NotificationCompat
27+
import java.io.File
28+
import java.io.FileOutputStream
29+
import java.text.SimpleDateFormat
30+
import java.util.Date
31+
import java.util.Locale
32+
33+
class ScreenCaptureService : Service() {
34+
companion object {
35+
private const val TAG = "ScreenCaptureService"
36+
private const val CHANNEL_ID = "ScreenCaptureChannel"
37+
private const val NOTIFICATION_ID = 1
38+
const val ACTION_START_CAPTURE = "com.google.ai.sample.START_CAPTURE"
39+
const val EXTRA_RESULT_CODE = "result_code"
40+
const val EXTRA_RESULT_DATA = "result_data"
41+
}
42+
43+
private var mediaProjection: MediaProjection? = null
44+
private var virtualDisplay: VirtualDisplay? = null
45+
private var imageReader: ImageReader? = null
46+
47+
override fun onCreate() {
48+
super.onCreate()
49+
createNotificationChannel()
50+
}
51+
52+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
53+
if (intent?.action == ACTION_START_CAPTURE) {
54+
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, -1)
55+
val resultData = intent.getParcelableExtra<Intent>(EXTRA_RESULT_DATA)
56+
57+
if (resultCode != -1 && resultData != null) {
58+
startForeground()
59+
startCapture(resultCode, resultData)
60+
} else {
61+
stopSelf()
62+
}
63+
}
64+
return START_NOT_STICKY
65+
}
66+
67+
private fun startForeground() {
68+
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
69+
.setContentTitle("Taking Screenshot")
70+
.setContentText("Processing...")
71+
.setSmallIcon(android.R.drawable.ic_menu_camera)
72+
.setPriority(NotificationCompat.PRIORITY_LOW)
73+
.build()
74+
75+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
76+
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
77+
} else {
78+
startForeground(NOTIFICATION_ID, notification)
79+
}
80+
}
81+
82+
private fun createNotificationChannel() {
83+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
84+
val channel = NotificationChannel(
85+
CHANNEL_ID,
86+
"Screen Capture",
87+
NotificationManager.IMPORTANCE_LOW
88+
).apply {
89+
description = "Used for taking screenshots"
90+
}
91+
val notificationManager = getSystemService(NotificationManager::class.java)
92+
notificationManager.createNotificationChannel(channel)
93+
}
94+
}
95+
96+
private fun startCapture(resultCode: Int, data: Intent) {
97+
val mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
98+
mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data)
99+
100+
Handler(Looper.getMainLooper()).postDelayed({
101+
takeScreenshot()
102+
}, 100)
103+
}
104+
105+
private fun takeScreenshot() {
106+
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
107+
val displayMetrics = DisplayMetrics()
108+
windowManager.defaultDisplay.getMetrics(displayMetrics)
109+
110+
val width = displayMetrics.widthPixels
111+
val height = displayMetrics.heightPixels
112+
val density = displayMetrics.densityDpi
113+
114+
imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 1)
115+
116+
virtualDisplay = mediaProjection?.createVirtualDisplay(
117+
"ScreenCapture",
118+
width, height, density,
119+
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
120+
imageReader!!.surface, null, null
121+
)
122+
123+
imageReader!!.setOnImageAvailableListener({ reader ->
124+
val image = reader.acquireLatestImage()
125+
if (image != null) {
126+
val planes = image.planes
127+
val buffer = planes[0].buffer
128+
val pixelStride = planes[0].pixelStride
129+
val rowStride = planes[0].rowStride
130+
val rowPadding = rowStride - pixelStride * width
131+
132+
val bitmap = Bitmap.createBitmap(
133+
width + rowPadding / pixelStride,
134+
height,
135+
Bitmap.Config.ARGB_8888
136+
)
137+
bitmap.copyPixelsFromBuffer(buffer)
138+
139+
saveScreenshot(bitmap)
140+
141+
image.close()
142+
cleanup()
143+
}
144+
}, Handler(Looper.getMainLooper()))
145+
}
146+
147+
private fun saveScreenshot(bitmap: Bitmap) {
148+
try {
149+
val picturesDir = File(getExternalFilesDir(Environment.DIRECTORY_PICTURES), "Screenshots")
150+
if (!picturesDir.exists()) {
151+
picturesDir.mkdirs()
152+
}
153+
154+
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
155+
val file = File(picturesDir, "screenshot_$timestamp.png")
156+
157+
val outputStream = FileOutputStream(file)
158+
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
159+
outputStream.flush()
160+
outputStream.close()
161+
162+
Log.i(TAG, "Screenshot saved to: ${file.absolutePath}")
163+
Toast.makeText(
164+
this,
165+
"Screenshot saved to: Android/data/com.google.ai.sample/files/Pictures/Screenshots/",
166+
Toast.LENGTH_LONG
167+
).show()
168+
} catch (e: Exception) {
169+
Log.e(TAG, "Failed to save screenshot", e)
170+
Toast.makeText(this, "Failed to save screenshot: ${e.message}", Toast.LENGTH_LONG).show()
171+
}
172+
}
173+
174+
private fun cleanup() {
175+
virtualDisplay?.release()
176+
virtualDisplay = null
177+
imageReader?.close()
178+
imageReader = null
179+
mediaProjection?.stop()
180+
mediaProjection = null
181+
stopForeground(true)
182+
stopSelf()
183+
}
184+
185+
override fun onDestroy() {
186+
cleanup()
187+
super.onDestroy()
188+
}
189+
190+
override fun onBind(intent: Intent?): IBinder? = null
191+
}

0 commit comments

Comments
 (0)