From 3052d48e86c04424f5fdf75ff965b2f1470a1988 Mon Sep 17 00:00:00 2001 From: lixr_SJTU Date: Thu, 5 Feb 2026 16:27:11 +0800 Subject: [PATCH] Upgrade Clipper for Android 13+ (API 34) compatibility Changes: - Upgrade targetSdkVersion to 34, minSdkVersion to 21 - Replace deprecated IntentService with Foreground Service - Add notification channel for foreground service - Update ClipboardManager API from deprecated android.text to android.content - Use ClipData/setPrimaryClip instead of deprecated setText/getText - Add POST_NOTIFICATIONS and FOREGROUND_SERVICE permissions - Handle clipboard operations via Activity intents with focus awareness - Add Gradle build system alongside Maven - Update .gitignore for Gradle and temp files Note: Android 13+ has system-level clipboard restrictions that may clear clipboard content after setPrimaryClip in some scenarios. For reliable clipboard access on Android 13+, consider using the ADBKeyBoard IME approach instead. Co-Authored-By: Claude --- .gitignore | 35 ++++- build.gradle | 60 +++++++++ gradle.properties | 3 + pom.xml | 4 +- settings.gradle | 1 + src/main/AndroidManifest.xml | 45 ++++--- .../ca/zgrs/clipper/ClipboardService.java | 84 +++++++++--- .../java/ca/zgrs/clipper/ClipperReceiver.java | 22 +++- src/main/java/ca/zgrs/clipper/Main.java | 123 +++++++++++++++++- 9 files changed, 321 insertions(+), 56 deletions(-) create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore index eb5a316..141e1f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,34 @@ -target +# Maven +target/ + +# Gradle +.gradle/ +build/ +gradle/ +gradlew +gradlew.bat +local.properties + +# IDE +.idea/ +*.iml + +# Build outputs +*.apk +*.ap_ +*.dex + +# Temp files +*.class +*.log +*.tmp +*.temp +nul +tmpclaude-* + +# OS +.DS_Store +Thumbs.db + +# ADBKeyBoard (separate project) +ADBKeyBoard/ diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..635df2e --- /dev/null +++ b/build.gradle @@ -0,0 +1,60 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.application' + +android { + namespace 'ca.zgrs.clipper' + compileSdk 34 + + defaultConfig { + applicationId "ca.zgrs.clipper" + minSdk 21 + targetSdk 34 + versionCode 3 + versionName "2.0" + } + + buildTypes { + release { + minifyEnabled false + } + debug { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + manifest.srcFile 'src/main/AndroidManifest.xml' + java.srcDirs = ['src/main/java'] + res.srcDirs = ['src/main/res'] + } + } + + lint { + abortOnError false + } +} + +dependencies { +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..b2519a7 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=false +android.enableJetifier=false +org.gradle.jvmargs=-Xmx1536m diff --git a/pom.xml b/pom.xml index 2f272a4..c352ce8 100644 --- a/pom.xml +++ b/pom.xml @@ -9,8 +9,8 @@ clipboard-service - 2.3.3 - 10 + 13 + 33 ${env.ANDROID_HOME} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..66fad80 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'Clipper' diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index a3bade4..d7c1773 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -1,33 +1,36 @@ + android:versionCode="3" android:versionName="2.0" android:enabled="true"> + + + + + + - + + + + + + + + - - - - - - - - - - - - - - + + + + - diff --git a/src/main/java/ca/zgrs/clipper/ClipboardService.java b/src/main/java/ca/zgrs/clipper/ClipboardService.java index b6a2f0f..db1c998 100644 --- a/src/main/java/ca/zgrs/clipper/ClipboardService.java +++ b/src/main/java/ca/zgrs/clipper/ClipboardService.java @@ -1,38 +1,82 @@ package ca.zgrs.clipper; -import android.app.IntentService; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; import android.content.Intent; - +import android.os.Build; +import android.os.IBinder; import android.util.Log; -public class ClipboardService extends IntentService { - private static String TAG = "ClipboardService"; +public class ClipboardService extends Service { + private static final String TAG = "ClipboardService"; + private static final String CHANNEL_ID = "clipper_service_channel"; + private static final int NOTIFICATION_ID = 1; - public ClipboardService() { - super("ClipboardService"); + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "ClipboardService created"); + createNotificationChannel(); } - /* Define service as sticky so that it stays in background */ @Override public int onStartCommand(Intent intent, int flags, int startId) { - super.onStartCommand(intent, flags, startId); + Log.d(TAG, "ClipboardService started"); + startForeground(NOTIFICATION_ID, createNotification()); return START_STICKY; } @Override - public void onCreate() { - super.onCreate(); - // start itself to ensure our broadcast receiver is active - Log.d(TAG, "Start clipboard service."); - startService(new Intent(getApplicationContext(), ClipboardService.class)); + public IBinder onBind(Intent intent) { + return null; } - /** - * The IntentService calls this method from the default worker thread with - * the intent that started the service. When this method returns, IntentService - * stops the service, as appropriate. - */ @Override - protected void onHandleIntent(Intent intent) { + public void onDestroy() { + Log.d(TAG, "ClipboardService destroyed"); + super.onDestroy(); + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + "Clipper Service", + NotificationManager.IMPORTANCE_LOW + ); + channel.setDescription("Keeps Clipper running for clipboard access"); + channel.setShowBadge(false); + + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager != null) { + manager.createNotificationChannel(channel); + } + } + } + + private Notification createNotification() { + Intent notificationIntent = new Intent(this, Main.class); + PendingIntent pendingIntent = PendingIntent.getActivity( + this, 0, notificationIntent, + PendingIntent.FLAG_IMMUTABLE + ); + + Notification.Builder builder; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder = new Notification.Builder(this, CHANNEL_ID); + } else { + builder = new Notification.Builder(this); + } + + return builder + .setContentTitle("Clipper") + .setContentText("Clipboard service is running") + .setSmallIcon(R.drawable.icon) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build(); } -} \ No newline at end of file +} diff --git a/src/main/java/ca/zgrs/clipper/ClipperReceiver.java b/src/main/java/ca/zgrs/clipper/ClipperReceiver.java index d833cf8..a8d3d8d 100644 --- a/src/main/java/ca/zgrs/clipper/ClipperReceiver.java +++ b/src/main/java/ca/zgrs/clipper/ClipperReceiver.java @@ -1,7 +1,8 @@ package ca.zgrs.clipper; import android.app.Activity; -import android.text.ClipboardManager; +import android.content.ClipboardManager; +import android.content.ClipData; import android.content.Context; import android.content.Intent; import android.content.BroadcastReceiver; @@ -36,7 +37,8 @@ public void onReceive(Context context, Intent intent) { Log.d(TAG, "Setting text into clipboard"); String text = intent.getStringExtra(EXTRA_TEXT); if (text != null) { - cb.setText(text); + ClipData clip = ClipData.newPlainText("Clipper", text); + cb.setPrimaryClip(clip); setResultCode(Activity.RESULT_OK); setResultData("Text is copied into clipboard."); } else { @@ -45,11 +47,17 @@ public void onReceive(Context context, Intent intent) { } } else if (isActionGet(intent.getAction())) { Log.d(TAG, "Getting text from clipboard"); - CharSequence clip = cb.getText(); - if (clip != null) { - Log.d(TAG, String.format("Clipboard text: %s", clip)); - setResultCode(Activity.RESULT_OK); - setResultData(clip.toString()); + ClipData clipData = cb.getPrimaryClip(); + if (clipData != null && clipData.getItemCount() > 0) { + CharSequence clip = clipData.getItemAt(0).getText(); + if (clip != null) { + Log.d(TAG, String.format("Clipboard text: %s", clip)); + setResultCode(Activity.RESULT_OK); + setResultData(clip.toString()); + } else { + setResultCode(Activity.RESULT_CANCELED); + setResultData(""); + } } else { setResultCode(Activity.RESULT_CANCELED); setResultData(""); diff --git a/src/main/java/ca/zgrs/clipper/Main.java b/src/main/java/ca/zgrs/clipper/Main.java index cb7aef6..5c539b3 100644 --- a/src/main/java/ca/zgrs/clipper/Main.java +++ b/src/main/java/ca/zgrs/clipper/Main.java @@ -1,19 +1,132 @@ package ca.zgrs.clipper; +import android.Manifest; import android.app.Activity; +import android.content.ClipboardManager; +import android.content.ClipData; +import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; import android.os.Bundle; - +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.widget.Toast; public class Main extends Activity { + private static final String TAG = "ClipperMain"; + private static final int PERMISSION_REQUEST_CODE = 1; + + public static final String ACTION_GET = "ca.zgrs.clipper.GET"; + public static final String ACTION_SET = "ca.zgrs.clipper.SET"; + + private Intent pendingIntent = null; + private boolean hasFocus = false; + private Handler handler = new Handler(Looper.getMainLooper()); + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.main); - // start clipboard service + // Store intent for later processing when we have focus + pendingIntent = getIntent(); + + // Request notification permission on Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions( + new String[]{Manifest.permission.POST_NOTIFICATIONS}, + PERMISSION_REQUEST_CODE + ); + } else { + startClipboardService(); + } + } else { + startClipboardService(); + } + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + Log.d(TAG, "onNewIntent: " + intent.getAction()); + pendingIntent = intent; + if (hasFocus) { + processPendingIntent(); + } + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + this.hasFocus = hasFocus; + Log.d(TAG, "onWindowFocusChanged: " + hasFocus); + if (hasFocus && pendingIntent != null) { + // Delay to ensure focus is fully established + handler.postDelayed(this::processPendingIntent, 500); + } + } + + private void processPendingIntent() { + if (pendingIntent == null) return; + Intent intent = pendingIntent; + pendingIntent = null; + handleClipboardIntent(intent); + } + + private void handleClipboardIntent(Intent intent) { + if (intent == null) return; + + String action = intent.getAction(); + if (action == null) return; + + ClipboardManager cb = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + + if (ACTION_SET.equals(action) || "clipper.set".equals(action)) { + String text = intent.getStringExtra("text"); + if (text != null) { + Log.d(TAG, "Setting clipboard: " + text); + ClipData clip = ClipData.newPlainText("", text); + cb.setPrimaryClip(clip); + Log.d(TAG, "Clipboard set complete"); + Toast.makeText(this, "Copied: " + text, Toast.LENGTH_SHORT).show(); + } + } else if (ACTION_GET.equals(action) || "clipper.get".equals(action)) { + Log.d(TAG, "Getting clipboard"); + ClipData clipData = cb.getPrimaryClip(); + if (clipData != null && clipData.getItemCount() > 0) { + CharSequence text = clipData.getItemAt(0).getText(); + if (text != null) { + Log.d(TAG, "Clipboard: " + text); + Toast.makeText(this, "Clipboard: " + text, Toast.LENGTH_LONG).show(); + } else { + Log.d(TAG, "Clipboard text is null"); + Toast.makeText(this, "Clipboard is empty", Toast.LENGTH_SHORT).show(); + } + } else { + Log.d(TAG, "No clipboard data"); + Toast.makeText(this, "Clipboard is empty", Toast.LENGTH_SHORT).show(); + } + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + if (requestCode == PERMISSION_REQUEST_CODE) { + startClipboardService(); + } + } + + private void startClipboardService() { Intent serviceIntent = new Intent(this, ClipboardService.class); - startService(serviceIntent); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(serviceIntent); + } else { + startService(serviceIntent); + } } -} \ No newline at end of file +}