Consolidate docs and finalize APK companion updates

This commit is contained in:
Dwindi Ramadhana
2026-03-16 01:06:41 +07:00
parent 95609dc0cf
commit 88218c7798
48 changed files with 7847 additions and 4502 deletions

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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