Port(89% Done):
# Problem Fixes - A LOT (feeling lazy) # New - > Chat Security & Messages Lock > Dynamic App Icons & Historical Originals: and few more... # Known Problems: > Hide Likes Count doesnt work. there can be more bugs as v3 is incomplete.
No files matched your search
@@ -69,10 +69,6 @@
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Instagram, Threads, and related domain deep linking -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
@@ -94,6 +90,193 @@
|
||||
<data android:host="www.instagr.am" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Launcher Icon Aliases for Dynamic Custom App Icon Switching -->
|
||||
<activity-alias
|
||||
android:name=".MainActivityDefault"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityRedesign"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_launcher_redesign"
|
||||
android:roundIcon="@mipmap/ic_launcher_redesign"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityPitchBlack"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_launcher_pitch_black"
|
||||
android:roundIcon="@mipmap/ic_launcher_pitch_black"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityAllWhite"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_launcher_all_white"
|
||||
android:roundIcon="@mipmap/ic_launcher_all_white"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityAbstract"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_launcher_abstract"
|
||||
android:roundIcon="@mipmap/ic_launcher_abstract"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityForest"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_launcher_forest"
|
||||
android:roundIcon="@mipmap/ic_launcher_forest"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityGolden"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_launcher_golden"
|
||||
android:roundIcon="@mipmap/ic_launcher_golden"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityMetal"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_launcher_metal"
|
||||
android:roundIcon="@mipmap/ic_launcher_metal"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityScribble"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_launcher_scribble"
|
||||
android:roundIcon="@mipmap/ic_launcher_scribble"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<!-- Originals (Historical Instagram Brand Logos) -->
|
||||
<activity-alias
|
||||
android:name=".MainActivityPolaroid"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_launcher_polaroid"
|
||||
android:roundIcon="@mipmap/ic_launcher_polaroid"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityViewfinder"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_launcher_viewfinder"
|
||||
android:roundIcon="@mipmap/ic_launcher_viewfinder"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityMinimalist"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_launcher_minimalist"
|
||||
android:roundIcon="@mipmap/ic_launcher_minimalist"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityVibrant"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_launcher_vibrant"
|
||||
android:roundIcon="@mipmap/ic_launcher_vibrant"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
|
||||
|
||||
<!-- Foreground Service for Session Timer & Screen Time Tracking -->
|
||||
<service
|
||||
|
||||
@@ -26,7 +26,11 @@ let cachedSettings = {
|
||||
ghostHideDirectSeen: true,
|
||||
ghostHideTypingIndicator: true,
|
||||
unblurMode: 'tap',
|
||||
videoDownloadEnabled: false
|
||||
videoDownloadEnabled: false,
|
||||
chatLockMode: 'individual',
|
||||
chatLockAll: false,
|
||||
lockedChatIds: [],
|
||||
lockedChatUsernames: {}
|
||||
};
|
||||
|
||||
let userIsAuthenticated = false;
|
||||
@@ -109,14 +113,148 @@ if (typeof browser !== 'undefined' && browser.webRequest && browser.webRequest.o
|
||||
{ urls: GHOST_BLOCKED_URL_PATTERNS },
|
||||
["blocking"]
|
||||
);
|
||||
|
||||
// --- Story Viewer List Interception (Client-Side Search) ---
|
||||
// Passively captures the response of Instagram's own web client when opening viewers list.
|
||||
// Zero automated/extra requests — uses web client's authenticated response.
|
||||
const storyViewersCache = new Map(); // mediaId -> Array<{pk, username, fullName, avatar, hasLiked}>
|
||||
|
||||
browser.webRequest.onBeforeRequest.addListener(
|
||||
function (details) {
|
||||
const url = details.url || '';
|
||||
const match = url.match(/\/api\/v1\/media\/(\d+)\/list_reel_media_viewer\//);
|
||||
if (!match) return;
|
||||
|
||||
const mediaId = match[1];
|
||||
|
||||
// Immediately broadcast detection when request is SENT so search bar appears right away!
|
||||
broadcastToTabs({
|
||||
action: 'storyViewersDetected',
|
||||
phase: 'loading',
|
||||
mediaId: mediaId,
|
||||
url: url
|
||||
});
|
||||
|
||||
if (typeof browser.webRequest.filterResponseData === 'function') {
|
||||
try {
|
||||
const filter = browser.webRequest.filterResponseData(details.requestId);
|
||||
const chunks = [];
|
||||
|
||||
filter.ondata = function (event) {
|
||||
chunks.push(event.data);
|
||||
filter.write(event.data); // Transparently stream data to Instagram web client
|
||||
};
|
||||
|
||||
filter.onstop = async function () {
|
||||
filter.close();
|
||||
try {
|
||||
const blob = new Blob(chunks);
|
||||
const text = await blob.text();
|
||||
const json = JSON.parse(text);
|
||||
const users = json.users || [];
|
||||
const rawViewers = json.viewers || [];
|
||||
|
||||
const existing = storyViewersCache.get(mediaId) || [];
|
||||
const userMap = new Map(existing.map(u => [String(u.pk || u.id), u]));
|
||||
|
||||
users.forEach(u => {
|
||||
const pk = String(u.pk || u.id);
|
||||
if (!pk) return;
|
||||
userMap.set(pk, {
|
||||
pk: pk,
|
||||
username: u.username || '',
|
||||
fullName: u.full_name || '',
|
||||
avatar: u.profile_pic_url || '',
|
||||
isVerified: !!u.is_verified,
|
||||
hasLiked: false
|
||||
});
|
||||
});
|
||||
|
||||
rawViewers.forEach(v => {
|
||||
const u = v.user || v;
|
||||
const pk = String(u.pk || u.id);
|
||||
if (!pk) return;
|
||||
const existingEntry = userMap.get(pk) || {};
|
||||
userMap.set(pk, {
|
||||
pk: pk,
|
||||
username: u.username || existingEntry.username || '',
|
||||
fullName: u.full_name || existingEntry.fullName || '',
|
||||
avatar: u.profile_pic_url || existingEntry.avatar || '',
|
||||
isVerified: !!(u.is_verified || existingEntry.isVerified),
|
||||
hasLiked: !!(v.has_liked || existingEntry.hasLiked)
|
||||
});
|
||||
});
|
||||
|
||||
const viewerList = Array.from(userMap.values());
|
||||
storyViewersCache.set(mediaId, viewerList);
|
||||
console.log(`[FocusGram] Story viewers captured for ${mediaId}: ${viewerList.length} users`);
|
||||
|
||||
// Broadcast captured viewers to active tabs
|
||||
broadcastToTabs({
|
||||
action: 'storyViewersDetected',
|
||||
phase: 'ready',
|
||||
mediaId: mediaId,
|
||||
viewers: viewerList
|
||||
});
|
||||
broadcastToTabs({
|
||||
action: 'storyViewersCaptured',
|
||||
mediaId: mediaId,
|
||||
viewers: viewerList
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[FocusGram] Error parsing story viewers payload:', e);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[FocusGram] filterResponseData error:', err);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ urls: ["*://*.instagram.com/api/v1/media/*/list_reel_media_viewer/*"] },
|
||||
["blocking"]
|
||||
);
|
||||
}
|
||||
|
||||
// Establish native messaging port with FocusGram Android host
|
||||
let nativePort = null;
|
||||
const pendingNativeMessages = [];
|
||||
|
||||
function sendToNative(msg) {
|
||||
if (!msg) return;
|
||||
if (nativePort) {
|
||||
try {
|
||||
nativePort.postMessage(msg);
|
||||
console.log('[FocusGram Background] Forwarded to native host:', JSON.stringify(msg));
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('[FocusGram Background] postMessage failed, queueing message:', e);
|
||||
nativePort = null;
|
||||
}
|
||||
}
|
||||
pendingNativeMessages.push(msg);
|
||||
if (pendingNativeMessages.length > 50) pendingNativeMessages.shift();
|
||||
connectNativePort();
|
||||
}
|
||||
|
||||
function flushPendingMessages() {
|
||||
if (!nativePort) return;
|
||||
while (pendingNativeMessages.length > 0) {
|
||||
const msg = pendingNativeMessages.shift();
|
||||
try {
|
||||
nativePort.postMessage(msg);
|
||||
console.log('[FocusGram Background] Flushed queued message to native host:', JSON.stringify(msg));
|
||||
} catch (e) {
|
||||
console.error('[FocusGram Background] Error flushing queued message:', e);
|
||||
pendingNativeMessages.unshift(msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connectNativePort() {
|
||||
try {
|
||||
if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.connectNative) {
|
||||
if (nativePort) return;
|
||||
nativePort = browser.runtime.connectNative("focusgram");
|
||||
console.log('[FocusGram Background] Connected to native host "focusgram"');
|
||||
|
||||
@@ -136,12 +274,14 @@ function connectNativePort() {
|
||||
nativePort.onDisconnect.addListener((p) => {
|
||||
console.warn('[FocusGram Background] Native port disconnected, will retry...');
|
||||
nativePort = null;
|
||||
setTimeout(connectNativePort, 5000);
|
||||
setTimeout(connectNativePort, 3000);
|
||||
});
|
||||
|
||||
flushPendingMessages();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[FocusGram Background] Error connecting native port:', e);
|
||||
setTimeout(connectNativePort, 5000);
|
||||
setTimeout(connectNativePort, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,33 +315,37 @@ if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessa
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!nativePort) {
|
||||
connectNativePort();
|
||||
if (message.action === 'getStoryViewers') {
|
||||
const mediaId = message.mediaId;
|
||||
const viewers = mediaId ? (storyViewersCache.get(mediaId) || []) : [];
|
||||
sendResponse({ viewers: viewers });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (nativePort) {
|
||||
try {
|
||||
if (message.type === 'ReelMetadata' || message.action === 'reelViewed' || message.action === 'reelWatched') {
|
||||
const payload = message.payload || message.data || message.reel || message;
|
||||
nativePort.postMessage({
|
||||
action: 'reelViewed',
|
||||
type: message.type || 'reelViewed',
|
||||
code: payload.code || message.code || '',
|
||||
url: payload.url || message.url || '',
|
||||
title: payload.caption || payload.title || message.caption || message.title || '',
|
||||
caption: payload.caption || message.caption || '',
|
||||
author: payload.author || message.author || '',
|
||||
thumbnailUrl: payload.thumbnailUrl || message.thumbnailUrl || ''
|
||||
});
|
||||
} else {
|
||||
nativePort.postMessage(message);
|
||||
}
|
||||
console.log('[FocusGram Background] Forwarded to native host:', JSON.stringify(message));
|
||||
} catch (e) {
|
||||
console.error('[FocusGram Background] Failed to forward message to native host:', e);
|
||||
}
|
||||
if (message.action === 'broadcastToTabs' && message.payload) {
|
||||
broadcastToTabs(message.payload);
|
||||
sendResponse({ status: 'broadcasted' });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'ReelMetadata' || message.action === 'reelViewed' || message.action === 'reelWatched') {
|
||||
const payload = message.payload || message.data || message.reel || message;
|
||||
sendToNative({
|
||||
action: 'reelViewed',
|
||||
type: message.type || 'reelViewed',
|
||||
code: payload.code || message.code || '',
|
||||
url: payload.url || message.url || '',
|
||||
title: payload.caption || payload.title || message.caption || message.title || '',
|
||||
caption: payload.caption || message.caption || '',
|
||||
author: payload.author || message.author || '',
|
||||
thumbnailUrl: payload.thumbnailUrl || message.thumbnailUrl || ''
|
||||
});
|
||||
} else {
|
||||
sendToNative(message);
|
||||
}
|
||||
|
||||
sendResponse({ status: 'forwarded' });
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
function isAuthPage() {
|
||||
try {
|
||||
const path = (window.location.pathname || '').toLowerCase();
|
||||
if (path.includes('/accounts/edit')) {
|
||||
return false;
|
||||
}
|
||||
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
function isAuthPage() {
|
||||
try {
|
||||
var path = (window.location.pathname || '').toLowerCase();
|
||||
if (path.includes('/accounts/edit')) return false;
|
||||
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true;
|
||||
var c = document.cookie || '';
|
||||
if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true;
|
||||
@@ -231,6 +232,271 @@
|
||||
}
|
||||
}
|
||||
|
||||
let localChatLockMode = 'individual';
|
||||
let localChatLockAll = false;
|
||||
let localLockedChatIds = [];
|
||||
let localUnlockedThreads = [];
|
||||
let localThreadUsernameMap = {};
|
||||
|
||||
function handleChatLockSync(detail) {
|
||||
if (!detail) return;
|
||||
if (typeof detail.chatLockMode !== 'undefined') localChatLockMode = String(detail.chatLockMode).toLowerCase();
|
||||
if (typeof detail.chatLockAll !== 'undefined') localChatLockAll = !!detail.chatLockAll;
|
||||
if (Array.isArray(detail.lockedChatIds)) localLockedChatIds = detail.lockedChatIds;
|
||||
if (Array.isArray(detail.unlockedThreads)) localUnlockedThreads = detail.unlockedThreads;
|
||||
if (detail.threadUsernameMap && typeof detail.threadUsernameMap === 'object') localThreadUsernameMap = detail.threadUsernameMap;
|
||||
}
|
||||
|
||||
window.addEventListener('focusgram:chat-lock-sync', (e) => handleChatLockSync(e.detail));
|
||||
document.addEventListener('focusgram:chat-lock-sync', (e) => handleChatLockSync(e.detail));
|
||||
|
||||
function isDirectThreadLocked(threadId, username) {
|
||||
const mode = window.__fgChatLockMode || localChatLockMode || 'individual';
|
||||
if (mode === 'off') return false;
|
||||
|
||||
const cleanTid = String(threadId || '').toLowerCase().trim();
|
||||
const cleanUser = String(username || '').toLowerCase().trim().replace(/^@/, '');
|
||||
|
||||
// Resolve mapped usernames/IDs from maps
|
||||
const gMap = window.__fgThreadUsernameMap || localThreadUsernameMap || {};
|
||||
const mappedUser = cleanTid ? (gMap.get ? gMap.get(cleanTid) : gMap[cleanTid]) : '';
|
||||
const mappedId = cleanUser ? (gMap.get ? gMap.get(cleanUser) : gMap[cleanUser]) : '';
|
||||
|
||||
const checkInCollection = (collection, val) => {
|
||||
if (!collection || !val) return false;
|
||||
if (typeof collection.has === 'function') return collection.has(val);
|
||||
if (Array.isArray(collection)) return collection.includes(val);
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check if unlocked in current session
|
||||
const unlockedSources = [window.__fgUnlockedThreads, localUnlockedThreads];
|
||||
for (const unl of unlockedSources) {
|
||||
if (unl) {
|
||||
if (checkInCollection(unl, '__all__') ||
|
||||
checkInCollection(unl, cleanTid) ||
|
||||
checkInCollection(unl, cleanUser) ||
|
||||
(mappedUser && checkInCollection(unl, mappedUser)) ||
|
||||
(mappedId && checkInCollection(unl, mappedId))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whole DM lock mode
|
||||
if (mode === 'whole_dm' || window.__fgChatLockAll || localChatLockAll) return true;
|
||||
|
||||
// Individual chat lock check
|
||||
const lockedSources = [window.__fgLockedChatIds, localLockedChatIds];
|
||||
for (const lk of lockedSources) {
|
||||
if (lk) {
|
||||
if (checkInCollection(lk, cleanTid) ||
|
||||
checkInCollection(lk, cleanUser) ||
|
||||
(mappedUser && checkInCollection(lk, mappedUser)) ||
|
||||
(mappedId && checkInCollection(lk, mappedId))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function maskMessageNode(node) {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
let didMask = false;
|
||||
if (typeof node.text_body === 'string' && node.text_body.length > 0) {
|
||||
node.text_body = '••••••••••••';
|
||||
didMask = true;
|
||||
}
|
||||
if (typeof node.text === 'string' && node.text.length > 0) {
|
||||
node.text = '••••••••••••';
|
||||
didMask = true;
|
||||
}
|
||||
if (typeof node.snippet === 'string' && node.snippet.length > 0) {
|
||||
node.snippet = '••••••••••••';
|
||||
didMask = true;
|
||||
}
|
||||
if (node.content && typeof node.content === 'object') {
|
||||
if (typeof node.content.text_body === 'string' && node.content.text_body.length > 0) {
|
||||
node.content.text_body = '••••••••••••';
|
||||
didMask = true;
|
||||
}
|
||||
if (typeof node.content.text === 'string' && node.content.text.length > 0) {
|
||||
node.content.text = '••••••••••••';
|
||||
didMask = true;
|
||||
}
|
||||
if (typeof node.content.snippet === 'string' && node.content.snippet.length > 0) {
|
||||
node.content.snippet = '••••••••••••';
|
||||
didMask = true;
|
||||
}
|
||||
}
|
||||
return didMask;
|
||||
}
|
||||
|
||||
function maskThreadMessages(thread) {
|
||||
if (!thread || typeof thread !== 'object') return false;
|
||||
let threadMod = false;
|
||||
|
||||
// 1. slide_messages.edges[].node (HAR / GraphQL IGDThreadDetailQuery format)
|
||||
if (thread.slide_messages && Array.isArray(thread.slide_messages.edges)) {
|
||||
for (const edge of thread.slide_messages.edges) {
|
||||
if (edge && edge.node && maskMessageNode(edge.node)) {
|
||||
threadMod = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. messages.edges or messages.items or messages[]
|
||||
if (thread.messages) {
|
||||
if (Array.isArray(thread.messages.edges)) {
|
||||
for (const edge of thread.messages.edges) {
|
||||
if (edge && edge.node && maskMessageNode(edge.node)) {
|
||||
threadMod = true;
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(thread.messages.items)) {
|
||||
for (const item of thread.messages.items) {
|
||||
if (maskMessageNode(item)) {
|
||||
threadMod = true;
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(thread.messages)) {
|
||||
for (const item of thread.messages) {
|
||||
if (maskMessageNode(item)) {
|
||||
threadMod = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. items[]
|
||||
if (Array.isArray(thread.items)) {
|
||||
for (const item of thread.items) {
|
||||
if (maskMessageNode(item)) {
|
||||
threadMod = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. last_permanent_item
|
||||
if (thread.last_permanent_item && maskMessageNode(thread.last_permanent_item)) {
|
||||
threadMod = true;
|
||||
}
|
||||
|
||||
// 5. snippet
|
||||
if (typeof thread.snippet === 'string') {
|
||||
thread.snippet = '••••••••••••';
|
||||
threadMod = true;
|
||||
}
|
||||
|
||||
return threadMod;
|
||||
}
|
||||
|
||||
function findThreadsInPayload(root) {
|
||||
const threads = [];
|
||||
if (!root || typeof root !== 'object') return threads;
|
||||
|
||||
if (root.get_slide_thread_nullable?.as_ig_direct_thread) {
|
||||
threads.push(root.get_slide_thread_nullable.as_ig_direct_thread);
|
||||
} else if (root.get_slide_thread_nullable && typeof root.get_slide_thread_nullable === 'object') {
|
||||
threads.push(root.get_slide_thread_nullable);
|
||||
}
|
||||
if (root.message_thread && typeof root.message_thread === 'object') {
|
||||
threads.push(root.message_thread);
|
||||
}
|
||||
if (root.thread && typeof root.thread === 'object') {
|
||||
threads.push(root.thread);
|
||||
}
|
||||
|
||||
const inbox = root.viewer?.inbox || root.inbox;
|
||||
if (inbox && Array.isArray(inbox.threads)) {
|
||||
for (const t of inbox.threads) {
|
||||
if (t && typeof t === 'object') threads.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
if (threads.length === 0) {
|
||||
for (const key of Object.keys(root)) {
|
||||
const val = root[key];
|
||||
if (val && typeof val === 'object') {
|
||||
if (val.slide_messages || val.as_ig_direct_thread || (val.thread_fbid && (val.items || val.messages))) {
|
||||
threads.push(val.as_ig_direct_thread || val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return threads;
|
||||
}
|
||||
|
||||
function processDirectMessagesPayload(data) {
|
||||
if (!data || typeof data !== 'object') return false;
|
||||
|
||||
let modified = false;
|
||||
const root = data.data || data;
|
||||
const threads = findThreadsInPayload(root);
|
||||
if (threads.length === 0) return false;
|
||||
|
||||
for (const thread of threads) {
|
||||
const tid = String(thread.thread_fbid || thread.id || thread.thread_id || '').toLowerCase().trim();
|
||||
const threadKey = String(thread.thread_key || '').toLowerCase().trim();
|
||||
let username = '';
|
||||
let displayName = thread.thread_title || '';
|
||||
const allUsernames = [];
|
||||
|
||||
if (Array.isArray(thread.users)) {
|
||||
for (const u of thread.users) {
|
||||
if (u && u.username) {
|
||||
const uname = String(u.username).toLowerCase().trim().replace(/^@/, '');
|
||||
if (uname) allUsernames.push(uname);
|
||||
}
|
||||
}
|
||||
if (allUsernames.length > 0) {
|
||||
username = allUsernames[0];
|
||||
if (!displayName) displayName = thread.users[0].full_name || username;
|
||||
}
|
||||
}
|
||||
|
||||
const primaryId = tid || threadKey;
|
||||
if (primaryId && (allUsernames.length > 0 || displayName)) {
|
||||
window.dispatchEvent(new CustomEvent('focusgram:thread-matched', {
|
||||
detail: {
|
||||
threadId: primaryId,
|
||||
threadKey: threadKey,
|
||||
usernames: allUsernames,
|
||||
displayName: displayName
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let isLocked = isDirectThreadLocked(tid, username);
|
||||
if (!isLocked && threadKey) isLocked = isDirectThreadLocked(threadKey, username);
|
||||
if (!isLocked) {
|
||||
for (const u of allUsernames) {
|
||||
if (isDirectThreadLocked(tid || threadKey, u)) {
|
||||
isLocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocked) {
|
||||
// Do not mask inbox list previews
|
||||
const isInboxItem = !thread.slide_messages && (thread.last_permanent_item || thread.snippet);
|
||||
if (isInboxItem) {
|
||||
continue;
|
||||
}
|
||||
if (maskThreadMessages(thread)) {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
window.__fgProcessDirectMessagesPayload = processDirectMessagesPayload;
|
||||
|
||||
// Override fetch in the main world
|
||||
const _fetch = window.fetch.bind(window);
|
||||
|
||||
@@ -241,35 +507,83 @@
|
||||
? input.href
|
||||
: input?.url ?? '';
|
||||
|
||||
// Story viewer request detection: immediately notify UI when request is sent and received
|
||||
if (url.includes('/list_reel_media_viewer/')) {
|
||||
const mediaIdMatch = url.match(/\/api\/v1\/media\/(\d+)\/list_reel_media_viewer\//);
|
||||
const mediaId = mediaIdMatch ? mediaIdMatch[1] : '';
|
||||
const loadingDetail = { phase: 'loading', mediaId: mediaId, url: url };
|
||||
|
||||
// Dispatch to both window & document for maximum compatibility across script compartments
|
||||
window.dispatchEvent(new CustomEvent('focusgram:story-viewers-detected', { detail: loadingDetail }));
|
||||
document.dispatchEvent(new CustomEvent('focusgram:story-viewers-detected', { detail: loadingDetail }));
|
||||
window.postMessage({ type: 'FOCUSGRAM_STORY_VIEWERS', detail: loadingDetail }, '*');
|
||||
handleStoryViewersDetected(loadingDetail);
|
||||
|
||||
const response = await _fetch(input, init);
|
||||
try {
|
||||
const cloned = response.clone();
|
||||
cloned.json().then(data => {
|
||||
const readyDetail = { phase: 'ready', mediaId: mediaId, url: url, data: data };
|
||||
window.dispatchEvent(new CustomEvent('focusgram:story-viewers-detected', { detail: readyDetail }));
|
||||
document.dispatchEvent(new CustomEvent('focusgram:story-viewers-detected', { detail: readyDetail }));
|
||||
window.postMessage({ type: 'FOCUSGRAM_STORY_VIEWERS', detail: readyDetail }, '*');
|
||||
handleStoryViewersDetected(readyDetail);
|
||||
}).catch(() => {});
|
||||
} catch (_) {}
|
||||
return response;
|
||||
}
|
||||
|
||||
// Always execute fetch
|
||||
const response = await _fetch(input, init);
|
||||
|
||||
// Only inspect GraphQL, feed, and clips endpoints
|
||||
if (!url.includes('/graphql') && !url.includes('/api/v1/feed') && !url.includes('/api/v1/clips/')) {
|
||||
// Only inspect GraphQL, feed, clips, and threads endpoints
|
||||
if (!url.includes('/graphql') && !url.includes('/api/v1/feed') && !url.includes('/api/v1/clips/') && !url.includes('/api/v1/direct_v2/threads/')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const config = window.__fgFilterConfig;
|
||||
if (config && config.blockAds === false) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const cloned = response.clone();
|
||||
try {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
if (!contentType.includes('json') && !contentType.includes('javascript')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const data = await cloned.json();
|
||||
harvestMediaFromData(data);
|
||||
walkAndFilter(data);
|
||||
let data;
|
||||
try {
|
||||
data = await cloned.json();
|
||||
} catch (_) {
|
||||
try {
|
||||
let text = await cloned.text();
|
||||
text = text.replace(/^for\s*\(\s*;\s*;\s*\)\s*;\s*/, '');
|
||||
data = JSON.parse(text);
|
||||
} catch (e) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
harvestMediaFromData(data);
|
||||
|
||||
const directModified = processDirectMessagesPayload(data);
|
||||
|
||||
let adModified = false;
|
||||
if (config && config.blockAds !== false) {
|
||||
walkAndFilter(data);
|
||||
adModified = true;
|
||||
}
|
||||
|
||||
if (!directModified && !adModified) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const originalContentType = response.headers.get('content-type') || 'application/json; charset=utf-8';
|
||||
const newHeaders = new Headers(response.headers);
|
||||
// CRITICAL: Must delete content-length and content-encoding so the uncompressed
|
||||
// JSON body is not truncated or corrupted by the browser HTTP layer
|
||||
newHeaders.delete('content-length');
|
||||
newHeaders.delete('content-encoding');
|
||||
newHeaders.set('content-type', 'application/json; charset=utf-8');
|
||||
newHeaders.set('content-type', originalContentType);
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: response.status,
|
||||
@@ -287,6 +601,259 @@
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// Hook XMLHttpRequest for complete coverage of background API calls
|
||||
const origXHR = window.XMLHttpRequest;
|
||||
if (origXHR && origXHR.prototype) {
|
||||
const origOpen = origXHR.prototype.open;
|
||||
const origSend = origXHR.prototype.send;
|
||||
|
||||
origXHR.prototype.open = function(method, url) {
|
||||
this._fgUrl = typeof url === 'string' ? url : (url ? url.toString() : '');
|
||||
return origOpen.apply(this, arguments);
|
||||
};
|
||||
|
||||
origXHR.prototype.send = function() {
|
||||
const xhr = this;
|
||||
const url = xhr._fgUrl || '';
|
||||
if (url.includes('/graphql') || url.includes('/api/v1/direct') || url.includes('/direct/')) {
|
||||
const onReady = function() {
|
||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
try {
|
||||
const cType = xhr.getResponseHeader('content-type') || '';
|
||||
if (cType.includes('json') || cType.includes('javascript')) {
|
||||
let text = xhr.responseText;
|
||||
if (text) {
|
||||
text = text.replace(/^for\s*\(\s*;\s*;\s*\)\s*;\s*/, '');
|
||||
const data = JSON.parse(text);
|
||||
if (processDirectMessagesPayload(data)) {
|
||||
const modifiedText = JSON.stringify(data);
|
||||
Object.defineProperty(xhr, 'responseText', { value: modifiedText, writable: true, configurable: true });
|
||||
Object.defineProperty(xhr, 'response', { value: modifiedText, writable: true, configurable: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
};
|
||||
xhr.addEventListener('readystatechange', onReady, false);
|
||||
}
|
||||
return origSend.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
STORY VIEWER CLIENT-SIDE SEARCH ENGINE
|
||||
Autonomous DOM detector + Network Interception.
|
||||
Injects a native-style Instagram search bar inside the "Viewers" modal
|
||||
whether opened from cached story data or network requests.
|
||||
========================================================================== */
|
||||
let storyViewersData = [];
|
||||
let storySearchCardEl = null;
|
||||
|
||||
function findViewersDialog() {
|
||||
const dialogs = document.querySelectorAll('div[aria-modal="true"][role="dialog"], div[role="dialog"]');
|
||||
for (const d of dialogs) {
|
||||
const headings = Array.from(d.querySelectorAll('h2, [role="heading"], div'));
|
||||
const h2 = headings.find(el => el.textContent.trim().toLowerCase() === 'viewers' && el.children.length === 0);
|
||||
if (h2) {
|
||||
// h2.parentElement is the flex row containing Close button, Viewers title, and spacer
|
||||
const headerRow = h2.parentElement;
|
||||
const scrollContainer = d.querySelector('div[style*="overflow"]') ||
|
||||
d.querySelector('div[style*="height"]') ||
|
||||
(headerRow ? headerRow.nextElementSibling : null);
|
||||
return { dialog: d, header: headerRow, scrollContainer: scrollContainer, h2: h2 };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createStorySearchCard() {
|
||||
const card = document.createElement('div');
|
||||
card.id = 'fg-story-search-card';
|
||||
card.style.cssText = `
|
||||
margin: 6px 16px 10px 16px !important;
|
||||
padding: 0 10px !important;
|
||||
height: 38px !important;
|
||||
background: #262626 !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
border-radius: 8px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
||||
color: #ffffff !important;
|
||||
box-sizing: border-box !important;
|
||||
flex-shrink: 0 !important;
|
||||
`;
|
||||
|
||||
const searchIcon = document.createElement('span');
|
||||
searchIcon.style.cssText = 'display: flex; align-items: center; color: #8e8e8e;';
|
||||
searchIcon.innerHTML = `<svg aria-label="Search" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" height="15" viewBox="0 0 24 24" width="15"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>`;
|
||||
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.id = 'fg-story-search-input';
|
||||
searchInput.type = 'text';
|
||||
searchInput.placeholder = 'Search viewers';
|
||||
searchInput.autocomplete = 'off';
|
||||
searchInput.style.cssText = `
|
||||
flex: 1 !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
color: #FAFAFA !important;
|
||||
font-size: 14px !important;
|
||||
outline: none !important;
|
||||
padding: 0 !important;
|
||||
height: 100% !important;
|
||||
font-family: inherit !important;
|
||||
`;
|
||||
|
||||
const clearBtn = document.createElement('button');
|
||||
clearBtn.type = 'button';
|
||||
clearBtn.textContent = '✕';
|
||||
clearBtn.style.cssText = `
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
color: #8e8e8e !important;
|
||||
font-size: 13px !important;
|
||||
cursor: pointer !important;
|
||||
padding: 4px !important;
|
||||
display: none !important;
|
||||
line-height: 1 !important;
|
||||
`;
|
||||
|
||||
clearBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
searchInput.value = '';
|
||||
clearBtn.style.display = 'none';
|
||||
filterViewers('');
|
||||
searchInput.focus();
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', () => {
|
||||
const val = searchInput.value;
|
||||
clearBtn.style.display = val.length > 0 ? 'block' : 'none';
|
||||
filterViewers(val.trim().toLowerCase());
|
||||
});
|
||||
|
||||
card.appendChild(searchIcon);
|
||||
card.appendChild(searchInput);
|
||||
card.appendChild(clearBtn);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function filterViewers(query) {
|
||||
const info = findViewersDialog();
|
||||
if (!info || !info.dialog) return;
|
||||
|
||||
const scrollContainer = info.scrollContainer ||
|
||||
info.dialog.querySelector('div[style*="overflow"]') ||
|
||||
info.dialog.querySelector('div[style*="height"]') ||
|
||||
info.dialog;
|
||||
|
||||
// Target viewer rows: .x1qnrgzn, or rows containing profile links
|
||||
const rows = scrollContainer.querySelectorAll('.x1qnrgzn, div[style*="display: flex"] > div:has(a[href^="/"]), div[style*="padding"] > div:has(a[href^="/"])');
|
||||
let matchedCount = 0;
|
||||
let totalCount = 0;
|
||||
|
||||
rows.forEach(row => {
|
||||
if (row.id === 'fg-story-search-card' || row.contains(document.getElementById('fg-story-search-card'))) return;
|
||||
const links = row.querySelectorAll('a[href^="/"]');
|
||||
if (links.length === 0) return;
|
||||
|
||||
totalCount++;
|
||||
if (!query) {
|
||||
row.style.display = '';
|
||||
matchedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
const text = (row.textContent || '').toLowerCase();
|
||||
let matched = text.includes(query);
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
const href = (links[i].getAttribute('href') || '').toLowerCase().replace(/\//g, '');
|
||||
if (href.includes(query)) {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
row.style.display = matched ? '' : 'none';
|
||||
if (matched) matchedCount++;
|
||||
});
|
||||
|
||||
// Handle "No results found" placeholder
|
||||
let noResultsEl = scrollContainer.querySelector('#fg-no-viewer-results');
|
||||
if (query && matchedCount === 0 && totalCount > 0) {
|
||||
if (!noResultsEl) {
|
||||
noResultsEl = document.createElement('div');
|
||||
noResultsEl.id = 'fg-no-viewer-results';
|
||||
noResultsEl.style.cssText = 'padding: 24px; text-align: center; color: #8e8e8e; font-size: 14px;';
|
||||
noResultsEl.textContent = 'No viewers found';
|
||||
scrollContainer.appendChild(noResultsEl);
|
||||
}
|
||||
noResultsEl.style.display = 'block';
|
||||
} else if (noResultsEl) {
|
||||
noResultsEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function checkAndMountStorySearch() {
|
||||
const info = findViewersDialog();
|
||||
if (!info || !info.header) {
|
||||
const existing = document.getElementById('fg-story-search-card');
|
||||
if (existing && !document.querySelector('div[aria-modal="true"][role="dialog"]')) {
|
||||
existing.remove();
|
||||
storySearchCardEl = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.dialog.querySelector('#fg-story-search-card')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const card = createStorySearchCard();
|
||||
info.header.after(card);
|
||||
storySearchCardEl = card;
|
||||
}
|
||||
|
||||
function handleStoryViewersDetected(detail) {
|
||||
if (!detail) return;
|
||||
checkAndMountStorySearch();
|
||||
|
||||
if (detail.phase === 'ready' && detail.data) {
|
||||
const data = detail.data;
|
||||
const users = data.users || [];
|
||||
const viewers = data.viewers || [];
|
||||
const userMap = new Map();
|
||||
users.forEach(u => {
|
||||
const pk = String(u.pk || u.id || '');
|
||||
if (pk) userMap.set(pk, { username: u.username || '', fullName: u.full_name || '' });
|
||||
});
|
||||
viewers.forEach(v => {
|
||||
const u = v.user || v;
|
||||
const pk = String(u.pk || u.id || '');
|
||||
if (pk) userMap.set(pk, { username: u.username || '', fullName: u.full_name || '' });
|
||||
});
|
||||
storyViewersData = Array.from(userMap.values());
|
||||
const input = document.getElementById('fg-story-search-input');
|
||||
if (input && input.value.trim()) {
|
||||
filterViewers(input.value.trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.__fgHandleStoryViewers = handleStoryViewersDetected;
|
||||
window.addEventListener('focusgram:story-viewers-detected', (e) => handleStoryViewersDetected(e.detail));
|
||||
document.addEventListener('focusgram:story-viewers-detected', (e) => handleStoryViewersDetected(e.detail));
|
||||
|
||||
// Continuous autonomous observer for Viewers modal
|
||||
const viewersObserver = new MutationObserver(() => {
|
||||
checkAndMountStorySearch();
|
||||
});
|
||||
viewersObserver.observe(document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
window.__fgSetFilterConfig = function (cfg) {
|
||||
if (typeof cfg === 'object' && cfg) {
|
||||
Object.assign(window.__fgFilterConfig, cfg);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
function isAuthPage() {
|
||||
try {
|
||||
var path = (window.location.pathname || '').toLowerCase();
|
||||
if (path.includes('/accounts/edit')) return false;
|
||||
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true;
|
||||
var c = document.cookie || '';
|
||||
if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true;
|
||||
@@ -206,6 +207,327 @@
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
7. LIVE BIO FONT STYLIST & REAL-TIME INPUT ENGINE
|
||||
Directly mounts a dedicated styling accessory bar above textarea#pepBio,
|
||||
handles bidirectional Unicode mathematical font conversion,
|
||||
and transforms typing in real time.
|
||||
========================================================================== */
|
||||
const BIO_FONT_MAPS = {
|
||||
normal: null,
|
||||
bold: { u: 0x1D400, l: 0x1D41A, d: 0x1D7CE }, // Mathematical Bold (Zero gaps)
|
||||
sans: { u: 0x1D5D4, l: 0x1D5EE, d: 0x1D7EC }, // Sans-Serif Bold (Zero gaps)
|
||||
sans_italic: { u: 0x1D608, l: 0x1D622, d: null }, // Sans-Serif Italic (Zero gaps)
|
||||
mono: { u: 0x1D670, l: 0x1D68A, d: 0x1D7F6 }, // Monospace (Zero gaps)
|
||||
gothic: { u: 0x1D56C, l: 0x1D586, d: null }, // Bold Fraktur/Gothic (Zero gaps)
|
||||
script: { u: 0x1D4D0, l: 0x1D4EA, d: null } // Bold Script/Cursive (Zero gaps)
|
||||
};
|
||||
|
||||
let activeBioStyle = 'bold';
|
||||
let activeBioTarget = null;
|
||||
let bioInlineBarEl = null;
|
||||
|
||||
function normalizeChar(char) {
|
||||
if (!char) return '';
|
||||
const cp = char.codePointAt(0);
|
||||
for (const [key, map] of Object.entries(BIO_FONT_MAPS)) {
|
||||
if (!map) continue;
|
||||
if (cp >= map.u && cp <= map.u + 25) {
|
||||
return String.fromCharCode(65 + (cp - map.u));
|
||||
}
|
||||
if (cp >= map.l && cp <= map.l + 25) {
|
||||
return String.fromCharCode(97 + (cp - map.l));
|
||||
}
|
||||
if (map.d && cp >= map.d && cp <= map.d + 9) {
|
||||
return String.fromCharCode(48 + (cp - map.d));
|
||||
}
|
||||
}
|
||||
return char;
|
||||
}
|
||||
|
||||
function convertCharToStyle(char, styleKey) {
|
||||
const map = BIO_FONT_MAPS[styleKey];
|
||||
if (!map || styleKey === 'normal') return normalizeChar(char);
|
||||
const norm = normalizeChar(char);
|
||||
const cp = norm.codePointAt(0);
|
||||
if (cp >= 65 && cp <= 90) { // A-Z
|
||||
return String.fromCodePoint(map.u + (cp - 65));
|
||||
}
|
||||
if (cp >= 97 && cp <= 122) { // a-z
|
||||
return String.fromCodePoint(map.l + (cp - 97));
|
||||
}
|
||||
if (map.d && cp >= 48 && cp <= 57) { // 0-9
|
||||
return String.fromCodePoint(map.d + (cp - 48));
|
||||
}
|
||||
return norm;
|
||||
}
|
||||
|
||||
function convertStringToStyle(text, styleKey) {
|
||||
if (!text) return '';
|
||||
return Array.from(text).map(ch => convertCharToStyle(ch, styleKey)).join('');
|
||||
}
|
||||
|
||||
function isBioTarget(el) {
|
||||
if (!el) return false;
|
||||
if (el.id === 'pepBio') return true;
|
||||
const name = (el.name || '').toLowerCase();
|
||||
const placeholder = (el.placeholder || '').toLowerCase();
|
||||
const aria = (el.getAttribute('aria-label') || '').toLowerCase();
|
||||
if (name === 'biography' || name === 'bio' || placeholder === 'bio' || aria === 'bio') return true;
|
||||
const path = (window.location.pathname || '').toLowerCase();
|
||||
if (path.includes('/accounts/edit') && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function updateBioCharCounter(target) {
|
||||
const el = target || activeBioTarget || document.getElementById('pepBio');
|
||||
const counter = document.getElementById('fg-bio-counter');
|
||||
if (!el || !counter) return;
|
||||
|
||||
const text = el.value || el.textContent || '';
|
||||
const codePoints = Array.from(text).length;
|
||||
|
||||
counter.textContent = `${codePoints} / 150`;
|
||||
if (codePoints > 140) {
|
||||
counter.style.color = '#ef4444';
|
||||
} else if (codePoints > 120) {
|
||||
counter.style.color = '#f59e0b';
|
||||
} else {
|
||||
counter.style.color = '#8e8e8e';
|
||||
}
|
||||
}
|
||||
|
||||
function setReactInputValue(input, val) {
|
||||
if (!input) return;
|
||||
try {
|
||||
const proto = window.HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : Object.getPrototypeOf(input);
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||||
if (setter) {
|
||||
setter.call(input, val);
|
||||
} else {
|
||||
input.value = val;
|
||||
}
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} catch (_) {
|
||||
input.value = val;
|
||||
}
|
||||
}
|
||||
|
||||
function applyBioStyleToTarget(styleId) {
|
||||
activeBioStyle = styleId;
|
||||
const target = activeBioTarget || document.getElementById('pepBio') || document.querySelector('textarea[aria-label="Bio"]');
|
||||
|
||||
// Update inline chip states (native Instagram Stories pill style)
|
||||
document.querySelectorAll('.fg-bio-chip').forEach(c => {
|
||||
const isSelected = c.dataset.styleId === activeBioStyle;
|
||||
c.style.background = isSelected ? '#ffffff' : '#262626';
|
||||
c.style.borderColor = isSelected ? '#ffffff' : 'rgba(255, 255, 255, 0.15)';
|
||||
c.style.color = isSelected ? '#000000' : '#d1d5db';
|
||||
c.style.fontWeight = isSelected ? '600' : '500';
|
||||
});
|
||||
|
||||
if (!target) return;
|
||||
|
||||
// Convert ONLY selected text if user made an explicit text selection
|
||||
const val = target.value || '';
|
||||
if (val && target.selectionStart !== undefined && target.selectionEnd !== undefined && target.selectionStart !== target.selectionEnd) {
|
||||
const start = target.selectionStart;
|
||||
const end = target.selectionEnd;
|
||||
const before = val.substring(0, start);
|
||||
const selected = val.substring(start, end);
|
||||
const after = val.substring(end);
|
||||
const converted = convertStringToStyle(selected, activeBioStyle);
|
||||
setReactInputValue(target, before + converted + after);
|
||||
target.setSelectionRange(start, start + converted.length);
|
||||
}
|
||||
// If NO text is selected, do NOT alter existing text!
|
||||
// activeBioStyle is set, so only characters typed afterwards will be converted.
|
||||
updateBioCharCounter(target);
|
||||
}
|
||||
|
||||
window.__fgSetBioStyle = applyBioStyleToTarget;
|
||||
|
||||
function notifyBioFocus(focused) {
|
||||
try {
|
||||
window.postMessage({ type: 'FOCUSGRAM_BIO_FOCUSED', focused: focused }, '*');
|
||||
document.dispatchEvent(new CustomEvent('focusgram:bio-focused', { detail: { focused: focused } }));
|
||||
if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.sendMessage) {
|
||||
browser.runtime.sendMessage({ action: 'bioInputFocused', focused: focused }).catch(() => {});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function mountBioInlineBar(textarea) {
|
||||
if (!textarea) return null;
|
||||
if (document.getElementById('fg-bio-inline-bar')) {
|
||||
return document.getElementById('fg-bio-inline-bar');
|
||||
}
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.id = 'fg-bio-inline-bar';
|
||||
bar.style.cssText = `
|
||||
margin: 10px 0 6px 0 !important;
|
||||
padding: 8px 10px !important;
|
||||
background: #18181c !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||
border-radius: 12px !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 6px !important;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
||||
box-sizing: border-box !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
`;
|
||||
|
||||
const topRow = document.createElement('div');
|
||||
topRow.style.cssText = `
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important;
|
||||
font-size: 12px !important;
|
||||
color: #d1d5db !important;
|
||||
`;
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.innerHTML = `<span style="font-size: 12px; font-weight: 600; color: #8e8e8e; letter-spacing: 0.3px; text-transform: uppercase;">Bio Font</span>`;
|
||||
|
||||
const counterSpan = document.createElement('span');
|
||||
counterSpan.id = 'fg-bio-counter';
|
||||
counterSpan.style.cssText = `
|
||||
font-size: 11px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #8e8e8e !important;
|
||||
background: rgba(255, 255, 255, 0.06) !important;
|
||||
padding: 2px 8px !important;
|
||||
border-radius: 10px !important;
|
||||
`;
|
||||
counterSpan.textContent = '0 / 150';
|
||||
|
||||
topRow.appendChild(titleSpan);
|
||||
topRow.appendChild(counterSpan);
|
||||
|
||||
const chipsRow = document.createElement('div');
|
||||
chipsRow.style.cssText = `
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 6px !important;
|
||||
overflow-x: auto !important;
|
||||
scrollbar-width: none !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
padding: 2px 0 !important;
|
||||
`;
|
||||
|
||||
const STYLES = [
|
||||
{ id: 'normal', label: 'Normal' },
|
||||
{ id: 'bold', label: 'Bold' },
|
||||
{ id: 'sans', label: 'Sans' },
|
||||
{ id: 'sans_italic', label: 'Italic' },
|
||||
{ id: 'mono', label: 'Mono' },
|
||||
{ id: 'gothic', label: 'Gothic' },
|
||||
{ id: 'script', label: 'Script' }
|
||||
];
|
||||
|
||||
STYLES.forEach(st => {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.className = 'fg-bio-chip';
|
||||
chip.dataset.styleId = st.id;
|
||||
chip.textContent = st.label;
|
||||
const isSelected = st.id === activeBioStyle;
|
||||
chip.style.cssText = `
|
||||
padding: 5px 12px !important;
|
||||
border-radius: 14px !important;
|
||||
border: 1px solid ${isSelected ? '#ffffff' : 'rgba(255, 255, 255, 0.15)'} !important;
|
||||
background: ${isSelected ? '#ffffff' : '#262626'} !important;
|
||||
color: ${isSelected ? '#000000' : '#d1d5db'} !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: ${isSelected ? '600' : '500'} !important;
|
||||
white-space: nowrap !important;
|
||||
cursor: pointer !important;
|
||||
outline: none !important;
|
||||
transition: background 0.15s ease, color 0.15s ease !important;
|
||||
`;
|
||||
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
applyBioStyleToTarget(st.id);
|
||||
if (textarea) textarea.focus();
|
||||
});
|
||||
|
||||
chipsRow.appendChild(chip);
|
||||
});
|
||||
|
||||
bar.appendChild(topRow);
|
||||
bar.appendChild(chipsRow);
|
||||
|
||||
if (textarea.parentElement) {
|
||||
textarea.parentElement.insertBefore(bar, textarea);
|
||||
} else {
|
||||
textarea.before(bar);
|
||||
}
|
||||
|
||||
bioInlineBarEl = bar;
|
||||
updateBioCharCounter(textarea);
|
||||
return bar;
|
||||
}
|
||||
|
||||
function checkAndMountBioStylist() {
|
||||
const bioEl = document.getElementById('pepBio') ||
|
||||
document.querySelector('textarea[aria-label="Bio"]') ||
|
||||
document.querySelector('textarea[placeholder="Bio"]') ||
|
||||
document.querySelector('textarea[name="biography"]');
|
||||
|
||||
if (bioEl) {
|
||||
activeBioTarget = bioEl;
|
||||
mountBioInlineBar(bioEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Intercept keystrokes for real-time live typing transformation
|
||||
document.addEventListener('beforeinput', (e) => {
|
||||
if (!isBioTarget(e.target) || activeBioStyle === 'normal') return;
|
||||
if (e.data && e.inputType === 'insertText') {
|
||||
e.preventDefault();
|
||||
const converted = convertStringToStyle(e.data, activeBioStyle);
|
||||
document.execCommand('insertText', false, converted);
|
||||
updateBioCharCounter(e.target);
|
||||
}
|
||||
}, true);
|
||||
|
||||
document.addEventListener('input', (e) => {
|
||||
if (!isBioTarget(e.target)) return;
|
||||
updateBioCharCounter(e.target);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('focusin', (e) => {
|
||||
if (isBioTarget(e.target)) {
|
||||
activeBioTarget = e.target;
|
||||
mountBioInlineBar(e.target);
|
||||
notifyBioFocus(true);
|
||||
updateBioCharCounter(e.target);
|
||||
}
|
||||
}, true);
|
||||
|
||||
document.addEventListener('focusout', (e) => {
|
||||
if (isBioTarget(e.target)) {
|
||||
setTimeout(() => {
|
||||
const active = document.activeElement;
|
||||
if (!isBioTarget(active)) {
|
||||
notifyBioFocus(false);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Watch for dynamic rendering of Bio textarea on /accounts/edit/
|
||||
const bioObserver = new MutationObserver(() => {
|
||||
checkAndMountBioStylist();
|
||||
});
|
||||
bioObserver.observe(document.documentElement, { childList: true, subtree: true });
|
||||
setInterval(checkAndMountBioStylist, 500);
|
||||
|
||||
// Initialize immediately or upon DOM ready
|
||||
injectNativeStyles();
|
||||
applyScrollSmoothing();
|
||||
@@ -213,6 +535,7 @@
|
||||
setupMediaUploadBridge();
|
||||
enhanceFileInputsForVideoSupport();
|
||||
preventLandscapeOrientationLock();
|
||||
checkAndMountBioStylist();
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -221,8 +544,9 @@
|
||||
setupMediaUploadBridge();
|
||||
enhanceFileInputsForVideoSupport();
|
||||
preventLandscapeOrientationLock();
|
||||
checkAndMountBioStylist();
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[FocusGram] Native Feel, Touch Smoothing, Media Upload Bridge & Video Support active.');
|
||||
console.log('[FocusGram] Native Feel, Bio Font Stylist & Video Support active.');
|
||||
})();
|
||||
@@ -27,6 +27,14 @@ class MainActivity : FragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
try {
|
||||
val appIcon = com.ujwal.focusgram.core.datastore.SettingsRepository(this).getSettingsSync().appIcon
|
||||
com.ujwal.focusgram.core.utils.AppIconManager.applyTaskDescription(this, appIcon)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun applyHighRefreshRate() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
|
||||
@@ -8,12 +8,14 @@ import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.ujwal.focusgram.core.model.FocusSettings
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONObject
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "focusgram_settings")
|
||||
|
||||
@@ -79,6 +81,15 @@ class SettingsRepository(private val context: Context) {
|
||||
notifySessionEnd = prefs[KEY_NOTIFY_SESSION_END] ?: false,
|
||||
notifyPersistent = prefs[KEY_NOTIFY_PERSISTENT] ?: false,
|
||||
portraitOnly = prefs[KEY_PORTRAIT_ONLY] ?: true,
|
||||
appIcon = prefs[KEY_APP_ICON] ?: "default",
|
||||
customIconsEnabled = prefs[KEY_CUSTOM_ICONS_ENABLED] ?: false,
|
||||
|
||||
chatLockMode = prefs[KEY_CHAT_LOCK_MODE] ?: if (prefs[KEY_CHAT_LOCK_ALL] == true) "whole_dm" else "individual",
|
||||
chatLockAll = (prefs[KEY_CHAT_LOCK_MODE] ?: if (prefs[KEY_CHAT_LOCK_ALL] == true) "whole_dm" else "individual") == "whole_dm",
|
||||
lockedChatIds = prefs[KEY_LOCKED_CHAT_IDS] ?: emptySet(),
|
||||
lockedChatUsernames = parseUsernamesMap(prefs[KEY_LOCKED_CHAT_USERNAMES]),
|
||||
useSeparateChatPin = prefs[KEY_USE_SEPARATE_CHAT_PIN] ?: false,
|
||||
chatBiometricEnabled = prefs[KEY_CHAT_BIOMETRIC_ENABLED] ?: true,
|
||||
|
||||
ghostModeEnabled = prefs[KEY_GHOST_MODE_ENABLED] ?: false,
|
||||
ghostHideStorySeen = prefs[KEY_GHOST_HIDE_STORY_SEEN] ?: true,
|
||||
@@ -87,6 +98,22 @@ class SettingsRepository(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseUsernamesMap(jsonStr: String?): Map<String, String> {
|
||||
if (jsonStr.isNullOrBlank()) return emptyMap()
|
||||
return try {
|
||||
val json = JSONObject(jsonStr)
|
||||
val map = mutableMapOf<String, String>()
|
||||
val keys = json.keys()
|
||||
while (keys.hasNext()) {
|
||||
val k = keys.next()
|
||||
map[k] = json.optString(k)
|
||||
}
|
||||
map
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
val lastSessionEndTimestampFlow: Flow<Long> = context.dataStore.data.map { prefs ->
|
||||
prefs[KEY_LAST_SESSION_END_TIMESTAMP] ?: 0L
|
||||
}
|
||||
@@ -237,6 +264,105 @@ class SettingsRepository(private val context: Context) {
|
||||
context.dataStore.edit { it[key] = value }
|
||||
}
|
||||
|
||||
suspend fun setAppIcon(icon: String) = setString(KEY_APP_ICON, icon)
|
||||
suspend fun setCustomIconsEnabled(enabled: Boolean) = setBoolean(KEY_CUSTOM_ICONS_ENABLED, enabled)
|
||||
suspend fun setChatLockMode(mode: String) {
|
||||
val cleanMode = when (mode.lowercase().trim()) {
|
||||
"whole_dm", "whole", "all" -> "whole_dm"
|
||||
"individual", "individual_dms", "single" -> "individual"
|
||||
else -> "off"
|
||||
}
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_CHAT_LOCK_MODE] = cleanMode
|
||||
prefs[KEY_CHAT_LOCK_ALL] = (cleanMode == "whole_dm")
|
||||
}
|
||||
}
|
||||
suspend fun setChatLockAll(enabled: Boolean) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_CHAT_LOCK_ALL] = enabled
|
||||
prefs[KEY_CHAT_LOCK_MODE] = if (enabled) "whole_dm" else "individual"
|
||||
}
|
||||
}
|
||||
suspend fun setLockedChatIds(ids: Set<String>) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_LOCKED_CHAT_IDS] = ids
|
||||
}
|
||||
}
|
||||
suspend fun addLockedChatId(id: String) {
|
||||
val clean = id.trim().lowercase().removePrefix("@")
|
||||
if (clean.isBlank()) return
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[KEY_LOCKED_CHAT_IDS] ?: emptySet()
|
||||
prefs[KEY_LOCKED_CHAT_IDS] = current + clean
|
||||
}
|
||||
}
|
||||
suspend fun removeLockedChatId(id: String) {
|
||||
val clean = id.trim().lowercase().removePrefix("@")
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[KEY_LOCKED_CHAT_IDS] ?: emptySet()
|
||||
val usernamesMap = parseUsernamesMap(prefs[KEY_LOCKED_CHAT_USERNAMES]).toMutableMap()
|
||||
val mappedUser = usernamesMap[clean]
|
||||
val toRemove = buildSet {
|
||||
add(clean)
|
||||
if (!mappedUser.isNullOrBlank()) add(mappedUser)
|
||||
}
|
||||
prefs[KEY_LOCKED_CHAT_IDS] = current - toRemove
|
||||
usernamesMap.remove(clean)
|
||||
if (!mappedUser.isNullOrBlank()) usernamesMap.remove(mappedUser)
|
||||
prefs[KEY_LOCKED_CHAT_USERNAMES] = JSONObject(usernamesMap as Map<*, *>).toString()
|
||||
}
|
||||
}
|
||||
suspend fun toggleLockedChat(threadId: String, username: String? = null): Boolean {
|
||||
val cleanId = threadId.trim().lowercase()
|
||||
if (cleanId.isBlank()) return false
|
||||
val cleanUser = username?.trim()?.lowercase()?.removePrefix("@")
|
||||
var isNowLocked = false
|
||||
context.dataStore.edit { prefs ->
|
||||
val currentIds = prefs[KEY_LOCKED_CHAT_IDS] ?: emptySet()
|
||||
val usernamesMap = parseUsernamesMap(prefs[KEY_LOCKED_CHAT_USERNAMES]).toMutableMap()
|
||||
val existingUser = usernamesMap[cleanId] ?: cleanUser
|
||||
val matchesExisting = currentIds.contains(cleanId) ||
|
||||
(!cleanUser.isNullOrBlank() && currentIds.contains(cleanUser)) ||
|
||||
(!existingUser.isNullOrBlank() && currentIds.contains(existingUser))
|
||||
|
||||
if (matchesExisting) {
|
||||
val toRemove = buildSet {
|
||||
add(cleanId)
|
||||
if (!cleanUser.isNullOrBlank()) add(cleanUser)
|
||||
if (!existingUser.isNullOrBlank()) add(existingUser)
|
||||
}
|
||||
prefs[KEY_LOCKED_CHAT_IDS] = currentIds - toRemove
|
||||
usernamesMap.remove(cleanId)
|
||||
if (!cleanUser.isNullOrBlank()) usernamesMap.remove(cleanUser)
|
||||
if (!existingUser.isNullOrBlank()) usernamesMap.remove(existingUser)
|
||||
isNowLocked = false
|
||||
} else {
|
||||
prefs[KEY_LOCKED_CHAT_IDS] = currentIds + cleanId
|
||||
val effectiveUser = if (!cleanUser.isNullOrBlank()) cleanUser else existingUser
|
||||
if (!effectiveUser.isNullOrBlank()) {
|
||||
usernamesMap[cleanId] = effectiveUser
|
||||
usernamesMap[effectiveUser] = cleanId
|
||||
}
|
||||
isNowLocked = true
|
||||
}
|
||||
prefs[KEY_LOCKED_CHAT_USERNAMES] = JSONObject(usernamesMap as Map<*, *>).toString()
|
||||
}
|
||||
return isNowLocked
|
||||
}
|
||||
suspend fun mapThreadUsername(threadId: String, username: String) {
|
||||
val cleanId = threadId.trim().lowercase()
|
||||
val cleanUser = username.trim().lowercase().removePrefix("@")
|
||||
if (cleanId.isBlank() || cleanUser.isBlank()) return
|
||||
context.dataStore.edit { prefs ->
|
||||
val usernamesMap = parseUsernamesMap(prefs[KEY_LOCKED_CHAT_USERNAMES]).toMutableMap()
|
||||
usernamesMap[cleanId] = cleanUser
|
||||
usernamesMap[cleanUser] = cleanId
|
||||
prefs[KEY_LOCKED_CHAT_USERNAMES] = JSONObject(usernamesMap as Map<*, *>).toString()
|
||||
}
|
||||
}
|
||||
suspend fun setUseSeparateChatPin(enabled: Boolean) = setBoolean(KEY_USE_SEPARATE_CHAT_PIN, enabled)
|
||||
suspend fun setChatBiometricEnabled(enabled: Boolean) = setBoolean(KEY_CHAT_BIOMETRIC_ENABLED, enabled)
|
||||
|
||||
companion object {
|
||||
val KEY_NO_ADS = booleanPreferencesKey("no_ads")
|
||||
val KEY_NO_STORIES = booleanPreferencesKey("no_stories")
|
||||
@@ -295,5 +421,14 @@ class SettingsRepository(private val context: Context) {
|
||||
val KEY_GHOST_HIDE_DIRECT_SEEN = booleanPreferencesKey("ghost_hide_direct_seen")
|
||||
val KEY_GHOST_HIDE_TYPING = booleanPreferencesKey("ghost_hide_typing")
|
||||
val KEY_LAST_SESSION_END_TIMESTAMP = longPreferencesKey("last_session_end_timestamp")
|
||||
|
||||
val KEY_APP_ICON = stringPreferencesKey("app_icon")
|
||||
val KEY_CUSTOM_ICONS_ENABLED = booleanPreferencesKey("custom_icons_enabled")
|
||||
val KEY_CHAT_LOCK_MODE = stringPreferencesKey("chat_lock_mode")
|
||||
val KEY_CHAT_LOCK_ALL = booleanPreferencesKey("chat_lock_all")
|
||||
val KEY_LOCKED_CHAT_IDS = stringSetPreferencesKey("locked_chat_ids")
|
||||
val KEY_LOCKED_CHAT_USERNAMES = stringPreferencesKey("locked_chat_usernames")
|
||||
val KEY_USE_SEPARATE_CHAT_PIN = booleanPreferencesKey("use_separate_chat_pin")
|
||||
val KEY_CHAT_BIOMETRIC_ENABLED = booleanPreferencesKey("chat_biometric_enabled")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.ujwal.focusgram.core.model.IslandMode
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.mozilla.geckoview.GeckoResult
|
||||
import org.mozilla.geckoview.GeckoRuntime
|
||||
@@ -28,6 +29,7 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
|
||||
var onOpenSettings: (() -> Unit)? = null
|
||||
var onInputFocused: ((Boolean) -> Unit)? = null
|
||||
var onBioInputFocused: ((Boolean) -> Unit)? = null
|
||||
var onUrlChanged: ((String) -> Unit)? = null
|
||||
var onModalOrChatActive: ((Boolean) -> Unit)? = null
|
||||
var onThemeChanged: ((String) -> Unit)? = null
|
||||
@@ -37,6 +39,9 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
var onDownloadError: ((error: String) -> Unit)? = null
|
||||
var onDownloadStarted: ((filename: String) -> Unit)? = null
|
||||
var onAuthStateChanged: ((isAuthenticated: Boolean, isAuthPage: Boolean) -> Unit)? = null
|
||||
var onThreadUsernameMapped: ((threadId: String, usernames: List<String>) -> Unit)? = null
|
||||
var onToggleChatLock: ((threadId: String, username: String?) -> Unit)? = null
|
||||
var onRequestUnlockChat: ((threadId: String, username: String?, targetUrl: String?) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Installs and activates built-in content blocking and FocusGram controller from APK assets.
|
||||
@@ -136,6 +141,11 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
Log.d(TAG, "Received inputFocused from Instagram web: $focused")
|
||||
mainHandler.post { onInputFocused?.invoke(focused) }
|
||||
}
|
||||
"bioInputFocused" -> {
|
||||
val focused = json.optBoolean("focused", false)
|
||||
Log.d(TAG, "Received bioInputFocused from Instagram web: $focused")
|
||||
mainHandler.post { onBioInputFocused?.invoke(focused) }
|
||||
}
|
||||
"urlChanged" -> {
|
||||
val url = json.optString("url")
|
||||
if (url.isNotEmpty()) {
|
||||
@@ -215,6 +225,52 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
onAuthStateChanged?.invoke(isAuthenticated, isAuthPage)
|
||||
}
|
||||
}
|
||||
"threadUsernameMapped" -> {
|
||||
val threadId = json.optString("threadId")
|
||||
val usernamesArr = json.optJSONArray("usernames")
|
||||
val singleUser = json.optString("username").takeIf { it.isNotBlank() }
|
||||
val displayName = json.optString("displayName").takeIf { it.isNotBlank() }
|
||||
val list = mutableListOf<String>()
|
||||
if (usernamesArr != null) {
|
||||
for (i in 0 until usernamesArr.length()) {
|
||||
val u = usernamesArr.optString(i)
|
||||
if (u.isNotBlank()) list.add(u)
|
||||
}
|
||||
}
|
||||
if (singleUser != null && !list.contains(singleUser)) {
|
||||
list.add(0, singleUser)
|
||||
}
|
||||
if (displayName != null && !list.contains(displayName)) {
|
||||
list.add(displayName)
|
||||
}
|
||||
if (threadId.isNotBlank()) {
|
||||
Log.d(TAG, "Received threadUsernameMapped from web: threadId=$threadId, usernames=$list, displayName=$displayName")
|
||||
mainHandler.post {
|
||||
onThreadUsernameMapped?.invoke(threadId, list)
|
||||
}
|
||||
}
|
||||
}
|
||||
"requestUnlockChat" -> {
|
||||
val threadId = json.optString("threadId")
|
||||
val username = json.optString("username").takeIf { it.isNotBlank() }
|
||||
val targetUrl = json.optString("targetUrl").takeIf { it.isNotBlank() }
|
||||
if (threadId.isNotBlank()) {
|
||||
Log.d(TAG, "Received requestUnlockChat from web: threadId=$threadId, username=$username, targetUrl=$targetUrl")
|
||||
mainHandler.post {
|
||||
onRequestUnlockChat?.invoke(threadId, username, targetUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
"toggleChatLock" -> {
|
||||
val threadId = json.optString("threadId")
|
||||
val username = json.optString("username").takeIf { it.isNotBlank() }
|
||||
if (threadId.isNotBlank()) {
|
||||
Log.d(TAG, "Received toggleChatLock from web: threadId=$threadId, username=$username")
|
||||
mainHandler.post {
|
||||
onToggleChatLock?.invoke(threadId, username)
|
||||
}
|
||||
}
|
||||
}
|
||||
"getSettings" -> {
|
||||
activePort?.let { sendSettingsToPort(it, cachedSettings) }
|
||||
}
|
||||
@@ -254,6 +310,11 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
put("ghostHideStorySeen", settings.ghostHideStorySeen)
|
||||
put("ghostHideDirectSeen", settings.ghostHideDirectSeen)
|
||||
put("ghostHideTypingIndicator", settings.ghostHideTypingIndicator)
|
||||
|
||||
put("chatLockMode", settings.chatLockMode)
|
||||
put("chatLockAll", settings.chatLockAll)
|
||||
put("lockedChatIds", JSONArray(settings.lockedChatIds.toList()))
|
||||
put("lockedChatUsernames", JSONObject(settings.lockedChatUsernames))
|
||||
}
|
||||
|
||||
val msg = JSONObject().apply {
|
||||
@@ -262,12 +323,45 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
put("revealMode", settings.unblurMode)
|
||||
}
|
||||
port.postMessage(msg)
|
||||
Log.d(TAG, "Broadcast settings to WebExtension: noAds=${settings.noAds}, ghost=${settings.ghostModeEnabled}, revealMode=${settings.unblurMode}")
|
||||
Log.d(TAG, "Broadcast settings to WebExtension: noAds=${settings.noAds}, ghost=${settings.ghostModeEnabled}, chatLockAll=${settings.chatLockAll}, lockedChats=${settings.lockedChatIds.size}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send settings to WebExtension port", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Informs WebExtension content script that a locked chat has successfully been unlocked.
|
||||
*/
|
||||
fun notifyChatUnlocked(threadId: String, isAll: Boolean = false) {
|
||||
try {
|
||||
val msg = JSONObject().apply {
|
||||
put("action", "unlockChat")
|
||||
put("threadId", threadId)
|
||||
put("all", isAll)
|
||||
}
|
||||
activePort?.postMessage(msg)
|
||||
Log.d(TAG, "Sent unlockChat to WebExtension port: threadId=$threadId, all=$isAll")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed sending unlockChat to WebExtension", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Informs WebExtension content script that a conversation has been locked.
|
||||
*/
|
||||
fun notifyChatLocked(threadId: String) {
|
||||
try {
|
||||
val msg = JSONObject().apply {
|
||||
put("action", "lockChat")
|
||||
put("threadId", threadId)
|
||||
}
|
||||
activePort?.postMessage(msg)
|
||||
Log.d(TAG, "Sent lockChat to WebExtension port: threadId=$threadId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed sending lockChat to WebExtension", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Senior Blueprint: Sets feed post reveal mode ("tap" or "hold") dynamically.
|
||||
*/
|
||||
@@ -380,6 +474,23 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the active Unicode font style selection to the WebExtension content script
|
||||
* for real-time live bio transformation.
|
||||
*/
|
||||
fun setBioStyle(style: String) {
|
||||
try {
|
||||
val msg = JSONObject().apply {
|
||||
put("action", "setBioStyle")
|
||||
put("style", style)
|
||||
}
|
||||
activePort?.postMessage(msg)
|
||||
Log.d(TAG, "Sent setBioStyle ($style) to WebExtension port")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send setBioStyle to port: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ExtensionManager"
|
||||
const val CONTENT_BLOCKER_EXTENSION_ID = "uBlock0@raymondhill.net"
|
||||
|
||||
@@ -118,6 +118,25 @@ class FocusGramNavigationDelegate(
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Location changed to: $it")
|
||||
val lower = it.lowercase()
|
||||
if (lower.contains("/direct/t/")) {
|
||||
val path = try { Uri.parse(it).path?.lowercase() ?: "" } catch (_: Exception) { "" }
|
||||
val tid = path.removePrefix("/direct/t/").trim('/').split('/').firstOrNull()?.trim() ?: ""
|
||||
val settings = sessionManager.currentSettings
|
||||
val isLocked = if (settings.chatLockMode == "off") {
|
||||
false
|
||||
} else if (settings.chatLockMode == "whole_dm" || settings.chatLockAll) {
|
||||
true
|
||||
} else {
|
||||
settings.lockedChatIds.any { lockedId ->
|
||||
val clean = lockedId.trim().lowercase().removePrefix("@")
|
||||
clean.equals(tid, ignoreCase = true) || lower.contains(clean)
|
||||
}
|
||||
}
|
||||
if (isLocked) {
|
||||
session.loadUri("javascript:(function(){if(typeof window.__fgApplyPrivacyVeil==='function'){window.__fgApplyPrivacyVeil();}})()")
|
||||
}
|
||||
}
|
||||
sessionManager.updateCurrentUrl(it)
|
||||
onRouteChanged?.invoke(it)
|
||||
sessionManager.injectBottomBarTrimmer()
|
||||
|
||||
@@ -52,7 +52,7 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
|
||||
isAuthenticated = auth
|
||||
if (auth && changed) {
|
||||
val url = _currentUrl.value.lowercase()
|
||||
val isAuth = url.contains("/accounts") || url.contains("/login") || url.contains("/signup")
|
||||
val isAuth = (url.contains("/accounts") && !url.contains("/accounts/edit")) || url.contains("/login") || url.contains("/signup")
|
||||
if (!isAuth) {
|
||||
injectBottomBarTrimmer()
|
||||
userScriptManager?.injectAll(session, currentSettings)
|
||||
@@ -91,7 +91,7 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
|
||||
_progress.value = 100
|
||||
if (success) {
|
||||
val url = _currentUrl.value.lowercase()
|
||||
val isAuth = url.contains("/accounts") || url.contains("/login") || url.contains("/signup")
|
||||
val isAuth = (url.contains("/accounts") && !url.contains("/accounts/edit")) || url.contains("/login") || url.contains("/signup")
|
||||
if (isAuthenticated && !isAuth) {
|
||||
injectBottomBarTrimmer()
|
||||
userScriptManager?.injectAll(session, currentSettings)
|
||||
@@ -212,11 +212,15 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
|
||||
target = document.querySelector('svg[aria-label="Reels"]')?.closest('a') ||
|
||||
document.querySelector('a[href*="/reels"]') ||
|
||||
document.querySelector('a[href*="/reel/"]');
|
||||
} else if (p.indexOf('direct') !== -1) {
|
||||
} else if (p == "/direct" || p == "/direct/" || p == "/direct/inbox" || p == "/direct/inbox/") {
|
||||
target = document.querySelector('svg[aria-label="Messages"]')?.closest('a') ||
|
||||
document.querySelector('svg[aria-label="Direct"]')?.closest('a') ||
|
||||
document.querySelector('a[href*="/direct/inbox"]') ||
|
||||
document.querySelector('a[href*="/direct"]');
|
||||
document.querySelector('a[href="/direct/inbox/"]') ||
|
||||
document.querySelector('a[href="/direct/inbox"]') ||
|
||||
document.querySelector('a[href="/direct/"]');
|
||||
} else if (p.indexOf('/direct/t/') !== -1) {
|
||||
target = document.querySelector('a[href*="' + p + '"]') ||
|
||||
document.querySelector('a[href*="' + p.replace(/\/$/, '') + '"]');
|
||||
}
|
||||
if (!target) {
|
||||
target = document.querySelector('a[href="' + p + '"]') ||
|
||||
@@ -300,7 +304,7 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
|
||||
val js = """javascript:(function() {
|
||||
try {
|
||||
var p = (window.location.pathname || '').toLowerCase();
|
||||
if (p.startsWith('/direct') || p.startsWith('/accounts') || p.startsWith('/login') || p.startsWith('/signup')) return;
|
||||
if (p.startsWith('/direct') || (p.startsWith('/accounts') && !p.startsWith('/accounts/edit')) || p.startsWith('/login') || p.startsWith('/signup')) return;
|
||||
var c = document.cookie || '';
|
||||
if ((p === '/' || p === '') && !c.includes('sessionid=') && !c.includes('ds_user_id=')) return;
|
||||
if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return;
|
||||
|
||||
@@ -30,6 +30,8 @@ class UserScriptManager(private val context: Context) {
|
||||
* and broadcasts updates to active scripts.
|
||||
*/
|
||||
fun injectConfig(session: GeckoSession, settings: FocusSettings) {
|
||||
val lockedIdsJson = org.json.JSONArray(settings.lockedChatIds.map { it.lowercase().trim().removePrefix("@") }).toString()
|
||||
val lockedUsersJson = org.json.JSONObject(settings.lockedChatUsernames).toString()
|
||||
val configJs = """
|
||||
(function() {
|
||||
window.__FOCUSGRAM_CONFIG__ = {
|
||||
@@ -59,6 +61,24 @@ class UserScriptManager(private val context: Context) {
|
||||
igSeen: ${settings.ghostHideDirectSeen},
|
||||
igTyping: ${settings.ghostHideTypingIndicator}
|
||||
};
|
||||
window.__fgChatLockMode = '${settings.chatLockMode}';
|
||||
window.__fgChatLockAll = ${settings.chatLockAll || settings.chatLockMode == "whole_dm"};
|
||||
window.__fgLockedChatIds = $lockedIdsJson;
|
||||
window.__fgLockedChatUsernames = $lockedUsersJson;
|
||||
window.__fgThreadUsernameMap = $lockedUsersJson;
|
||||
|
||||
const syncDetail = {
|
||||
chatLockMode: '${settings.chatLockMode}',
|
||||
chatLockAll: ${settings.chatLockAll || settings.chatLockMode == "whole_dm"},
|
||||
lockedChatIds: $lockedIdsJson,
|
||||
lockedChatUsernames: $lockedUsersJson,
|
||||
threadUsernameMap: $lockedUsersJson
|
||||
};
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('focusgram:chat-lock-sync', { detail: syncDetail }));
|
||||
document.dispatchEvent(new CustomEvent('focusgram:chat-lock-sync', { detail: syncDetail }));
|
||||
} catch(_) {}
|
||||
|
||||
if (window.__focusgramUpdateStyles) {
|
||||
window.__focusgramUpdateStyles();
|
||||
}
|
||||
|
||||
@@ -63,6 +63,16 @@ data class FocusSettings(
|
||||
val notifySessionEnd: Boolean = false,
|
||||
val notifyPersistent: Boolean = false,
|
||||
val portraitOnly: Boolean = true,
|
||||
val appIcon: String = "default",
|
||||
val customIconsEnabled: Boolean = false,
|
||||
|
||||
// Chat Lock & Direct Message Privacy
|
||||
val chatLockMode: String = "individual", // "off", "individual", "whole_dm"
|
||||
val chatLockAll: Boolean = false,
|
||||
val lockedChatIds: Set<String> = emptySet(),
|
||||
val lockedChatUsernames: Map<String, String> = emptyMap(),
|
||||
val useSeparateChatPin: Boolean = false,
|
||||
val chatBiometricEnabled: Boolean = true,
|
||||
|
||||
// Ghost Mode (Privacy & Stealth)
|
||||
val ghostModeEnabled: Boolean = false,
|
||||
|
||||
@@ -44,7 +44,7 @@ enum class IslandMode {
|
||||
val path = extractPath(url)
|
||||
|
||||
return when {
|
||||
path.startsWith("/accounts/") || path.startsWith("/login") || path.startsWith("/signup") -> HIDDEN
|
||||
(path.startsWith("/accounts/") && !path.startsWith("/accounts/edit")) || path.startsWith("/login") || path.startsWith("/signup") -> HIDDEN
|
||||
path.startsWith("/direct") -> HIDDEN
|
||||
path.contains("/comments") -> HIDDEN
|
||||
path.startsWith("/stories/") -> HIDDEN
|
||||
|
||||
@@ -82,6 +82,46 @@ class EncryptedStorage(context: Context) {
|
||||
Log.i(TAG, "App Lock PIN removed from vault.")
|
||||
}
|
||||
|
||||
// --- Chat Lock PIN Management ---
|
||||
|
||||
fun saveChatPin(pin: String) {
|
||||
val hashedPin = hashPin("chat_$pin")
|
||||
securePrefs.edit()
|
||||
.putString(KEY_CHAT_PIN_HASH, hashedPin)
|
||||
.putBoolean(KEY_HAS_CHAT_PIN, true)
|
||||
.apply()
|
||||
resetFailedAttempts()
|
||||
Log.i(TAG, "Chat Lock PIN securely stored in MasterKey AES-GCM vault.")
|
||||
}
|
||||
|
||||
fun verifyChatPin(enteredPin: String): Boolean {
|
||||
if (isLockedOut()) {
|
||||
Log.w(TAG, "Chat PIN verification rejected: Vault is currently locked out.")
|
||||
return false
|
||||
}
|
||||
val storedHash = securePrefs.getString(KEY_CHAT_PIN_HASH, null) ?: return false
|
||||
val enteredHash = hashPin("chat_$enteredPin")
|
||||
val isValid = storedHash == enteredHash
|
||||
if (isValid) {
|
||||
resetFailedAttempts()
|
||||
} else {
|
||||
recordFailedAttempt()
|
||||
}
|
||||
return isValid
|
||||
}
|
||||
|
||||
fun hasChatPin(): Boolean {
|
||||
return securePrefs.getBoolean(KEY_HAS_CHAT_PIN, false) && securePrefs.getString(KEY_CHAT_PIN_HASH, null) != null
|
||||
}
|
||||
|
||||
fun clearChatPin() {
|
||||
securePrefs.edit()
|
||||
.remove(KEY_CHAT_PIN_HASH)
|
||||
.putBoolean(KEY_HAS_CHAT_PIN, false)
|
||||
.apply()
|
||||
Log.i(TAG, "Chat Lock PIN removed from vault.")
|
||||
}
|
||||
|
||||
// --- Brute Force Protection ---
|
||||
|
||||
fun isLockedOut(): Boolean {
|
||||
@@ -138,6 +178,8 @@ class EncryptedStorage(context: Context) {
|
||||
|
||||
private const val KEY_PIN_HASH = "vault_pin_hash"
|
||||
private const val KEY_HAS_PIN = "vault_has_pin"
|
||||
private const val KEY_CHAT_PIN_HASH = "vault_chat_pin_hash"
|
||||
private const val KEY_HAS_CHAT_PIN = "vault_has_chat_pin"
|
||||
private const val KEY_FAILED_ATTEMPTS = "vault_failed_attempts"
|
||||
private const val KEY_LOCKOUT_UNTIL = "vault_lockout_until"
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.ujwal.focusgram.core.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import com.ujwal.focusgram.MainActivity
|
||||
import com.ujwal.focusgram.R
|
||||
|
||||
/**
|
||||
* AppIconManager
|
||||
* Controls dynamic launcher icon switching via Android PackageManager activity-alias toggles.
|
||||
* LITERALLY replaces the app icon across Home Screen, App Drawer, Recents Page, and App Info page.
|
||||
* Safe, purely client-side, with DONT_KILL_APP flag to avoid killing running tasks.
|
||||
*/
|
||||
object AppIconManager {
|
||||
private const val TAG = "AppIconManager"
|
||||
|
||||
enum class IconOption(
|
||||
val id: String,
|
||||
val aliasSuffix: String,
|
||||
val displayName: String,
|
||||
val subtitle: String,
|
||||
val category: String,
|
||||
@DrawableRes val iconRes: Int
|
||||
) {
|
||||
// --- Core Icons ---
|
||||
DEFAULT(
|
||||
id = "default",
|
||||
aliasSuffix = "MainActivityDefault",
|
||||
displayName = "FocusGram Classic",
|
||||
subtitle = "Signature focus gradient on dark",
|
||||
category = "Core",
|
||||
iconRes = R.mipmap.ic_launcher
|
||||
),
|
||||
PITCH_BLACK(
|
||||
id = "pitch_black",
|
||||
aliasSuffix = "MainActivityPitchBlack",
|
||||
displayName = "Pitch Black",
|
||||
subtitle = "True OLED stealth black aesthetic",
|
||||
category = "Core",
|
||||
iconRes = R.mipmap.ic_launcher_pitch_black
|
||||
),
|
||||
ALL_WHITE(
|
||||
id = "all_white",
|
||||
aliasSuffix = "MainActivityAllWhite",
|
||||
displayName = "All White",
|
||||
subtitle = "Minimalist bright white with clean camera",
|
||||
category = "Core",
|
||||
iconRes = R.mipmap.ic_launcher_all_white
|
||||
),
|
||||
|
||||
// --- Custom Redesigns (alt-logos) ---
|
||||
REDESIGN(
|
||||
id = "redesign",
|
||||
aliasSuffix = "MainActivityRedesign",
|
||||
displayName = "FocusGram Redesign",
|
||||
subtitle = "Official modern brand redesign",
|
||||
category = "Redesigns",
|
||||
iconRes = R.mipmap.ic_launcher_redesign
|
||||
),
|
||||
ABSTRACT(
|
||||
id = "abstract",
|
||||
aliasSuffix = "MainActivityAbstract",
|
||||
displayName = "Abstract Art",
|
||||
subtitle = "Geometric gradient art redesign",
|
||||
category = "Redesigns",
|
||||
iconRes = R.mipmap.ic_launcher_abstract
|
||||
),
|
||||
FOREST(
|
||||
id = "forest",
|
||||
aliasSuffix = "MainActivityForest",
|
||||
displayName = "Forest Nature",
|
||||
subtitle = "Earthy organic botanical tones",
|
||||
category = "Redesigns",
|
||||
iconRes = R.mipmap.ic_launcher_forest
|
||||
),
|
||||
GOLDEN(
|
||||
id = "golden",
|
||||
aliasSuffix = "MainActivityGolden",
|
||||
displayName = "Golden Luxury",
|
||||
subtitle = "Deep gold emblem on obsidian",
|
||||
category = "Redesigns",
|
||||
iconRes = R.mipmap.ic_launcher_golden
|
||||
),
|
||||
METAL(
|
||||
id = "metal",
|
||||
aliasSuffix = "MainActivityMetal",
|
||||
displayName = "Titanium Metal",
|
||||
subtitle = "Brushed industrial steel & chrome",
|
||||
category = "Redesigns",
|
||||
iconRes = R.mipmap.ic_launcher_metal
|
||||
),
|
||||
SCRIBBLE(
|
||||
id = "scribble",
|
||||
aliasSuffix = "MainActivityScribble",
|
||||
displayName = "Scribble Sketch",
|
||||
subtitle = "Handcrafted playful illustration",
|
||||
category = "Redesigns",
|
||||
iconRes = R.mipmap.ic_launcher_scribble
|
||||
),
|
||||
|
||||
// --- Originals (Authentic Brand History) ---
|
||||
POLAROID(
|
||||
id = "polaroid",
|
||||
aliasSuffix = "MainActivityPolaroid",
|
||||
displayName = "Polaroid Camera",
|
||||
subtitle = "Iconic 2010 debut with rainbow stripe",
|
||||
category = "Originals",
|
||||
iconRes = R.mipmap.ic_launcher_polaroid
|
||||
),
|
||||
VIEWFINDER(
|
||||
id = "viewfinder",
|
||||
aliasSuffix = "MainActivityViewfinder",
|
||||
displayName = "Viewfinder",
|
||||
subtitle = "2011 classic retro leather camera",
|
||||
category = "Originals",
|
||||
iconRes = R.mipmap.ic_launcher_viewfinder
|
||||
),
|
||||
MINIMALIST(
|
||||
id = "minimalist",
|
||||
aliasSuffix = "MainActivityMinimalist",
|
||||
displayName = "Minimalist Colors",
|
||||
subtitle = "2016 breakthrough flat sunset gradient",
|
||||
category = "Originals",
|
||||
iconRes = R.mipmap.ic_launcher_minimalist
|
||||
),
|
||||
VIBRANT(
|
||||
id = "vibrant",
|
||||
aliasSuffix = "MainActivityVibrant",
|
||||
displayName = "Vibrant Gradient",
|
||||
subtitle = "2022 radiant illuminated brand design",
|
||||
category = "Originals",
|
||||
iconRes = R.mipmap.ic_launcher_vibrant
|
||||
);
|
||||
|
||||
companion object {
|
||||
fun fromId(id: String): IconOption {
|
||||
return values().firstOrNull { it.id.equals(id, ignoreCase = true) } ?: DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically switches active launcher icon by enabling the chosen alias and disabling others.
|
||||
* Replaces the app icon on Home Screen, App Drawer (App Screen), and App Info (System Settings).
|
||||
* Also updates the running Activity's Recents overview TaskDescription immediately.
|
||||
*/
|
||||
fun setAppIcon(context: Context, iconId: String) {
|
||||
val targetOption = IconOption.fromId(iconId)
|
||||
val packageManager = context.packageManager
|
||||
val packageName = context.packageName
|
||||
|
||||
Log.i(TAG, "Switching app icon to: ${targetOption.displayName} (${targetOption.aliasSuffix})")
|
||||
|
||||
// 1. Enable target alias so there's always at least one enabled launcher entry point
|
||||
try {
|
||||
val targetComponent = ComponentName(packageName, "$packageName.${targetOption.aliasSuffix}")
|
||||
packageManager.setComponentEnabledSetting(
|
||||
targetComponent,
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed enabling target launcher alias ${targetOption.aliasSuffix}: ${e.message}", e)
|
||||
}
|
||||
|
||||
// 2. Disable all other aliases
|
||||
for (option in IconOption.values()) {
|
||||
if (option == targetOption) continue
|
||||
try {
|
||||
val comp = ComponentName(packageName, "$packageName.${option.aliasSuffix}")
|
||||
packageManager.setComponentEnabledSetting(
|
||||
comp,
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed disabling launcher alias ${option.aliasSuffix}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Immediately update TaskDescription for the Recents screen
|
||||
if (context is Activity) {
|
||||
applyTaskDescription(context, iconId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the Activity's TaskDescription so the Recents / Multitasking overview screen
|
||||
* reflects the exact chosen icon and label immediately.
|
||||
*/
|
||||
fun applyTaskDescription(activity: Activity, iconId: String) {
|
||||
try {
|
||||
val option = IconOption.fromId(iconId)
|
||||
val label = activity.getString(R.string.app_name)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val taskDesc = ActivityManager.TaskDescription.Builder()
|
||||
.setLabel(label)
|
||||
.setIcon(option.iconRes)
|
||||
.build()
|
||||
activity.setTaskDescription(taskDesc)
|
||||
} else {
|
||||
val bmp = BitmapFactory.decodeResource(activity.resources, option.iconRes)
|
||||
@Suppress("DEPRECATION")
|
||||
val taskDesc = ActivityManager.TaskDescription(label, bmp)
|
||||
activity.setTaskDescription(taskDesc)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed applying TaskDescription for Recents screen: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins a custom home screen shortcut with a user-provided or dynamically downloaded bitmap.
|
||||
* Uses ShortcutManagerCompat to support pinning on supported Android launchers.
|
||||
*/
|
||||
fun pinCustomShortcut(context: Context, bitmap: Bitmap, label: String = "Instagram"): Boolean {
|
||||
if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
|
||||
Log.w(TAG, "Pin shortcut is not supported by current launcher")
|
||||
return false
|
||||
}
|
||||
|
||||
return try {
|
||||
val launchIntent = Intent(context, MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_MAIN
|
||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
|
||||
}
|
||||
|
||||
val iconCompat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
IconCompat.createWithAdaptiveBitmap(bitmap)
|
||||
} else {
|
||||
IconCompat.createWithBitmap(bitmap)
|
||||
}
|
||||
|
||||
val shortcutId = "focusgram_icon_${System.currentTimeMillis()}"
|
||||
val shortcut = ShortcutInfoCompat.Builder(context, shortcutId)
|
||||
.setShortLabel(label)
|
||||
.setIcon(iconCompat)
|
||||
.setIntent(launchIntent)
|
||||
.build()
|
||||
|
||||
ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to pin custom shortcut: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private val iconCache = mutableMapOf<Int, Bitmap>()
|
||||
|
||||
/**
|
||||
* Safely rasterizes any Android drawable (AdaptiveIconDrawable, VectorDrawable, BitmapDrawable)
|
||||
* onto an in-memory ARGB_8888 Bitmap for Compose rendering without crashing on adaptive icons.
|
||||
*/
|
||||
fun getIconBitmap(context: Context, @DrawableRes resId: Int): Bitmap? {
|
||||
iconCache[resId]?.let { return it }
|
||||
return try {
|
||||
val drawable = androidx.core.content.ContextCompat.getDrawable(context, resId) ?: return null
|
||||
val width = if (drawable.intrinsicWidth > 0) drawable.intrinsicWidth else 192
|
||||
val height = if (drawable.intrinsicHeight > 0) drawable.intrinsicHeight else 192
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = android.graphics.Canvas(bitmap)
|
||||
drawable.setBounds(0, 0, canvas.width, canvas.height)
|
||||
drawable.draw(canvas)
|
||||
iconCache[resId] = bitmap
|
||||
bitmap
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed creating icon bitmap for resId $resId: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.ujwal.focusgram.core.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* OriginalIcon (Historical Instagram Brand Icon)
|
||||
* Represents an authentic Instagram brand era logo downloaded dynamically on demand.
|
||||
*/
|
||||
data class OriginalIcon(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val downloadUrl: String,
|
||||
val year: String = ""
|
||||
)
|
||||
|
||||
typealias HistoricalIcon = OriginalIcon
|
||||
|
||||
/**
|
||||
* HistoricalIconManager
|
||||
* Dynamically downloads authentic original Instagram brand logos on demand,
|
||||
* preventing any APK size bloat.
|
||||
*/
|
||||
object HistoricalIconManager {
|
||||
private const val TAG = "HistoricalIconManager"
|
||||
|
||||
val ORIGINAL_ICONS = listOf(
|
||||
OriginalIcon(
|
||||
id = "polaroid_camera",
|
||||
title = "Polaroid Camera",
|
||||
subtitle = "Iconic debut Polaroid camera with rainbow stripe",
|
||||
downloadUrl = "https://logovent.com/blog/wp-content/uploads/2024/06/Polaroid-Camera-Instagram-Logo.png"
|
||||
),
|
||||
OriginalIcon(
|
||||
id = "viewfinder",
|
||||
title = "Viewfinder",
|
||||
subtitle = "Retro brown leather camera with viewfinder banner",
|
||||
downloadUrl = "https://logovent.com/blog/wp-content/uploads/2024/06/Viewfinder-Logo.png"
|
||||
),
|
||||
OriginalIcon(
|
||||
id = "minimalist_colors",
|
||||
title = "Minimalist Colors",
|
||||
subtitle = "Flat minimalist neon sunset camera outline",
|
||||
downloadUrl = "https://logovent.com/blog/wp-content/uploads/2024/06/Minimalist-with-Colors.png"
|
||||
),
|
||||
OriginalIcon(
|
||||
id = "vibrant_version",
|
||||
title = "Vibrant Gradient",
|
||||
subtitle = "Refined brand identity with vibrant illumination",
|
||||
downloadUrl = "https://logovent.com/blog/wp-content/uploads/2024/06/Vibrant-Version.png"
|
||||
)
|
||||
)
|
||||
|
||||
val HISTORICAL_ICONS = ORIGINAL_ICONS
|
||||
|
||||
private val _downloadedIcons = MutableStateFlow<Map<String, Bitmap>>(emptyMap())
|
||||
val downloadedIcons: StateFlow<Map<String, Bitmap>> = _downloadedIcons.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _downloadProgress = MutableStateFlow(0f)
|
||||
val downloadProgress: StateFlow<Float> = _downloadProgress.asStateFlow()
|
||||
|
||||
/**
|
||||
* Ensures all original icons are loaded from cache or downloaded on-the-fly.
|
||||
*/
|
||||
suspend fun loadHistoricalIcons(context: Context) {
|
||||
if (_isLoading.value) return
|
||||
withContext(Dispatchers.IO) {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
val iconDir = File(context.cacheDir, "original_icons").apply { mkdirs() }
|
||||
val loadedMap = HashMap<String, Bitmap>()
|
||||
|
||||
// 1. First check existing disk cache
|
||||
for (icon in ORIGINAL_ICONS) {
|
||||
val file = File(iconDir, "${icon.id}.png")
|
||||
if (file.exists() && file.length() > 0) {
|
||||
decodeBitmapSafely(file)?.let { loadedMap[icon.id] = it }
|
||||
}
|
||||
}
|
||||
_downloadedIcons.value = loadedMap.toMap()
|
||||
|
||||
// 2. Identify missing icons to download
|
||||
val missing = ORIGINAL_ICONS.filter { !loadedMap.containsKey(it.id) }
|
||||
if (missing.isEmpty()) {
|
||||
_downloadProgress.value = 1f
|
||||
return@withContext
|
||||
}
|
||||
|
||||
var completed = 0
|
||||
for (icon in missing) {
|
||||
val file = File(iconDir, "${icon.id}.png")
|
||||
try {
|
||||
val connection = (URL(icon.downloadUrl).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 10000
|
||||
readTimeout = 15000
|
||||
setRequestProperty("User-Agent", "FocusGram/1.0 (Android; Mobile)")
|
||||
}
|
||||
if (connection.responseCode in 200..299) {
|
||||
connection.inputStream.use { input ->
|
||||
FileOutputStream(file).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
decodeBitmapSafely(file)?.let { bitmap ->
|
||||
loadedMap[icon.id] = bitmap
|
||||
_downloadedIcons.value = loadedMap.toMap()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed downloading icon ${icon.id}: ${e.message}", e)
|
||||
}
|
||||
completed++
|
||||
_downloadProgress.value = completed.toFloat() / missing.size
|
||||
}
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeBitmapSafely(file: File): Bitmap? {
|
||||
return try {
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeFile(file.absolutePath, options)
|
||||
|
||||
var inSampleSize = 1
|
||||
while (options.outWidth / inSampleSize > 512 || options.outHeight / inSampleSize > 512) {
|
||||
inSampleSize *= 2
|
||||
}
|
||||
val decodeOptions = BitmapFactory.Options().apply {
|
||||
this.inSampleSize = inSampleSize
|
||||
}
|
||||
BitmapFactory.decodeFile(file.absolutePath, decodeOptions)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error decoding file ${file.name}: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Backspace
|
||||
import androidx.compose.material.icons.filled.Fingerprint
|
||||
@@ -67,6 +68,8 @@ fun AppLockScreen(
|
||||
isBiometricAllowed: Boolean = true,
|
||||
title: String = "FocusGram is Locked",
|
||||
subtitle: String = "Enter your PIN to unlock",
|
||||
customVerifyPin: ((String) -> Boolean)? = null,
|
||||
onCancel: (() -> Unit)? = null,
|
||||
onUnlockSuccess: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -120,7 +123,7 @@ fun AppLockScreen(
|
||||
)
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Unlock FocusGram")
|
||||
.setTitle(title)
|
||||
.setSubtitle("Confirm your biometric to proceed")
|
||||
.setNegativeButtonText("Use PIN")
|
||||
.build()
|
||||
@@ -129,9 +132,11 @@ fun AppLockScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger BiometricPrompt if enabled and available
|
||||
LaunchedEffect(Unit) {
|
||||
triggerBiometricPrompt()
|
||||
// Trigger BiometricPrompt only if enabled and allowed
|
||||
LaunchedEffect(isBiometricAllowed) {
|
||||
if (isBiometricAllowed) {
|
||||
triggerBiometricPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyEnteredPin(pin: String) {
|
||||
@@ -142,7 +147,11 @@ fun AppLockScreen(
|
||||
return
|
||||
}
|
||||
|
||||
val isValid = encryptedStorage.verifyPin(pin)
|
||||
val isValid = if (customVerifyPin != null) {
|
||||
customVerifyPin(pin)
|
||||
} else {
|
||||
encryptedStorage.verifyPin(pin)
|
||||
}
|
||||
if (isValid) {
|
||||
onUnlockSuccess()
|
||||
} else {
|
||||
@@ -200,6 +209,26 @@ fun AppLockScreen(
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (onCancel != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
Text(
|
||||
text = "Cancel",
|
||||
color = InstagramBlue,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onCancel() }
|
||||
.padding(vertical = 6.dp, horizontal = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Lock Icon
|
||||
@@ -277,11 +306,12 @@ fun AppLockScreen(
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
val bioKey = if (isBiometricAllowed) "BIO" else ""
|
||||
val keypadRows = listOf(
|
||||
listOf("1", "2", "3"),
|
||||
listOf("4", "5", "6"),
|
||||
listOf("7", "8", "9"),
|
||||
listOf("BIO", "0", "DEL")
|
||||
listOf(bioKey, "0", "DEL")
|
||||
)
|
||||
|
||||
for (row in keypadRows) {
|
||||
@@ -340,6 +370,7 @@ private fun KeypadButton(
|
||||
tint = InstagramBlue,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
"" -> {}
|
||||
else -> Text(
|
||||
text = key,
|
||||
fontSize = 26.sp,
|
||||
|
||||
@@ -1,52 +1,48 @@
|
||||
package com.ujwal.focusgram.feature.applock
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Fingerprint
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ujwal.focusgram.core.datastore.SettingsRepository
|
||||
import com.ujwal.focusgram.core.model.FocusSettings
|
||||
import com.ujwal.focusgram.core.security.EncryptedStorage
|
||||
import androidx.activity.compose.BackHandler
|
||||
import com.ujwal.focusgram.core.ui.components.FocusGramCard
|
||||
import com.ujwal.focusgram.core.ui.components.FocusGramSectionHeader
|
||||
import com.ujwal.focusgram.core.ui.components.FocusGramToggleRow
|
||||
import com.ujwal.focusgram.core.ui.components.FocusGramTopBar
|
||||
import com.ujwal.focusgram.core.ui.theme.AlertRed
|
||||
import com.ujwal.focusgram.core.ui.theme.InstagramBlue
|
||||
import com.ujwal.focusgram.core.ui.components.IOSGroupedCard
|
||||
import com.ujwal.focusgram.core.ui.components.IOSSectionTitle
|
||||
import com.ujwal.focusgram.core.ui.components.IOSSettingsRow
|
||||
import com.ujwal.focusgram.core.ui.theme.PureBlack
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* AppLockSettingsPage
|
||||
* Configure PIN protection, biometric sensor authentication, and auto-lock timeout.
|
||||
*/
|
||||
private val IOSBlue = Color(0xFF007AFF)
|
||||
private val IOSGreen = Color(0xFF34C759)
|
||||
private val IOSOrange = Color(0xFFFF9500)
|
||||
|
||||
@Composable
|
||||
fun AppLockSettingsPage(
|
||||
repository: SettingsRepository,
|
||||
encryptedStorage: EncryptedStorage,
|
||||
onBack: () -> Unit,
|
||||
onChangePin: () -> Unit,
|
||||
onChangeChatPin: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val settings by repository.settingsFlow.collectAsState(initial = FocusSettings())
|
||||
@@ -63,88 +59,86 @@ fun AppLockSettingsPage(
|
||||
.padding(WindowInsets.statusBars.asPaddingValues())
|
||||
) {
|
||||
FocusGramTopBar(
|
||||
title = "App Lock Settings",
|
||||
title = "App Lock",
|
||||
onBack = onBack
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
item {
|
||||
FocusGramSectionHeader(
|
||||
title = "Security Guard",
|
||||
subtitle = "Restrict app access to prevent unconscious Instagram opens"
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
FocusGramToggleRow(
|
||||
title = "Enable App Lock",
|
||||
subtitle = if (settings.appLockEnabled) "App requires 4-digit PIN to open" else "Disabled",
|
||||
checked = settings.appLockEnabled,
|
||||
onCheckedChange = { isChecked ->
|
||||
if (isChecked && !encryptedStorage.hasPin()) {
|
||||
onChangePin()
|
||||
} else {
|
||||
scope.launch { repository.setAppLock(isChecked, settings.biometricEnabled) }
|
||||
}
|
||||
},
|
||||
icon = Icons.Default.Lock
|
||||
)
|
||||
IOSSectionTitle(title = "APP PROTECTION")
|
||||
IOSGroupedCard {
|
||||
IOSSettingsRow(
|
||||
title = "App Lock Protection",
|
||||
subtitle = "Require 4-digit PIN or biometrics upon opening FocusGram",
|
||||
icon = Icons.Default.Lock,
|
||||
iconBgColor = IOSBlue,
|
||||
checked = settings.appLockEnabled,
|
||||
onCheckedChange = { isChecked ->
|
||||
scope.launch {
|
||||
repository.setAppLock(isChecked, settings.biometricEnabled)
|
||||
}
|
||||
if (isChecked && !encryptedStorage.hasPin()) {
|
||||
onChangePin()
|
||||
}
|
||||
},
|
||||
showDivider = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.appLockEnabled) {
|
||||
item {
|
||||
FocusGramToggleRow(
|
||||
title = "Biometric Unlock",
|
||||
subtitle = "Use fingerprint or face recognition to unlock",
|
||||
checked = settings.biometricEnabled,
|
||||
onCheckedChange = { scope.launch { repository.setAppLock(true, it) } }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
FocusGramToggleRow(
|
||||
title = "Auto-Lock on Background",
|
||||
subtitle = "Immediately lock whenever leaving FocusGram",
|
||||
checked = settings.autoLockOnBackground,
|
||||
onCheckedChange = { isChecked ->
|
||||
scope.launch { repository.setAutoLockBackground(isChecked) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Button(
|
||||
onClick = onChangePin,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = InstagramBlue)
|
||||
) {
|
||||
Text("Change PIN", color = PureBlack)
|
||||
IOSSectionTitle(title = "AUTHENTICATION METHOD")
|
||||
IOSGroupedCard {
|
||||
IOSSettingsRow(
|
||||
title = "Biometric Unlock",
|
||||
subtitle = "Use fingerprint or face unlock",
|
||||
icon = Icons.Default.Fingerprint,
|
||||
iconBgColor = IOSGreen,
|
||||
checked = settings.biometricEnabled,
|
||||
onCheckedChange = { isChecked ->
|
||||
scope.launch {
|
||||
repository.setAppLock(settings.appLockEnabled, isChecked)
|
||||
}
|
||||
}
|
||||
)
|
||||
IOSSettingsRow(
|
||||
title = if (encryptedStorage.hasPin()) "Change 4-Digit PIN" else "Set 4-Digit PIN",
|
||||
subtitle = "Update master passcode for app entry",
|
||||
icon = Icons.Default.Lock,
|
||||
iconBgColor = IOSOrange,
|
||||
onClick = onChangePin,
|
||||
showDivider = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
encryptedStorage.clearPin()
|
||||
repository.setAppLock(enabled = false, biometric = false)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Disable & Remove PIN", color = AlertRed)
|
||||
IOSSectionTitle(title = "AUTO-LOCK BEHAVIOR")
|
||||
IOSGroupedCard {
|
||||
IOSSettingsRow(
|
||||
title = "Auto-Lock in Background",
|
||||
subtitle = "Lock immediately whenever FocusGram is minimized",
|
||||
icon = Icons.Default.Schedule,
|
||||
iconBgColor = Color(0xFF636366),
|
||||
checked = settings.autoLockOnBackground,
|
||||
onCheckedChange = { isChecked ->
|
||||
scope.launch {
|
||||
repository.setAutoLockBackground(isChecked)
|
||||
}
|
||||
},
|
||||
showDivider = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(30.dp))
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ fun AppLockSetupScreen(
|
||||
settingsRepository: SettingsRepository,
|
||||
onBack: () -> Unit,
|
||||
onSuccess: () -> Unit,
|
||||
title: String = "Set App Lock PIN",
|
||||
subtitle: String = "Used to unlock FocusGram and secure your mindful limits.",
|
||||
isChatPinSetup: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var step by remember { mutableStateOf(1) } // 1: Enter, 2: Confirm
|
||||
@@ -89,8 +92,13 @@ fun AppLockSetupScreen(
|
||||
if (firstPin == confirmPin) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
scope.launch {
|
||||
encryptedStorage.savePin(confirmPin)
|
||||
settingsRepository.setAppLock(enabled = true, biometric = false)
|
||||
if (isChatPinSetup) {
|
||||
encryptedStorage.saveChatPin(confirmPin)
|
||||
settingsRepository.setUseSeparateChatPin(true)
|
||||
} else {
|
||||
encryptedStorage.savePin(confirmPin)
|
||||
settingsRepository.setAppLock(enabled = true, biometric = false)
|
||||
}
|
||||
onSuccess()
|
||||
}
|
||||
} else {
|
||||
@@ -121,7 +129,7 @@ fun AppLockSetupScreen(
|
||||
.padding(WindowInsets.statusBars.asPaddingValues())
|
||||
) {
|
||||
FocusGramTopBar(
|
||||
title = "Set App Lock PIN",
|
||||
title = title,
|
||||
onBack = onBack
|
||||
)
|
||||
|
||||
@@ -160,7 +168,7 @@ fun AppLockSetupScreen(
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Text(
|
||||
text = "Used to unlock FocusGram and secure your mindful limits.",
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextSecondary
|
||||
)
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
package com.ujwal.focusgram.feature.applock
|
||||
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Backspace
|
||||
import androidx.compose.material.icons.filled.Fingerprint
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.ujwal.focusgram.core.security.EncryptedStorage
|
||||
import com.ujwal.focusgram.core.ui.theme.AlertRed
|
||||
import com.ujwal.focusgram.core.ui.theme.DarkCard
|
||||
import com.ujwal.focusgram.core.ui.theme.PureBlack
|
||||
import com.ujwal.focusgram.core.ui.theme.TextMuted
|
||||
import com.ujwal.focusgram.core.ui.theme.TextSecondary
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val ChatLockAccent = Color(0xFF9D4EDD)
|
||||
private val ChatLockAccentGlow = Color(0xFFC77DFF)
|
||||
private val KeypadCardBg = Color(0xFF16161A)
|
||||
|
||||
/**
|
||||
* ChatLockScreen
|
||||
* Bespoke, distinct hardware-backed PIN and Biometric security gate specifically
|
||||
* tailored for locked Direct Messages and private conversations.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatLockScreen(
|
||||
encryptedStorage: EncryptedStorage,
|
||||
isBiometricAllowed: Boolean = true,
|
||||
title: String = "Chat Protected",
|
||||
subtitle: String = "Enter 4-digit PIN to view conversation",
|
||||
customVerifyPin: ((String) -> Boolean)? = null,
|
||||
onCancel: (() -> Unit)? = null,
|
||||
onUnlockSuccess: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var enteredPin by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
val shakeOffset = remember { Animatable(0f) }
|
||||
|
||||
fun triggerBiometricPrompt() {
|
||||
if (!isBiometricAllowed) return
|
||||
var ctx = context
|
||||
while (ctx is android.content.ContextWrapper) {
|
||||
if (ctx is FragmentActivity) break
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
val activity = ctx as? FragmentActivity ?: return
|
||||
|
||||
val biometricManager = BiometricManager.from(activity)
|
||||
val canAuth = biometricManager.canAuthenticate(
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
)
|
||||
if (canAuth == BiometricManager.BIOMETRIC_SUCCESS) {
|
||||
val executor = ContextCompat.getMainExecutor(activity)
|
||||
val prompt = BiometricPrompt(
|
||||
activity,
|
||||
executor,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
onUnlockSuccess()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
if (errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON &&
|
||||
errorCode != BiometricPrompt.ERROR_CANCELED
|
||||
) {
|
||||
errorMessage = errString.toString()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
errorMessage = "Biometric not recognized"
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle("Authenticate to view locked messages")
|
||||
.setNegativeButtonText("Use PIN")
|
||||
.build()
|
||||
|
||||
prompt.authenticate(promptInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger Biometric only if explicitly allowed and configured
|
||||
LaunchedEffect(isBiometricAllowed) {
|
||||
if (isBiometricAllowed) {
|
||||
triggerBiometricPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyEnteredPin(pin: String) {
|
||||
if (encryptedStorage.isLockedOut()) {
|
||||
val remainingSec = encryptedStorage.getRemainingLockoutSeconds()
|
||||
errorMessage = "Too many attempts. Locked out for ${remainingSec}s."
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
return
|
||||
}
|
||||
|
||||
val isValid = if (customVerifyPin != null) {
|
||||
customVerifyPin(pin)
|
||||
} else {
|
||||
encryptedStorage.verifyPin(pin)
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
errorMessage = null
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onUnlockSuccess()
|
||||
} else {
|
||||
errorMessage = "Incorrect PIN"
|
||||
enteredPin = ""
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
coroutineScope.launch {
|
||||
shakeOffset.snapTo(0f)
|
||||
shakeOffset.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = keyframes {
|
||||
durationMillis = 400
|
||||
0f at 0
|
||||
(-25f) at 50
|
||||
25f at 100
|
||||
(-20f) at 150
|
||||
20f at 200
|
||||
(-10f) at 250
|
||||
10f at 300
|
||||
0f at 400
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDigitPressed(digit: String) {
|
||||
if (enteredPin.length < 4) {
|
||||
val newPin = enteredPin + digit
|
||||
enteredPin = newPin
|
||||
errorMessage = null
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
if (newPin.length == 4) {
|
||||
verifyEnteredPin(newPin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onBackspacePressed() {
|
||||
if (enteredPin.isNotEmpty()) {
|
||||
enteredPin = enteredPin.dropLast(1)
|
||||
errorMessage = null
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = PureBlack
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF0F0B17),
|
||||
Color(0xFF060608),
|
||||
PureBlack
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.padding(horizontal = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Top Navigation Bar (Exit / Cancel)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (onCancel != null) {
|
||||
IconButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back to inbox",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(36.dp))
|
||||
}
|
||||
|
||||
// Subtle privacy status pill
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(ChatLockAccent.copy(alpha = 0.15f))
|
||||
.border(0.5.dp, ChatLockAccent.copy(alpha = 0.3f), RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Shield,
|
||||
contentDescription = null,
|
||||
tint = ChatLockAccentGlow,
|
||||
modifier = Modifier.size(13.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
Text(
|
||||
text = "ENCRYPTED GATE",
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ChatLockAccentGlow,
|
||||
letterSpacing = 0.8.sp
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(36.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(0.7f))
|
||||
|
||||
// Chat Lock Shield Badge
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors = listOf(
|
||||
ChatLockAccent.copy(alpha = 0.25f),
|
||||
Color(0xFF3C096C).copy(alpha = 0.15f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.border(1.dp, ChatLockAccent.copy(alpha = 0.4f), CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = "Lock",
|
||||
tint = ChatLockAccentGlow,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextSecondary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
|
||||
// 4 PIN Dots with Smooth Glow & Shake Animation
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(shakeOffset.value.roundToInt(), 0) }
|
||||
.padding(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
for (i in 0 until 4) {
|
||||
val isFilled = i < enteredPin.length
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isFilled) ChatLockAccentGlow else Color.White.copy(alpha = 0.18f)
|
||||
)
|
||||
.border(
|
||||
width = if (isFilled) 1.5.dp else 1.dp,
|
||||
color = if (isFilled) Color.White else Color.White.copy(alpha = 0.1f),
|
||||
shape = CircleShape
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Error Message
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage ?: "",
|
||||
color = AlertRed,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1.2f))
|
||||
|
||||
// Modern 3x4 Keypad
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
// Only render "BIO" key if biometric is explicitly allowed & enrolled!
|
||||
val bioKey = if (isBiometricAllowed) "BIO" else ""
|
||||
val keypadRows = listOf(
|
||||
listOf("1", "2", "3"),
|
||||
listOf("4", "5", "6"),
|
||||
listOf("7", "8", "9"),
|
||||
listOf(bioKey, "0", "DEL")
|
||||
)
|
||||
|
||||
for (row in keypadRows) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
for (key in row) {
|
||||
KeypadCell(
|
||||
key = key,
|
||||
onDigit = { onDigitPressed(it) },
|
||||
onBackspace = { onBackspacePressed() },
|
||||
onBiometric = { triggerBiometricPrompt() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeypadCell(
|
||||
key: String,
|
||||
onDigit: (String) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
onBiometric: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (key.isNotBlank()) KeypadCardBg else Color.Transparent)
|
||||
.clickable(enabled = key.isNotBlank()) {
|
||||
when (key) {
|
||||
"DEL" -> onBackspace()
|
||||
"BIO" -> onBiometric()
|
||||
else -> onDigit(key)
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (key) {
|
||||
"DEL" -> Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Backspace,
|
||||
contentDescription = "Delete",
|
||||
tint = Color.White.copy(alpha = 0.75f),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
"BIO" -> Icon(
|
||||
imageVector = Icons.Default.Fingerprint,
|
||||
contentDescription = "Biometric",
|
||||
tint = ChatLockAccentGlow,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
"" -> {
|
||||
// Empty spacer cell so layout remains symmetrical without any biometric icon
|
||||
}
|
||||
else -> Text(
|
||||
text = key,
|
||||
fontSize = 25.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,11 +84,12 @@ fun ReelsHistoryScreen(
|
||||
|
||||
val allReels by reelHistory.items.collectAsState()
|
||||
val reels = remember(allReels, searchQuery) {
|
||||
val deduplicated = allReels.distinctBy { it.code.ifBlank { it.url } }
|
||||
if (searchQuery.isBlank()) {
|
||||
allReels
|
||||
deduplicated
|
||||
} else {
|
||||
val q = searchQuery.trim().lowercase()
|
||||
allReels.filter {
|
||||
deduplicated.filter {
|
||||
it.author.lowercase().contains(q) ||
|
||||
it.caption.lowercase().contains(q) ||
|
||||
it.code.lowercase().contains(q)
|
||||
|
||||
@@ -149,7 +149,8 @@ fun NativeBottomNavBar(
|
||||
} else {
|
||||
isInDirect
|
||||
}
|
||||
val isAuth = isAuthPage || !isAuthenticated || lowerUrl.contains("/accounts") || lowerUrl.contains("/login") || lowerUrl.contains("/signup")
|
||||
val isExplicitAuthRoute = (lowerUrl.contains("/accounts") && !lowerUrl.contains("/accounts/edit")) || lowerUrl.contains("/login") || lowerUrl.contains("/signup")
|
||||
val isAuth = isAuthPage || isExplicitAuthRoute
|
||||
val isVisible = !isAuth && !isImeVisible && !shouldHideInDirect && !isWebInputFocused && !isModalActive && islandMode != IslandMode.HIDDEN
|
||||
|
||||
// Real-time animated color palette matching Instagram's web theme
|
||||
|
||||
@@ -38,6 +38,18 @@ import kotlinx.coroutines.delay
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import com.ujwal.focusgram.core.utils.AppIconManager
|
||||
import com.ujwal.focusgram.core.utils.HistoricalIconManager
|
||||
import com.ujwal.focusgram.core.security.EncryptedStorage
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Block
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
@@ -113,9 +125,11 @@ enum class SettingsSubpage {
|
||||
GUARDRAILS,
|
||||
EXTRAS,
|
||||
APPEARANCE,
|
||||
CHOOSE_APP_ICON,
|
||||
NOTIFICATIONS,
|
||||
GHOST,
|
||||
LICENSES
|
||||
LICENSES,
|
||||
CHAT_SECURITY
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,6 +141,7 @@ enum class SettingsSubpage {
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
repository: SettingsRepository,
|
||||
inputSettings: FocusSettings? = null,
|
||||
onBack: () -> Unit,
|
||||
onNavigateReelsHistory: () -> Unit,
|
||||
onNavigateScreenTime: () -> Unit,
|
||||
@@ -138,9 +153,12 @@ fun SettingsScreen(
|
||||
reelHistory: ReelHistory? = null,
|
||||
currentSubpage: SettingsSubpage = SettingsSubpage.MAIN,
|
||||
onSubpageChange: (SettingsSubpage) -> Unit = {},
|
||||
encryptedStorage: EncryptedStorage? = null,
|
||||
onNavigateChatPinSetup: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val settings by repository.settingsFlow.collectAsState(initial = FocusSettings())
|
||||
val collectedSettings by repository.settingsFlow.collectAsState(initial = inputSettings ?: FocusSettings())
|
||||
val settings = inputSettings ?: collectedSettings
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -165,7 +183,9 @@ fun SettingsScreen(
|
||||
|
||||
// Intercept back presses: go back to MAIN subpage first, or pop screen
|
||||
BackHandler {
|
||||
if (activeSubpage != SettingsSubpage.MAIN) {
|
||||
if (activeSubpage == SettingsSubpage.CHOOSE_APP_ICON) {
|
||||
setSubpage(SettingsSubpage.APPEARANCE)
|
||||
} else if (activeSubpage != SettingsSubpage.MAIN) {
|
||||
setSubpage(SettingsSubpage.MAIN)
|
||||
} else {
|
||||
onBack()
|
||||
@@ -380,7 +400,9 @@ fun SettingsScreen(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
if (activeSubpage != SettingsSubpage.MAIN) {
|
||||
if (activeSubpage == SettingsSubpage.CHOOSE_APP_ICON) {
|
||||
setSubpage(SettingsSubpage.APPEARANCE)
|
||||
} else if (activeSubpage != SettingsSubpage.MAIN) {
|
||||
setSubpage(SettingsSubpage.MAIN)
|
||||
} else {
|
||||
onBack()
|
||||
@@ -399,9 +421,11 @@ fun SettingsScreen(
|
||||
SettingsSubpage.GUARDRAILS -> "Time Control & Guardrails"
|
||||
SettingsSubpage.EXTRAS -> "Extras"
|
||||
SettingsSubpage.APPEARANCE -> "Appearance"
|
||||
SettingsSubpage.CHOOSE_APP_ICON -> "Choose App Icon"
|
||||
SettingsSubpage.NOTIFICATIONS -> "Privacy & Notifications"
|
||||
SettingsSubpage.GHOST -> "Ghost Mode"
|
||||
SettingsSubpage.LICENSES -> "Open Source & Credits"
|
||||
SettingsSubpage.CHAT_SECURITY -> "Chat Security & DM Lock"
|
||||
},
|
||||
color = IOSTextPrimary,
|
||||
fontSize = 17.sp,
|
||||
@@ -454,7 +478,18 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
SettingsSubpage.APPEARANCE -> {
|
||||
AppearanceSubpage(repository = repository, settings = settings)
|
||||
AppearanceSubpage(
|
||||
repository = repository,
|
||||
settings = settings,
|
||||
onNavigateChooseIcon = { setSubpage(SettingsSubpage.CHOOSE_APP_ICON) }
|
||||
)
|
||||
}
|
||||
SettingsSubpage.CHOOSE_APP_ICON -> {
|
||||
ChooseAppIconSubpage(
|
||||
repository = repository,
|
||||
settings = settings,
|
||||
onBack = { setSubpage(SettingsSubpage.APPEARANCE) }
|
||||
)
|
||||
}
|
||||
SettingsSubpage.NOTIFICATIONS -> {
|
||||
NotificationsSubpage(repository = repository, settings = settings)
|
||||
@@ -465,6 +500,14 @@ fun SettingsScreen(
|
||||
SettingsSubpage.LICENSES -> {
|
||||
LicensesSubpage()
|
||||
}
|
||||
SettingsSubpage.CHAT_SECURITY -> {
|
||||
ChatSecuritySubpage(
|
||||
repository = repository,
|
||||
settings = settings,
|
||||
encryptedStorage = encryptedStorage,
|
||||
onNavigateChatPinSetup = onNavigateChatPinSetup
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -713,14 +756,26 @@ private fun MainSettingsContent(
|
||||
item {
|
||||
IOSSectionTitle(title = "SECURITY & PRIVACY")
|
||||
IOSGroupedCard {
|
||||
val isBiometricsActive = settings.appLockEnabled && settings.biometricEnabled
|
||||
IOSSettingsRow(
|
||||
title = "App Lock",
|
||||
subtitle = if (settings.appLockEnabled) "PIN & Biometrics enabled" else "Off",
|
||||
icon = Icons.Default.Fingerprint,
|
||||
iconBgColor = IOSGreen,
|
||||
subtitle = when {
|
||||
!settings.appLockEnabled -> "Off"
|
||||
isBiometricsActive -> "PIN & Biometrics enabled"
|
||||
else -> "4-Digit PIN enabled"
|
||||
},
|
||||
icon = if (isBiometricsActive) Icons.Default.Fingerprint else Icons.Default.Lock,
|
||||
iconBgColor = if (isBiometricsActive) IOSGreen else IOSOrange,
|
||||
trailingText = if (settings.appLockEnabled) "Enabled" else "Off",
|
||||
onClick = onNavigateAppLock
|
||||
)
|
||||
IOSSettingsRow(
|
||||
title = "Chat Security & DM Lock",
|
||||
subtitle = if (settings.chatLockAll || settings.lockedChatIds.isNotEmpty()) "Active · Locked conversations" else "Lock inbox & chats with PIN/Biometrics",
|
||||
icon = Icons.AutoMirrored.Filled.Chat,
|
||||
iconBgColor = IOSBlue,
|
||||
onClick = { onNavigateSubpage(SettingsSubpage.CHAT_SECURITY) }
|
||||
)
|
||||
IOSSettingsRow(
|
||||
title = "Ghost Mode (Stealth)",
|
||||
subtitle = if (settings.ghostModeEnabled) "Active · Hiding story & DM seen" else "Off",
|
||||
@@ -896,7 +951,7 @@ private fun FocusModeSubpage(
|
||||
IOSGroupedCard {
|
||||
IOSSettingsRow(
|
||||
title = "Block Video Autoplay",
|
||||
subtitle = "Stop videos and reels from playing automatically",
|
||||
subtitle = "Stop videos and reels from playing automatically (click audio icon to play)",
|
||||
icon = Icons.Default.PlayArrow,
|
||||
iconBgColor = IOSBlue,
|
||||
checked = settings.noAutoplay,
|
||||
@@ -1603,7 +1658,8 @@ private fun ExtrasSubpage(
|
||||
@Composable
|
||||
private fun AppearanceSubpage(
|
||||
repository: SettingsRepository,
|
||||
settings: FocusSettings
|
||||
settings: FocusSettings,
|
||||
onNavigateChooseIcon: () -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -1627,6 +1683,320 @@ private fun AppearanceSubpage(
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IOSSectionTitle(title = "APP ICON")
|
||||
IOSGroupedCard {
|
||||
IOSSettingsRow(
|
||||
title = "Custom Icons",
|
||||
subtitle = "Enable custom launcher and era icons",
|
||||
icon = Icons.Default.Palette,
|
||||
iconBgColor = IOSPurple,
|
||||
checked = settings.customIconsEnabled,
|
||||
onCheckedChange = { isChecked ->
|
||||
scope.launch { repository.setCustomIconsEnabled(isChecked) }
|
||||
},
|
||||
showDivider = settings.customIconsEnabled
|
||||
)
|
||||
if (settings.customIconsEnabled) {
|
||||
val activeOption = AppIconManager.IconOption.fromId(settings.appIcon)
|
||||
IOSSettingsRow(
|
||||
title = "Choose App Icon",
|
||||
subtitle = "Select launcher icon style",
|
||||
icon = Icons.Default.Palette,
|
||||
iconBgColor = IOSBlue,
|
||||
trailingText = activeOption.displayName,
|
||||
onClick = onNavigateChooseIcon,
|
||||
showDivider = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CHOOSE APP ICON SUBPAGE ───────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ChooseAppIconSubpage(
|
||||
repository: SettingsRepository,
|
||||
settings: FocusSettings,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val currentIconId = settings.appIcon.ifEmpty { "default" }
|
||||
|
||||
val coreOptions = remember {
|
||||
AppIconManager.IconOption.values().filter { it.category == "Core" }
|
||||
}
|
||||
val originalOptions = remember {
|
||||
AppIconManager.IconOption.values().filter { it.category == "Originals" }
|
||||
}
|
||||
val redesignOptions = remember {
|
||||
AppIconManager.IconOption.values().filter { it.category == "Redesigns" }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
// Section 1: Core System Icons
|
||||
item {
|
||||
IOSSectionTitle(title = "CORE ICONS")
|
||||
IOSGroupedCard {
|
||||
coreOptions.forEachIndexed { index, option ->
|
||||
val isSelected = currentIconId == option.id
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
scope.launch {
|
||||
repository.setAppIcon(option.id)
|
||||
AppIconManager.setAppIcon(context, option.id)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val iconBitmap = remember(option.iconRes) {
|
||||
AppIconManager.getIconBitmap(context, option.iconRes)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.border(
|
||||
width = if (isSelected) 2.dp else 1.dp,
|
||||
color = if (isSelected) IOSBlue else Color.White.copy(alpha = 0.15f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (iconBitmap != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = iconBitmap.asImageBitmap(),
|
||||
contentDescription = option.displayName,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = option.displayName,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = IOSTextPrimary
|
||||
)
|
||||
Text(
|
||||
text = option.subtitle,
|
||||
fontSize = 12.sp,
|
||||
color = IOSTextSecondary,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Active",
|
||||
tint = IOSBlue,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (index < coreOptions.size - 1) {
|
||||
androidx.compose.material3.HorizontalDivider(
|
||||
color = IOSDivider,
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.padding(start = 80.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Section 2: Originals
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IOSSectionTitle(title = "ORIGINALS")
|
||||
IOSGroupedCard {
|
||||
originalOptions.forEachIndexed { index, option ->
|
||||
val isSelected = currentIconId == option.id
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
scope.launch {
|
||||
repository.setAppIcon(option.id)
|
||||
AppIconManager.setAppIcon(context, option.id)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val iconBitmap = remember(option.iconRes) {
|
||||
AppIconManager.getIconBitmap(context, option.iconRes)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.border(
|
||||
width = if (isSelected) 2.dp else 1.dp,
|
||||
color = if (isSelected) IOSBlue else Color.White.copy(alpha = 0.15f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (iconBitmap != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = iconBitmap.asImageBitmap(),
|
||||
contentDescription = option.displayName,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = option.displayName,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = IOSTextPrimary
|
||||
)
|
||||
Text(
|
||||
text = option.subtitle,
|
||||
fontSize = 12.sp,
|
||||
color = IOSTextSecondary,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Active",
|
||||
tint = IOSBlue,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (index < originalOptions.size - 1) {
|
||||
androidx.compose.material3.HorizontalDivider(
|
||||
color = IOSDivider,
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.padding(start = 80.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Section 3: Redesigns (alt-logos)
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IOSSectionTitle(title = "REDESIGNS")
|
||||
IOSGroupedCard {
|
||||
redesignOptions.forEachIndexed { index, option ->
|
||||
val isSelected = currentIconId == option.id
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
scope.launch {
|
||||
repository.setAppIcon(option.id)
|
||||
AppIconManager.setAppIcon(context, option.id)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val iconBitmap = remember(option.iconRes) {
|
||||
AppIconManager.getIconBitmap(context, option.iconRes)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.border(
|
||||
width = if (isSelected) 2.dp else 1.dp,
|
||||
color = if (isSelected) IOSBlue else Color.White.copy(alpha = 0.15f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (iconBitmap != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = iconBitmap.asImageBitmap(),
|
||||
contentDescription = option.displayName,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = option.displayName,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = IOSTextPrimary
|
||||
)
|
||||
Text(
|
||||
text = option.subtitle,
|
||||
fontSize = 12.sp,
|
||||
color = IOSTextSecondary,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Active",
|
||||
tint = IOSBlue,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (index < redesignOptions.size - 1) {
|
||||
androidx.compose.material3.HorizontalDivider(
|
||||
color = IOSDivider,
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.padding(start = 80.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Core, Original, and Redesign icons replace the application icon system-wide across Home Screen, App Drawer, and Recents.",
|
||||
color = Color(0xFF8E8E93),
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
}
|
||||
@@ -1859,3 +2229,231 @@ private fun LicensesSubpage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CHAT SECURITY & DM PRIVACY SUBPAGE ─────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ChatSecuritySubpage(
|
||||
repository: SettingsRepository,
|
||||
settings: FocusSettings,
|
||||
encryptedStorage: EncryptedStorage?,
|
||||
onNavigateChatPinSetup: () -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var showLockModeModal by remember { mutableStateOf(false) }
|
||||
|
||||
val lockModeOptions = remember {
|
||||
listOf(
|
||||
"off" to "Off",
|
||||
"individual" to "Individual DMs",
|
||||
"whole_dm" to "Whole DM"
|
||||
)
|
||||
}
|
||||
val currentLockModeIdx = remember(settings.chatLockMode) {
|
||||
lockModeOptions.indexOfFirst { it.first == settings.chatLockMode }.coerceAtLeast(0)
|
||||
}
|
||||
val lockModeLabel = when (settings.chatLockMode) {
|
||||
"whole_dm" -> "Whole DM"
|
||||
"individual" -> "Individual DMs"
|
||||
else -> "Off"
|
||||
}
|
||||
val lockModeSubtitle = when (settings.chatLockMode) {
|
||||
"whole_dm" -> "Require authentication to open your DM inbox and any chat"
|
||||
"individual" -> "Protect selected chats individually with lock button in header"
|
||||
else -> "Direct messages protection disabled"
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
item {
|
||||
IOSSectionTitle(title = "DIRECT MESSAGE PRIVACY")
|
||||
IOSGroupedCard {
|
||||
IOSSettingsRow(
|
||||
title = "Messages Lock Mode",
|
||||
subtitle = lockModeSubtitle,
|
||||
trailingText = lockModeLabel,
|
||||
icon = Icons.AutoMirrored.Filled.Chat,
|
||||
iconBgColor = IOSBlue,
|
||||
onClick = { showLockModeModal = true },
|
||||
showDivider = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
IOSSectionTitle(title = "AUTHENTICATION METHOD")
|
||||
IOSGroupedCard {
|
||||
IOSSettingsRow(
|
||||
title = "Biometric Unlock",
|
||||
subtitle = "Use fingerprint or face recognition for chat locks",
|
||||
icon = Icons.Default.Fingerprint,
|
||||
iconBgColor = IOSGreen,
|
||||
checked = settings.chatBiometricEnabled,
|
||||
onCheckedChange = { scope.launch { repository.setChatBiometricEnabled(it) } }
|
||||
)
|
||||
IOSSettingsRow(
|
||||
title = "Use Separate Chat PIN",
|
||||
subtitle = if (settings.useSeparateChatPin) {
|
||||
if (encryptedStorage?.hasChatPin() == true) "Dedicated Chat PIN configured" else "Tap below to set dedicated PIN"
|
||||
} else {
|
||||
"Uses the main App Lock PIN"
|
||||
},
|
||||
icon = Icons.Default.Lock,
|
||||
iconBgColor = IOSOrange,
|
||||
checked = settings.useSeparateChatPin,
|
||||
onCheckedChange = { isChecked ->
|
||||
scope.launch { repository.setUseSeparateChatPin(isChecked) }
|
||||
if (isChecked && encryptedStorage?.hasChatPin() != true) {
|
||||
onNavigateChatPinSetup()
|
||||
}
|
||||
},
|
||||
showDivider = settings.useSeparateChatPin
|
||||
)
|
||||
if (settings.useSeparateChatPin) {
|
||||
IOSSettingsRow(
|
||||
title = if (encryptedStorage?.hasChatPin() == true) "Change Chat PIN" else "Set Chat PIN",
|
||||
subtitle = "Configure dedicated 4-digit PIN for chats",
|
||||
icon = Icons.Default.Lock,
|
||||
iconBgColor = Color(0xFF636366),
|
||||
onClick = onNavigateChatPinSetup,
|
||||
showDivider = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
IOSSectionTitle(title = "LOCKED CONVERSATIONS")
|
||||
IOSGroupedCard {
|
||||
if (settings.lockedChatIds.isEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "No individual conversations locked yet. Open any chat on Instagram and tap the Lock icon in the header to protect it.",
|
||||
color = IOSTextSecondary,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
settings.lockedChatIds.toList().forEachIndexed { index, id ->
|
||||
val username = settings.lockedChatUsernames[id]
|
||||
val displayTitle = if (!username.isNullOrBlank()) {
|
||||
"@${username.removePrefix("@")}"
|
||||
} else if (!id.all { it.isDigit() }) {
|
||||
"@${id.removePrefix("@")}"
|
||||
} else {
|
||||
"Conversation (@$id)"
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF2C2C2E)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
tint = IOSPurple,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = displayTitle,
|
||||
color = IOSTextPrimary,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = if (!username.isNullOrBlank()) "Protected direct message" else if (!id.all { it.isDigit() }) "Protected direct message" else "Direct conversation · $id",
|
||||
color = IOSTextSecondary,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch { repository.removeLockedChatId(id) }
|
||||
Toast.makeText(context, "Unlocked $displayTitle", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Remove lock",
|
||||
tint = IOSRed,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (index < settings.lockedChatIds.size - 1) {
|
||||
androidx.compose.material3.HorizontalDivider(
|
||||
color = IOSDivider,
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.padding(start = 64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
IOSGroupedCard {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
tint = IOSBlue,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "To lock a conversation, open the chat in Direct Messages and tap the Lock icon next to the conversation details (i) in the top bar.",
|
||||
color = IOSTextSecondary,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (showLockModeModal) {
|
||||
WheelPickerModalDialog(
|
||||
title = "Messages Lock Mode",
|
||||
subtitle = "Choose lock protection mode for Instagram direct messages",
|
||||
options = lockModeOptions,
|
||||
initialIndex = currentLockModeIdx,
|
||||
onDismiss = { showLockModeModal = false },
|
||||
onConfirm = { option ->
|
||||
scope.launch { repository.setChatLockMode(option.first) }
|
||||
},
|
||||
labelProvider = { it.second }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -152,9 +153,11 @@ fun AdsterraAdScreen(
|
||||
onRewardClaimed: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
var timerCycle by remember { mutableIntStateOf(0) }
|
||||
var elapsedSeconds by remember { mutableIntStateOf(0) }
|
||||
var adsClicked by remember { mutableIntStateOf(0) }
|
||||
var timerCycle by rememberSaveable { mutableIntStateOf(0) }
|
||||
var elapsedSeconds by rememberSaveable { mutableIntStateOf(0) }
|
||||
var adsClicked by rememberSaveable { mutableIntStateOf(0) }
|
||||
var lastClickedUrl by rememberSaveable { mutableStateOf("") }
|
||||
var lastClickTime by rememberSaveable { mutableStateOf(0L) }
|
||||
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
val isSettingToggle = sessionType == "setting_toggle"
|
||||
@@ -162,9 +165,17 @@ fun AdsterraAdScreen(
|
||||
val bothClicked = adsClicked >= 2
|
||||
val isComplete = if (isSettingToggle) timerDone else (timerDone && bothClicked)
|
||||
|
||||
// 20s Timer Loop - restarts cleanly on timerCycle increment
|
||||
// Ensure clean countdown and state when entering a setting toggle session
|
||||
LaunchedEffect(sessionType) {
|
||||
if (isSettingToggle) {
|
||||
elapsedSeconds = 0
|
||||
adsClicked = 0
|
||||
timerCycle++
|
||||
}
|
||||
}
|
||||
|
||||
// 20s Timer Loop - continues cleanly from saved elapsedSeconds without resetting
|
||||
LaunchedEffect(timerCycle) {
|
||||
elapsedSeconds = 0
|
||||
while (elapsedSeconds < requiredSeconds) {
|
||||
delay(1000L)
|
||||
elapsedSeconds++
|
||||
@@ -308,25 +319,43 @@ fun AdsterraAdScreen(
|
||||
}
|
||||
setBackgroundColor(0xFF111111.toInt())
|
||||
|
||||
fun handleAdClick(url: String): Boolean {
|
||||
val clean = url.trim()
|
||||
if (clean.isNotBlank() && !clean.contains("effectivecpmnetwork.com") && !clean.contains("highperformanceformat.com") && !clean.startsWith("about:") && !clean.startsWith("data:")) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (clean != lastClickedUrl || now - lastClickTime > 1500L) {
|
||||
lastClickedUrl = clean
|
||||
lastClickTime = now
|
||||
if (adsClicked < 2) {
|
||||
adsClicked++
|
||||
}
|
||||
}
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(clean)).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
ctx.startActivity(browserIntent)
|
||||
} catch (_: Exception) {}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
): Boolean {
|
||||
val url = request?.url?.toString() ?: ""
|
||||
if (url.isNotBlank() && !url.contains("effectivecpmnetwork.com") && !url.contains("highperformanceformat.com") && !url.startsWith("about:")) {
|
||||
if (adsClicked < 2) {
|
||||
adsClicked++
|
||||
}
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
ctx.startActivity(browserIntent)
|
||||
} catch (_: Exception) {}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return handleAdClick(url)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
url: String?
|
||||
): Boolean {
|
||||
return handleAdClick(url ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,25 +368,34 @@ fun AdsterraAdScreen(
|
||||
): Boolean {
|
||||
val transport = resultMsg?.obj as? WebView.WebViewTransport
|
||||
val tempWebView = WebView(ctx).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.setSupportMultipleWindows(true)
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
): Boolean {
|
||||
val url = request?.url?.toString() ?: ""
|
||||
if (url.isNotBlank() && !url.startsWith("about:")) {
|
||||
if (adsClicked < 2) {
|
||||
adsClicked++
|
||||
return handleAdClick(url)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
url: String?
|
||||
): Boolean {
|
||||
return handleAdClick(url ?: "")
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
val u = url ?: ""
|
||||
if (u.isNotBlank() && !u.startsWith("about:") && !u.startsWith("data:")) {
|
||||
if (!u.contains("effectivecpmnetwork.com") && !u.contains("highperformanceformat.com")) {
|
||||
handleAdClick(u)
|
||||
}
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
ctx.startActivity(browserIntent)
|
||||
} catch (_: Exception) {}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,7 +421,11 @@ fun AdsterraAdScreen(
|
||||
) {
|
||||
// Primary Claim Button: Apple Style Pill Button
|
||||
Button(
|
||||
onClick = onRewardClaimed,
|
||||
onClick = {
|
||||
elapsedSeconds = 0
|
||||
adsClicked = 0
|
||||
onRewardClaimed()
|
||||
},
|
||||
enabled = isComplete,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -419,7 +461,10 @@ fun AdsterraAdScreen(
|
||||
Button(
|
||||
onClick = {
|
||||
timerCycle++
|
||||
elapsedSeconds = 0
|
||||
adsClicked = 0
|
||||
lastClickedUrl = ""
|
||||
lastClickTime = 0L
|
||||
webViewRef?.loadDataWithBaseURL("https://effectivecpmnetwork.com", AD_HTML, "text/html", "UTF-8", null)
|
||||
},
|
||||
modifier = Modifier
|
||||
|
||||
@@ -35,6 +35,7 @@ import com.ujwal.focusgram.core.utils.MediaDownloadHelper
|
||||
import com.ujwal.focusgram.feature.screentime.ScreenTimeService
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -42,9 +43,28 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
@@ -61,6 +81,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -89,6 +110,7 @@ import com.ujwal.focusgram.core.ui.theme.PureBlack
|
||||
import com.ujwal.focusgram.feature.applock.AppLockScreen
|
||||
import com.ujwal.focusgram.feature.applock.AppLockSettingsPage
|
||||
import com.ujwal.focusgram.feature.applock.AppLockSetupScreen
|
||||
import com.ujwal.focusgram.feature.applock.ChatLockScreen
|
||||
import com.ujwal.focusgram.feature.diagnostics.DebugMenuScreen
|
||||
import com.ujwal.focusgram.feature.history.ReelsHistoryRepository
|
||||
import com.ujwal.focusgram.feature.history.ReelsHistoryScreen
|
||||
@@ -119,10 +141,17 @@ enum class AppDestination {
|
||||
GUARDRAILS,
|
||||
APP_LOCK_SETUP,
|
||||
APP_LOCK_SETTINGS,
|
||||
CHAT_LOCK_SETUP,
|
||||
DEBUG_MENU,
|
||||
ADSTERRA_REWARDED
|
||||
}
|
||||
|
||||
data class PendingChatUnlock(
|
||||
val threadId: String,
|
||||
val username: String?,
|
||||
val targetUrl: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* FocusGramApp
|
||||
* Primary Jetpack Compose application composable.
|
||||
@@ -165,16 +194,111 @@ fun FocusGramApp() {
|
||||
val settings by settingsRepository.settingsFlow.collectAsState(initial = initialSettings)
|
||||
val currentUrl by sessionManager.currentUrl.collectAsState()
|
||||
var isAppInForeground by remember { mutableStateOf(true) }
|
||||
var destination by remember { mutableStateOf(AppDestination.MAIN_WEBVIEW) }
|
||||
var destination by rememberSaveable { mutableStateOf(AppDestination.MAIN_WEBVIEW) }
|
||||
val isViewingReels = destination == AppDestination.MAIN_WEBVIEW && currentUrl.lowercase().contains("/reel")
|
||||
val hasPin = remember { encryptedStorage.hasPin() }
|
||||
var isAppUnlocked by remember { mutableStateOf(false) }
|
||||
var isAppUnlocked by rememberSaveable { mutableStateOf(false) }
|
||||
val shouldShowLock = settings.appLockEnabled && hasPin && !isAppUnlocked
|
||||
var hasPassedSessionPicker by remember { mutableStateOf(false) }
|
||||
var hasPassedBreathGate by remember { mutableStateOf(!initialSettings.showBreathGate || initialSettings.breathGateSeconds <= 0) }
|
||||
var hasPassedEffortFriction by remember { mutableStateOf(!initialSettings.effortFrictionEnabled || initialSettings.wordChallengeCount <= 0) }
|
||||
|
||||
// Direct Messages & Chat Lock Evaluation
|
||||
val threadToUsernameMap = remember { androidx.compose.runtime.mutableStateMapOf<String, Set<String>>() }
|
||||
var unlockedChatThreadIds by remember { mutableStateOf(emptySet<String>()) }
|
||||
var isDirectAllUnlocked by remember { mutableStateOf(false) }
|
||||
var pendingUnlockChat by remember { mutableStateOf<PendingChatUnlock?>(null) }
|
||||
|
||||
LaunchedEffect(extensionManager) {
|
||||
extensionManager.onThreadUsernameMapped = { threadId, usernames ->
|
||||
val cleanThreadId = threadId.lowercase().trim()
|
||||
val existing = threadToUsernameMap[cleanThreadId] ?: emptySet()
|
||||
threadToUsernameMap[cleanThreadId] = existing + usernames.map { it.lowercase().trim().removePrefix("@") }
|
||||
scope.launch {
|
||||
usernames.firstOrNull()?.let { user ->
|
||||
settingsRepository.mapThreadUsername(threadId, user)
|
||||
}
|
||||
}
|
||||
}
|
||||
extensionManager.onRequestUnlockChat = { threadId, username, targetUrl ->
|
||||
val cleanThreadId = threadId.lowercase().trim()
|
||||
val cleanUser = username?.lowercase()?.trim()?.removePrefix("@")
|
||||
?: threadToUsernameMap[cleanThreadId]?.firstOrNull()
|
||||
?: settings.lockedChatUsernames[cleanThreadId]
|
||||
pendingUnlockChat = PendingChatUnlock(cleanThreadId, cleanUser, targetUrl)
|
||||
}
|
||||
extensionManager.onToggleChatLock = { threadId, username ->
|
||||
scope.launch {
|
||||
val isNowLocked = settingsRepository.toggleLockedChat(threadId, username)
|
||||
val label = if (!username.isNullOrBlank()) "@$username" else "Conversation"
|
||||
val msg = if (isNowLocked) "Locked $label" else "Unlocked $label"
|
||||
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val currentUri = remember(currentUrl) { try { Uri.parse(currentUrl) } catch (_: Exception) { null } }
|
||||
val currentPath = currentUri?.path?.lowercase() ?: ""
|
||||
val isDirectRoute = currentPath.startsWith("/direct")
|
||||
val currentThreadId = remember(currentPath) {
|
||||
if (currentPath.startsWith("/direct/t/")) {
|
||||
currentPath.removePrefix("/direct/t/").trim('/').split('/').firstOrNull()?.trim()
|
||||
} else null
|
||||
}
|
||||
|
||||
var activeThreadId by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(currentThreadId) {
|
||||
if (activeThreadId != null && activeThreadId != currentThreadId) {
|
||||
val closedThreadId = activeThreadId!!
|
||||
unlockedChatThreadIds = unlockedChatThreadIds - closedThreadId
|
||||
extensionManager.notifyChatLocked(closedThreadId)
|
||||
sessionManager.userScriptManager?.evaluate(sessionManager.session, """
|
||||
if (window.__fgUnlockedThreads && Array.isArray(window.__fgUnlockedThreads)) {
|
||||
window.__fgUnlockedThreads = window.__fgUnlockedThreads.filter(function(id) { return id !== '${closedThreadId.lowercase().trim()}'; });
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('focusgram:chat-lock-sync', {
|
||||
detail: { unlockedThreads: window.__fgUnlockedThreads || [] }
|
||||
}));
|
||||
""")
|
||||
}
|
||||
activeThreadId = currentThreadId
|
||||
}
|
||||
|
||||
val isChatLockActive = remember(
|
||||
currentUrl,
|
||||
currentPath,
|
||||
currentThreadId,
|
||||
settings,
|
||||
unlockedChatThreadIds,
|
||||
isDirectAllUnlocked,
|
||||
threadToUsernameMap.size,
|
||||
pendingUnlockChat,
|
||||
settings.lockedChatUsernames.size
|
||||
) {
|
||||
if (pendingUnlockChat != null) return@remember true
|
||||
if (!isDirectRoute) return@remember false
|
||||
if (settings.chatLockMode == "off") return@remember false
|
||||
if (settings.chatLockMode == "whole_dm" || settings.chatLockAll) {
|
||||
if (isDirectAllUnlocked) return@remember false
|
||||
return@remember true
|
||||
}
|
||||
if (currentThreadId != null) {
|
||||
if (unlockedChatThreadIds.contains(currentThreadId)) return@remember false
|
||||
val associatedUsers = threadToUsernameMap[currentThreadId] ?: emptySet()
|
||||
val mappedName = settings.lockedChatUsernames[currentThreadId]
|
||||
val isLocked = settings.lockedChatIds.any { lockedId ->
|
||||
val clean = lockedId.trim().lowercase().removePrefix("@")
|
||||
clean.equals(currentThreadId, ignoreCase = true) ||
|
||||
currentUrl.contains(clean, ignoreCase = true) ||
|
||||
associatedUsers.any { it.equals(clean, ignoreCase = true) } ||
|
||||
(!mappedName.isNullOrBlank() && clean.equals(mappedName.lowercase().trim().removePrefix("@"), ignoreCase = true))
|
||||
}
|
||||
return@remember isLocked
|
||||
}
|
||||
false
|
||||
}
|
||||
var hasPassedSessionPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var hasPassedBreathGate by rememberSaveable { mutableStateOf(!initialSettings.showBreathGate || initialSettings.breathGateSeconds <= 0) }
|
||||
var hasPassedEffortFriction by rememberSaveable { mutableStateOf(!initialSettings.effortFrictionEnabled || initialSettings.wordChallengeCount <= 0) }
|
||||
val lastSessionEndTimestamp by settingsRepository.lastSessionEndTimestampFlow.collectAsState(initial = 0L)
|
||||
var hasPassedCooldownGate by remember { mutableStateOf(false) }
|
||||
var hasPassedCooldownGate by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.US) }
|
||||
val today = remember { dateFormat.format(Date()) }
|
||||
@@ -193,6 +317,8 @@ fun FocusGramApp() {
|
||||
}
|
||||
|
||||
var isWebInputFocused by remember { mutableStateOf(false) }
|
||||
var isBioFocused by remember { mutableStateOf(false) }
|
||||
var selectedBioStyle by remember { mutableStateOf("bold") }
|
||||
var isModalActive by remember { mutableStateOf(false) }
|
||||
var isUserAuthenticated by remember { mutableStateOf(false) }
|
||||
var isAuthPage by remember { mutableStateOf(false) }
|
||||
@@ -207,12 +333,14 @@ fun FocusGramApp() {
|
||||
var settingsSubpage by rememberSaveable { mutableStateOf(com.ujwal.focusgram.feature.settings.SettingsSubpage.MAIN) }
|
||||
|
||||
// Evaluates whether any barrier, lock, breath pause, effort friction, cooldown, or intention picker is active
|
||||
val isGateActive = shouldShowLock ||
|
||||
((isDailyLimitExhausted || isSessionExhausted) && destination != AppDestination.ADSTERRA_REWARDED) ||
|
||||
val isGateActive = destination != AppDestination.ADSTERRA_REWARDED && (
|
||||
shouldShowLock ||
|
||||
isDailyLimitExhausted || isSessionExhausted ||
|
||||
(remainingCooldownSeconds > 0 && !hasPassedCooldownGate) ||
|
||||
(settings.showBreathGate && settings.breathGateSeconds > 0 && !hasPassedBreathGate) ||
|
||||
(settings.effortFrictionEnabled && settings.wordChallengeCount > 0 && !hasPassedEffortFriction) ||
|
||||
!hasPassedSessionPicker
|
||||
)
|
||||
|
||||
// System File Chooser / Camera / Gallery Launchers for Story, Post, DMs & Profile Uploads
|
||||
val pendingFilePickerCallback = remember { AtomicReference<((List<Uri>) -> Unit)?>(null) }
|
||||
@@ -562,21 +690,32 @@ fun FocusGramApp() {
|
||||
}
|
||||
}
|
||||
|
||||
val currentDestination by rememberUpdatedState(destination)
|
||||
val currentHasPassedSessionPicker by rememberUpdatedState(hasPassedSessionPicker)
|
||||
val currentTotalElapsedSeconds by rememberUpdatedState(totalElapsedSeconds)
|
||||
|
||||
// Lifecycle observer for foreground/background state, media pausing, and auto-lock
|
||||
DisposableEffect(lifecycleOwner, settings.appLockEnabled, settings.autoLockOnBackground) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
isAppInForeground = false
|
||||
sessionManager.pauseMedia()
|
||||
if (hasPassedSessionPicker && totalElapsedSeconds >= 15) {
|
||||
if (currentHasPassedSessionPicker && currentTotalElapsedSeconds >= 15) {
|
||||
scope.launch { settingsRepository.recordSessionEnd() }
|
||||
}
|
||||
if (settings.appLockEnabled && (settings.autoLockOnBackground || !hasPassedSessionPicker)) {
|
||||
isAppUnlocked = false
|
||||
// Do not lock the app if the user is currently on the rewarded ad screen (e.g. clicking out to view an ad in browser)
|
||||
if (currentDestination != AppDestination.ADSTERRA_REWARDED) {
|
||||
if (settings.appLockEnabled && (settings.autoLockOnBackground || !currentHasPassedSessionPicker)) {
|
||||
isAppUnlocked = false
|
||||
}
|
||||
if (settings.autoLockOnBackground) {
|
||||
unlockedChatThreadIds = emptySet()
|
||||
isDirectAllUnlocked = false
|
||||
}
|
||||
}
|
||||
} else if (event == Lifecycle.Event.ON_START) {
|
||||
isAppInForeground = true
|
||||
if (destination == AppDestination.MAIN_WEBVIEW && !isGateActive) {
|
||||
if (currentDestination == AppDestination.MAIN_WEBVIEW && !isGateActive) {
|
||||
sessionManager.resumeMedia()
|
||||
}
|
||||
}
|
||||
@@ -595,12 +734,22 @@ fun FocusGramApp() {
|
||||
extensionManager.onInputFocused = { focused ->
|
||||
isWebInputFocused = focused
|
||||
}
|
||||
extensionManager.onBioInputFocused = { focused ->
|
||||
isBioFocused = focused
|
||||
}
|
||||
extensionManager.onUrlChanged = { newUrl ->
|
||||
val mode = IslandMode.fromUrl(newUrl)
|
||||
val lower = newUrl.lowercase()
|
||||
val isAuthRoute = (lower.contains("/accounts") && !lower.contains("/accounts/edit")) || lower.contains("/login") || lower.contains("/signup")
|
||||
isAuthPage = isAuthRoute
|
||||
if (!isAuthRoute) {
|
||||
isUserAuthenticated = true
|
||||
}
|
||||
sessionManager.updateCurrentUrl(newUrl)
|
||||
sessionManager.updateIslandMode(mode)
|
||||
extensionManager.setIslandMode(mode)
|
||||
isWebInputFocused = false
|
||||
isBioFocused = false
|
||||
isModalActive = mode == IslandMode.HIDDEN
|
||||
}
|
||||
extensionManager.onModalOrChatActive = { active ->
|
||||
@@ -680,13 +829,16 @@ fun FocusGramApp() {
|
||||
onRouteChanged = { newUrl ->
|
||||
val mode = IslandMode.fromUrl(newUrl)
|
||||
val lower = newUrl.lowercase()
|
||||
if (lower.contains("/accounts") || lower.contains("/login") || lower.contains("/signup")) {
|
||||
isAuthPage = true
|
||||
val isAuthRoute = (lower.contains("/accounts") && !lower.contains("/accounts/edit")) || lower.contains("/login") || lower.contains("/signup")
|
||||
isAuthPage = isAuthRoute
|
||||
if (!isAuthRoute) {
|
||||
isUserAuthenticated = true
|
||||
}
|
||||
sessionManager.updateCurrentUrl(newUrl)
|
||||
sessionManager.updateIslandMode(mode)
|
||||
extensionManager.setIslandMode(mode)
|
||||
isWebInputFocused = false
|
||||
isBioFocused = false
|
||||
isModalActive = mode == IslandMode.HIDDEN
|
||||
}
|
||||
)
|
||||
@@ -873,6 +1025,82 @@ fun FocusGramApp() {
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
|
||||
// Native DM Chat Header Lock Overlay Button (positioned adjacent to Instagram's Info button)
|
||||
if (!isGateActive && destination == AppDestination.MAIN_WEBVIEW && currentThreadId != null && settings.chatLockMode == "individual") {
|
||||
val isChatExplicitlyLocked = remember(currentThreadId, settings.lockedChatIds, threadToUsernameMap.size, settings.lockedChatUsernames.size) {
|
||||
val associatedUsers = threadToUsernameMap[currentThreadId] ?: emptySet()
|
||||
val mappedName = settings.lockedChatUsernames[currentThreadId]
|
||||
settings.lockedChatIds.any { lockedId ->
|
||||
val clean = lockedId.trim().lowercase().removePrefix("@")
|
||||
clean.equals(currentThreadId, ignoreCase = true) ||
|
||||
associatedUsers.any { it.equals(clean, ignoreCase = true) } ||
|
||||
(!mappedName.isNullOrBlank() && clean.equals(mappedName.lowercase().trim().removePrefix("@"), ignoreCase = true))
|
||||
}
|
||||
}
|
||||
val isCurrentlyLocked = isChatExplicitlyLocked
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.statusBarsPadding()
|
||||
.padding(end = 52.dp, top = 2.dp)
|
||||
.size(44.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val iconTint = if (isCurrentlyLocked) {
|
||||
Color(0xFFFF3B30)
|
||||
} else if (isAppLight) {
|
||||
Color(0xFF262626)
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.9f)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
var username = threadToUsernameMap[currentThreadId]?.firstOrNull()
|
||||
?: settings.lockedChatUsernames[currentThreadId]
|
||||
scope.launch {
|
||||
if (username.isNullOrBlank()) {
|
||||
try {
|
||||
sessionManager.userScriptManager?.evaluate(
|
||||
sessionManager.session,
|
||||
"if (typeof window.__fgGetThreadParticipant === 'function') { window.__fgGetThreadParticipant(); }"
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
username = threadToUsernameMap[currentThreadId]?.firstOrNull()
|
||||
?: settings.lockedChatUsernames[currentThreadId]
|
||||
}
|
||||
val isNowLocked = settingsRepository.toggleLockedChat(currentThreadId, username)
|
||||
if (isNowLocked) {
|
||||
// Conversation is now locked: engage lock immediately
|
||||
unlockedChatThreadIds = unlockedChatThreadIds - currentThreadId
|
||||
extensionManager.notifyChatLocked(currentThreadId)
|
||||
} else {
|
||||
// Conversation is now unlocked: keep unlocked in current session
|
||||
unlockedChatThreadIds = unlockedChatThreadIds + currentThreadId
|
||||
extensionManager.notifyChatUnlocked(currentThreadId, isAll = false)
|
||||
}
|
||||
val label = if (!username.isNullOrBlank()) "@$username" else "Conversation"
|
||||
val msg = if (isNowLocked) "Locked $label" else "Unlocked $label"
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
HapticHelper.performClick(context)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = androidx.compose.ui.res.painterResource(
|
||||
if (isCurrentlyLocked) com.ujwal.focusgram.R.drawable.ic_chat_lock_closed
|
||||
else com.ujwal.focusgram.R.drawable.ic_chat_lock_open
|
||||
),
|
||||
contentDescription = if (isCurrentlyLocked) "Unlock conversation" else "Lock conversation",
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ambient background behind the floating nav bar on Reels (seamless gradient, no harsh border line)
|
||||
if (!isGateActive && destination == AppDestination.MAIN_WEBVIEW && islandMode == IslandMode.FLOATING && !isKeyboardOrInputActive) {
|
||||
Box(
|
||||
@@ -941,6 +1169,64 @@ fun FocusGramApp() {
|
||||
)
|
||||
}
|
||||
|
||||
// Native Floating Bio Font Stylist (Anchored directly above the Soft Keyboard via imePadding)
|
||||
if (isBioFocused && !isGateActive && destination == AppDestination.MAIN_WEBVIEW) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.imePadding()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = Color(0xFA181820),
|
||||
border = BorderStroke(1.dp, Color(0x33FFFFFF)),
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
listOf(
|
||||
"normal" to "Normal",
|
||||
"bold" to "Bold",
|
||||
"sans" to "Sans",
|
||||
"sans_italic" to "Italic",
|
||||
"mono" to "Mono",
|
||||
"gothic" to "Gothic",
|
||||
"script" to "Script"
|
||||
).forEach { (id, label) ->
|
||||
val isSelected = selectedBioStyle == id
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(if (isSelected) Color.White else Color(0xFF262626))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = if (isSelected) Color.White else Color(0x33FFFFFF),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
)
|
||||
.clickable {
|
||||
selectedBioStyle = id
|
||||
extensionManager.setBioStyle(id)
|
||||
HapticHelper.performClick(context)
|
||||
sessionManager.session.loadUri("javascript:(function(){window.__fgSetBioStyle&&window.__fgSetBioStyle('$id');})()")
|
||||
}
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = if (isSelected) Color.Black else Color(0xFFD1D5DB),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-screen Animated Overlay (Settings, Reels History, App Lock, etc.)
|
||||
AnimatedVisibility(
|
||||
visible = !isGateActive && destination != AppDestination.MAIN_WEBVIEW,
|
||||
@@ -971,6 +1257,7 @@ fun FocusGramApp() {
|
||||
AppDestination.SETTINGS -> {
|
||||
com.ujwal.focusgram.feature.settings.SettingsScreen(
|
||||
repository = settingsRepository,
|
||||
inputSettings = settings,
|
||||
onBack = { destination = AppDestination.MAIN_WEBVIEW },
|
||||
onNavigateReelsHistory = { destination = AppDestination.REELS_HISTORY },
|
||||
onNavigateScreenTime = { destination = AppDestination.SCREEN_TIME },
|
||||
@@ -987,7 +1274,9 @@ fun FocusGramApp() {
|
||||
screenTimeDao = database.screenTimeDao(),
|
||||
reelHistory = reelHistory,
|
||||
currentSubpage = settingsSubpage,
|
||||
onSubpageChange = { settingsSubpage = it }
|
||||
onSubpageChange = { settingsSubpage = it },
|
||||
encryptedStorage = encryptedStorage,
|
||||
onNavigateChatPinSetup = { destination = AppDestination.CHAT_LOCK_SETUP }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1043,7 +1332,20 @@ fun FocusGramApp() {
|
||||
repository = settingsRepository,
|
||||
encryptedStorage = encryptedStorage,
|
||||
onBack = { destination = AppDestination.SETTINGS },
|
||||
onChangePin = { destination = AppDestination.APP_LOCK_SETUP }
|
||||
onChangePin = { destination = AppDestination.APP_LOCK_SETUP },
|
||||
onChangeChatPin = { destination = AppDestination.CHAT_LOCK_SETUP }
|
||||
)
|
||||
}
|
||||
|
||||
AppDestination.CHAT_LOCK_SETUP -> {
|
||||
AppLockSetupScreen(
|
||||
encryptedStorage = encryptedStorage,
|
||||
settingsRepository = settingsRepository,
|
||||
onBack = { destination = AppDestination.APP_LOCK_SETTINGS },
|
||||
onSuccess = { destination = AppDestination.APP_LOCK_SETTINGS },
|
||||
title = "Set Chat Lock PIN",
|
||||
subtitle = "Used to unlock protected Direct Messages and conversations.",
|
||||
isChatPinSetup = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1061,8 +1363,15 @@ fun FocusGramApp() {
|
||||
val pendingAction = adRewardAction
|
||||
adRewardAction = null
|
||||
if (pendingAction != null) {
|
||||
scope.launch { pendingAction.invoke() }
|
||||
destination = AppDestination.SETTINGS
|
||||
scope.launch {
|
||||
try {
|
||||
pendingAction.invoke()
|
||||
} catch (e: Exception) {
|
||||
Log.e("FocusGramApp", "Failed to apply rewarded setting action", e)
|
||||
} finally {
|
||||
destination = AppDestination.SETTINGS
|
||||
}
|
||||
}
|
||||
} else if (isDailyLimitExhausted) {
|
||||
scope.launch { settingsRepository.extendDailyLimit5Minutes(today) }
|
||||
sessionRemainingSeconds += 5 * 60
|
||||
@@ -1166,6 +1475,117 @@ fun FocusGramApp() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chat Lock Privacy Screen (Instant Black Cover + Hardware Biometric / PIN Gate)
|
||||
if (destination == AppDestination.MAIN_WEBVIEW && !isGateActive && isChatLockActive) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PureBlack)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {}
|
||||
) {
|
||||
val activeThread = pendingUnlockChat?.threadId ?: currentThreadId
|
||||
val lockedUsername = pendingUnlockChat?.username
|
||||
?: activeThread?.let { settings.lockedChatUsernames[it] }
|
||||
?: activeThread?.let { threadToUsernameMap[it]?.firstOrNull() }
|
||||
val chatTitle = when {
|
||||
settings.chatLockAll && activeThread == null -> "Direct Messages"
|
||||
!lockedUsername.isNullOrBlank() -> "@$lockedUsername"
|
||||
else -> "Locked Conversation"
|
||||
}
|
||||
ChatLockScreen(
|
||||
encryptedStorage = encryptedStorage,
|
||||
isBiometricAllowed = settings.chatBiometricEnabled,
|
||||
title = chatTitle,
|
||||
subtitle = "Enter 4-digit PIN to view conversation",
|
||||
customVerifyPin = if (settings.useSeparateChatPin && encryptedStorage.hasChatPin()) {
|
||||
{ pin -> encryptedStorage.verifyChatPin(pin) }
|
||||
} else null,
|
||||
onCancel = {
|
||||
val pending = pendingUnlockChat
|
||||
pendingUnlockChat = null
|
||||
if (currentThreadId != null) {
|
||||
unlockedChatThreadIds = unlockedChatThreadIds - currentThreadId
|
||||
}
|
||||
sessionManager.userScriptManager?.evaluate(sessionManager.session, """
|
||||
if (typeof window.__fgRemovePrivacyVeil === 'function') {
|
||||
window.__fgRemovePrivacyVeil();
|
||||
}
|
||||
""")
|
||||
if (settings.chatLockMode == "whole_dm" || settings.chatLockAll) {
|
||||
sessionManager.navigateTo("https://www.instagram.com/")
|
||||
} else {
|
||||
sessionManager.navigateTo("https://www.instagram.com/direct/inbox/")
|
||||
}
|
||||
},
|
||||
onUnlockSuccess = {
|
||||
val pending = pendingUnlockChat
|
||||
if (pending != null) {
|
||||
pendingUnlockChat = null
|
||||
unlockedChatThreadIds = unlockedChatThreadIds + pending.threadId
|
||||
extensionManager.notifyChatUnlocked(pending.threadId, isAll = false)
|
||||
|
||||
val cleanId = pending.threadId.lowercase().trim()
|
||||
sessionManager.userScriptManager?.evaluate(sessionManager.session, """
|
||||
window.__fgUnlockedThreads = window.__fgUnlockedThreads || [];
|
||||
if (!window.__fgUnlockedThreads.includes('$cleanId')) {
|
||||
window.__fgUnlockedThreads.push('$cleanId');
|
||||
}
|
||||
if (typeof window.__fgRemovePrivacyVeil === 'function') {
|
||||
window.__fgRemovePrivacyVeil();
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('focusgram:chat-lock-sync', {
|
||||
detail: { unlockedThreads: window.__fgUnlockedThreads }
|
||||
}));
|
||||
""")
|
||||
|
||||
val dest = pending.targetUrl ?: "https://www.instagram.com/direct/t/${pending.threadId}/"
|
||||
val current = sessionManager.currentUrl.value
|
||||
if (current.contains(pending.threadId)) {
|
||||
sessionManager.session.reload()
|
||||
} else {
|
||||
sessionManager.navigateTo(dest)
|
||||
}
|
||||
} else if ((settings.chatLockMode == "whole_dm" || settings.chatLockAll) && currentThreadId == null) {
|
||||
isDirectAllUnlocked = true
|
||||
extensionManager.notifyChatUnlocked("", isAll = true)
|
||||
sessionManager.userScriptManager?.evaluate(sessionManager.session, """
|
||||
window.__fgUnlockedThreads = window.__fgUnlockedThreads || [];
|
||||
if (!window.__fgUnlockedThreads.includes('__all__')) {
|
||||
window.__fgUnlockedThreads.push('__all__');
|
||||
}
|
||||
if (typeof window.__fgRemovePrivacyVeil === 'function') {
|
||||
window.__fgRemovePrivacyVeil();
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('focusgram:chat-lock-sync', {
|
||||
detail: { unlockedThreads: window.__fgUnlockedThreads }
|
||||
}));
|
||||
""")
|
||||
} else if (currentThreadId != null) {
|
||||
val cleanId = currentThreadId.lowercase().trim()
|
||||
unlockedChatThreadIds = unlockedChatThreadIds + currentThreadId
|
||||
extensionManager.notifyChatUnlocked(currentThreadId, isAll = false)
|
||||
sessionManager.userScriptManager?.evaluate(sessionManager.session, """
|
||||
window.__fgUnlockedThreads = window.__fgUnlockedThreads || [];
|
||||
if (!window.__fgUnlockedThreads.includes('$cleanId')) {
|
||||
window.__fgUnlockedThreads.push('$cleanId');
|
||||
}
|
||||
if (typeof window.__fgRemovePrivacyVeil === 'function') {
|
||||
window.__fgRemovePrivacyVeil();
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('focusgram:chat-lock-sync', {
|
||||
detail: { unlockedThreads: window.__fgUnlockedThreads }
|
||||
}));
|
||||
""")
|
||||
sessionManager.session.reload()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 409 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 145 KiB |
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M6,22h12c1.1,0 2,-0.9 2,-2v-9c0,-1.1 -0.9,-2 -2,-2H9V7c0,-1.65 1.35,-3 3,-3s3,1.35 3,3h2c0,-2.76 -2.24,-5 -5,-5S7,4.24 7,7v2H6c-1.1,0 -2,0.9 -2,2v9c0,1.1 0.9,2 2,2m6,-8c1.1,0 2,0.9 2,2a2,2 0 0,1 -1,1.72V20h-2v-2.28A2,2 0 0,1 10,16c0,-1.1 0.9,-2 2,-2"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M20,9h-8V7c0,-2.76 -2.24,-5 -5,-5S2,4.24 2,7h2c0,-1.65 1.35,-3 3,-3s3,1.35 3,3v2H8c-1.1,0 -2,0.9 -2,2v9c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2v-9c0,-1.1 -0.9,-2 -2,-2m-5,8.72V20h-2v-2.28A2,2 0 0,1 12,16c0,-1.1 0.9,-2 2,-2s2,0.9 2,2a2,2 0 0,1 -1,1.72"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Instagram Authentic Sunset Gradient Background -->
|
||||
<path
|
||||
android:pathData="M0,0 h108 v108 h-108 z"
|
||||
android:fillColor="#833AB4"/>
|
||||
<path
|
||||
android:pathData="M0,0 h108 v108 h-108 z"
|
||||
android:fillColor="#E1306C"
|
||||
android:fillAlpha="0.75"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,24 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Clean Minimalist Black Glyph on Pure White Background -->
|
||||
<path
|
||||
android:pathData="M44,34 L64,34 A10,10 0 0,1 74,44 L74,64 A10,10 0 0,1 64,74 L44,74 A10,10 0 0,1 34,64 L34,44 A10,10 0 0,1 44,34 Z"
|
||||
android:strokeWidth="3.5"
|
||||
android:strokeColor="#000000"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:pathData="M54,45 A9,9 0 1,1 53.99,45 Z"
|
||||
android:strokeWidth="3.5"
|
||||
android:strokeColor="#000000"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:pathData="M65.5,42.5 A2,2 0 1,1 65.49,42.5 Z"
|
||||
android:fillColor="#000000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,26 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Camera Body -->
|
||||
<path
|
||||
android:pathData="M44,34 L64,34 A10,10 0 0,1 74,44 L74,64 A10,10 0 0,1 64,74 L44,74 A10,10 0 0,1 34,64 L34,44 A10,10 0 0,1 44,34 Z"
|
||||
android:strokeWidth="3.5"
|
||||
android:strokeColor="#FF512F"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<!-- Camera Lens -->
|
||||
<path
|
||||
android:pathData="M54,45 A9,9 0 1,1 53.99,45 Z"
|
||||
android:strokeWidth="3.5"
|
||||
android:strokeColor="#FF512F"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<!-- Flash Dot -->
|
||||
<path
|
||||
android:pathData="M65.5,42.5 A2,2 0 1,1 65.49,42.5 Z"
|
||||
android:fillColor="#FF512F"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,41 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Notepad / Document Outline -->
|
||||
<path
|
||||
android:pathData="M40,32 L60,32 L70,42 L70,74 A6,6 0 0,1 64,80 L40,80 A6,6 0 0,1 34,74 L34,38 A6,6 0 0,1 40,32 Z"
|
||||
android:strokeWidth="3.2"
|
||||
android:strokeColor="#E0E0E0"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<!-- Folded Corner -->
|
||||
<path
|
||||
android:pathData="M60,32 L60,42 L70,42"
|
||||
android:strokeWidth="3.2"
|
||||
android:strokeColor="#E0E0E0"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<!-- Note Content Lines -->
|
||||
<path
|
||||
android:pathData="M42,50 L62,50"
|
||||
android:strokeWidth="3"
|
||||
android:strokeColor="#B0B0B0"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M42,58 L62,58"
|
||||
android:strokeWidth="3"
|
||||
android:strokeColor="#B0B0B0"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M42,66 L54,66"
|
||||
android:strokeWidth="3"
|
||||
android:strokeColor="#B0B0B0"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,24 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Official Instagram White Camera Glyph -->
|
||||
<path
|
||||
android:pathData="M44,34 L64,34 A10,10 0 0,1 74,44 L74,64 A10,10 0 0,1 64,74 L44,74 A10,10 0 0,1 34,64 L34,44 A10,10 0 0,1 44,34 Z"
|
||||
android:strokeWidth="3.6"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:pathData="M54,45 A9,9 0 1,1 53.99,45 Z"
|
||||
android:strokeWidth="3.6"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:pathData="M65.5,42.5 A2,2 0 1,1 65.49,42.5 Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,26 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Camera Body -->
|
||||
<path
|
||||
android:pathData="M44,34 L64,34 A10,10 0 0,1 74,44 L74,64 A10,10 0 0,1 64,74 L44,74 A10,10 0 0,1 34,64 L34,44 A10,10 0 0,1 44,34 Z"
|
||||
android:strokeWidth="3.5"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<!-- Camera Lens -->
|
||||
<path
|
||||
android:pathData="M54,45 A9,9 0 1,1 53.99,45 Z"
|
||||
android:strokeWidth="3.5"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<!-- Flash Dot -->
|
||||
<path
|
||||
android:pathData="M65.5,42.5 A2,2 0 1,1 65.49,42.5 Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,26 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Camera Body -->
|
||||
<path
|
||||
android:pathData="M44,34 L64,34 A10,10 0 0,1 74,44 L74,64 A10,10 0 0,1 64,74 L44,74 A10,10 0 0,1 34,64 L34,44 A10,10 0 0,1 44,34 Z"
|
||||
android:strokeWidth="3.5"
|
||||
android:strokeColor="#00F2FE"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<!-- Camera Lens -->
|
||||
<path
|
||||
android:pathData="M54,45 A9,9 0 1,1 53.99,45 Z"
|
||||
android:strokeWidth="3.5"
|
||||
android:strokeColor="#00F2FE"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<!-- Flash Dot -->
|
||||
<path
|
||||
android:pathData="M65.5,42.5 A2,2 0 1,1 65.49,42.5 Z"
|
||||
android:fillColor="#00F2FE"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,26 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Pitch Black Stealth Camera Body -->
|
||||
<path
|
||||
android:pathData="M44,34 L64,34 A10,10 0 0,1 74,44 L74,64 A10,10 0 0,1 64,74 L44,74 A10,10 0 0,1 34,64 L34,44 A10,10 0 0,1 44,34 Z"
|
||||
android:strokeWidth="3.5"
|
||||
android:strokeColor="#2C2C2E"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<!-- Pitch Black Lens -->
|
||||
<path
|
||||
android:pathData="M54,45 A9,9 0 1,1 53.99,45 Z"
|
||||
android:strokeWidth="3.5"
|
||||
android:strokeColor="#2C2C2E"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<!-- Pitch Black Flash Dot -->
|
||||
<path
|
||||
android:pathData="M65.5,42.5 A2,2 0 1,1 65.49,42.5 Z"
|
||||
android:fillColor="#3A3A3C"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg_abstract"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_fg_abstract"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/white"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_fg_all_white"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg_classic"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_fg_classic"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg_discreet"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_fg_discreet"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg_forest"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_fg_forest"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg_golden"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_fg_golden"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_bg_instagram"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_fg_instagram_official"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg_metal"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_fg_metal"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg_minimalist"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_fg_minimalist"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg_mono"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_fg_mono"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg_oled"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_fg_oled"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/black"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_fg_pitch_black"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg_polaroid"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_fg_polaroid"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg_redesign"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_fg_redesign"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg_scribble"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_fg_scribble"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg_vibrant"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_fg_vibrant"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_bg_viewfinder"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_fg_viewfinder"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -6,4 +6,19 @@
|
||||
<color name="card_dark">#121212</color>
|
||||
<color name="accent_blue">#3797EF</color>
|
||||
<color name="accent_gold">#FFD700</color>
|
||||
<color name="ic_launcher_bg_default">#000000</color>
|
||||
<color name="ic_launcher_bg_oled">#000000</color>
|
||||
<color name="ic_launcher_bg_classic">#1C0E18</color>
|
||||
<color name="ic_launcher_bg_mono">#121212</color>
|
||||
<color name="ic_launcher_bg_discreet">#1C1C1E</color>
|
||||
<color name="ic_launcher_bg_redesign">#462220</color>
|
||||
<color name="ic_launcher_bg_abstract">#1C1D25</color>
|
||||
<color name="ic_launcher_bg_forest">#6A6151</color>
|
||||
<color name="ic_launcher_bg_golden">#141519</color>
|
||||
<color name="ic_launcher_bg_metal">#313744</color>
|
||||
<color name="ic_launcher_bg_scribble">#1F2227</color>
|
||||
<color name="ic_launcher_bg_polaroid">#121212</color>
|
||||
<color name="ic_launcher_bg_viewfinder">#1A1410</color>
|
||||
<color name="ic_launcher_bg_minimalist">#000000</color>
|
||||
<color name="ic_launcher_bg_vibrant">#000000</color>
|
||||
</resources>
|
||||
@@ -35,4 +35,67 @@ class AdsterraAdFlowTest {
|
||||
val isComplete4 = (25 >= requiredSeconds) && (3 >= requiredClicks)
|
||||
assertTrue(isComplete4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSettingToggleCriteria() {
|
||||
val requiredSeconds = 20
|
||||
// Setting toggles only require watching the 20s ad countdown without mandatory external clicks
|
||||
fun isToggleComplete(elapsed: Int) = elapsed >= requiredSeconds
|
||||
|
||||
assertFalse(isToggleComplete(0))
|
||||
assertFalse(isToggleComplete(19))
|
||||
assertTrue(isToggleComplete(20))
|
||||
assertTrue(isToggleComplete(25))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAdDestinationGateExemption() {
|
||||
fun isGateActive(
|
||||
destination: String,
|
||||
shouldShowLock: Boolean,
|
||||
isExhausted: Boolean,
|
||||
effortFrictionActive: Boolean
|
||||
): Boolean {
|
||||
if (destination == "ADSTERRA_REWARDED") return false
|
||||
return shouldShowLock || isExhausted || effortFrictionActive
|
||||
}
|
||||
|
||||
// When watching ad, gate must NOT be active even if lock, exhaustion, or friction are present
|
||||
assertFalse(isGateActive("ADSTERRA_REWARDED", shouldShowLock = true, isExhausted = true, effortFrictionActive = true))
|
||||
assertFalse(isGateActive("ADSTERRA_REWARDED", shouldShowLock = false, isExhausted = false, effortFrictionActive = false))
|
||||
|
||||
// When in Main WebView or Settings, gate evaluates normally
|
||||
assertTrue(isGateActive("MAIN_WEBVIEW", shouldShowLock = true, isExhausted = false, effortFrictionActive = false))
|
||||
assertTrue(isGateActive("MAIN_WEBVIEW", shouldShowLock = false, isExhausted = true, effortFrictionActive = false))
|
||||
assertFalse(isGateActive("MAIN_WEBVIEW", shouldShowLock = false, isExhausted = false, effortFrictionActive = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAdClickDebouncing() {
|
||||
fun shouldCountClick(
|
||||
url: String,
|
||||
lastUrl: String,
|
||||
now: Long,
|
||||
lastTime: Long
|
||||
): Boolean {
|
||||
val clean = url.trim()
|
||||
if (clean.isBlank() || clean.contains("effectivecpmnetwork.com") || clean.startsWith("about:")) return false
|
||||
return clean != lastUrl || (now - lastTime > 1500L)
|
||||
}
|
||||
|
||||
val url1 = "https://example.com/ad1"
|
||||
val url2 = "https://example.com/ad2"
|
||||
|
||||
// First click count
|
||||
assertTrue(shouldCountClick(url1, "", 1000L, 0L))
|
||||
|
||||
// Rapid duplicate click on same url within 500ms -> debounced
|
||||
assertFalse(shouldCountClick(url1, url1, 1500L, 1000L))
|
||||
|
||||
// Duplicate click after 2000ms -> accepted
|
||||
assertTrue(shouldCountClick(url1, url1, 3100L, 1000L))
|
||||
|
||||
// Different ad url even rapidly -> accepted
|
||||
assertTrue(shouldCountClick(url2, url1, 1500L, 1000L))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.ujwal.focusgram
|
||||
|
||||
import android.net.Uri
|
||||
import com.ujwal.focusgram.core.model.FocusSettings
|
||||
import com.ujwal.focusgram.core.utils.AppIconManager
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* ChatLockAndAppIconTest
|
||||
* Unit tests verifying Custom App Icon options, Chat Lock route evaluation,
|
||||
* message preview masking rules, and separate Chat PIN hashing determinism.
|
||||
*/
|
||||
class ChatLockAndAppIconTest {
|
||||
|
||||
@Test
|
||||
fun testDefaultSettingsForChatLockAndAppIcon() {
|
||||
val settings = FocusSettings()
|
||||
assertEquals("default", settings.appIcon)
|
||||
assertEquals("individual", settings.chatLockMode)
|
||||
assertFalse(settings.chatLockAll)
|
||||
assertTrue(settings.lockedChatIds.isEmpty())
|
||||
assertFalse(settings.useSeparateChatPin)
|
||||
assertTrue(settings.chatBiometricEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAppIconOptionsCompleteness() {
|
||||
val options = AppIconManager.IconOption.values()
|
||||
assertEquals(13, options.size)
|
||||
|
||||
val ids = options.map { it.id }.toSet()
|
||||
// Core icons
|
||||
assertTrue(ids.contains("default"))
|
||||
assertTrue(ids.contains("pitch_black"))
|
||||
assertTrue(ids.contains("all_white"))
|
||||
|
||||
// Alt Redesigns
|
||||
assertTrue(ids.contains("redesign"))
|
||||
assertTrue(ids.contains("abstract"))
|
||||
assertTrue(ids.contains("forest"))
|
||||
assertTrue(ids.contains("golden"))
|
||||
assertTrue(ids.contains("metal"))
|
||||
assertTrue(ids.contains("scribble"))
|
||||
|
||||
// Originals (Historical Brand Icons)
|
||||
assertTrue(ids.contains("polaroid"))
|
||||
assertTrue(ids.contains("viewfinder"))
|
||||
assertTrue(ids.contains("minimalist"))
|
||||
assertTrue(ids.contains("vibrant"))
|
||||
|
||||
options.forEach { option ->
|
||||
assertTrue(option.aliasSuffix.startsWith("MainActivity"))
|
||||
assertTrue(option.displayName.isNotBlank())
|
||||
assertTrue(option.subtitle.isNotBlank())
|
||||
assertTrue(option.category.isNotBlank())
|
||||
}
|
||||
|
||||
assertEquals(AppIconManager.IconOption.DEFAULT, AppIconManager.IconOption.fromId("default"))
|
||||
assertEquals(AppIconManager.IconOption.REDESIGN, AppIconManager.IconOption.fromId("redesign"))
|
||||
assertEquals(AppIconManager.IconOption.PITCH_BLACK, AppIconManager.IconOption.fromId("pitch_black"))
|
||||
assertEquals(AppIconManager.IconOption.ALL_WHITE, AppIconManager.IconOption.fromId("all_white"))
|
||||
assertEquals(AppIconManager.IconOption.POLAROID, AppIconManager.IconOption.fromId("polaroid"))
|
||||
assertEquals(AppIconManager.IconOption.VIEWFINDER, AppIconManager.IconOption.fromId("viewfinder"))
|
||||
assertEquals(AppIconManager.IconOption.MINIMALIST, AppIconManager.IconOption.fromId("minimalist"))
|
||||
assertEquals(AppIconManager.IconOption.VIBRANT, AppIconManager.IconOption.fromId("vibrant"))
|
||||
assertEquals(AppIconManager.IconOption.DEFAULT, AppIconManager.IconOption.fromId("unknown_id"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHistoricalIconsCompleteness() {
|
||||
val icons = com.ujwal.focusgram.core.utils.HistoricalIconManager.ORIGINAL_ICONS
|
||||
assertEquals(4, icons.size)
|
||||
val ids = icons.map { it.id }.toSet()
|
||||
assertTrue(ids.contains("polaroid_camera"))
|
||||
assertTrue(ids.contains("viewfinder"))
|
||||
assertTrue(ids.contains("minimalist_colors"))
|
||||
assertTrue(ids.contains("vibrant_version"))
|
||||
icons.forEach {
|
||||
assertTrue(it.downloadUrl.startsWith("https://"))
|
||||
assertTrue(it.title.isNotBlank())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChatLockRouteEvaluation() {
|
||||
fun isRouteLocked(url: String, settings: FocusSettings, unlockedThreads: Set<String>, isAllUnlocked: Boolean): Boolean {
|
||||
val path = url.removePrefix("https://www.instagram.com").removePrefix("https://instagram.com").lowercase()
|
||||
if (!path.startsWith("/direct")) return false
|
||||
|
||||
if (settings.chatLockAll) {
|
||||
return !isAllUnlocked
|
||||
}
|
||||
|
||||
if (path.startsWith("/direct/t/")) {
|
||||
val threadId = path.removePrefix("/direct/t/").trim('/').split('/').firstOrNull()?.trim() ?: ""
|
||||
if (unlockedThreads.contains(threadId)) return false
|
||||
return settings.lockedChatIds.any { it.equals(threadId, ignoreCase = true) || url.contains(it, ignoreCase = true) }
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
val baseSettings = FocusSettings()
|
||||
// Default settings: nothing locked
|
||||
assertFalse(isRouteLocked("https://www.instagram.com/direct/inbox/", baseSettings, emptySet(), false))
|
||||
assertFalse(isRouteLocked("https://www.instagram.com/direct/t/17849999/", baseSettings, emptySet(), false))
|
||||
|
||||
// ChatLockAll enabled: all direct routes locked
|
||||
val lockAllSettings = baseSettings.copy(chatLockAll = true)
|
||||
assertTrue(isRouteLocked("https://www.instagram.com/direct/inbox/", lockAllSettings, emptySet(), false))
|
||||
assertTrue(isRouteLocked("https://www.instagram.com/direct/t/17849999/", lockAllSettings, emptySet(), false))
|
||||
// Non-direct routes should not be locked
|
||||
assertFalse(isRouteLocked("https://www.instagram.com/reels/", lockAllSettings, emptySet(), false))
|
||||
|
||||
// When all unlocked
|
||||
assertFalse(isRouteLocked("https://www.instagram.com/direct/inbox/", lockAllSettings, emptySet(), true))
|
||||
|
||||
// Specific chat locked:
|
||||
val specificLockSettings = baseSettings.copy(lockedChatIds = setOf("17849999", "filian_editz"))
|
||||
// Inbox itself is not locked when individual chat locked
|
||||
assertFalse(isRouteLocked("https://www.instagram.com/direct/inbox/", specificLockSettings, emptySet(), false))
|
||||
// Target thread is locked
|
||||
assertTrue(isRouteLocked("https://www.instagram.com/direct/t/17849999/", specificLockSettings, emptySet(), false))
|
||||
assertTrue(isRouteLocked("https://www.instagram.com/direct/t/filian_editz/", specificLockSettings, emptySet(), false))
|
||||
// Unlocked thread is accessible
|
||||
assertFalse(isRouteLocked("https://www.instagram.com/direct/t/17849999/", specificLockSettings, setOf("17849999"), false))
|
||||
// Other threads not locked
|
||||
assertFalse(isRouteLocked("https://www.instagram.com/direct/t/99999999/", specificLockSettings, emptySet(), false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSeparateChatPinHashing() {
|
||||
fun hashChatPin(pin: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val saltedBytes = ("focusgram_salt_v2_chat_" + pin).toByteArray(Charsets.UTF_8)
|
||||
val hashBytes = digest.digest(saltedBytes)
|
||||
return hashBytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
fun hashMainPin(pin: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val saltedBytes = ("focusgram_salt_v2_" + pin).toByteArray(Charsets.UTF_8)
|
||||
val hashBytes = digest.digest(saltedBytes)
|
||||
return hashBytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
val mainPinHash = hashMainPin("1234")
|
||||
val chatPinHash = hashChatPin("1234")
|
||||
|
||||
// Chat PIN hash must be isolated and distinct from main PIN hash even with the same digits
|
||||
org.junit.Assert.assertNotEquals(mainPinHash, chatPinHash)
|
||||
assertEquals(64, chatPinHash.length)
|
||||
assertEquals(chatPinHash, hashChatPin("1234"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUsernameResolutionForLockedChat() {
|
||||
fun resolveDisplayTitle(id: String, lockedChatUsernames: Map<String, String>): String {
|
||||
val username = lockedChatUsernames[id]
|
||||
return if (!username.isNullOrBlank()) {
|
||||
"@${username.removePrefix("@")}"
|
||||
} else if (!id.all { it.isDigit() }) {
|
||||
"@${id.removePrefix("@")}"
|
||||
} else {
|
||||
"Conversation (${id.takeLast(6)})"
|
||||
}
|
||||
}
|
||||
|
||||
val usernames = mapOf("1784140001" to "johndoe", "1784140002" to "@alice")
|
||||
assertEquals("@johndoe", resolveDisplayTitle("1784140001", usernames))
|
||||
assertEquals("@alice", resolveDisplayTitle("1784140002", usernames))
|
||||
// Alphabetic ID fallback
|
||||
assertEquals("@filian_editz", resolveDisplayTitle("filian_editz", emptyMap()))
|
||||
// Pure digit ID with no username yet fallback
|
||||
assertEquals("Conversation (123456)", resolveDisplayTitle("9876123456", emptyMap()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChatLockCancelRoutingDestination() {
|
||||
val cancelUrl = "https://www.instagram.com/direct/inbox/"
|
||||
assertTrue(cancelUrl.endsWith("/direct/inbox/"))
|
||||
assertFalse(cancelUrl.contains("/direct/t/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPreRenderBlockingInExtensionAssets() {
|
||||
val baseDir = java.io.File("src/main/assets/extensions/ublock")
|
||||
val altDir = java.io.File("app/src/main/assets/extensions/ublock")
|
||||
val uBlockDir = if (baseDir.exists()) baseDir else altDir
|
||||
val fgFile = java.io.File(uBlockDir, "focusgram_content.js")
|
||||
assertTrue("focusgram_content.js must exist", fgFile.exists())
|
||||
val text = fgFile.readText()
|
||||
|
||||
assertTrue("Must intercept click before React router", text.contains("requestUnlockChat"))
|
||||
assertTrue("Must prevent default click navigation", text.contains("e.preventDefault()"))
|
||||
assertTrue("Must stop event propagation", text.contains("e.stopPropagation()"))
|
||||
assertTrue("Must stop immediate propagation", text.contains("e.stopImmediatePropagation()"))
|
||||
assertTrue("Must export applyPrivacyVeil", text.contains("__fgApplyPrivacyVeil"))
|
||||
assertTrue("Must export removePrivacyVeil", text.contains("__fgRemovePrivacyVeil"))
|
||||
assertTrue("Must export getThreadParticipant", text.contains("__fgGetThreadParticipant"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,4 +54,81 @@ class UBlockExtensionAssetsTest {
|
||||
assertTrue("Must contain [data-fg-reel-card]", text.contains("[data-fg-reel-card]"))
|
||||
assertTrue("Must contain a[href*=\"/audio/\"] styling", text.contains("a[href*=\"/audio/\"]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLiveBioFontStylistIntegrity() {
|
||||
val baseDir = File("src/main/assets/extensions/ublock")
|
||||
val altDir = File("app/src/main/assets/extensions/ublock")
|
||||
val uBlockDir = if (baseDir.exists()) baseDir else altDir
|
||||
val fgFile = File(uBlockDir, "focusgram_content.js")
|
||||
assertTrue("focusgram_content.js must exist", fgFile.exists())
|
||||
val text = fgFile.readText()
|
||||
|
||||
assertTrue("Must define fg-bio-font-toolbar", text.contains("fg-bio-font-toolbar"))
|
||||
assertTrue("Must intercept beforeinput event", text.contains("beforeinput"))
|
||||
assertTrue("Must support mathematical bold glyphs (0x1D400)", text.contains("0x1D400"))
|
||||
assertTrue("Must support mathematical monospace glyphs (0x1D670)", text.contains("0x1D670"))
|
||||
assertTrue("Must include surrogate pair counter (150 limit)", text.contains("150"))
|
||||
|
||||
val scriptBase = File("src/main/assets/scripts")
|
||||
val scriptAlt = File("app/src/main/assets/scripts")
|
||||
val scriptDir = if (scriptBase.exists()) scriptBase else scriptAlt
|
||||
val nativeFeelFile = File(scriptDir, "native_feel.js")
|
||||
assertTrue("native_feel.js must exist", nativeFeelFile.exists())
|
||||
val nativeFeelText = nativeFeelFile.readText()
|
||||
assertTrue("native_feel.js must target pepBio", nativeFeelText.contains("pepBio"))
|
||||
assertTrue("native_feel.js must define fg-bio-inline-bar", nativeFeelText.contains("fg-bio-inline-bar"))
|
||||
assertTrue("native_feel.js must support beforeinput live typing", nativeFeelText.contains("beforeinput"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testStoryViewerSearchIntegrity() {
|
||||
val baseDir = File("src/main/assets/extensions/ublock")
|
||||
val altDir = File("app/src/main/assets/extensions/ublock")
|
||||
val uBlockDir = if (baseDir.exists()) baseDir else altDir
|
||||
|
||||
val bgFile = File(uBlockDir, "background.js")
|
||||
val bgText = bgFile.readText()
|
||||
assertTrue("background.js must capture list_reel_media_viewer", bgText.contains("list_reel_media_viewer"))
|
||||
assertTrue("background.js must manage storyViewersCache", bgText.contains("storyViewersCache"))
|
||||
|
||||
val fgFile = File(uBlockDir, "focusgram_content.js")
|
||||
val fgText = fgFile.readText()
|
||||
assertTrue("focusgram_content.js must define fg-story-search-card", fgText.contains("fg-story-search-card"))
|
||||
assertTrue("focusgram_content.js must define fg-story-search-input", fgText.contains("fg-story-search-input"))
|
||||
assertTrue("focusgram_content.js must handle story-viewers-detected", fgText.contains("story-viewers-detected"))
|
||||
|
||||
val scriptBase = File("src/main/assets/scripts")
|
||||
val scriptAlt = File("app/src/main/assets/scripts")
|
||||
val scriptDir = if (scriptBase.exists()) scriptBase else scriptAlt
|
||||
val fetchInterceptorFile = File(scriptDir, "fetch_interceptor.js")
|
||||
assertTrue("fetch_interceptor.js must exist", fetchInterceptorFile.exists())
|
||||
val fetchText = fetchInterceptorFile.readText()
|
||||
assertTrue("fetch_interceptor.js must detect list_reel_media_viewer", fetchText.contains("list_reel_media_viewer"))
|
||||
assertTrue("fetch_interceptor.js must define fg-story-search-card", fetchText.contains("fg-story-search-card"))
|
||||
assertTrue("fetch_interceptor.js must define fg-story-search-input", fetchText.contains("fg-story-search-input"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChatLockAndPreviewMaskingIntegrity() {
|
||||
val baseDir = File("src/main/assets/extensions/ublock")
|
||||
val altDir = File("app/src/main/assets/extensions/ublock")
|
||||
val uBlockDir = if (baseDir.exists()) baseDir else altDir
|
||||
|
||||
val fgFile = File(uBlockDir, "focusgram_content.js")
|
||||
assertTrue("focusgram_content.js must exist", fgFile.exists())
|
||||
val fgText = fgFile.readText()
|
||||
assertTrue("focusgram_content.js must define focusgram-chat-privacy-veil", fgText.contains("focusgram-chat-privacy-veil"))
|
||||
assertTrue("focusgram_content.js must define syncChatLockSettings", fgText.contains("syncChatLockSettings"))
|
||||
assertTrue("focusgram_content.js must define maskInboxChatPreviews", fgText.contains("maskInboxChatPreviews"))
|
||||
assertTrue("focusgram_content.js must handle unlockChat action", fgText.contains("unlockChat"))
|
||||
|
||||
val scriptBase = File("src/main/assets/scripts")
|
||||
val scriptAlt = File("app/src/main/assets/scripts")
|
||||
val scriptDir = if (scriptBase.exists()) scriptBase else scriptAlt
|
||||
val fetchInterceptorFile = File(scriptDir, "fetch_interceptor.js")
|
||||
assertTrue("fetch_interceptor.js must exist", fetchInterceptorFile.exists())
|
||||
val fetchText = fetchInterceptorFile.readText()
|
||||
assertTrue("fetch_interceptor.js must NOT intercept direct_v2/inbox (client-side DOM only)", !fetchText.contains("direct_v2/inbox"))
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 699 KiB |
|
After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 549 KiB |
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 499 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6 22h12c1.1 0 2-.9 2-2v-9c0-1.1-.9-2-2-2H9V7c0-1.65 1.35-3 3-3s3 1.35 3 3h2c0-2.76-2.24-5-5-5S7 4.24 7 7v2H6c-1.1 0-2 .9-2 2v9c0 1.1.9 2 2 2m6-8c1.1 0 2 .9 2 2a2 2 0 0 1-1 1.72V20h-2v-2.28A2 2 0 0 1 10 16c0-1.1.9-2 2-2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M20 9h-8V7c0-2.76-2.24-5-5-5S2 4.24 2 7h2c0-1.65 1.35-3 3-3s3 1.35 3 3v2H8c-1.1 0-2 .9-2 2v9c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-9c0-1.1-.9-2-2-2m-5 8.72V20h-2v-2.28A2 2 0 0 1 12 16c0-1.1.9-2 2-2s2 .9 2 2a2 2 0 0 1-1 1.72" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 321 B |