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.
This commit is contained in:
Ujwal223 committed 2026-10-18 15:09:33 +05:45
1 parent 9b00435d72
commit 11cbc0cbb5
85 files changed
+5660 -203

No files matched your search

+187 -4
View File
@@ -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;
}
File diff suppressed because it is too large. Load diff
@@ -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()
}
}
)
}
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+15
View File
@@ -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"))
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

+3
View File
@@ -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

+3
View File
@@ -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