Consolidate docs and finalize APK companion updates
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -22,22 +28,19 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="dewemoji.com" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="www.dewemoji.com" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".OverlayBubbleService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:stopWithTask="false">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Floating overlay bubble for quick emoji search and copy" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@@ -48,8 +51,4 @@
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
|
||||
import com.dewemoji.app.plugins.DewemojiOverlayPlugin;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
import org.json.JSONObject;
|
||||
@@ -30,24 +31,44 @@ import java.nio.charset.StandardCharsets;
|
||||
public class MainActivity extends BridgeActivity {
|
||||
private static final String TAG = "DewemojiUpdater";
|
||||
private static final String VERSION_URL = "https://dewemoji.com/downloads/version.json";
|
||||
private static final String EXTRA_OPENED_FROM_BUBBLE = "dewemoji_opened_from_bubble";
|
||||
private static final int CONNECT_TIMEOUT_MS = 10000;
|
||||
private static final int READ_TIMEOUT_MS = 15000;
|
||||
private boolean openedFromBubble = false;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(DewemojiOverlayPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
hideSystemBars();
|
||||
openedFromBubble = wasOpenedFromBubble(getIntent());
|
||||
applySystemBarMode();
|
||||
getWindow().getDecorView().post(this::applySystemBarMode);
|
||||
checkForUpdates(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
openedFromBubble = wasOpenedFromBubble(intent);
|
||||
applySystemBarMode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) {
|
||||
hideSystemBars();
|
||||
applySystemBarMode();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
applySystemBarMode();
|
||||
getWindow().getDecorView().post(this::applySystemBarMode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
WebView webView = getBridge() != null ? getBridge().getWebView() : null;
|
||||
@@ -80,6 +101,20 @@ public class MainActivity extends BridgeActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean wasOpenedFromBubble(Intent intent) {
|
||||
return intent != null && intent.getBooleanExtra(EXTRA_OPENED_FROM_BUBBLE, false);
|
||||
}
|
||||
|
||||
private void applySystemBarMode() {
|
||||
// Fallback to non-immersive mode whenever the overlay bubble service is active;
|
||||
// some launch paths may not preserve the intent extra on all OEMs.
|
||||
if (openedFromBubble || OverlayBubbleService.isRunning()) {
|
||||
showSystemBars();
|
||||
return;
|
||||
}
|
||||
hideSystemBars();
|
||||
}
|
||||
|
||||
private void hideSystemBars() {
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
WindowInsetsControllerCompat controller =
|
||||
@@ -90,6 +125,13 @@ public class MainActivity extends BridgeActivity {
|
||||
);
|
||||
}
|
||||
|
||||
private void showSystemBars() {
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), true);
|
||||
WindowInsetsControllerCompat controller =
|
||||
new WindowInsetsControllerCompat(getWindow(), getWindow().getDecorView());
|
||||
controller.show(WindowInsetsCompat.Type.statusBars() | WindowInsetsCompat.Type.navigationBars());
|
||||
}
|
||||
|
||||
private void checkForUpdates(boolean manual) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
package com.dewemoji.app;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.view.Gravity;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.WebChromeClient;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
public class OverlayBubbleService extends Service {
|
||||
private static final String TAG = "DewemojiBubbleService";
|
||||
public static final String ACTION_START = "com.dewemoji.app.action.BUBBLE_START";
|
||||
public static final String ACTION_STOP = "com.dewemoji.app.action.BUBBLE_STOP";
|
||||
public static final String ACTION_OPEN = "com.dewemoji.app.action.BUBBLE_OPEN";
|
||||
|
||||
public static final String PREFS_NAME = "dewemoji_native_state";
|
||||
public static final String PREF_BUBBLE_X = "bubblePositionX";
|
||||
public static final String PREF_BUBBLE_Y = "bubblePositionY";
|
||||
public static final String NOTIFICATION_CHANNEL_ID = "dewemoji_bubble";
|
||||
|
||||
private static final int NOTIFICATION_ID = 41001;
|
||||
private static volatile boolean running = false;
|
||||
|
||||
private WindowManager windowManager;
|
||||
private ImageView bubbleView;
|
||||
private View panelBackdropView;
|
||||
private View panelView;
|
||||
private WebView panelWebView;
|
||||
private WindowManager.LayoutParams bubbleParams;
|
||||
private WindowManager.LayoutParams panelBackdropParams;
|
||||
private WindowManager.LayoutParams panelParams;
|
||||
private SharedPreferences prefs;
|
||||
|
||||
public static boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
|
||||
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
ensureNotificationChannel();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
final String action = intent != null ? intent.getAction() : null;
|
||||
|
||||
if (ACTION_STOP.equals(action)) {
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
if (ACTION_OPEN.equals(action)) {
|
||||
openMainActivity();
|
||||
if (running) {
|
||||
startForegroundCompat();
|
||||
return START_STICKY;
|
||||
}
|
||||
}
|
||||
|
||||
if (!canDrawOverlays()) {
|
||||
Toast.makeText(this, "Overlay permission is required for Dewemoji bubble", Toast.LENGTH_SHORT).show();
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
try {
|
||||
startForegroundCompat();
|
||||
ensureBubbleView();
|
||||
// `isAttachedToWindow()` can be false immediately after addView on some
|
||||
// devices/OEM builds even though the overlay is successfully added.
|
||||
running = bubbleView != null;
|
||||
if (!running) {
|
||||
Toast.makeText(this, "Could not show Dewemoji bubble", Toast.LENGTH_SHORT).show();
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
return START_STICKY;
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, "Failed to start bubble service", ex);
|
||||
Toast.makeText(this, "Failed to start Dewemoji bubble", Toast.LENGTH_SHORT).show();
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
running = false;
|
||||
removePanelView();
|
||||
removeBubbleView();
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean canDrawOverlays() {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(this);
|
||||
}
|
||||
|
||||
private void ensureNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return;
|
||||
}
|
||||
|
||||
NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
if (manager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
NotificationChannel channel = manager.getNotificationChannel(NOTIFICATION_CHANNEL_ID);
|
||||
if (channel != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
channel = new NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
"Dewemoji Bubble",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
);
|
||||
channel.setDescription("Quick access bubble for search and copy");
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
private Notification buildNotification() {
|
||||
PendingIntent openPendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
1001,
|
||||
buildMainActivityIntent(),
|
||||
pendingIntentFlags(true)
|
||||
);
|
||||
|
||||
Intent stopIntent = new Intent(this, OverlayBubbleService.class);
|
||||
stopIntent.setAction(ACTION_STOP);
|
||||
PendingIntent stopPendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
1002,
|
||||
stopIntent,
|
||||
pendingIntentFlags(false)
|
||||
);
|
||||
|
||||
return new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("Dewemoji bubble is running")
|
||||
.setContentText("Tap bubble to open Dewemoji, copy emoji, then paste in your other app")
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentIntent(openPendingIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.addAction(android.R.drawable.ic_menu_view, "Open Dewemoji", openPendingIntent)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop Bubble", stopPendingIntent)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void startForegroundCompat() {
|
||||
Notification notification = buildNotification();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
);
|
||||
return;
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, notification);
|
||||
}
|
||||
|
||||
private int pendingIntentFlags(boolean updateCurrent) {
|
||||
int flags = updateCurrent ? PendingIntent.FLAG_UPDATE_CURRENT : 0;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
flags |= PendingIntent.FLAG_IMMUTABLE;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
private Intent buildMainActivityIntent() {
|
||||
Intent launch = new Intent(this, MainActivity.class);
|
||||
launch.setAction(Intent.ACTION_MAIN);
|
||||
launch.addCategory(Intent.CATEGORY_LAUNCHER);
|
||||
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
launch.putExtra("dewemoji_opened_from_bubble", true);
|
||||
return launch;
|
||||
}
|
||||
|
||||
private void openMainActivity() {
|
||||
try {
|
||||
hidePanelView();
|
||||
startActivity(buildMainActivityIntent());
|
||||
} catch (Exception ex) {
|
||||
Toast.makeText(this, "Could not open Dewemoji", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureBubbleView() {
|
||||
if (bubbleView != null && bubbleView.isAttachedToWindow()) {
|
||||
return;
|
||||
}
|
||||
if (windowManager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
bubbleView = new ImageView(this);
|
||||
bubbleView.setImageResource(R.drawable.dewemoji_bubble_mark);
|
||||
bubbleView.setScaleType(ImageView.ScaleType.CENTER_CROP);
|
||||
int padding = dp(3);
|
||||
bubbleView.setPadding(padding, padding, padding, padding);
|
||||
|
||||
GradientDrawable background = new GradientDrawable();
|
||||
background.setShape(GradientDrawable.OVAL);
|
||||
background.setColor(0xFFFFFFFF);
|
||||
background.setStroke(dp(1), 0x22000000);
|
||||
bubbleView.setBackground(background);
|
||||
bubbleView.setElevation(dp(6));
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
bubbleView.setClipToOutline(true);
|
||||
}
|
||||
|
||||
int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||
: WindowManager.LayoutParams.TYPE_PHONE;
|
||||
|
||||
bubbleParams = new WindowManager.LayoutParams(
|
||||
dp(64),
|
||||
dp(64),
|
||||
type,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
|
||||
PixelFormat.TRANSLUCENT
|
||||
);
|
||||
bubbleParams.gravity = Gravity.TOP | Gravity.START;
|
||||
bubbleParams.x = prefs.getInt(PREF_BUBBLE_X, dp(10));
|
||||
bubbleParams.y = prefs.getInt(PREF_BUBBLE_Y, dp(180));
|
||||
clampBubblePosition();
|
||||
|
||||
bubbleView.setOnTouchListener(new View.OnTouchListener() {
|
||||
private int startX;
|
||||
private int startY;
|
||||
private float downRawX;
|
||||
private float downRawY;
|
||||
private long downTime;
|
||||
private boolean moved;
|
||||
|
||||
@Override
|
||||
public boolean onTouch(View v, MotionEvent event) {
|
||||
if (bubbleParams == null || windowManager == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (event.getActionMasked()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
startX = bubbleParams.x;
|
||||
startY = bubbleParams.y;
|
||||
downRawX = event.getRawX();
|
||||
downRawY = event.getRawY();
|
||||
downTime = System.currentTimeMillis();
|
||||
moved = false;
|
||||
return true;
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
int dx = Math.round(event.getRawX() - downRawX);
|
||||
int dy = Math.round(event.getRawY() - downRawY);
|
||||
if (Math.abs(dx) > dp(3) || Math.abs(dy) > dp(3)) {
|
||||
moved = true;
|
||||
}
|
||||
bubbleParams.x = startX + dx;
|
||||
bubbleParams.y = startY + dy;
|
||||
clampBubblePosition();
|
||||
try {
|
||||
windowManager.updateViewLayout(bubbleView, bubbleParams);
|
||||
} catch (Exception ignored) {}
|
||||
return true;
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
persistBubblePosition();
|
||||
long pressDuration = System.currentTimeMillis() - downTime;
|
||||
if (!moved && pressDuration < 325) {
|
||||
togglePanelView();
|
||||
} else if (moved) {
|
||||
positionPanelNearBubble(false);
|
||||
try {
|
||||
if (panelView != null && panelView.isAttachedToWindow()) {
|
||||
windowManager.updateViewLayout(panelView, panelParams);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
windowManager.addView(bubbleView, bubbleParams);
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, "windowManager.addView failed", ex);
|
||||
String reason = ex.getClass().getSimpleName();
|
||||
String message = ex.getMessage();
|
||||
if (message != null && !message.trim().isEmpty()) {
|
||||
String oneLine = message.replace('\n', ' ').trim();
|
||||
if (oneLine.length() > 90) oneLine = oneLine.substring(0, 90) + "…";
|
||||
reason = reason + ": " + oneLine;
|
||||
}
|
||||
Toast.makeText(this, reason, Toast.LENGTH_LONG).show();
|
||||
bubbleView = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void persistBubblePosition() {
|
||||
if (bubbleParams == null || prefs == null) {
|
||||
return;
|
||||
}
|
||||
prefs.edit()
|
||||
.putInt(PREF_BUBBLE_X, bubbleParams.x)
|
||||
.putInt(PREF_BUBBLE_Y, bubbleParams.y)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private void togglePanelView() {
|
||||
if (panelView != null && panelView.isAttachedToWindow()) {
|
||||
hidePanelView();
|
||||
return;
|
||||
}
|
||||
showPanelView();
|
||||
}
|
||||
|
||||
private void showPanelView() {
|
||||
if (windowManager == null) return;
|
||||
ensurePanelBackdropView();
|
||||
ensurePanelView();
|
||||
if (panelView == null || panelParams == null) return;
|
||||
|
||||
showPanelBackdrop();
|
||||
positionPanelNearBubble(true);
|
||||
try {
|
||||
if (panelView.isAttachedToWindow()) {
|
||||
windowManager.updateViewLayout(panelView, panelParams);
|
||||
} else {
|
||||
windowManager.addView(panelView, panelParams);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, "windowManager.addView(panel) failed", ex);
|
||||
Toast.makeText(this, "Failed to show panel", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private void hidePanelView() {
|
||||
if (windowManager != null && panelView != null) {
|
||||
try {
|
||||
if (panelView.isAttachedToWindow()) {
|
||||
windowManager.removeView(panelView);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
hidePanelBackdrop();
|
||||
}
|
||||
|
||||
private void removePanelView() {
|
||||
hidePanelView();
|
||||
if (panelWebView != null) {
|
||||
try {
|
||||
panelWebView.stopLoading();
|
||||
panelWebView.loadUrl("about:blank");
|
||||
panelWebView.destroy();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
panelWebView = null;
|
||||
panelView = null;
|
||||
panelParams = null;
|
||||
panelBackdropView = null;
|
||||
panelBackdropParams = null;
|
||||
}
|
||||
|
||||
private void ensurePanelBackdropView() {
|
||||
if (panelBackdropView != null && panelBackdropParams != null) return;
|
||||
if (windowManager == null) return;
|
||||
|
||||
View backdrop = new View(this);
|
||||
backdrop.setBackgroundColor(0x26000000);
|
||||
backdrop.setClickable(true);
|
||||
backdrop.setFocusable(false);
|
||||
backdrop.setOnTouchListener((v, event) -> {
|
||||
if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
|
||||
hidePanelView();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
panelBackdropView = backdrop;
|
||||
|
||||
int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||
: WindowManager.LayoutParams.TYPE_PHONE;
|
||||
|
||||
panelBackdropParams = new WindowManager.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
type,
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
|
||||
PixelFormat.TRANSLUCENT
|
||||
);
|
||||
panelBackdropParams.gravity = Gravity.TOP | Gravity.START;
|
||||
panelBackdropParams.x = 0;
|
||||
panelBackdropParams.y = 0;
|
||||
}
|
||||
|
||||
private void showPanelBackdrop() {
|
||||
if (windowManager == null || panelBackdropView == null || panelBackdropParams == null) return;
|
||||
try {
|
||||
if (panelBackdropView.isAttachedToWindow()) {
|
||||
windowManager.updateViewLayout(panelBackdropView, panelBackdropParams);
|
||||
} else {
|
||||
windowManager.addView(panelBackdropView, panelBackdropParams);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Log.w(TAG, "Failed to show panel backdrop", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void hidePanelBackdrop() {
|
||||
if (windowManager == null || panelBackdropView == null) return;
|
||||
try {
|
||||
if (panelBackdropView.isAttachedToWindow()) {
|
||||
windowManager.removeView(panelBackdropView);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void ensurePanelView() {
|
||||
if (panelView != null && panelParams != null) return;
|
||||
if (windowManager == null) return;
|
||||
|
||||
FrameLayout card = new FrameLayout(this);
|
||||
card.setClickable(true);
|
||||
card.setFocusable(true);
|
||||
card.setFocusableInTouchMode(true);
|
||||
card.setElevation(dp(18));
|
||||
|
||||
GradientDrawable bg = new GradientDrawable();
|
||||
bg.setColor(0xFFFFFFFF);
|
||||
bg.setCornerRadius(dp(18));
|
||||
bg.setStroke(dp(1), 0x22000000);
|
||||
card.setBackground(bg);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
card.setClipToOutline(true);
|
||||
}
|
||||
|
||||
WebView webView = new WebView(this);
|
||||
webView.setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
webView.setVerticalScrollBarEnabled(false);
|
||||
webView.setHorizontalScrollBarEnabled(false);
|
||||
webView.setBackgroundColor(0xFFFFFFFF);
|
||||
webView.setWebChromeClient(new WebChromeClient());
|
||||
webView.setWebViewClient(new WebViewClient());
|
||||
webView.addJavascriptInterface(new OverlayWebBridge(), "DewemojiOverlayHost");
|
||||
|
||||
WebSettings ws = webView.getSettings();
|
||||
ws.setJavaScriptEnabled(true);
|
||||
ws.setDomStorageEnabled(true);
|
||||
ws.setAllowFileAccess(true);
|
||||
ws.setAllowContentAccess(true);
|
||||
ws.setMediaPlaybackRequiresUserGesture(false);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
|
||||
ws.setAllowFileAccessFromFileURLs(true);
|
||||
ws.setAllowUniversalAccessFromFileURLs(true);
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
ws.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE);
|
||||
}
|
||||
|
||||
FrameLayout.LayoutParams webLp = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
);
|
||||
card.addView(webView, webLp);
|
||||
panelWebView = webView;
|
||||
|
||||
try {
|
||||
webView.loadUrl(buildOverlayWebViewUrl());
|
||||
} catch (Exception ex) {
|
||||
Log.w(TAG, "Failed to load overlay webview URL", ex);
|
||||
}
|
||||
|
||||
panelView = card;
|
||||
|
||||
int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||
: WindowManager.LayoutParams.TYPE_PHONE;
|
||||
panelParams = new WindowManager.LayoutParams(
|
||||
preferredPanelWidthPx(),
|
||||
maxPanelHeightPx(),
|
||||
type,
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
|
||||
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
|
||||
PixelFormat.TRANSLUCENT
|
||||
);
|
||||
panelParams.gravity = Gravity.TOP | Gravity.START;
|
||||
panelParams.x = dp(12);
|
||||
panelParams.y = dp(120);
|
||||
panelParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
|
||||
}
|
||||
|
||||
private void positionPanelNearBubble(boolean resetIfOffscreen) {
|
||||
if (panelParams == null) return;
|
||||
int screenW = getResources().getDisplayMetrics().widthPixels;
|
||||
int screenH = getResources().getDisplayMetrics().heightPixels;
|
||||
int topSafe = safeTopInsetPx();
|
||||
int bottomSafe = safeBottomInsetPx();
|
||||
int availableH = Math.max(dp(280), screenH - topSafe - bottomSafe);
|
||||
panelParams.width = preferredPanelWidthPx();
|
||||
panelParams.height = Math.min(panelParams.height > 0 ? panelParams.height : maxPanelHeightPx(), maxPanelHeightPx());
|
||||
panelParams.x = Math.max(safeSideMarginPx(), (screenW - panelParams.width) / 2);
|
||||
panelParams.y = topSafe + Math.max(0, (availableH - panelParams.height) / 2);
|
||||
clampPanelPosition();
|
||||
}
|
||||
|
||||
private void clampPanelPosition() {
|
||||
if (panelParams == null) return;
|
||||
int screenW = getResources().getDisplayMetrics().widthPixels;
|
||||
int screenH = getResources().getDisplayMetrics().heightPixels;
|
||||
int sideMargin = safeSideMarginPx();
|
||||
int topSafe = safeTopInsetPx();
|
||||
int bottomSafe = safeBottomInsetPx();
|
||||
int panelW = panelParams.width > 0 ? panelParams.width : preferredPanelWidthPx();
|
||||
int panelH = panelParams.height > 0 ? panelParams.height : maxPanelHeightPx();
|
||||
|
||||
int maxX = Math.max(sideMargin, screenW - panelW - sideMargin);
|
||||
int maxY = Math.max(topSafe, screenH - panelH - bottomSafe);
|
||||
panelParams.x = Math.max(sideMargin, Math.min(panelParams.x, maxX));
|
||||
panelParams.y = Math.max(topSafe, Math.min(panelParams.y, maxY));
|
||||
}
|
||||
|
||||
private void clampBubblePosition() {
|
||||
if (bubbleParams == null) return;
|
||||
int screenW = getResources().getDisplayMetrics().widthPixels;
|
||||
int screenH = getResources().getDisplayMetrics().heightPixels;
|
||||
int sideMargin = safeSideMarginPx();
|
||||
int topSafe = safeTopInsetPx();
|
||||
int bottomSafe = safeBottomInsetPx();
|
||||
int bubbleW = bubbleParams.width > 0 ? bubbleParams.width : dp(64);
|
||||
int bubbleH = bubbleParams.height > 0 ? bubbleParams.height : dp(64);
|
||||
|
||||
int maxX = Math.max(sideMargin, screenW - bubbleW - sideMargin);
|
||||
int maxY = Math.max(topSafe, screenH - bubbleH - bottomSafe);
|
||||
bubbleParams.x = Math.max(sideMargin, Math.min(bubbleParams.x, maxX));
|
||||
bubbleParams.y = Math.max(topSafe, Math.min(bubbleParams.y, maxY));
|
||||
}
|
||||
|
||||
private int preferredPanelWidthPx() {
|
||||
int screenW = getResources().getDisplayMetrics().widthPixels;
|
||||
int minMargin = dp(10);
|
||||
int maxWidth = Math.max(dp(220), screenW - (minMargin * 2));
|
||||
int target = Math.round(screenW * 0.94f);
|
||||
int minWidth = Math.min(dp(280), maxWidth);
|
||||
return Math.max(minWidth, Math.min(maxWidth, target));
|
||||
}
|
||||
|
||||
private int maxPanelHeightPx() {
|
||||
int screenH = getResources().getDisplayMetrics().heightPixels;
|
||||
int topSafe = safeTopInsetPx();
|
||||
int bottomSafe = safeBottomInsetPx();
|
||||
int available = Math.max(dp(360), screenH - topSafe - bottomSafe);
|
||||
int maxHeight = Math.max(dp(260), available - dp(8));
|
||||
int target = Math.round(available * 0.92f);
|
||||
int minHeight = Math.min(dp(360), maxHeight);
|
||||
return Math.max(minHeight, Math.min(maxHeight, target));
|
||||
}
|
||||
|
||||
private int minPanelHeightPx() {
|
||||
int max = maxPanelHeightPx();
|
||||
return Math.min(max, dp(260));
|
||||
}
|
||||
|
||||
private int safeSideMarginPx() {
|
||||
return dp(10);
|
||||
}
|
||||
|
||||
private int safeTopInsetPx() {
|
||||
return readSystemDimenPx("status_bar_height") + dp(8);
|
||||
}
|
||||
|
||||
private int safeBottomInsetPx() {
|
||||
return readSystemDimenPx("navigation_bar_height") + dp(8);
|
||||
}
|
||||
|
||||
private int readSystemDimenPx(String name) {
|
||||
try {
|
||||
int resId = getResources().getIdentifier(name, "dimen", "android");
|
||||
if (resId > 0) {
|
||||
return getResources().getDimensionPixelSize(resId);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private String buildOverlayWebViewUrl() {
|
||||
return "file:///android_asset/public/index.html?mode=overlay";
|
||||
}
|
||||
|
||||
private boolean copyTextToClipboardNative(String text) {
|
||||
try {
|
||||
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
if (clipboard == null) return false;
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("dewemoji", String.valueOf(text == null ? "" : text)));
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
Log.w(TAG, "Native clipboard copy failed", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private final class OverlayWebBridge {
|
||||
@JavascriptInterface
|
||||
public boolean copyText(String text) {
|
||||
return copyTextToClipboardNative(text);
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public void setContentHeight(int cssPx) {
|
||||
if (cssPx <= 0) return;
|
||||
View target = panelView;
|
||||
if (target == null) return;
|
||||
target.post(() -> applyPanelContentHeightCss(cssPx));
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public void closePanel() {
|
||||
hidePanelView();
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public void openFullApp() {
|
||||
openMainActivity();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyPanelContentHeightCss(int cssPx) {
|
||||
if (panelParams == null || panelView == null || windowManager == null) return;
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
int desiredPx = Math.round(cssPx * density);
|
||||
desiredPx += dp(2);
|
||||
desiredPx = Math.max(minPanelHeightPx(), Math.min(desiredPx, maxPanelHeightPx()));
|
||||
if (Math.abs(desiredPx - panelParams.height) < dp(6)) {
|
||||
return;
|
||||
}
|
||||
panelParams.height = desiredPx;
|
||||
|
||||
int screenH = getResources().getDisplayMetrics().heightPixels;
|
||||
int topSafe = safeTopInsetPx();
|
||||
int bottomSafe = safeBottomInsetPx();
|
||||
int availableH = Math.max(dp(280), screenH - topSafe - bottomSafe);
|
||||
panelParams.y = topSafe + Math.max(0, (availableH - panelParams.height) / 2);
|
||||
clampPanelPosition();
|
||||
|
||||
try {
|
||||
if (panelView.isAttachedToWindow()) {
|
||||
windowManager.updateViewLayout(panelView, panelParams);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Log.w(TAG, "Failed to apply panel content height", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeBubbleView() {
|
||||
if (windowManager == null || bubbleView == null) {
|
||||
bubbleView = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
windowManager.removeView(bubbleView);
|
||||
} catch (Exception ignored) {
|
||||
} finally {
|
||||
bubbleView = null;
|
||||
}
|
||||
}
|
||||
|
||||
private int dp(int value) {
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
return Math.round(value * density);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.dewemoji.app.plugins;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.Settings;
|
||||
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.dewemoji.app.OverlayBubbleService;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
@CapacitorPlugin(name = "DewemojiOverlay")
|
||||
public class DewemojiOverlayPlugin extends Plugin {
|
||||
private static final String APP_STATE_KEY = "app_state_json";
|
||||
|
||||
@PluginMethod
|
||||
public void isOverlayPermissionGranted(PluginCall call) {
|
||||
JSObject out = new JSObject();
|
||||
out.put("granted", canDrawOverlays());
|
||||
call.resolve(out);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void openOverlayPermissionSettings(PluginCall call) {
|
||||
try {
|
||||
Intent intent = new Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.parse("package:" + getContext().getPackageName())
|
||||
);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
getContext().startActivity(intent);
|
||||
call.resolve();
|
||||
} catch (Exception ex) {
|
||||
call.reject("Failed to open overlay settings", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void areNotificationsEnabled(PluginCall call) {
|
||||
JSObject out = new JSObject();
|
||||
out.put("enabled", notificationsEnabled());
|
||||
call.resolve(out);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void openNotificationSettings(PluginCall call) {
|
||||
try {
|
||||
Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS);
|
||||
intent.putExtra(Settings.EXTRA_APP_PACKAGE, getContext().getPackageName());
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
getContext().startActivity(intent);
|
||||
call.resolve();
|
||||
} catch (Exception ex) {
|
||||
call.reject("Failed to open notification settings", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void isBubbleRunning(PluginCall call) {
|
||||
JSObject out = new JSObject();
|
||||
out.put("running", OverlayBubbleService.isRunning());
|
||||
call.resolve(out);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void startBubble(PluginCall call) {
|
||||
JSObject out = new JSObject();
|
||||
|
||||
if (!canDrawOverlays()) {
|
||||
out.put("started", false);
|
||||
out.put("reason", "overlay_permission_required");
|
||||
call.resolve(out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!notificationsEnabled()) {
|
||||
out.put("started", false);
|
||||
out.put("reason", "notifications_required");
|
||||
call.resolve(out);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Intent intent = new Intent(getContext(), OverlayBubbleService.class);
|
||||
intent.setAction(OverlayBubbleService.ACTION_START);
|
||||
ContextCompat.startForegroundService(getContext(), intent);
|
||||
out.put("started", true);
|
||||
call.resolve(out);
|
||||
} catch (Exception ex) {
|
||||
call.reject("Failed to start Dewemoji bubble", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void stopBubble(PluginCall call) {
|
||||
try {
|
||||
Intent intent = new Intent(getContext(), OverlayBubbleService.class);
|
||||
intent.setAction(OverlayBubbleService.ACTION_STOP);
|
||||
getContext().startService(intent);
|
||||
JSObject out = new JSObject();
|
||||
out.put("stopped", true);
|
||||
call.resolve(out);
|
||||
} catch (Exception ex) {
|
||||
call.reject("Failed to stop Dewemoji bubble", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getAppState(PluginCall call) {
|
||||
JSObject out = new JSObject();
|
||||
try {
|
||||
SharedPreferences prefs = nativePrefs();
|
||||
String raw = prefs.getString(APP_STATE_KEY, "{}");
|
||||
JSObject state = (raw == null || raw.trim().isEmpty()) ? new JSObject() : new JSObject(raw);
|
||||
out.put("state", state);
|
||||
call.resolve(out);
|
||||
} catch (Exception ex) {
|
||||
call.reject("Failed to read app state", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void setAppState(PluginCall call) {
|
||||
try {
|
||||
JSObject incoming = call.getObject("state", new JSObject());
|
||||
if (incoming == null) {
|
||||
incoming = new JSObject();
|
||||
}
|
||||
nativePrefs().edit().putString(APP_STATE_KEY, incoming.toString()).apply();
|
||||
JSObject out = new JSObject();
|
||||
out.put("saved", true);
|
||||
call.resolve(out);
|
||||
} catch (Exception ex) {
|
||||
call.reject("Failed to save app state", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private SharedPreferences nativePrefs() {
|
||||
return getContext().getSharedPreferences(OverlayBubbleService.PREFS_NAME, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
private boolean canDrawOverlays() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
return true;
|
||||
}
|
||||
return Settings.canDrawOverlays(getContext());
|
||||
}
|
||||
|
||||
private boolean notificationsEnabled() {
|
||||
if (!NotificationManagerCompat.from(getContext()).areNotificationsEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
getContext(),
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Reference in New Issue
Block a user