diff --git a/app/build.gradle b/app/build.gradle old mode 100644 new mode 100755 index f87bcc8..bf6900c --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { applicationId "com.github.droserasprout.lockscreencamera" minSdk 30 targetSdk 34 - versionCode 2 - versionName "2.0.0" + versionCode 3 + versionName "3.1.0" } signingConfigs { @@ -59,4 +59,5 @@ dependencies { implementation 'com.github.chrisbanes:PhotoView:2.3.0' implementation 'androidx.annotation:annotation:1.7.0' implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.preference:preference:1.2.1' } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro old mode 100644 new mode 100755 diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json old mode 100644 new mode 100755 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml old mode 100644 new mode 100755 index 24a9020..e97e2af --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,13 +2,31 @@ + + + + android:allowBackup="true" + android:icon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/ic_launcher_round" + android:label="@string/app_name" + android:description="@string/xposed_description" + android:supportsRtl="true" + tools:ignore="AllowBackup"> + + + + + + + + + fieldCache = new ConcurrentHashMap<>(); - - // 書き換え対象の内部フィールド名リスト - private static final String[] TARGET_BOOLEAN_FIELDS = { - "mIsSecure", "mIsSecureCamera", "mKeyguardLocked", - "mInLockScreen", "mIgnoreKeyguard", "mIsScreenOn", - "mSecureCamera", "mIsKeyguardLocked", "mIsHideForeground", - "mIsGalleryLock", "mIsCaptureIntent", "mIsPortraitIntent", "mIsVideoIntent", - "mUserAuthenticationFlag", "mIgnoreKeyguardCheck", - "mIsCameraApp", "mPrivacyAuthorized", "mIsForeground" - }; public LockscreenCamera() { super(); } - // === SessionManager (カメラアプリプロセス内で動作) === - public static class SessionManager { - public static volatile boolean isActive = false; - public static final List SESSION_URIS = new CopyOnWriteArrayList<>(); - - public static void start() { - isActive = true; - SESSION_URIS.clear(); - Log.i(TAG, "Secure Camera Session Started"); - } - - public static void end() { - isActive = false; - SESSION_URIS.clear(); - Log.i(TAG, "Secure Camera Session Cleared"); - } - - public static void add(Uri uri) { - if (isActive && uri != null) { - if (!SESSION_URIS.contains(uri)) { - SESSION_URIS.add(uri); - Log.d(TAG, "Added to Session: " + uri + " | Total: " + SESSION_URIS.size()); - } else { - Log.d(TAG, "Duplicate URI skipped: " + uri); - } - } - } - } - @Override public void onPackageReady(@NonNull PackageReadyParam param) { - // カメラパッケージのみを対象にする String pkg = param.getPackageName(); - if (!isCameraPackage(pkg)) { - return; - } - - log(Log.INFO, TAG, "Targeting Camera App: " + pkg); - - // 0. DecorView の透明化・非表示化を物理的に阻止 - try { - hook(View.class.getDeclaredMethod("setAlpha", float.class)).intercept(chain -> { - View view = (View) chain.getThisObject(); - if (isDecorView(view) && isCameraContext(view.getContext())) { - List args = chain.getArgs(); - float alpha = (float) args.get(0); - if (alpha < 1.0f) args.set(0, 1.0f); - } - return chain.proceed(); - }); - hook(View.class.getDeclaredMethod("setVisibility", int.class)).intercept(chain -> { - View view = (View) chain.getThisObject(); - if (isDecorView(view) && isCameraContext(view.getContext())) { - List args = chain.getArgs(); - int vis = (int) args.get(0); - if (vis != View.VISIBLE) args.set(0, View.VISIBLE); - } - return chain.proceed(); - }); - } catch (Throwable ignored) {} - - // 1. Keyguard の解除要求(PIN 画面表示)を完全にブロック - try { - Method dismissMethod = KeyguardManager.class.getDeclaredMethod( - "requestDismissKeyguard", Activity.class, KeyguardManager.KeyguardDismissCallback.class); - hook(dismissMethod).intercept(chain -> { - log(Log.WARN, TAG, "BLOCKED: requestDismissKeyguard (Preventing PIN screen)"); - return null; - }); - } catch (Throwable ignored) {} - - // 2. Activity Visibility Spoofing - try { - hook(Activity.class.getDeclaredMethod("hasWindowFocus")).intercept(chain -> { - if (isCameraActivity((Activity) chain.getThisObject())) return true; - return (Boolean) chain.proceed(); - }); - hook(Activity.class.getDeclaredMethod("isResumed")).intercept(chain -> { - if (isCameraActivity((Activity) chain.getThisObject())) return true; - return (Boolean) chain.proceed(); - }); - } catch (Throwable ignored) {} - - // 3. Intent の動的書き換え - try { - hook(Activity.class.getDeclaredMethod("getIntent")).intercept(chain -> { - Intent intent = (Intent) chain.proceed(); - if (isCameraActivity((Activity) chain.getThisObject()) && intent != null) { - if (intent.getBooleanExtra("com.miui.camera.extra.START_BY_KEYGUARD", false)) { - intent.putExtra("is_secure_camera", true); - intent.putExtra("ShowCameraWhenLocked", true); - } - } - return intent; - }); - } catch (Throwable ignored) {} - - // 4. SurfaceView/View 非表示化を阻止 - try { - hook(View.class.getMethod("setVisibility", int.class)).intercept(chain -> { - View v = (View) chain.getThisObject(); - if (isCameraContext(v.getContext()) && !isDecorView(v)) { - List args = chain.getArgs(); - int vis = (int) args.get(0); - if (vis != View.VISIBLE) args.set(0, View.VISIBLE); - } - return chain.proceed(); - }); - } catch (Throwable ignored) {} - // 5. Secure Gallery Redirect + // libxposed API 101: Context は取得できないため getRemotePreferences を使用 + SharedPreferences prefs; try { - Method startAct = Activity.class.getDeclaredMethod("startActivity", Intent.class); - hook(startAct).intercept(chain -> { - handleGalleryRedirect((Context) chain.getThisObject(), (Intent) chain.getArgs().get(0)); - return chain.proceed(); - }); - - Method startRes = Activity.class.getDeclaredMethod("startActivityForResult", Intent.class, int.class); - hook(startRes).intercept(chain -> { - handleGalleryRedirect((Context) chain.getThisObject(), (Intent) chain.getArgs().get(0)); - return chain.proceed(); - }); - - Method startCtx = ContextWrapper.class.getDeclaredMethod("startActivity", Intent.class); - hook(startCtx).intercept(chain -> { - handleGalleryRedirect((Context) chain.getThisObject(), (Intent) chain.getArgs().get(0)); - return chain.proceed(); - }); - } catch (Throwable t) { - log(Log.ERROR, TAG, "Failed to hook gallery redirect", t); + prefs = getRemotePreferences(ModulePrefs.PREFS_NAME); + } catch (Exception e) { + log(Log.WARN, TAG, "Failed to get remote preferences, using fallback"); + prefs = null; } - // 6. ライフサイクルフック(+ 自動終了機能の実装) - String[] criticalMethods = {"attachBaseContext", "onCreate", "onStart", "onResume", "onWindowFocusChanged", "onDestroy"}; - - for (String mname : criticalMethods) { - try { - Method m; - if ("attachBaseContext".equals(mname)) { - m = ContextWrapper.class.getDeclaredMethod("attachBaseContext", Context.class); - } else if ("onCreate".equals(mname)) { - m = Activity.class.getDeclaredMethod("onCreate", Bundle.class); - } else if ("onWindowFocusChanged".equals(mname)) { - m = Activity.class.getDeclaredMethod("onWindowFocusChanged", boolean.class); - } else if ("onDestroy".equals(mname)) { - m = Activity.class.getDeclaredMethod("onDestroy"); - } else { - m = Activity.class.getDeclaredMethod(mname); - } - - final String methodName = mname; - hook(m).intercept(chain -> { - Object thisObj = chain.getThisObject(); - if (thisObj instanceof Activity) { - Activity act = (Activity) thisObj; - boolean isTarget = isCameraActivity(act); - - if (isTarget) { - // onDestroy でセッションをクリーンアップ - if ("onDestroy".equals(methodName)) { - if (SessionManager.isActive) { - SessionManager.end(); - } - return chain.proceed(); - } - - if ("onWindowFocusChanged".equals(methodName)) { - boolean hasFocus = (boolean) ((List) chain.getArgs()).get(0); - if (!hasFocus) return chain.proceed(); - } - - if ("onCreate".equals(methodName)) { - Object res = chain.proceed(); - - Intent intent = act.getIntent(); - // フラグで判定(GCam/MIUI 共通) - boolean isLockscreenLaunch = intent != null && intent.getBooleanExtra("com.miui.camera.extra.START_BY_KEYGUARD", false); - - if (isLockscreenLaunch) { - // 【修正】ここ(カメラプロセス内)で Session を開始 - SessionManager.start(); - log(Log.INFO, TAG, "Secure Lockscreen launch detected. Session Started."); - - IntentFilter filter = new IntentFilter(Intent.ACTION_SCREEN_OFF); - BroadcastReceiver screenOffReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent i) { - SessionManager.end(); - act.finish(); - } - }; - - try { - if (Build.VERSION.SDK_INT >= 33) { - act.registerReceiver(screenOffReceiver, filter, Context.RECEIVER_NOT_EXPORTED); - } else { - act.registerReceiver(screenOffReceiver, filter); - } - } catch (Exception e) { - log(Log.WARN, TAG, "Failed to register receiver: " + e.getMessage()); - } - } - - applyWindowAndBufferFixes(act); - return res; - } - applyWindowAndBufferFixes(act); - } - } - return chain.proceed(); - }); - } catch (Throwable ignored) {} + // 設定ベースの判定(フォールバック付き) + boolean enabled; + if (prefs != null) { + enabled = ModulePrefs.isPackageEnabled(prefs, pkg); + } else { + enabled = CameraPackageUtil.isCameraPackage(pkg); } - // 7. プレビュー画像の絞り込み(写真保存のトラッキング) - try { - hook(ContentResolver.class.getDeclaredMethod("insert", Uri.class, ContentValues.class)) - .intercept(chain -> { - if (SessionManager.isActive) { - Uri uri = (Uri) chain.getArgs().get(0); - Uri returnedUri = (Uri) chain.getArgs().get(1); - if (returnedUri != null) { - SessionManager.add(returnedUri); - } - return returnedUri; - } - return chain.proceed(); - }); - } catch (Throwable ignored) {} - - // --- Added try-catch block for the update hook --- - try { - hook(ContentResolver.class.getDeclaredMethod("update", Uri.class, ContentValues.class, String.class, String[].class)) - .intercept(chain -> { - if (SessionManager.isActive) { - Uri uri = (Uri) chain.getArgs().get(0); - ContentValues values = (ContentValues) chain.getArgs().get(1); - - if (uri != null && values != null) { - boolean isFinished = false; - if (Build.VERSION.SDK_INT >= 29 && values.containsKey(MediaStore.MediaColumns.IS_PENDING)) { - isFinished = (Integer) values.get(MediaStore.MediaColumns.IS_PENDING) == 0; - } else if (values.containsKey(MediaStore.Images.Media.DATA)) { - isFinished = true; - } - - if (isFinished) { - SessionManager.add(uri); - } - } - } - return chain.proceed(); - }); - } catch (Throwable ignored) {} - - // 8. その他システムフック群 - try { - Class callbackClass = Class.forName("android.hardware.camera2.CameraManager$AvailabilityCallback", true, param.getClassLoader()); - hook(callbackClass.getDeclaredMethod("onCameraUnavailable", String.class)).intercept(chain -> { - log(Log.INFO, TAG, "Blocked onCameraUnavailable for ID: " + ((List) chain.getArgs()).get(0)); - return null; - }); - } catch (Throwable ignored) {} - - try { - Class biometricClass = Class.forName("android.hardware.biometrics.BiometricManager", true, param.getClassLoader()); - hook(biometricClass.getDeclaredMethod("canAuthenticate", int.class)).intercept(chain -> 0); - } catch (Throwable ignored) {} - } - - // --- ヘルパーメソッド群 --- - - private boolean isCameraPackage(String pkg) { - if (pkg == null) return false; - return pkg.equals("com.android.camera") || - pkg.contains("GoogleCamera") || - pkg.equals("org.codeaurora.snapcam") || - pkg.contains("camera"); - } - - private boolean isCameraActivity(Activity act) { - try { - return act != null && isCameraPackage(act.getPackageName()); - } - catch (Exception e) { - try { return act.getClass().getName().contains("camera"); } - catch (Exception e2) { return false; } + if (!enabled) { + log(Log.DEBUG, TAG, "Skipping non-target package: " + pkg); + return; } - } - - private boolean isCameraContext(Context ctx) { - if (ctx == null) return false; - try { return isCameraPackage(ctx.getPackageName()); } - catch (Exception e) { return ctx.getClass().getName().contains("camera"); } - } - private boolean isDecorView(View v) { - if (v == null) return false; - return v.getClass().getName().endsWith("DecorView"); - } + log(Log.INFO, TAG, "Targeting Camera App: " + pkg); - private void handleGalleryRedirect(Context ctx, Intent intent) { - if (ctx == null || intent == null || intent.getAction() == null) return; - if (!SessionManager.isActive) return; - - try { - if (!isCameraPackage(ctx.getPackageName())) return; - } catch (Exception e) { return; } - - String action = intent.getAction(); - // GCamのレビューアクションを確実に捉える - boolean isGallery = Intent.ACTION_VIEW.equals(action) || - Intent.ACTION_PICK.equals(action) || - action.contains("REVIEW") || - action.contains("STILL_IMAGE_CAMERA"); - - if (isGallery) { - Log.i(TAG, "Redirecting to SecureViewer: Force hijacking intent"); - - ArrayList uriList = new ArrayList<>(SessionManager.SESSION_URIS); - if (uriList.isEmpty() && intent.getData() != null) { - uriList.add(intent.getData()); - } - - // 既存のインテントを完全に再利用・改変して、システムを騙す - intent.setComponent(new ComponentName( - "com.github.droserasprout.lockscreencamera", - "com.github.droserasprout.lockscreencamera.SecureViewerActivity" - )); - - // Googleフォトがリストに並ぶのを防ぐ - intent.setPackage(null); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { - intent.setSelector(null); - } - - // 複数枚のプレビューに対応させるためのClipData - if (!uriList.isEmpty()) { - ClipData clipData = ClipData.newRawUri("Photos", uriList.get(0)); - for (int i = 1; i < uriList.size(); i++) { - clipData.addItem(new ClipData.Item(uriList.get(i))); - } - intent.setClipData(clipData); - } - - intent.putParcelableArrayListExtra("session_photos_list", uriList); - - // ロック解除要求を回避するためのフラグ群 - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | // 永続権限 - Intent.FLAG_ACTIVITY_NEW_TASK | - Intent.FLAG_ACTIVITY_CLEAR_TOP | - Intent.FLAG_ACTIVITY_NO_ANIMATION); - - // ロック画面上に表示するための隠しフラグ (SHOW_WHEN_LOCKED) - intent.addFlags(0x00080000 | 0x00400000 | 0x00200000); - - // ここで startActivity を呼ぶのではなく、この intent 自体を書き換えた状態で - // 元のメソッド(proceed)に戻すことで、カメラアプリに「自分の意志で」起動させる - Log.d(TAG, "Intent modification complete. Proceeding with hijacked intent."); - } + DecorViewProtectionHook.install(this, prefs); + KeyguardDismissBlockHook.install(this); + ActivityVisibilitySpoofHook.install(this, prefs); + KeyguardIntentRewriteHook.install(this, prefs); + GalleryRedirectHook.install(this, prefs); + CameraActivityLifecycleHook.install(this, prefs); + MediaStoreSessionTrackingHook.install(this); + MiscSystemHook.install(this, param); } - // システムサーバー側の起動ロジックを最適化 - @Override + @Override public void onSystemServerStarting(@NonNull SystemServerStartingParam param) { - try { - Class gestureClass = Class.forName("com.android.server.GestureLauncherService", true, param.getClassLoader()); - Method handleCameraGesture = gestureClass.getDeclaredMethod("handleCameraGesture", boolean.class, int.class); - - hook(handleCameraGesture).intercept(chain -> { - try { - Method getContextMethod = chain.getThisObject().getClass().getMethod("getContext"); - Context context = (Context) getContextMethod.invoke(chain.getThisObject()); - - KeyguardManager km = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); - boolean isLocked = (km != null && km.isKeyguardLocked()); - - // 1. デフォルトカメラを解決 - PackageManager pm = context.getPackageManager(); - Intent resolveIntent = new Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA_SECURE); - ResolveInfo info = pm.resolveActivity(resolveIntent, PackageManager.MATCH_DEFAULT_ONLY); - - // SECURE が解決できない場合は標準アクションにフォールバック - if (info == null || info.activityInfo.name.contains("Resolver")) { - resolveIntent = new Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA); - info = pm.resolveActivity(resolveIntent, PackageManager.MATCH_DEFAULT_ONLY); - } - - String targetPkg = "com.android.camera"; - String targetCls = "com.android.camera.Camera"; - - // 2. 解決結果が有効かつ Resolver でない場合はそちらを優先 - if (info != null && !info.activityInfo.name.contains("Resolver")) { - targetPkg = info.activityInfo.packageName; - targetCls = info.activityInfo.name; - } - - // 3. Intent 生成 - Intent intent = new Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA_SECURE); - - // 4. MIUI カメラの場合は Resolver を完全にスキップするため Component を強制指定 - // GCam などのサードパーティ製がデフォルトに設定されている場合も同様に明示起動 - if (targetPkg != null && targetCls != null) { - intent.setComponent(new ComponentName(targetPkg, targetCls)); - } - - int flags = 0x00040000 | Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP; - - if (isLocked) { - flags |= Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS; - intent.putExtra("com.miui.camera.extra.START_BY_KEYGUARD", true); - intent.putExtra("StartActivityWhenLocked", true); - intent.putExtra("is_secure_camera", true); - } else { - intent.putExtra("com.miui.camera.extra.START_BY_KEYGUARD", false); - intent.putExtra("StartActivityWhenLocked", false); - } - - intent.addFlags(flags); - intent.putExtra("android.intent.extra.CAMERA_OPEN_ONLY", true); - intent.putExtra("com.android.systemui.camera_launch_source", "lockscreen_affordance"); - - ActivityOptions options = ActivityOptions.makeBasic(); - if (Build.VERSION.SDK_INT >= 34) { - options.setPendingIntentBackgroundActivityStartMode(2); - } - - // SessionManager.start() はカメラアプリ側の onCreate で実行されるためここでは呼ばない - context.startActivity(intent, options.toBundle()); - return true; - } catch (Throwable t) { - log(Log.ERROR, TAG, "Failed to launch", t); - } - return chain.proceed(); - }); - } catch (Throwable t) { - log(Log.WARN, TAG, "System hook skipped", t); - } - } - private void applyWindowAndBufferFixes(Activity activity) { - try { - activity.setShowWhenLocked(true); - activity.setTurnScreenOn(true); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { - activity.setInheritShowWhenLocked(true); - } - - Window window = activity.getWindow(); - if (window != null) { - window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE); - window.setFormat(PixelFormat.TRANSLUCENT); - - WindowManager.LayoutParams lp = window.getAttributes(); - lp.flags |= WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED - | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON - | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON - | WindowManager.LayoutParams.FLAG_FULLSCREEN - | WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON; - - // 描画レイヤーを最前面に固定し、背面に隠れるのを防ぐ - lp.flags |= WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN - | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS; - - lp.flags &= ~WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - lp.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; - } - window.setAttributes(lp); - window.addFlags(lp.flags); - - window.setFlags( - WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, - WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED - ); - - View decorView = window.getDecorView(); - if (decorView != null) { - decorView.setAlpha(1.0f); - decorView.setVisibility(View.VISIBLE); - decorView.requestFocus(); - } - } - - for (String fieldName : TARGET_BOOLEAN_FIELDS) { - setFieldFast(activity, fieldName, true); - } - setFieldFast(activity, "mIsNormalIntent", false); - setFieldFast(activity, "mShowEnteringAnimation", false); - setFieldFast(activity, "mKeyguardStatus", 1); - setFieldFast(activity, "mIsSecureCameraId", 0); - - } catch (Throwable t) { - log(Log.DEBUG, TAG, "UI Fixes failed: " + t.toString()); - } - } - - private void setFieldFast(Object obj, String fieldName, Object value) { - try { - Class current = obj.getClass(); - String cacheKey = current.getName() + ":" + fieldName; - Field f = fieldCache.get(cacheKey); - - if (f == null && !fieldCache.containsKey(cacheKey)) { - while (current != null && !current.getName().equals("android.app.Activity")) { - try { - f = current.getDeclaredField(fieldName); - f.setAccessible(true); - break; - } catch (NoSuchFieldException e) { - current = current.getSuperclass(); - } - } - fieldCache.put(cacheKey, f); - } - - if (f != null) f.set(obj, value); - } catch (Throwable ignored) {} + CameraGestureLauncherHook.install(this, param); } - } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/SecureViewerActivity.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/SecureViewerActivity.java old mode 100644 new mode 100755 index 30af12b..891e6f2 --- a/app/src/main/java/com/github/droserasprout/lockscreencamera/SecureViewerActivity.java +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/SecureViewerActivity.java @@ -5,109 +5,133 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.util.Log; -import android.util.TypedValue; -import android.view.MotionEvent; +import android.view.KeyEvent; import android.view.ViewGroup; -import android.view.WindowManager; -import android.widget.FrameLayout; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; +import android.view.WindowManager; +import android.view.Window; import androidx.viewpager2.widget.ViewPager2; -import com.github.chrisbanes.photoview.PhotoView; +import com.github.droserasprout.lockscreencamera.ui.PhotoAdapter; +import com.github.droserasprout.lockscreencamera.ui.SwipeDismissLayout; -import java.io.InputStream; -import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.concurrent.ConcurrentHashMap; +/** + * ロック画面上でセッション中の写真をスワイプ閲覧するための Activity。 + * FLAG_SECURE を維持し、スクリーンショット・画面録画からは保護する。 + */ public class SecureViewerActivity extends Activity { private static final String TAG = "SecureViewer"; + private static final String EXTRA_SESSION_PHOTOS = "session_photos_list"; - // 並列デコードのためスレッド数を2に拡張 private final ExecutorService executor = Executors.newFixedThreadPool(2); - - // position → 実行中タスク のマップ(高速スワイプ時の古いタスクをキャンセルするため) private final ConcurrentHashMap> pendingTasks = new ConcurrentHashMap<>(); private BroadcastReceiver screenOffReceiver; private ViewPager2 viewPager; - private PhotoAdapter adapter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + // ロック画面上に確実に表示するための設定 setShowWhenLocked(true); setTurnScreenOn(true); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { setInheritShowWhenLocked(true); } - // SecureViewerActivity では FLAG_SECURE を意図的に維持する - // (LockscreenCamera の clearFlags フックとは異なり、ここではスクリーンショット禁止が必要) - getWindow().setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE - ); - List uris = getIntent().getParcelableArrayListExtra("session_photos_list"); - if (uris == null || uris.isEmpty()) { - Uri singleUri = getIntent().getData(); - if (singleUri != null) { - uris = new ArrayList<>(); - uris.add(singleUri); - } + // Window レベルでもフラグを設定(API 27 以降は Activity API で十分だが、 + // 一部 OEM では Window フラグも必要) + Window window = getWindow(); + if (window != null) { + WindowManager.LayoutParams lp = window.getAttributes(); + lp.flags |= WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + | WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON; + window.setAttributes(lp); + // スクリーンショット禁止 + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE); } - if (uris == null || uris.isEmpty()) { + List safeUris = resolveSafeUris(); + if (safeUris.isEmpty()) { + Log.w(TAG, "No URIs to display, finishing"); finish(); return; } - // URI のスキームを検証し content:// 以外を除外する - List safeUris = new ArrayList<>(); - for (Uri uri : uris) { - if ("content".equals(uri.getScheme())) { - safeUris.add(uri); - } else { - Log.w(TAG, "Rejected non-content URI: " + uri.getScheme()); - } + Log.i(TAG, "Displaying " + safeUris.size() + " photos"); + setupViewPager(safeUris); + registerScreenOffReceiver(); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus) { + // フォーカス取得時に再度ロック画面上への表示を確保 + setShowWhenLocked(true); + getWindow().addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON); } + } - if (safeUris.isEmpty()) { + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + // バックキーで閉じる + if (keyCode == KeyEvent.KEYCODE_BACK) { finish(); - return; + return true; } + return super.onKeyDown(keyCode, event); + } + + private List resolveSafeUris() { + List uris = getIntent().getParcelableArrayListExtra(EXTRA_SESSION_PHOTOS); + if (uris == null || uris.isEmpty()) { + uris = new ArrayList<>(); + Uri singleUri = getIntent().getData(); + if (singleUri != null) uris.add(singleUri); + if (!uris.isEmpty()) { + Log.i(TAG, "Fallback to single image from Intent Data: " + uris.get(0)); + } + } + return uris != null ? uris : new ArrayList<>(); + } + private void setupViewPager(List safeUris) { viewPager = new ViewPager2(this); viewPager.setLayoutParams(new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - )); + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); viewPager.setBackgroundColor(0xFF000000); viewPager.setOffscreenPageLimit(1); - SwipeDismissLayout container = new SwipeDismissLayout(this); + SwipeDismissLayout container = new SwipeDismissLayout(this, viewPager, this::finish); container.addView(viewPager); setContentView(container); - adapter = new PhotoAdapter(safeUris, this, executor, pendingTasks); + PhotoAdapter adapter = new PhotoAdapter(safeUris, this, executor, pendingTasks); viewPager.setAdapter(adapter); viewPager.setCurrentItem(safeUris.size() - 1, false); + } + private void registerScreenOffReceiver() { screenOffReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { @@ -126,26 +150,13 @@ public void onReceive(Context context, Intent intent) { @Override protected void onStop() { super.onStop(); - // onDestroy より早い段階で解除することで、プロセスが強制終了する前にも対応 - if (screenOffReceiver != null) { - try { - unregisterReceiver(screenOffReceiver); - } catch (Exception ignored) {} - screenOffReceiver = null; - } + unregisterScreenOffReceiver(); } @Override protected void onDestroy() { super.onDestroy(); - // onStop で解除済みでも念のりガード - if (screenOffReceiver != null) { - try { - unregisterReceiver(screenOffReceiver); - } catch (Exception ignored) {} - screenOffReceiver = null; - } - // 実行中タスクを全キャンセル + unregisterScreenOffReceiver(); for (Future task : pendingTasks.values()) { task.cancel(true); } @@ -153,216 +164,11 @@ protected void onDestroy() { executor.shutdownNow(); } - // ========================================================================= - // ビットマップデコード(static — アクティビティへの暗黙参照を持たない) - // ========================================================================= - - @Nullable - static Bitmap decodeSampledBitmapFromUri( - Context context, Uri uri, int reqWidth, int reqHeight) { + private void unregisterScreenOffReceiver() { + if (screenOffReceiver == null) return; try { - BitmapFactory.Options options = new BitmapFactory.Options(); - options.inJustDecodeBounds = true; - try (InputStream is = context.getContentResolver().openInputStream(uri)) { - BitmapFactory.decodeStream(is, null, options); - } - options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); - options.inJustDecodeBounds = false; - try (InputStream is = context.getContentResolver().openInputStream(uri)) { - return BitmapFactory.decodeStream(is, null, options); - } - } catch (Exception e) { - Log.e(TAG, "Failed to decode: " + uri, e); - return null; - } - } - - static int calculateInSampleSize( - BitmapFactory.Options options, int reqWidth, int reqHeight) { - final int height = options.outHeight; - final int width = options.outWidth; - int inSampleSize = 1; - if (height > reqHeight || width > reqWidth) { - final int halfHeight = height / 2; - final int halfWidth = width / 2; - while ((halfHeight / inSampleSize) >= reqHeight - && (halfWidth / inSampleSize) >= reqWidth) { - inSampleSize *= 2; - } - } - return inSampleSize; - } - - // ========================================================================= - // SwipeDismissLayout - // ========================================================================= - - private class SwipeDismissLayout extends FrameLayout { - private float initialY; - private float initialX; - private boolean isSwiping; - // dp → px 変換でハードコードピクセル値を排除 - private final float swipeStartThresholdPx; - private final float swipeDismissThresholdPx; - - SwipeDismissLayout(Context context) { - super(context); - swipeDismissThresholdPx = - context.getResources().getDisplayMetrics().heightPixels * 0.2f; - swipeStartThresholdPx = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, 16, - context.getResources().getDisplayMetrics() - ); - } - - @Override - public boolean onInterceptTouchEvent(MotionEvent ev) { - switch (ev.getActionMasked()) { - case MotionEvent.ACTION_DOWN: - initialY = ev.getY(); - initialX = ev.getX(); - isSwiping = false; - break; - case MotionEvent.ACTION_MOVE: - float dy = ev.getY() - initialY; - float dx = Math.abs(ev.getX() - initialX); - // 下方向かつ水平移動より大きい場合のみ dismiss スワイプとして横取り - if (dy > swipeStartThresholdPx && dy > dx) { - isSwiping = true; - return true; - } - break; - } - return super.onInterceptTouchEvent(ev); - } - - @Override - public boolean onTouchEvent(MotionEvent ev) { - if (!isSwiping) return super.onTouchEvent(ev); - switch (ev.getActionMasked()) { - case MotionEvent.ACTION_MOVE: - float dy = ev.getY() - initialY; - float translationY = Math.max(0, dy); - float progress = getHeight() > 0 ? translationY / getHeight() : 0; - viewPager.setTranslationY(translationY); - viewPager.setAlpha(1.0f - (progress * 0.8f)); - break; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - if (viewPager.getTranslationY() > swipeDismissThresholdPx) { - viewPager.setAlpha(0f); - finish(); - } else { - viewPager.animate() - .translationY(0) - .alpha(1.0f) - .setDuration(200) - .withStartAction(() -> isSwiping = false) - .start(); - } - break; - } - return true; - } - } - - // ========================================================================= - // PhotoAdapter(静的内部クラス — アクティビティへの暗黙参照を持たない) - // ========================================================================= - - private static class PhotoAdapter extends RecyclerView.Adapter { - - private final List uris; - private final WeakReference activityRef; - private final ExecutorService executor; - private final ConcurrentHashMap> pendingTasks; - - PhotoAdapter( - List uris, - Activity activity, - ExecutorService executor, - ConcurrentHashMap> pendingTasks) { - this.uris = uris; - this.activityRef = new WeakReference<>(activity); - this.executor = executor; - this.pendingTasks = pendingTasks; - } - - @NonNull - @Override - public PhotoViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - PhotoView photoView = new PhotoView(parent.getContext()); - photoView.setBackgroundColor(0xFF000000); - photoView.setLayoutParams(new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - )); - photoView.setZoomable(true); - return new PhotoViewHolder(photoView); - } - - @Override - public void onBindViewHolder(@NonNull PhotoViewHolder holder, int position) { - holder.photoView.setImageDrawable(null); - - // 同じポジションの前回タスクをキャンセルして競合を防ぐ - Future prev = pendingTasks.remove(position); - if (prev != null) prev.cancel(true); - - Activity activity = activityRef.get(); - if (activity == null || activity.isDestroyed()) return; - - int screenWidth = activity.getResources().getDisplayMetrics().widthPixels; - int screenHeight = activity.getResources().getDisplayMetrics().heightPixels; - // Context のみ渡し、アクティビティへの強参照をラムダに持ち込まない - Context appContext = activity.getApplicationContext(); - Uri uri = uris.get(position); - - Future task = executor.submit(() -> { - Bitmap bitmap = decodeSampledBitmapFromUri( - appContext, uri, screenWidth, screenHeight); - if (bitmap == null) return; - - Activity act = activityRef.get(); - if (act == null || act.isDestroyed()) { - bitmap.recycle(); - return; - } - - act.runOnUiThread(() -> { - pendingTasks.remove(position); - // バインド時のポジションと現在のポジションが一致する場合のみ反映 - if (holder.getAdapterPosition() == position) { - holder.photoView.setImageBitmap(bitmap); - } else { - // ポジションずれが発生した場合はネイティブメモリを即解放 - bitmap.recycle(); - } - }); - }); - - pendingTasks.put(position, task); - } - - @Override - public void onViewRecycled(@NonNull PhotoViewHolder holder) { - super.onViewRecycled(holder); - // Drawable 参照を切り、GC がビットマップを回収できるようにする - holder.photoView.setImageDrawable(null); - } - - @Override - public int getItemCount() { - return uris.size(); - } - - static class PhotoViewHolder extends RecyclerView.ViewHolder { - final PhotoView photoView; - - PhotoViewHolder(PhotoView pv) { - super(pv); - photoView = pv; - } - } + unregisterReceiver(screenOffReceiver); + } catch (Exception ignored) {} + screenOffReceiver = null; } } diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/SettingsActivity.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/SettingsActivity.java new file mode 100644 index 0000000..9161984 --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/SettingsActivity.java @@ -0,0 +1,299 @@ +package com.github.droserasprout.lockscreencamera; + +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.Bundle; +import android.provider.MediaStore; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.MultiSelectListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.SwitchPreferenceCompat; + +import com.github.droserasprout.lockscreencamera.util.ModulePrefs; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * モジュールの設定画面。 + * LSPosed マネージャーから起動され、対象カメラパッケージと + * SecureViewer のオン/オフ・除外設定を管理する。 + */ +public class SettingsActivity extends AppCompatActivity { + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState == null) { + getSupportFragmentManager() + .beginTransaction() + .replace(android.R.id.content, new SettingsFragment()) + .commit(); + } + } + + public static class SettingsFragment extends PreferenceFragmentCompat { + + private static final String TAG = "LockscreenCamera.Settings"; + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.preferences, rootKey); + + // 初回起動時は自動検出を実行 + if (ModulePrefs.isFirstRun(requireContext())) { + autoDetectAndSave(); + ModulePrefs.markFirstRunDone(requireContext()); + } + + setupAutoDetect(); + setupPackageList(); + setupViewerSettings(); + setupLockscreenSettings(); + } + + // ---- 自動検出 ---- + + private void setupAutoDetect() { + Preference autoDetect = findPreference("pref_auto_detect"); + if (autoDetect != null) { + autoDetect.setOnPreferenceClickListener(pref -> { + autoDetectAndSave(); + return true; + }); + } + } + + /** + * システムにインストール済みのカメラアプリを検出し、 + * 既存の選択状態をマージして保存する。 + */ + private void autoDetectAndSave() { + Set detected = detectCameraPackages(); + + // 既存の選択をマージ(ユーザーが手動追加したものも保持) + Set current = ModulePrefs.getPrefs(requireContext()) + .getStringSet(ModulePrefs.KEY_ENABLED_PACKAGES, new HashSet<>()); + Set merged = new HashSet<>(current); + merged.addAll(detected); + + ModulePrefs.setEnabledPackages(requireContext(), merged); + + // リストを再構築 + refreshPackageList(merged); + + String msg = getString(R.string.auto_detect_result, detected.size()); + Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show(); + Log.i(TAG, "Auto-detected cameras: " + detected); + } + + /** + * PackageManager を使ってカメラアプリを検出する。 + * 1. INTENT_ACTION_STILL_IMAGE_CAMERA_SECURE / STILL_IMAGE_CAMERA を resolve + * 2. 既知のパッケージリストと照合してインストール済みのものを追加 + */ + private Set detectCameraPackages() { + Set found = new HashSet<>(); + PackageManager pm = requireContext().getPackageManager(); + + // resolve で見つかるもの + String[] actions = { + MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA_SECURE, + MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA + }; + + for (String action : actions) { + try { + List list = pm.queryIntentActivities( + new Intent(action), PackageManager.MATCH_DEFAULT_ONLY); + for (ResolveInfo ri : list) { + if (ri.activityInfo != null) { + addIfInstalled(found, ri.activityInfo.packageName, pm); + } + } + } catch (Exception ignored) {} + } + + // 既知のカメラパッケージでインストール済みのもの + for (String pkg : ModulePrefs.getKnownCameraPackages()) { + addIfInstalled(found, pkg, pm); + } + + return found; + } + + private void addIfInstalled(Set target, String pkg, PackageManager pm) { + try { + pm.getPackageInfo(pkg, 0); + target.add(pkg); + } catch (PackageManager.NameNotFoundException ignored) { + // 未インストール + } + } + + // ---- パッケージ一覧 ---- + + private void setupPackageList() { + MultiSelectListPreference pref = findPreference("pref_enabled_packages"); + if (pref == null) return; + pref.setOnPreferenceChangeListener((preference, newValue) -> { + @SuppressWarnings("unchecked") + Set selected = (Set) newValue; + ModulePrefs.setEnabledPackages(requireContext(), selected); + // サマリー更新 + pref.setSummary(buildSummary(selected)); + // Viewer 除外リストも最新化 + refreshViewerExclusions(selected); + return true; + }); + + // 初期表示 + Set current = ModulePrefs.getPrefs(requireContext()) + .getStringSet(ModulePrefs.KEY_ENABLED_PACKAGES, new HashSet<>()); + refreshPackageList(current); + } + + private void refreshPackageList(Set selected) { + MultiSelectListPreference pref = findPreference("pref_enabled_packages"); + if (pref == null) return; + + PackageManager pm = requireContext().getPackageManager(); + List entries = new ArrayList<>(); + List values = new ArrayList<>(); + + // 選択済み + 既知パッケージ + 自動検出結果をまとめて表示 + Set allCandidates = new HashSet<>(selected); + allCandidates.addAll(ModulePrefs.getKnownCameraPackages()); + allCandidates.addAll(detectCameraPackages()); + + for (String pkg : allCandidates) { + try { + ApplicationInfo ai = pm.getApplicationInfo(pkg, 0); + String label = pm.getApplicationLabel(ai).toString(); + entries.add(label + " (" + pkg + ")"); + values.add(pkg); + } catch (PackageManager.NameNotFoundException e) { + // アンインストール済みでも選択済みなら表示 + if (selected.contains(pkg)) { + entries.add(pkg); + values.add(pkg); + } + } + } + + Collections.sort(entries); + // values も entries に合わせてソート + List sortedValues = new ArrayList<>(); + for (String entry : entries) { + // "label (pkg)" 形式から pkg を抽出 + int lastParen = entry.lastIndexOf("("); + String pkg = lastParen >= 0 + ? entry.substring(lastParen + 1, entry.length() - 1) + : entry; + sortedValues.add(pkg); + } + + pref.setEntries(entries.toArray(new String[0])); + pref.setEntryValues(sortedValues.toArray(new String[0])); + pref.setValues(selected); + pref.setSummary(buildSummary(selected)); + } + + private String buildSummary(Set selected) { + if (selected == null || selected.isEmpty()) { + return "未選択(デフォルト使用)"; + } + return selected.size() + " 個のアプリを選択中"; + } + + // ---- SecureViewer 設定 ---- + + private void setupViewerSettings() { + // メインスイッチ + SwitchPreferenceCompat viewerToggle = findPreference("pref_secure_viewer_enabled"); + if (viewerToggle != null) { + viewerToggle.setOnPreferenceChangeListener((preference, newValue) -> { + ModulePrefs.setSecureViewerEnabled(requireContext(), (Boolean) newValue); + return true; + }); + } + + // 除外リスト + MultiSelectListPreference exclusions = findPreference("pref_secure_viewer_exclusions"); + if (exclusions != null) { + Set enabledPkgs = ModulePrefs.getPrefs(requireContext()) + .getStringSet(ModulePrefs.KEY_ENABLED_PACKAGES, new HashSet<>()); + refreshViewerExclusions(enabledPkgs); + + exclusions.setOnPreferenceChangeListener((preference, newValue) -> { + @SuppressWarnings("unchecked") + Set selected = (Set) newValue; + ModulePrefs.setSecureViewerExclusions(requireContext(), selected); + exclusions.setSummary(buildExclusionSummary(selected)); + return true; + }); + } + } + + private void refreshViewerExclusions(Set enabledPackages) { + MultiSelectListPreference exclusions = findPreference("pref_secure_viewer_exclusions"); + if (exclusions == null) return; + + PackageManager pm = requireContext().getPackageManager(); + List entries = new ArrayList<>(); + List values = new ArrayList<>(); + + Set currentExclusions = ModulePrefs.getPrefs(requireContext()) + .getStringSet(ModulePrefs.KEY_SECURE_VIEWER_EXCLUSIONS, new HashSet<>()); + + for (String pkg : enabledPackages) { + try { + ApplicationInfo ai = pm.getApplicationInfo(pkg, 0); + String label = pm.getApplicationLabel(ai).toString(); + entries.add(label + " (" + pkg + ")"); + values.add(pkg); + } catch (PackageManager.NameNotFoundException e) { + if (currentExclusions.contains(pkg)) { + entries.add(pkg); + values.add(pkg); + } + } + } + + exclusions.setEntries(entries.toArray(new String[0])); + exclusions.setEntryValues(values.toArray(new String[0])); + exclusions.setValues(currentExclusions); + exclusions.setSummary(buildExclusionSummary(currentExclusions)); + } + + private String buildExclusionSummary(Set exclusions) { + if (exclusions == null || exclusions.isEmpty()) { + return "全カメラアプリで有効"; + } + return exclusions.size() + " 個のアプリで無効化"; + } + + // ---- ロック画面動作設定 ---- + + private void setupLockscreenSettings() { + SwitchPreferenceCompat showAboveLock = findPreference("pref_show_above_lock"); + if (showAboveLock != null) { + showAboveLock.setOnPreferenceChangeListener((preference, newValue) -> { + ModulePrefs.setShowAboveLock(requireContext(), (Boolean) newValue); + return true; + }); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/ActivityVisibilitySpoofHook.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/ActivityVisibilitySpoofHook.java new file mode 100755 index 0000000..1226b14 --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/ActivityVisibilitySpoofHook.java @@ -0,0 +1,30 @@ +package com.github.droserasprout.lockscreencamera.hook; + +import android.app.Activity; +import android.content.SharedPreferences; + +import com.github.droserasprout.lockscreencamera.util.CameraPackageUtil; + +import io.github.libxposed.api.XposedModule; + +/** hasWindowFocus/isResumed を常に true として返す(フォーカス喪失の誤検知対策)。 */ +public final class ActivityVisibilitySpoofHook { + + private static SharedPreferences prefs; + + private ActivityVisibilitySpoofHook() {} + + public static void install(XposedModule module, SharedPreferences prefs) { + ActivityVisibilitySpoofHook.prefs = prefs; + try { + module.hook(Activity.class.getDeclaredMethod("hasWindowFocus")).intercept(chain -> { + if (CameraPackageUtil.isCameraActivity((Activity) chain.getThisObject(), ActivityVisibilitySpoofHook.prefs)) return true; + return (Boolean) chain.proceed(); + }); + module.hook(Activity.class.getDeclaredMethod("isResumed")).intercept(chain -> { + if (CameraPackageUtil.isCameraActivity((Activity) chain.getThisObject(), ActivityVisibilitySpoofHook.prefs)) return true; + return (Boolean) chain.proceed(); + }); + } catch (Throwable ignored) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/CameraActivityLifecycleHook.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/CameraActivityLifecycleHook.java new file mode 100644 index 0000000..b1901df --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/CameraActivityLifecycleHook.java @@ -0,0 +1,167 @@ +package com.github.droserasprout.lockscreencamera.hook; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; + +import com.github.droserasprout.lockscreencamera.session.SessionManager; +import com.github.droserasprout.lockscreencamera.util.CameraPackageUtil; +import com.github.droserasprout.lockscreencamera.util.ModulePrefs; + +import io.github.libxposed.api.XposedModule; + +/** + * カメラ Activity のライフサイクルをフックし、 + * セッション開始・画面OFF時の自動終了・ウィンドウ属性の再適用を行う。 + */ +public final class CameraActivityLifecycleHook { + + private static final String TAG = "LockscreenCamera"; + private static final String EXTRA_START_BY_KEYGUARD = "com.miui.camera.extra.START_BY_KEYGUARD"; + private static final String[] LIFECYCLE_METHODS = + {"attachBaseContext", "onCreate", "onStart", "onResume", "onWindowFocusChanged", "onDestroy"}; + + private static final Map LOCKSCREEN_LAUNCHES = new WeakHashMap<>(); + private static final Map ACTIVE_RECEIVERS = new WeakHashMap<>(); + private static SharedPreferences prefs; + + private CameraActivityLifecycleHook() {} + + public static void install(XposedModule module, SharedPreferences prefs) { + CameraActivityLifecycleHook.prefs = prefs; + + for (String methodName : LIFECYCLE_METHODS) { + try { + Method method = resolveMethod(methodName); + final String mName = methodName; + module.hook(method).intercept(chain -> { + Object thisObj = chain.getThisObject(); + if (!(thisObj instanceof Activity)) return chain.proceed(); + + Activity act = (Activity) thisObj; + if (!CameraPackageUtil.isCameraActivity(act, CameraActivityLifecycleHook.prefs)) return chain.proceed(); + + if ("onDestroy".equals(mName)) { + if (SessionManager.isActive) { + SessionManager.release(); + } + unregisterReceiver(act); + LOCKSCREEN_LAUNCHES.remove(act); + return chain.proceed(); + } + + if ("onWindowFocusChanged".equals(mName)) { + boolean hasFocus = (boolean) ((List) chain.getArgs()).get(0); + if (!hasFocus) return chain.proceed(); + if (shouldApplyWindowBypass(act)) { + WindowSecurityBypass.apply(act); + } + return chain.proceed(); + } + + if ("onCreate".equals(mName)) { + Object res = chain.proceed(); + + Intent intent = act.getIntent(); + boolean isLockscreenLaunch = + intent != null && (intent.getBooleanExtra(EXTRA_START_BY_KEYGUARD, false) + || intent.getBooleanExtra("is_secure_camera", false) + || intent.getBooleanExtra("StartActivityWhenLocked", false)); + + // INTENT_ACTION_STILL_IMAGE_CAMERA_SECURE で起動された場合もロック画面起動とみなす + if (!isLockscreenLaunch && intent != null + && intent.getAction() != null + && intent.getAction().contains("SECURE")) { + isLockscreenLaunch = true; + } + + LOCKSCREEN_LAUNCHES.put(act, isLockscreenLaunch); + + if (isLockscreenLaunch) { + SessionManager.start(); + Log.i(TAG, "Secure Lockscreen launch detected. Session Started."); + registerScreenOffReceiver(act); + } + + if (shouldApplyWindowBypass(act)) { + WindowSecurityBypass.apply(act); + } + return res; + } + + if (shouldApplyWindowBypass(act)) { + WindowSecurityBypass.apply(act); + } + return chain.proceed(); + }); + } catch (Throwable ignored) {} + } + } + + /** + * ウィンドウセキュリティバイパスを適用すべきか判定する。 + * ロック画面起動時は常に適用。通常起動時は設定に依存。 + */ + private static boolean shouldApplyWindowBypass(Activity act) { + Boolean isLockscreen = LOCKSCREEN_LAUNCHES.get(act); + if (isLockscreen != null && isLockscreen) return true; + return ModulePrefs.shouldShowAboveLock(prefs); + } + + private static Method resolveMethod(String methodName) throws NoSuchMethodException { + switch (methodName) { + case "attachBaseContext": + return ContextWrapper.class.getDeclaredMethod("attachBaseContext", Context.class); + case "onCreate": + return Activity.class.getDeclaredMethod("onCreate", Bundle.class); + case "onWindowFocusChanged": + return Activity.class.getDeclaredMethod("onWindowFocusChanged", boolean.class); + case "onDestroy": + return Activity.class.getDeclaredMethod("onDestroy"); + default: + return Activity.class.getDeclaredMethod(methodName); + } + } + + private static void registerScreenOffReceiver(Activity act) { + IntentFilter filter = new IntentFilter(Intent.ACTION_SCREEN_OFF); + BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent i) { + SessionManager.end(); + act.finish(); + } + }; + + try { + if (Build.VERSION.SDK_INT >= 33) { + act.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED); + } else { + act.registerReceiver(receiver, filter); + } + ACTIVE_RECEIVERS.put(act, receiver); + } catch (Exception e) { + Log.w(TAG, "Failed to register receiver: " + e.getMessage()); + } + } + + private static void unregisterReceiver(Activity act) { + BroadcastReceiver receiver = ACTIVE_RECEIVERS.remove(act); + if (receiver == null) return; + try { + act.unregisterReceiver(receiver); + } catch (Exception ignored) {} + } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/CameraGestureLauncherHook.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/CameraGestureLauncherHook.java new file mode 100755 index 0000000..07f946e --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/CameraGestureLauncherHook.java @@ -0,0 +1,220 @@ +package com.github.droserasprout.lockscreencamera.hook; + +import android.app.ActivityOptions; +import android.app.KeyguardManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.Build; +import android.provider.MediaStore; +import android.util.Log; + +import java.lang.reflect.Method; + +import io.github.libxposed.api.XposedModule; +import io.github.libxposed.api.XposedModuleInterface.SystemServerStartingParam; + +/** + * 電源ボタン二度押し等の「カメラジェスチャー」ハンドラをフックし、 + * ロック中でも Secure Camera Intent を明示的な Component 指定で起動する。 + * AOSP の GestureLauncherService と MIUI 固有クラスの両方に対応する。 + */ +public final class CameraGestureLauncherHook { + + private static final String TAG = "LockscreenCamera.Gesture"; + private static final String EXTRA_START_BY_KEYGUARD = "com.miui.camera.extra.START_BY_KEYGUARD"; + private static final int BACKGROUND_ACTIVITY_START_ALLOWED = 2; + + private CameraGestureLauncherHook() {} + + public static void install(XposedModule module, SystemServerStartingParam param) { + boolean hooked = false; + + // 1. AOSP GestureLauncherService + hooked |= tryHookGestureService(module, param.getClassLoader(), + "com.android.server.GestureLauncherService"); + + // 2. MIUI GestureLauncherService(一部 MIUI/HyperOS バージョン) + hooked |= tryHookGestureService(module, param.getClassLoader(), + "com.miui.server.GestureLauncherService"); + + // 3. MIUI のキーガード カメラ起動 (KeyguardCameraLauncher) + hooked |= tryHookMiuiKeyguardCamera(module, param.getClassLoader()); + + if (!hooked) { + module.log(Log.WARN, TAG, "No camera gesture hook point found on this device"); + } else { + module.log(Log.INFO, TAG, "Camera gesture hook installed successfully"); + } + } + + /** + * 指定された GestureLauncherService クラスの handleCameraGesture をフックする。 + * メソッドが見つからない場合は何もしない(false を返す)。 + */ + private static boolean tryHookGestureService(XposedModule module, ClassLoader cl, String className) { + try { + Class gestureClass = Class.forName(className, true, cl); + + // (boolean, int) シグネチャ + try { + Method m = gestureClass.getDeclaredMethod("handleCameraGesture", boolean.class, int.class); + module.hook(m).intercept(chain -> { + try { + Context ctx = getContextFromService(chain.getThisObject()); + launchSecureCamera(ctx); + return true; + } catch (Throwable t) { + module.log(Log.WARN, TAG, "Custom launch failed, falling back to original"); + return chain.proceed(); + } + }); + module.log(Log.INFO, TAG, "Hooked " + className + ".handleCameraGesture(boolean, int)"); + return true; + } catch (NoSuchMethodException ignored) {} + + // 引数なしシグネチャ(一部デバイス) + try { + Method m = gestureClass.getDeclaredMethod("handleCameraGesture"); + module.hook(m).intercept(chain -> { + try { + Context ctx = getContextFromService(chain.getThisObject()); + launchSecureCamera(ctx); + return null; + } catch (Throwable t) { + module.log(Log.WARN, TAG, "Custom launch failed, falling back to original"); + return chain.proceed(); + } + }); + module.log(Log.INFO, TAG, "Hooked " + className + ".handleCameraGesture()"); + return true; + } catch (NoSuchMethodException ignored) {} + + // (boolean) シグネチャ + try { + Method m = gestureClass.getDeclaredMethod("handleCameraGesture", boolean.class); + module.hook(m).intercept(chain -> { + try { + Context ctx = getContextFromService(chain.getThisObject()); + launchSecureCamera(ctx); + return null; + } catch (Throwable t) { + module.log(Log.WARN, TAG, "Custom launch failed, falling back to original"); + return chain.proceed(); + } + }); + module.log(Log.INFO, TAG, "Hooked " + className + ".handleCameraGesture(boolean)"); + return true; + } catch (NoSuchMethodException ignored) {} + + } catch (ClassNotFoundException ignored) {} + return false; + } + + /** + * MIUI 固有のキーガードカメラ起動をフックする。 + * launchCamera / startCameraFromKeyguard 等のメソッド名を試行する。 + */ + private static boolean tryHookMiuiKeyguardCamera(XposedModule module, ClassLoader cl) { + String[] classNames = { + "com.android.keyguard.KeyguardCameraLauncher", + "com.miui.keyguard.camera.KeyguardCameraLauncher", + "com.android.systemui.camera.CameraLauncher" + }; + String[] methodNames = { + "launchCamera", "startCamera", "startCameraFromKeyguard", "launchSecureCamera" + }; + + for (String cn : classNames) { + try { + Class clazz = Class.forName(cn, true, cl); + for (String mn : methodNames) { + for (Method m : clazz.getDeclaredMethods()) { + if (m.getName().equals(mn) && m.getParameterCount() == 0) { + module.hook(m).intercept(chain -> { + try { + Context ctx = getContextFromService(chain.getThisObject()); + if (ctx != null) launchSecureCamera(ctx); + } catch (Throwable t) { + module.log(Log.WARN, TAG, "MIUI camera launch failed: " + t.getMessage()); + } + return chain.proceed(); + }); + module.log(Log.INFO, TAG, "Hooked " + cn + "." + mn + "()"); + return true; + } + } + } + } catch (ClassNotFoundException ignored) {} + } + return false; + } + + private static Context getContextFromService(Object service) { + try { + Method m = service.getClass().getMethod("getContext"); + return (Context) m.invoke(service); + } catch (Exception e) { + Log.w(TAG, "getContext failed: " + e.getMessage()); + return null; + } + } + + private static void launchSecureCamera(Context context) { + if (context == null) { + Log.w(TAG, "launchSecureCamera: context is null"); + return; + } + + KeyguardManager km = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); + boolean isLocked = km != null && km.isKeyguardLocked(); + + ComponentName target = resolveCameraComponent(context); + + Intent intent = new Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA_SECURE); + if (target != null) { + intent.setComponent(target); + } + + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + + if (isLocked) { + intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + intent.putExtra(EXTRA_START_BY_KEYGUARD, true); + intent.putExtra("StartActivityWhenLocked", true); + intent.putExtra("is_secure_camera", true); + } + + intent.putExtra("android.intent.extra.CAMERA_OPEN_ONLY", true); + intent.putExtra("com.android.systemui.camera_launch_source", "lockscreen_affordance"); + + ActivityOptions options = ActivityOptions.makeBasic(); + if (Build.VERSION.SDK_INT >= 34) { + options.setPendingIntentBackgroundActivityStartMode(BACKGROUND_ACTIVITY_START_ALLOWED); + } + + Log.i(TAG, "Launching secure camera: " + target + ", locked=" + isLocked); + context.startActivity(intent, options.toBundle()); + } + + private static ComponentName resolveCameraComponent(Context context) { + PackageManager pm = context.getPackageManager(); + + // SECURE アクションを試す + Intent resolveIntent = new Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA_SECURE); + ResolveInfo info = pm.resolveActivity(resolveIntent, PackageManager.MATCH_DEFAULT_ONLY); + + // Resolver に解決された場合は通常アクションにフォールバック + if (info == null || info.activityInfo.name.contains("Resolver")) { + resolveIntent = new Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA); + info = pm.resolveActivity(resolveIntent, PackageManager.MATCH_DEFAULT_ONLY); + } + + if (info != null && !info.activityInfo.name.contains("Resolver")) { + return new ComponentName(info.activityInfo.packageName, info.activityInfo.name); + } + return new ComponentName("com.android.camera", "com.android.camera.Camera"); + } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/DecorViewProtectionHook.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/DecorViewProtectionHook.java new file mode 100755 index 0000000..946543d --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/DecorViewProtectionHook.java @@ -0,0 +1,55 @@ +package com.github.droserasprout.lockscreencamera.hook; + +import android.content.SharedPreferences; +import android.util.Log; +import android.view.View; + +import java.util.List; + +import com.github.droserasprout.lockscreencamera.util.CameraPackageUtil; + +import io.github.libxposed.api.XposedModule; + +/** + * DecorView およびカメラアプリ内の全 View の透明化・非表示化を防ぐ。 + */ +public final class DecorViewProtectionHook { + + private static final String TAG = "LockscreenCamera"; + private static SharedPreferences prefs; + + private DecorViewProtectionHook() {} + + public static void install(XposedModule module, SharedPreferences prefs) { + DecorViewProtectionHook.prefs = prefs; + + try { + module.hook(View.class.getDeclaredMethod("setAlpha", float.class)).intercept(chain -> { + View view = (View) chain.getThisObject(); + if (CameraPackageUtil.isDecorView(view) + && CameraPackageUtil.isCameraContext(view.getContext(), DecorViewProtectionHook.prefs)) { + List args = chain.getArgs(); + float alpha = (float) args.get(0); + if (alpha < 1.0f) args.set(0, 1.0f); + } + return chain.proceed(); + }); + } catch (Throwable t) { + module.log(Log.WARN, TAG, "DecorView setAlpha hook failed: " + t); + } + + try { + module.hook(View.class.getDeclaredMethod("setVisibility", int.class)).intercept(chain -> { + View view = (View) chain.getThisObject(); + if (CameraPackageUtil.isCameraContext(view.getContext(), DecorViewProtectionHook.prefs)) { + List args = chain.getArgs(); + int vis = (int) args.get(0); + if (vis != View.VISIBLE) args.set(0, View.VISIBLE); + } + return chain.proceed(); + }); + } catch (Throwable t) { + module.log(Log.WARN, TAG, "DecorView setVisibility hook failed: " + t); + } + } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/GalleryRedirectHook.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/GalleryRedirectHook.java new file mode 100755 index 0000000..fad94b3 --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/GalleryRedirectHook.java @@ -0,0 +1,123 @@ +package com.github.droserasprout.lockscreencamera.hook; + +import android.app.Activity; +import android.content.ClipData; +import android.content.ComponentName; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Build; +import android.util.Log; + +import android.view.WindowManager; + +import java.lang.reflect.Method; +import java.util.ArrayList; + +import com.github.droserasprout.lockscreencamera.session.SessionManager; +import com.github.droserasprout.lockscreencamera.util.CameraPackageUtil; +import com.github.droserasprout.lockscreencamera.util.ModulePrefs; + +import io.github.libxposed.api.XposedModule; + +/** + * カメラアプリが「ギャラリー確認画面」を開こうとした際、 + * 設定に応じて SecureViewerActivity にリダイレクトする。 + *

+ * GCam 等で SecureViewer が動作しない場合は設定画面で該当パッケージを + * 「ビューアー除外」に追加することで、カメラアプリ標準のレビューアが使われる。 + */ +public final class GalleryRedirectHook { + + private static final String TAG = "LockscreenCamera"; + private static final String VIEWER_PACKAGE = "com.github.droserasprout.lockscreencamera"; + private static final String VIEWER_CLASS = "com.github.droserasprout.lockscreencamera.SecureViewerActivity"; + + private static final int FLAG_SHOW_WHEN_LOCKED = WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED; + private static final int FLAG_TURN_SCREEN_ON = WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON; + private static final int FLAG_ALLOW_LOCK_WHILE_SCREEN_ON = WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON; + + private static SharedPreferences prefs; + + private GalleryRedirectHook() {} + + public static void install(XposedModule module, SharedPreferences prefs) { + GalleryRedirectHook.prefs = prefs; + try { + Method startAct = Activity.class.getDeclaredMethod("startActivity", Intent.class); + module.hook(startAct).intercept(chain -> { + handleGalleryRedirect((Context) chain.getThisObject(), (Intent) chain.getArgs().get(0)); + return chain.proceed(); + }); + + Method startRes = Activity.class.getDeclaredMethod("startActivityForResult", Intent.class, int.class); + module.hook(startRes).intercept(chain -> { + handleGalleryRedirect((Context) chain.getThisObject(), (Intent) chain.getArgs().get(0)); + return chain.proceed(); + }); + + Method startCtx = ContextWrapper.class.getDeclaredMethod("startActivity", Intent.class); + module.hook(startCtx).intercept(chain -> { + handleGalleryRedirect((Context) chain.getThisObject(), (Intent) chain.getArgs().get(0)); + return chain.proceed(); + }); + } catch (Throwable t) { + module.log(Log.ERROR, TAG, "Failed to hook gallery redirect", t); + } + } + + private static void handleGalleryRedirect(Context ctx, Intent intent) { + if (ctx == null || intent == null || intent.getAction() == null) return; + if (!SessionManager.isActive) return; + + String pkg; + try { pkg = ctx.getPackageName(); } catch (Exception e) { return; } + if (!CameraPackageUtil.isCameraPackage(pkg, GalleryRedirectHook.prefs)) return; + + // 設定で SecureViewer が無効化されているパッケージならスキップ + if (!ModulePrefs.shouldUseSecureViewer(GalleryRedirectHook.prefs, pkg)) { + Log.d(TAG, "SecureViewer disabled for: " + pkg); + return; + } + + String action = intent.getAction(); + boolean isGallery = Intent.ACTION_VIEW.equals(action) + || Intent.ACTION_PICK.equals(action) + || action.contains("REVIEW"); + if (!isGallery) return; + + Log.i(TAG, "Redirecting to SecureViewer: Force hijacking intent"); + + ArrayList uriList = new ArrayList<>(SessionManager.SESSION_URIS); + if (uriList.isEmpty() && intent.getData() != null) { + uriList.add(intent.getData()); + } + + intent.setComponent(new ComponentName(VIEWER_PACKAGE, VIEWER_CLASS)); + intent.setPackage(null); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + intent.setSelector(null); + } + + if (!uriList.isEmpty()) { + ClipData clipData = ClipData.newRawUri("Photos", uriList.get(0)); + for (int i = 1; i < uriList.size(); i++) { + clipData.addItem(new ClipData.Item(uriList.get(i))); + } + intent.setClipData(clipData); + } + intent.putParcelableArrayListExtra("session_photos_list", uriList); + + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_CLEAR_TOP + | FLAG_SHOW_WHEN_LOCKED + | FLAG_TURN_SCREEN_ON + | FLAG_ALLOW_LOCK_WHILE_SCREEN_ON); + + Log.d(TAG, "Intent modification complete. Proceeding with hijacked intent."); + } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/KeyguardDismissBlockHook.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/KeyguardDismissBlockHook.java new file mode 100755 index 0000000..e2a3513 --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/KeyguardDismissBlockHook.java @@ -0,0 +1,30 @@ +package com.github.droserasprout.lockscreencamera.hook; + +import android.app.Activity; +import android.app.KeyguardManager; +import android.util.Log; + +import java.lang.reflect.Method; + +import io.github.libxposed.api.XposedModule; + +/** requestDismissKeyguard(PIN 画面表示要求)を完全にブロックする。 */ +public final class KeyguardDismissBlockHook { + + private static final String TAG = "LockscreenCamera"; + + private KeyguardDismissBlockHook() {} + + public static void install(XposedModule module) { + try { + Method dismissMethod = KeyguardManager.class.getDeclaredMethod( + "requestDismissKeyguard", Activity.class, KeyguardManager.KeyguardDismissCallback.class); + module.hook(dismissMethod).intercept(chain -> { + module.log(Log.WARN, TAG, "BLOCKED: requestDismissKeyguard (Preventing PIN screen)"); + return null; + }); + } catch (Throwable ignored) { + // このデバイス/OS バージョンに該当メソッドが存在しない場合はスキップ + } + } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/KeyguardIntentRewriteHook.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/KeyguardIntentRewriteHook.java new file mode 100755 index 0000000..1f693ad --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/KeyguardIntentRewriteHook.java @@ -0,0 +1,34 @@ +package com.github.droserasprout.lockscreencamera.hook; + +import android.app.Activity; +import android.content.Intent; +import android.content.SharedPreferences; + +import com.github.droserasprout.lockscreencamera.util.CameraPackageUtil; + +import io.github.libxposed.api.XposedModule; + +/** getIntent() の動的書き換え:MIUI のキーガード起動フラグから is_secure_camera 等を補完する。 */ +public final class KeyguardIntentRewriteHook { + + private static final String EXTRA_START_BY_KEYGUARD = "com.miui.camera.extra.START_BY_KEYGUARD"; + private static SharedPreferences prefs; + + private KeyguardIntentRewriteHook() {} + + public static void install(XposedModule module, SharedPreferences prefs) { + KeyguardIntentRewriteHook.prefs = prefs; + try { + module.hook(Activity.class.getDeclaredMethod("getIntent")).intercept(chain -> { + Intent intent = (Intent) chain.proceed(); + Activity act = (Activity) chain.getThisObject(); + if (CameraPackageUtil.isCameraActivity(act, KeyguardIntentRewriteHook.prefs) && intent != null + && intent.getBooleanExtra(EXTRA_START_BY_KEYGUARD, false)) { + intent.putExtra("is_secure_camera", true); + intent.putExtra("ShowCameraWhenLocked", true); + } + return intent; + }); + } catch (Throwable ignored) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/MediaStoreSessionTrackingHook.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/MediaStoreSessionTrackingHook.java new file mode 100755 index 0000000..48c26b8 --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/MediaStoreSessionTrackingHook.java @@ -0,0 +1,67 @@ +package com.github.droserasprout.lockscreencamera.hook; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.net.Uri; +import android.os.Build; +import android.provider.MediaStore; + +import com.github.droserasprout.lockscreencamera.session.SessionManager; + +import io.github.libxposed.api.XposedModule; + +/** + * セッション中に MediaStore へ挿入・更新された画像 URI を追跡し、 + * ギャラリーリダイレクト({@link GalleryRedirectHook})でプレビュー一覧として使えるようにする。 + * + * 修正メモ: insert(Uri, ContentValues) の元の実装は、実際に挿入された行の URI + * (chain.proceed() の戻り値)ではなく第2引数の ContentValues を Uri にキャストしていたため、 + * 呼び出しのたびに ClassCastException が発生していた(フックの例外保護により黙って握りつぶされ、 + * 実質「撮影した写真がセッションに追加されない」状態になっていた)。 + */ +public final class MediaStoreSessionTrackingHook { + + private MediaStoreSessionTrackingHook() {} + + public static void install(XposedModule module) { + installInsertHook(module); + installUpdateHook(module); + } + + private static void installInsertHook(XposedModule module) { + try { + module.hook(ContentResolver.class.getDeclaredMethod("insert", Uri.class, ContentValues.class)) + .intercept(chain -> { + Uri returnedUri = (Uri) chain.proceed(); + if (SessionManager.isActive && returnedUri != null) { + SessionManager.add(returnedUri); + } + return returnedUri; + }); + } catch (Throwable ignored) {} + } + + private static void installUpdateHook(XposedModule module) { + try { + module.hook(ContentResolver.class.getDeclaredMethod( + "update", Uri.class, ContentValues.class, String.class, String[].class)) + .intercept(chain -> { + if (SessionManager.isActive) { + Uri uri = (Uri) chain.getArgs().get(0); + ContentValues values = (ContentValues) chain.getArgs().get(1); + if (uri != null && values != null && isWriteFinished(values)) { + SessionManager.add(uri); + } + } + return chain.proceed(); + }); + } catch (Throwable ignored) {} + } + + private static boolean isWriteFinished(ContentValues values) { + if (Build.VERSION.SDK_INT >= 29 && values.containsKey(MediaStore.MediaColumns.IS_PENDING)) { + return (Integer) values.get(MediaStore.MediaColumns.IS_PENDING) == 0; + } + return values.containsKey(MediaStore.Images.Media.DATA); + } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/MiscSystemHook.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/MiscSystemHook.java new file mode 100755 index 0000000..ac59849 --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/MiscSystemHook.java @@ -0,0 +1,35 @@ +package com.github.droserasprout.lockscreencamera.hook; + +import android.util.Log; + +import io.github.libxposed.api.XposedModule; +import io.github.libxposed.api.XposedModuleInterface.PackageReadyParam; + +/** + * その他の細かいシステムフック群: + * - CameraManager.AvailabilityCallback.onCameraUnavailable を無効化 + * - BiometricManager.canAuthenticate を常に「利用可能」として返す + */ +public final class MiscSystemHook { + + private static final String TAG = "LockscreenCamera"; + + private MiscSystemHook() {} + + public static void install(XposedModule module, PackageReadyParam param) { + try { + Class callbackClass = Class.forName( + "android.hardware.camera2.CameraManager$AvailabilityCallback", true, param.getClassLoader()); + module.hook(callbackClass.getDeclaredMethod("onCameraUnavailable", String.class)).intercept(chain -> { + module.log(Log.INFO, TAG, "Blocked onCameraUnavailable for ID: " + chain.getArgs().get(0)); + return null; + }); + } catch (Throwable ignored) {} + + try { + Class biometricClass = Class.forName( + "android.hardware.biometrics.BiometricManager", true, param.getClassLoader()); + module.hook(biometricClass.getDeclaredMethod("canAuthenticate", int.class)).intercept(chain -> 0); + } catch (Throwable ignored) {} + } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/ViewVisibilityProtectionHook.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/ViewVisibilityProtectionHook.java new file mode 100755 index 0000000..e1471bb --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/ViewVisibilityProtectionHook.java @@ -0,0 +1,25 @@ +package com.github.droserasprout.lockscreencamera.hook; + +import io.github.libxposed.api.XposedModule; + +/** + * カメラアプリ内の SurfaceView 等が非表示化されるのを阻止する。 + * + * 修正メモ: このクラスの setVisibility フックは DecorViewProtectionHook に統合された。 + * View.setVisibility は単一のフックで DecorView / 非 DecorView を問わず + * カメラコンテキスト内で VISIBLE を強制するため、このクラスは空のまま残され、 + * LockscreenCamera からの install 呼び出しは削除される。 + */ +public final class ViewVisibilityProtectionHook { + + private ViewVisibilityProtectionHook() {} + + /** + * 何もしない。setVisibility の保護は {@link DecorViewProtectionHook} に統合済み。 + * 互換性のためクラス自体は残すが、新規コードからの呼び出しは不要。 + */ + public static void install(XposedModule module) { + // setVisibility フックは DecorViewProtectionHook に統合されたため、 + // ここでは何もしない。 + } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/WindowSecurityBypass.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/WindowSecurityBypass.java new file mode 100755 index 0000000..07b9e9f --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/hook/WindowSecurityBypass.java @@ -0,0 +1,86 @@ +package com.github.droserasprout.lockscreencamera.hook; + +import android.app.Activity; +import android.graphics.PixelFormat; +import android.os.Build; +import android.util.Log; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; + +import com.github.droserasprout.lockscreencamera.util.ReflectionFieldUtil; + +/** カメラ Activity のウィンドウ属性・内部フラグをロック画面上で描画可能な状態に強制する。 */ +public final class WindowSecurityBypass { + + private static final String TAG = "LockscreenCamera"; + + // 書き換え対象の内部フィールド名リスト(各 OEM のカメラ実装で使われがちな boolean 系フィールド) + private static final String[] TARGET_BOOLEAN_FIELDS = { + "mIsSecure", "mIsSecureCamera", "mKeyguardLocked", + "mInLockScreen", "mIgnoreKeyguard", "mIsScreenOn", + "mSecureCamera", "mIsKeyguardLocked", "mIsHideForeground", + "mIsGalleryLock", "mIsCaptureIntent", "mIsPortraitIntent", "mIsVideoIntent", + "mUserAuthenticationFlag", "mIgnoreKeyguardCheck", + "mIsCameraApp", "mPrivacyAuthorized", "mIsForeground" + }; + + private WindowSecurityBypass() {} + + public static void apply(Activity activity) { + try { + activity.setShowWhenLocked(true); + activity.setTurnScreenOn(true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + activity.setInheritShowWhenLocked(true); + } + + Window window = activity.getWindow(); + if (window != null) { + applyWindowFlags(window); + } + + for (String fieldName : TARGET_BOOLEAN_FIELDS) { + ReflectionFieldUtil.setFieldFast(activity, fieldName, true); + } + ReflectionFieldUtil.setFieldFast(activity, "mIsNormalIntent", false); + ReflectionFieldUtil.setFieldFast(activity, "mShowEnteringAnimation", false); + ReflectionFieldUtil.setFieldFast(activity, "mKeyguardStatus", 1); + ReflectionFieldUtil.setFieldFast(activity, "mIsSecureCameraId", 0); + } catch (Throwable t) { + Log.d(TAG, "UI Fixes failed: " + t); + } + } + + private static void applyWindowFlags(Window window) { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE); + window.setFormat(PixelFormat.TRANSLUCENT); + + WindowManager.LayoutParams lp = window.getAttributes(); + lp.flags |= WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + | WindowManager.LayoutParams.FLAG_FULLSCREEN + | WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON + | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN + | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS; + lp.flags &= ~WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + lp.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + window.setAttributes(lp); + window.addFlags(lp.flags); + window.setFlags( + WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, + WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED + ); + + View decorView = window.getDecorView(); + if (decorView != null) { + decorView.setAlpha(1.0f); + decorView.setVisibility(View.VISIBLE); + decorView.requestFocus(); + } + } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/session/SessionManager.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/session/SessionManager.java new file mode 100644 index 0000000..7470e67 --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/session/SessionManager.java @@ -0,0 +1,46 @@ +package com.github.droserasprout.lockscreencamera.session; + +import android.net.Uri; +import android.util.Log; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +public final class SessionManager { + private static final String TAG = "LockscreenCamera.Session"; + public static volatile boolean isActive = false; + public static final List SESSION_URIS = new CopyOnWriteArrayList<>(); + private static int refCount = 0; + private SessionManager() {} + + public static void start() { + if (!isActive) { SESSION_URIS.clear(); } + isActive = true; + refCount++; + Log.i(TAG, "Secure Camera Session Started (refCount=" + refCount + ")"); + } + + public static void release() { + if (refCount > 0) refCount--; + if (refCount <= 0) { + refCount = 0; + isActive = false; + SESSION_URIS.clear(); + Log.i(TAG, "Secure Camera Session Cleared"); + } else { + Log.d(TAG, "Session release (refCount=" + refCount + "), stays active"); + } + } + + public static void end() { + refCount = 0; + isActive = false; + SESSION_URIS.clear(); + Log.i(TAG, "Secure Camera Session Cleared (forced)"); + } + + public static void add(Uri uri) { + if (!isActive || uri == null) return; + if (SESSION_URIS.contains(uri)) return; + SESSION_URIS.add(uri); + } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/ui/PhotoAdapter.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/ui/PhotoAdapter.java new file mode 100755 index 0000000..69f9b6e --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/ui/PhotoAdapter.java @@ -0,0 +1,116 @@ +package com.github.droserasprout.lockscreencamera.ui; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Bitmap; +import android.net.Uri; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.github.chrisbanes.photoview.PhotoView; +import com.github.droserasprout.lockscreencamera.util.BitmapDecoder; + +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; + +/** + * セッション中の写真 URI 一覧をズーム可能な PhotoView として表示するアダプタ。 + * Activity への強参照は持たず、WeakReference 経由でのみアクセスする。 + */ +public class PhotoAdapter extends RecyclerView.Adapter { + + private final List uris; + private final WeakReference activityRef; + private final ExecutorService executor; + private final ConcurrentHashMap> pendingTasks; + + public PhotoAdapter( + List uris, + Activity activity, + ExecutorService executor, + ConcurrentHashMap> pendingTasks) { + this.uris = uris; + this.activityRef = new WeakReference<>(activity); + this.executor = executor; + this.pendingTasks = pendingTasks; + } + + @NonNull + @Override + public PhotoViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + PhotoView photoView = new PhotoView(parent.getContext()); + photoView.setBackgroundColor(0xFF000000); + photoView.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + photoView.setZoomable(true); + return new PhotoViewHolder(photoView); + } + + @Override + public void onBindViewHolder(@NonNull PhotoViewHolder holder, int position) { + holder.photoView.setImageDrawable(null); + + // 同じポジションの前回タスクをキャンセルして競合を防ぐ + Future prev = pendingTasks.remove(position); + if (prev != null) prev.cancel(true); + + Activity activity = activityRef.get(); + if (activity == null || activity.isDestroyed()) return; + + int screenWidth = activity.getResources().getDisplayMetrics().widthPixels; + int screenHeight = activity.getResources().getDisplayMetrics().heightPixels; + // Context のみ渡し、アクティビティへの強参照をラムダに持ち込まない + Context appContext = activity.getApplicationContext(); + Uri uri = uris.get(position); + + Future task = executor.submit(() -> { + Bitmap bitmap = BitmapDecoder.decodeSampledBitmapFromUri( + appContext, uri, screenWidth, screenHeight); + if (bitmap == null) return; + + Activity act = activityRef.get(); + if (act == null || act.isDestroyed()) { + bitmap.recycle(); + return; + } + + act.runOnUiThread(() -> { + pendingTasks.remove(position); + // バインド時のポジションと現在のポジションが一致する場合のみ反映 + if (holder.getAdapterPosition() == position) { + holder.photoView.setImageBitmap(bitmap); + } else { + bitmap.recycle(); + } + }); + }); + + pendingTasks.put(position, task); + } + + @Override + public void onViewRecycled(@NonNull PhotoViewHolder holder) { + super.onViewRecycled(holder); + holder.photoView.setImageDrawable(null); + } + + @Override + public int getItemCount() { + return uris.size(); + } + + static class PhotoViewHolder extends RecyclerView.ViewHolder { + final PhotoView photoView; + + PhotoViewHolder(PhotoView pv) { + super(pv); + photoView = pv; + } + } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/ui/SwipeDismissLayout.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/ui/SwipeDismissLayout.java new file mode 100755 index 0000000..ad7fe0a --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/ui/SwipeDismissLayout.java @@ -0,0 +1,96 @@ +package com.github.droserasprout.lockscreencamera.ui; + +import android.content.Context; +import android.util.TypedValue; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; + +/** + * 下方向スワイプで dismiss できる汎用コンテナ。 + * 内部の1 View(通常は ViewPager2)を追随させて動かし、閾値を超えたら {@link DismissListener#onDismiss()} を呼ぶ。 + */ +public class SwipeDismissLayout extends FrameLayout { + + /** dismiss ジェスチャーが確定した際のコールバック */ + public interface DismissListener { + void onDismiss(); + } + + private final View target; + private final DismissListener listener; + + private float initialY; + private float initialX; + private boolean isSwiping; + + // dp → px 変換でハードコードピクセル値を排除 + private final float swipeStartThresholdPx; + private final float swipeDismissThresholdPx; + + public SwipeDismissLayout(Context context, View target, DismissListener listener) { + super(context); + this.target = target; + this.listener = listener; + swipeDismissThresholdPx = + context.getResources().getDisplayMetrics().heightPixels * 0.2f; + swipeStartThresholdPx = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 16, + context.getResources().getDisplayMetrics()); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + switch (ev.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + initialY = ev.getY(); + initialX = ev.getX(); + isSwiping = false; + break; + case MotionEvent.ACTION_MOVE: + float dy = ev.getY() - initialY; + float dx = Math.abs(ev.getX() - initialX); + // 下方向かつ水平移動より大きい場合のみ dismiss スワイプとして横取り + if (dy > swipeStartThresholdPx && dy > dx) { + isSwiping = true; + return true; + } + break; + default: + break; + } + return super.onInterceptTouchEvent(ev); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (!isSwiping) return super.onTouchEvent(ev); + + switch (ev.getActionMasked()) { + case MotionEvent.ACTION_MOVE: + float dy = ev.getY() - initialY; + float translationY = Math.max(0, dy); + float progress = getHeight() > 0 ? translationY / getHeight() : 0; + target.setTranslationY(translationY); + target.setAlpha(1.0f - (progress * 0.8f)); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + if (target.getTranslationY() > swipeDismissThresholdPx) { + target.setAlpha(0f); + if (listener != null) listener.onDismiss(); + } else { + target.animate() + .translationY(0) + .alpha(1.0f) + .setDuration(200) + .withStartAction(() -> isSwiping = false) + .start(); + } + break; + default: + break; + } + return true; + } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/util/BitmapDecoder.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/util/BitmapDecoder.java new file mode 100755 index 0000000..c7c2bac --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/util/BitmapDecoder.java @@ -0,0 +1,55 @@ +package com.github.droserasprout.lockscreencamera.util; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.util.Log; + +import androidx.annotation.Nullable; + +import java.io.InputStream; + +/** Uri からダウンサンプリング済みの Bitmap をデコードするユーティリティ。 */ +public final class BitmapDecoder { + + private static final String TAG = "BitmapDecoder"; + + private BitmapDecoder() {} + + @Nullable + public static Bitmap decodeSampledBitmapFromUri( + Context context, Uri uri, int reqWidth, int reqHeight) { + try { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + try (InputStream is = context.getContentResolver().openInputStream(uri)) { + BitmapFactory.decodeStream(is, null, options); + } + options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); + options.inJustDecodeBounds = false; + try (InputStream is = context.getContentResolver().openInputStream(uri)) { + return BitmapFactory.decodeStream(is, null, options); + } + } catch (Exception e) { + Log.e(TAG, "Failed to decode: " + uri, e); + return null; + } + } + + public static int calculateInSampleSize( + BitmapFactory.Options options, int reqWidth, int reqHeight) { + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + if (height > reqHeight || width > reqWidth) { + final int halfHeight = height / 2; + final int halfWidth = width / 2; + while ((halfHeight / inSampleSize) >= reqHeight + && (halfWidth / inSampleSize) >= reqWidth) { + inSampleSize *= 2; + } + } + return inSampleSize; + } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/util/CameraPackageUtil.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/util/CameraPackageUtil.java new file mode 100755 index 0000000..6939ee0 --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/util/CameraPackageUtil.java @@ -0,0 +1,106 @@ +package com.github.droserasprout.lockscreencamera.util; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.view.View; + +/** + * カメラアプリのパッケージ/コンテキスト/DecorView かどうかを判定するヘルパー群。 + *

+ * リファクタリング版では pkg.contains("camera") という広すぎるワイルドカードで + * カメラ以外のパッケージ(オーバーレイAPK等)に誤マッチする問題があった。 + * 修正後は {@link ModulePrefs} に保存された設定済みパッケージリストと照合し、 + * 設定されていないパッケージは一律で拒否する。 + */ +public final class CameraPackageUtil { + + private CameraPackageUtil() {} + + /** + * フックプロセス用: 設定済みパッケージリストと照合する。 + * prefs は {@code XposedModule.getRemotePreferences()} で取得したもの。 + */ + public static boolean isCameraPackage(String pkg, SharedPreferences prefs) { + if (pkg == null || prefs == null) return false; + return ModulePrefs.isPackageEnabled(prefs, pkg); + } + + /** + * フォールバック用: prefs が取得できない場合にデフォルトリストで判定する。 + */ + public static boolean isCameraPackage(String pkg) { + if (pkg == null) return false; + return pkg.equals("com.android.camera") + || pkg.equals("com.google.android.GoogleCamera") + || pkg.equals("com.android.camera2") + || pkg.equals("org.codeaurora.snapcam") + || pkg.equals("com.miui.camera"); + } + + /** + * 設定が利用可能かどうかをチェックし、 + * 可能なら設定リスト、不可ならフォールバックリストを使う。 + */ + public static boolean isCameraPackage(String pkg, SharedPreferences prefs, boolean useSettings) { + if (!useSettings || prefs == null) return isCameraPackage(pkg); + try { + return isCameraPackage(pkg, prefs); + } catch (Exception e) { + return isCameraPackage(pkg); + } + } + + public static boolean isCameraActivity(Activity act, SharedPreferences prefs) { + if (act == null) return false; + try { + return isCameraPackage(act.getPackageName(), prefs); + } catch (Exception e) { + return isCameraPackage(act.getPackageName()); + } + } + + /** + * @deprecated SharedPreferences 版 {@link #isCameraActivity(Activity, SharedPreferences)} を推奨。 + */ + @Deprecated + public static boolean isCameraActivity(Activity act) { + if (act == null) return false; + try { + return isCameraPackage(act.getPackageName()); + } catch (Exception e) { + try { return act.getClass().getName().contains("camera"); } + catch (Exception e2) { return false; } + } + } + + /** + * フックプロセス用: View の Context のパッケージがカメラか判定する。 + * prefs は {@code XposedModule.getRemotePreferences()} で取得したもの。 + */ + public static boolean isCameraContext(Context ctx, SharedPreferences prefs) { + if (ctx == null) return false; + try { + return isCameraPackage(ctx.getPackageName(), prefs); + } catch (Exception e) { + return isCameraPackage(ctx.getPackageName()); + } + } + + /** + * @deprecated SharedPreferences 版 {@link #isCameraContext(Context, SharedPreferences)} を推奨。 + */ + @Deprecated + public static boolean isCameraContext(Context ctx) { + if (ctx == null) return false; + try { + return isCameraPackage(ctx.getPackageName()); + } catch (Exception e) { + return ctx.getClass().getName().contains("camera"); + } + } + + public static boolean isDecorView(View v) { + return v != null && v.getClass().getName().endsWith("DecorView"); + } +} diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/util/ModulePrefs.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/util/ModulePrefs.java new file mode 100644 index 0000000..97fbcd1 --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/util/ModulePrefs.java @@ -0,0 +1,161 @@ +package com.github.droserasprout.lockscreencamera.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * モジュールの SharedPreferences への読み書きを一本化するヘルパー。 + *

+ * 設定画面(モジュール自身のプロセス)では Context ベースのメソッドを、 + * フック側(ターゲットアプリのプロセス)では SharedPreferences ベースのメソッドを使う。 + * フック側では {@code XposedModule.getRemotePreferences(PREFS_NAME)} で取得した + * SharedPreferences を渡す。 + */ +public final class ModulePrefs { + + private static final String TAG = "LockscreenCamera"; + public static final String PREFS_NAME = "secure_camera_prefs"; + + // キー名 + public static final String KEY_ENABLED_PACKAGES = "enabled_packages"; + public static final String KEY_SECURE_VIEWER_ENABLED = "secure_viewer_enabled"; + public static final String KEY_SECURE_VIEWER_EXCLUSIONS = "secure_viewer_exclusions"; + public static final String KEY_SHOW_ABOVE_LOCK = "show_above_lock"; + public static final String KEY_FIRST_RUN = "first_run"; + + private ModulePrefs() {} + + // ---- 書き込み(設定画面プロセスから呼ばれる) ---- + + public static SharedPreferences getPrefs(Context context) { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + } + + public static void setEnabledPackages(Context ctx, Set packages) { + getPrefs(ctx).edit() + .putStringSet(KEY_ENABLED_PACKAGES, packages) + .apply(); + } + + public static void setSecureViewerEnabled(Context ctx, boolean enabled) { + getPrefs(ctx).edit() + .putBoolean(KEY_SECURE_VIEWER_ENABLED, enabled) + .apply(); + } + + public static void setSecureViewerExclusions(Context ctx, Set packages) { + getPrefs(ctx).edit() + .putStringSet(KEY_SECURE_VIEWER_EXCLUSIONS, packages) + .apply(); + } + + public static void setShowAboveLock(Context ctx, boolean enabled) { + getPrefs(ctx).edit() + .putBoolean(KEY_SHOW_ABOVE_LOCK, enabled) + .apply(); + } + + public static void markFirstRunDone(Context ctx) { + getPrefs(ctx).edit() + .putBoolean(KEY_FIRST_RUN, false) + .apply(); + } + + // ---- 読み込み(フック側プロセスから呼ばれる) ---- + + /** + * フックプロセス用: 有効なカメラパッケージセットを取得する。 + * 設定が空の場合はフォールバックとしてデフォルトリストを返す。 + * + * @param prefs {@code XposedModule.getRemotePreferences(PREFS_NAME)} で取得したもの + */ + public static Set getEnabledPackages(SharedPreferences prefs) { + if (prefs == null) return getDefaultPackages(); + Set packages = prefs.getStringSet(KEY_ENABLED_PACKAGES, null); + if (packages == null || packages.isEmpty()) return getDefaultPackages(); + return new HashSet<>(packages); + } + + /** + * フックプロセス用: SecureViewer が有効かどうか。 + * デフォルトは true(有効)。 + */ + public static boolean isSecureViewerEnabled(SharedPreferences prefs) { + if (prefs == null) return true; + return prefs.getBoolean(KEY_SECURE_VIEWER_ENABLED, true); + } + + /** + * フックプロセス用: SecureViewer を無効化するパッケージセット。 + */ + public static Set getSecureViewerExclusions(SharedPreferences prefs) { + if (prefs == null) return Collections.emptySet(); + Set exclusions = prefs.getStringSet(KEY_SECURE_VIEWER_EXCLUSIONS, null); + if (exclusions == null) return Collections.emptySet(); + return new HashSet<>(exclusions); + } + + /** + * フックプロセス用: 指定パッケージで SecureViewer を使うべきか。 + */ + public static boolean shouldUseSecureViewer(SharedPreferences prefs, String pkg) { + if (!isSecureViewerEnabled(prefs)) return false; + return !getSecureViewerExclusions(prefs).contains(pkg); + } + + /** + * フックプロセス用: 指定パッケージが設定済みのカメラパッケージか。 + */ + public static boolean isPackageEnabled(SharedPreferences prefs, String pkg) { + return getEnabledPackages(prefs).contains(pkg); + } + + /** + * フックプロセス用: 通常起動時もカメラをロック画面上に表示するか。 + * デフォルトは false(ロック画面起動時のみ表示)。 + */ + public static boolean shouldShowAboveLock(SharedPreferences prefs) { + if (prefs == null) return false; + return prefs.getBoolean(KEY_SHOW_ABOVE_LOCK, false); + } + + // ---- デフォルト値 ---- + + /** フォールバック: 設定が空の場合に使うデフォルトパッケージ */ + private static Set getDefaultPackages() { + Set defaults = new HashSet<>(); + defaults.add("com.android.camera"); + defaults.add("com.google.android.GoogleCamera"); + defaults.add("com.android.camera2"); + defaults.add("org.codeaurora.snapcam"); + return defaults; + } + + /** + * 設定画面用: フォールバック候補パッケージ(自動検出に使われる補助リスト)。 + */ + public static Set getKnownCameraPackages() { + Set known = new HashSet<>(); + known.add("com.android.camera"); + known.add("com.android.camera2"); + known.add("com.google.android.GoogleCamera"); + known.add("com.android.MGC"); + known.add("org.codeaurora.snapcam"); + known.add("com.miui.camera"); + known.add("com.samsung.android.camera"); + known.add("com.oneplus.camera"); + known.add("com.oplus.camera"); + known.add("com.sonysmartphone.camera"); + return known; + } + + /** 初回起動かどうか(設定画面用) */ + public static boolean isFirstRun(Context ctx) { + return getPrefs(ctx).getBoolean(KEY_FIRST_RUN, true); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/droserasprout/lockscreencamera/util/ReflectionFieldUtil.java b/app/src/main/java/com/github/droserasprout/lockscreencamera/util/ReflectionFieldUtil.java new file mode 100755 index 0000000..dc903ec --- /dev/null +++ b/app/src/main/java/com/github/droserasprout/lockscreencamera/util/ReflectionFieldUtil.java @@ -0,0 +1,58 @@ +package com.github.droserasprout.lockscreencamera.util; + +import java.lang.reflect.Field; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * private フィールドをキャッシュ付きで高速に書き換えるためのユーティリティ。 + * + * 修正メモ: 元の実装は「見つからなかった」結果を + * {@code fieldCache.put(cacheKey, null)} でキャッシュしようとしていたが、 + * ConcurrentHashMap は null 値を許容しないため実際には NullPointerException が発生し + * (呼び出し側の try-catch で握りつぶされていた)、 + * 見つからないフィールドについては毎回クラス階層を走査し直す状態になっていた。 + * ここでは「探索済みかどうか」を別の Map で管理することで意図通りにキャッシュされるようにしている。 + */ +public final class ReflectionFieldUtil { + + private static final Map FIELD_CACHE = new ConcurrentHashMap<>(); + private static final Map SEARCHED = new ConcurrentHashMap<>(); + + private ReflectionFieldUtil() {} + + /** + * obj のクラス階層(android.app.Activity まで)を辿り、 + * fieldName という名前の private フィールドに value をセットする。 + * 見つからない/アクセスできない場合は何もしない(例外は握りつぶす)。 + */ + public static void setFieldFast(Object obj, String fieldName, Object value) { + try { + Class current = obj.getClass(); + String cacheKey = current.getName() + ":" + fieldName; + + Field f = FIELD_CACHE.get(cacheKey); + if (f == null && !SEARCHED.containsKey(cacheKey)) { + while (current != null && !current.getName().equals("android.app.Activity")) { + try { + f = current.getDeclaredField(fieldName); + f.setAccessible(true); + break; + } catch (NoSuchFieldException e) { + current = current.getSuperclass(); + } + } + SEARCHED.put(cacheKey, Boolean.TRUE); + if (f != null) { + FIELD_CACHE.put(cacheKey, f); + } + } + + if (f != null) { + f.set(obj, value); + } + } catch (Throwable ignored) { + // リフレクション失敗は致命的ではないため無視する + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..82cd4a2 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..82cd4a2 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..e9c4ed1 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..5dd0e2a Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..cc464cf Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..56d3f8d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..0803ece Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..6d575dd Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..ad6a351 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..f28f153 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e350dec Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..3c69817 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..cacd044 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..00da4f2 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..df8b33a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..743f5a9 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e74853e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml new file mode 100644 index 0000000..d293eb2 --- /dev/null +++ b/app/src/main/res/values/arrays.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml old mode 100644 new mode 100755 index 148300b..4c7298c --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,37 @@ Secure Lockscreen Camera Skip authentication on \"Quickly open camera\" gesture. + + + 設定 + + + 対象カメラアプリ + モジュールを注入するカメラアプリを選択します + カメラアプリを自動検出 + インストール済みのカメラアプリを検出して一覧に追加します + 有効なカメラアプリ + 選択されたパッケージにのみフックを適用します + カメラアプリを選択 + カメラアプリが見つかりませんでした + %d 個のカメラアプリを検出しました + + + 写真ビューアー + 撮影後のプレビュー画面の動作を設定します + SecureViewer を使用 + 有効時はロック画面上で独自ビューアーにリダイレクトします。無効時はカメラアプリ標準のレビューアを使用します + ビューアー除外アプリ + 選択したアプリでは SecureViewer を無効化し、標準のレビューアーを使用します(GCam 等) + 除外アプリを選択 + + + ロック画面動作 + ロック画面上でのカメラ表示動作を設定します + 通常起動時もロック画面上に表示 + オンにすると、ロック画面から起動していない場合でもカメラがロック画面上に表示されます。オフ(推奨)ではロック画面起動時のみ表示します + + + 情報 + バージョン diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml new file mode 100644 index 0000000..d144e78 --- /dev/null +++ b/app/src/main/res/xml/preferences.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/resources/META-INF/xposed/java_init.list b/app/src/main/resources/META-INF/xposed/java_init.list old mode 100644 new mode 100755 diff --git a/app/src/main/resources/META-INF/xposed/module.prop b/app/src/main/resources/META-INF/xposed/module.prop old mode 100644 new mode 100755 diff --git a/app/src/main/resources/META-INF/xposed/scope.list b/app/src/main/resources/META-INF/xposed/scope.list old mode 100644 new mode 100755 index f220653..301f0f8 --- a/app/src/main/resources/META-INF/xposed/scope.list +++ b/app/src/main/resources/META-INF/xposed/scope.list @@ -1,2 +1,11 @@ com.android.camera -system +com.android.camera2 +com.google.android.GoogleCamera +com.android.MGC +org.codeaurora.snapcam +com.miui.camera +com.samsung.android.camera +com.oneplus.camera +com.oplus.camera +com.sonysmartphone.camera +system \ No newline at end of file diff --git a/build.gradle b/build.gradle old mode 100644 new mode 100755 diff --git a/gradle.properties b/gradle.properties old mode 100644 new mode 100755 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar old mode 100644 new mode 100755 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties old mode 100644 new mode 100755 diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/gradlew.bat b/gradlew.bat old mode 100644 new mode 100755 diff --git a/settings.gradle b/settings.gradle old mode 100644 new mode 100755