Android TV app to quickly take an screenshot and do something with it.

initial commit

frabarz.cl 0dfa604a

+42
.github/workflows/android.yml
···
··· 1 + name: Android CI 2 + 3 + on: 4 + push: 5 + branches: [ main ] 6 + pull_request: 7 + branches: [ main ] 8 + 9 + jobs: 10 + build: 11 + runs-on: ubuntu-latest 12 + steps: 13 + - name: Checkout code 14 + uses: actions/checkout@v4 15 + 16 + - name: Set up JDK 17 17 + uses: actions/setup-java@v4 18 + with: 19 + distribution: 'temurin' 20 + java-version: '17' 21 + 22 + - name: Cache Gradle dependencies 23 + uses: actions/cache@v4 24 + with: 25 + path: | 26 + ~/.gradle/caches 27 + ~/.gradle/wrapper 28 + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} 29 + restore-keys: | 30 + gradle-${{ runner.os }}- 31 + 32 + - name: Grant execute permission for gradlew 33 + run: chmod +x ./gradlew 34 + 35 + - name: Build with Gradle 36 + run: ./gradlew assembleDebug 37 + 38 + - name: Upload APK artifact 39 + uses: actions/upload-artifact@v4 40 + with: 41 + name: app-debug-apk 42 + path: app/build/outputs/apk/debug/app-debug.apk
+31
Dockerfile
···
··· 1 + FROM eclipse-temurin:17-jdk 2 + 3 + ENV ANDROID_SDK_ROOT /sdk 4 + ENV PATH "$PATH:/sdk/cmdline-tools/latest/bin:/sdk/platform-tools:/sdk/emulator" 5 + 6 + # Install required packages 7 + RUN apt-get update && apt-get install -y unzip wget git && rm -rf /var/lib/apt/lists/* 8 + 9 + # Download and install Android SDK command-line tools 10 + RUN mkdir -p /sdk/cmdline-tools && \ 11 + wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /cmdline-tools.zip && \ 12 + unzip /cmdline-tools.zip -d /sdk/cmdline-tools && \ 13 + mv /sdk/cmdline-tools/cmdline-tools /sdk/cmdline-tools/latest && \ 14 + rm /cmdline-tools.zip 15 + 16 + # Accept licenses and install build tools and platform 17 + RUN yes | sdkmanager --sdk_root=/sdk --licenses 18 + RUN sdkmanager --sdk_root=/sdk "platform-tools" "platforms;android-28" "build-tools;34.0.0" 19 + 20 + # Copy project 21 + WORKDIR /workspace 22 + COPY . . 23 + 24 + # Make gradlew executable 25 + RUN chmod +x ./gradlew 26 + 27 + # Build APK 28 + RUN ./gradlew assembleDebug 29 + 30 + # Final command: copy APK to /output if mounted 31 + CMD ["/bin/sh", "-c", "cp app/build/outputs/apk/debug/app-debug.apk /output/ 2>/dev/null || true"]
+29
README.md
···
··· 1 + # ShotNShare 2 + 3 + ## Button Mapper Integration 4 + 5 + To trigger a screenshot from your remote using Button Mapper: 6 + 7 + 1. Open Button Mapper and select the button you want to remap. 8 + 2. Choose "Add action" > "Applications". 9 + 3. Select "ShotNShare ScreenshotActivity" (may appear as just "ScreenshotActivity"). 10 + 4. Now, pressing the button will trigger a screenshot and open the share dialog. 11 + 12 + If you want to trigger via intent, use: 13 + 14 + ``` 15 + Intent action: cl.frabarz.shotnshare.ACTION_TRIGGER_SCREENSHOT 16 + Component: cl.frabarz.shotnshare/.ScreenshotActivity 17 + ``` 18 + 19 + ## Local Container Build 20 + 21 + To build the APK locally in a container (no Android Studio or SDK needed): 22 + 23 + ```sh 24 + docker build -t shotnshare-android . 25 + mkdir -p app/build/outputs/apk/debug 26 + # Run the build and copy the APK to your host 27 + # (the APK will appear in ./app/build/outputs/apk/debug) 28 + docker run --rm -v "$PWD/app/build/outputs/apk/debug:/output" shotnshare-android 29 + ```
+59
SPECS.md
···
··· 1 + # ShotNShare: Android TV Screenshot & Share App 2 + 3 + ## Overview 4 + An Android TV app that enables users to quickly take a screenshot of their current screen and share it via Twitter, WhatsApp, or other supported platforms. The action is triggered by remapping a remote control button for seamless access. 5 + 6 + --- 7 + 8 + ## User Flow 9 + 1. **Trigger**: User presses a remapped button on the Android TV remote. 10 + 2. **Screenshot**: The app captures the current screen. 11 + 3. **Preview & Share**: 12 + - A simple overlay appears with a preview of the screenshot and sharing options (Twitter, WhatsApp, Copy to Clipboard, etc.). 13 + - User selects the desired sharing method. 14 + 4. **Share**: The app invokes the chosen sharing intent (e.g., opens Twitter/WhatsApp with the screenshot attached). 15 + 5. **Confirmation**: Optional toast/notification confirming the action. 16 + 17 + --- 18 + 19 + ## Technical Requirements 20 + - **Platform**: Android TV (API 21+ recommended) 21 + - **Screenshot Capture**: 22 + - Use MediaProjection API for screen capture (requires user consent on first use). 23 + - Handle overlays and system UI appropriately. 24 + - **Button Remapping**: 25 + - Support for remapping a remote control button (e.g., via Button Mapper or custom accessibility service). 26 + - Provide instructions for users to set up remapping. 27 + - **Sharing**: 28 + - Integrate with Android's sharing intents for Twitter, WhatsApp, and others. 29 + - Support for direct sharing to specific apps. 30 + - **UI/UX**: 31 + - Minimal, TV-friendly overlay for preview and sharing options. 32 + - Large, easily navigable buttons. 33 + - **Permissions**: 34 + - `FOREGROUND_SERVICE` (for persistent capture service, if needed) 35 + - `WRITE_EXTERNAL_STORAGE` (for saving screenshots, if required) 36 + - `INTERNET` (for sharing via social media APIs, if direct integration is added) 37 + - `CAPTURE_VIDEO_OUTPUT` (if available) 38 + - `SYSTEM_ALERT_WINDOW` (for overlays, if needed) 39 + - `BIND_ACCESSIBILITY_SERVICE` (if using accessibility for button remapping) 40 + - **Error Handling**: 41 + - Graceful handling of permission denials. 42 + - User feedback for failed captures or shares. 43 + - Logging for debugging. 44 + 45 + --- 46 + 47 + ## Extensibility & Future Features 48 + - Add support for more sharing platforms (Telegram, Facebook, etc.). 49 + - Allow basic image editing (crop, annotate, blur). 50 + - Cloud upload (Google Drive, Dropbox, etc.). 51 + - History of recent screenshots. 52 + - Customizable overlay UI. 53 + 54 + --- 55 + 56 + ## Notes 57 + - Some Android TV models may restrict screen capture due to DRM or system limitations. 58 + - Button remapping may require third-party apps (such as Button Mapper) or accessibility services, as not all remotes are customizable by default. On some Android TVs, the Accessibility service is missing, but Button Mapper can still launch an exported activity or send a broadcast intent to trigger the screenshot action. 59 + - The app should be modular to allow easy addition of new sharing targets or features.
+25
app/build.gradle
···
··· 1 + plugins { 2 + id 'com.android.application' 3 + } 4 + 5 + android { 6 + compileSdk 28 7 + 8 + defaultConfig { 9 + applicationId "cl.frabarz.shotnshare" 10 + minSdk 21 11 + targetSdk 28 12 + versionCode 1 13 + versionName "1.0" 14 + } 15 + 16 + buildTypes { 17 + release { 18 + minifyEnabled false 19 + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 20 + } 21 + } 22 + } 23 + 24 + dependencies { 25 + }
+1
app/proguard-rules.pro
···
··· 1 +
+35
app/src/main/AndroidManifest.xml
···
··· 1 + <manifest xmlns:android="http://schemas.android.com/apk/res/android" 2 + package="cl.frabarz.shotnshare"> 3 + 4 + <application 5 + android:allowBackup="true" 6 + android:label="ShotNShare" 7 + android:icon="@mipmap/ic_launcher" 8 + android:roundIcon="@mipmap/ic_launcher_round" 9 + android:supportsRtl="true" 10 + android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"> 11 + <activity android:name=".MainActivity"> 12 + <intent-filter> 13 + <action android:name="android.intent.action.MAIN" /> 14 + <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> 15 + </intent-filter> 16 + </activity> 17 + <activity android:name=".ScreenshotActivity" 18 + android:exported="true"> 19 + <intent-filter> 20 + <action android:name="android.intent.action.MAIN" /> 21 + <category android:name="android.intent.category.LAUNCHER" /> 22 + </intent-filter> 23 + </activity> 24 + <provider 25 + android:name="androidx.core.content.FileProvider" 26 + android:authorities="cl.frabarz.shotnshare.fileprovider" 27 + android:exported="false" 28 + android:grantUriPermissions="true"> 29 + <meta-data 30 + android:name="android.support.FILE_PROVIDER_PATHS" 31 + android:resource="@xml/file_paths" /> 32 + </provider> 33 + </application> 34 + 35 + </manifest>
+222
app/src/main/java/cl/frabarz/shotnshare/MainActivity.java
···
··· 1 + package cl.frabarz.shotnshare; 2 + 3 + import android.app.Activity; 4 + import android.content.Context; 5 + import android.content.Intent; 6 + import android.media.projection.MediaProjection; 7 + import android.media.projection.MediaProjectionManager; 8 + import android.os.Bundle; 9 + import android.util.Log; 10 + import android.graphics.Bitmap; 11 + import android.graphics.PixelFormat; 12 + import android.media.Image; 13 + import android.media.ImageReader; 14 + import android.os.Handler; 15 + import android.os.HandlerThread; 16 + import android.util.DisplayMetrics; 17 + import android.view.Display; 18 + import android.view.WindowManager; 19 + import java.nio.ByteBuffer; 20 + import android.content.pm.PackageManager; 21 + import android.net.Uri; 22 + import androidx.core.content.FileProvider; 23 + import java.io.File; 24 + import java.io.FileOutputStream; 25 + import java.io.IOException; 26 + import android.widget.Button; 27 + import android.widget.ImageView; 28 + 29 + public class MainActivity extends Activity { 30 + private static final int REQUEST_CODE_SCREEN_CAPTURE = 1001; 31 + private static final String TAG = "ShotNShare"; 32 + 33 + private MediaProjectionManager mediaProjectionManager; 34 + private MediaProjection mediaProjection; 35 + private ImageReader imageReader; 36 + private Handler backgroundHandler; 37 + private int screenWidth; 38 + private int screenHeight; 39 + private int screenDensity; 40 + private Bitmap lastScreenshot; 41 + private ImageView imgPreview; 42 + private Button btnShare; 43 + 44 + @Override 45 + protected void onCreate(Bundle savedInstanceState) { 46 + super.onCreate(savedInstanceState); 47 + setContentView(R.layout.activity_main); 48 + imgPreview = findViewById(R.id.img_preview); 49 + Button btnCapture = findViewById(R.id.btn_capture); 50 + btnShare = findViewById(R.id.btn_share); 51 + btnShare.setEnabled(false); 52 + btnCapture.setOnClickListener(v -> { 53 + if (mediaProjection != null) { 54 + triggerScreenshotCapture(); 55 + } else { 56 + requestScreenCapturePermission(); 57 + } 58 + }); 59 + btnShare.setOnClickListener(v -> shareLastScreenshot()); 60 + mediaProjectionManager = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); 61 + requestScreenCapturePermission(); 62 + initScreenMetrics(); 63 + } 64 + 65 + @Override 66 + protected void onNewIntent(Intent intent) { 67 + super.onNewIntent(intent); 68 + if (intent != null && "cl.frabarz.shotnshare.ACTION_TRIGGER_SCREENSHOT".equals(intent.getAction())) { 69 + if (mediaProjection != null) { 70 + triggerScreenshotCapture(); 71 + shareLastScreenshot(); 72 + } else { 73 + requestScreenCapturePermission(); 74 + } 75 + } 76 + } 77 + 78 + private void initScreenMetrics() { 79 + WindowManager windowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE); 80 + Display display = windowManager.getDefaultDisplay(); 81 + DisplayMetrics metrics = new DisplayMetrics(); 82 + display.getRealMetrics(metrics); 83 + screenWidth = metrics.widthPixels; 84 + screenHeight = metrics.heightPixels; 85 + screenDensity = metrics.densityDpi; 86 + } 87 + 88 + private void requestScreenCapturePermission() { 89 + Intent captureIntent = mediaProjectionManager.createScreenCaptureIntent(); 90 + startActivityForResult(captureIntent, REQUEST_CODE_SCREEN_CAPTURE); 91 + } 92 + 93 + @Override 94 + protected void onActivityResult(int requestCode, int resultCode, Intent data) { 95 + super.onActivityResult(requestCode, resultCode, data); 96 + if (requestCode == REQUEST_CODE_SCREEN_CAPTURE) { 97 + if (resultCode == RESULT_OK && data != null) { 98 + mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data); 99 + Log.d(TAG, "MediaProjection permission granted."); 100 + startBackgroundThread(); 101 + setUpImageReader(); 102 + // Screenshot capture is now triggered on demand 103 + } else { 104 + Log.e(TAG, "MediaProjection permission denied."); 105 + } 106 + } 107 + } 108 + 109 + @Override 110 + protected void onDestroy() { 111 + super.onDestroy(); 112 + if (mediaProjection != null) { 113 + mediaProjection.stop(); 114 + } 115 + } 116 + 117 + // Call this method to trigger a screenshot capture after permission is granted 118 + private void triggerScreenshotCapture() { 119 + captureScreenshot(); 120 + } 121 + 122 + // TODO: Replace this with actual event (e.g., button or remote trigger) 123 + // Example: call triggerScreenshotCapture() when a certain event occurs 124 + 125 + private void setUpImageReader() { 126 + imageReader = ImageReader.newInstance(screenWidth, screenHeight, PixelFormat.RGBA_8888, 2); 127 + } 128 + 129 + private void startBackgroundThread() { 130 + HandlerThread handlerThread = new HandlerThread("ScreenshotThread"); 131 + handlerThread.start(); 132 + backgroundHandler = new Handler(handlerThread.getLooper()); 133 + } 134 + 135 + private void captureScreenshot() { 136 + if (mediaProjection == null || imageReader == null) { 137 + Log.e(TAG, "MediaProjection or ImageReader not initialized"); 138 + return; 139 + } 140 + mediaProjection.createVirtualDisplay( 141 + "ShotNShareDisplay", 142 + screenWidth, 143 + screenHeight, 144 + screenDensity, 145 + 0, 146 + imageReader.getSurface(), 147 + null, 148 + backgroundHandler 149 + ); 150 + imageReader.setOnImageAvailableListener(reader -> { 151 + Image image = null; 152 + try { 153 + image = reader.acquireLatestImage(); 154 + if (image != null) { 155 + Image.Plane[] planes = image.getPlanes(); 156 + if (planes.length > 0) { 157 + ByteBuffer buffer = planes[0].getBuffer(); 158 + int pixelStride = planes[0].getPixelStride(); 159 + int rowStride = planes[0].getRowStride(); 160 + int rowPadding = rowStride - pixelStride * screenWidth; 161 + Bitmap bitmap = Bitmap.createBitmap( 162 + screenWidth + rowPadding / pixelStride, 163 + screenHeight, 164 + Bitmap.Config.ARGB_8888 165 + ); 166 + bitmap.copyPixelsFromBuffer(buffer); 167 + lastScreenshot = Bitmap.createBitmap(bitmap, 0, 0, screenWidth, screenHeight); 168 + bitmap.recycle(); 169 + Log.d(TAG, "Screenshot captured"); 170 + runOnUiThread(() -> { 171 + imgPreview.setImageBitmap(lastScreenshot); 172 + btnShare.setEnabled(true); 173 + }); 174 + } 175 + } 176 + } catch (Exception e) { 177 + Log.e(TAG, "Error capturing screenshot", e); 178 + } finally { 179 + if (image != null) { 180 + image.close(); 181 + } 182 + } 183 + }, backgroundHandler); 184 + } 185 + 186 + private void shareLastScreenshot() { 187 + if (lastScreenshot == null) { 188 + Log.e(TAG, "No screenshot to share"); 189 + return; 190 + } 191 + File imageFile = new File(getCacheDir(), "screenshot.png"); 192 + try (FileOutputStream out = new FileOutputStream(imageFile)) { 193 + lastScreenshot.compress(Bitmap.CompressFormat.PNG, 100, out); 194 + out.flush(); 195 + } catch (IOException e) { 196 + Log.e(TAG, "Failed to save screenshot for sharing", e); 197 + return; 198 + } 199 + Uri contentUri = FileProvider.getUriForFile(this, "cl.frabarz.shotnshare.fileprovider", imageFile); 200 + Intent shareIntent = new Intent(Intent.ACTION_SEND); 201 + shareIntent.setType("image/png"); 202 + shareIntent.putExtra(Intent.EXTRA_STREAM, contentUri); 203 + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 204 + // Optionally, restrict to Twitter/WhatsApp by setting package 205 + if (isAppInstalled("com.twitter.android")) { 206 + shareIntent.setPackage("com.twitter.android"); 207 + } else if (isAppInstalled("com.whatsapp")) { 208 + shareIntent.setPackage("com.whatsapp"); 209 + } 210 + startActivity(Intent.createChooser(shareIntent, "Share Screenshot")); 211 + } 212 + 213 + private boolean isAppInstalled(String packageName) { 214 + PackageManager pm = getPackageManager(); 215 + try { 216 + pm.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES); 217 + return true; 218 + } catch (PackageManager.NameNotFoundException e) { 219 + return false; 220 + } 221 + } 222 + }
+22
app/src/main/java/cl/frabarz/shotnshare/ScreenshotActivity.java
···
··· 1 + package cl.frabarz.shotnshare; 2 + 3 + import android.app.Activity; 4 + import android.content.Intent; 5 + import android.os.Bundle; 6 + import android.util.Log; 7 + 8 + public class ScreenshotActivity extends Activity { 9 + @Override 10 + protected void onCreate(Bundle savedInstanceState) { 11 + super.onCreate(savedInstanceState); 12 + try { 13 + Intent mainIntent = new Intent(this, MainActivity.class); 14 + mainIntent.setAction("cl.frabarz.shotnshare.ACTION_TRIGGER_SCREENSHOT"); 15 + mainIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); 16 + startActivity(mainIntent); 17 + } catch (Exception e) { 18 + Log.e("ShotNShare", "Failed to trigger screenshot", e); 19 + } 20 + finish(); 21 + } 22 + }
+29
app/src/main/res/layout/activity_main.xml
···
··· 1 + <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" 2 + android:layout_width="match_parent" 3 + android:layout_height="match_parent" 4 + android:orientation="vertical" 5 + android:gravity="center" 6 + android:padding="32dp"> 7 + 8 + <Button 9 + android:id="@+id/btn_capture" 10 + android:layout_width="wrap_content" 11 + android:layout_height="wrap_content" 12 + android:text="Capture Screenshot" /> 13 + 14 + <ImageView 15 + android:id="@+id/img_preview" 16 + android:layout_width="320dp" 17 + android:layout_height="180dp" 18 + android:layout_marginTop="32dp" 19 + android:scaleType="fitCenter" 20 + android:contentDescription="Screenshot Preview" /> 21 + 22 + <Button 23 + android:id="@+id/btn_share" 24 + android:layout_width="wrap_content" 25 + android:layout_height="wrap_content" 26 + android:layout_marginTop="32dp" 27 + android:text="Share Screenshot" /> 28 + 29 + </LinearLayout>
+4
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
···
··· 1 + <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> 2 + <background android:drawable="@android:color/holo_blue_light" /> 3 + <foreground android:drawable="@android:color/white" /> 4 + </adaptive-icon>
+4
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
···
··· 1 + <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> 2 + <background android:drawable="@android:color/holo_blue_dark" /> 3 + <foreground android:drawable="@android:color/white" /> 4 + </adaptive-icon>
+3
app/src/main/res/values/styles.xml
···
··· 1 + <resources> 2 + <style name="Theme.AppCompat.DayNight.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar" /> 3 + </resources>
+3
app/src/main/res/xml/file_paths.xml
···
··· 1 + <paths> 2 + <cache-path name="cache" path="." /> 3 + </paths>
+16
build.gradle
···
··· 1 + buildscript { 2 + repositories { 3 + google() 4 + mavenCentral() 5 + } 6 + dependencies { 7 + classpath 'com.android.tools.build:gradle:8.2.2' 8 + } 9 + } 10 + 11 + allprojects { 12 + repositories { 13 + google() 14 + mavenCentral() 15 + } 16 + }
+2
settings.gradle
···
··· 1 + rootProject.name = 'ShotNShare' 2 + include ':app'