diff --git a/Ported/app/src/main/AndroidManifest.xml b/Ported/app/src/main/AndroidManifest.xml index e8c5cfa..12f0510 100644 --- a/Ported/app/src/main/AndroidManifest.xml +++ b/Ported/app/src/main/AndroidManifest.xml @@ -69,10 +69,6 @@ android:launchMode="singleTop" android:screenOrientation="portrait" android:windowSoftInputMode="adjustResize"> - - - - @@ -94,6 +90,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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; }); } + diff --git a/Ported/app/src/main/assets/extensions/ublock/content.js b/Ported/app/src/main/assets/extensions/ublock/content.js index cbeb70b..134d1f7 100644 --- a/Ported/app/src/main/assets/extensions/ublock/content.js +++ b/Ported/app/src/main/assets/extensions/ublock/content.js @@ -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; } diff --git a/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js b/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js index 1e8ea06..e6d0f1c 100644 --- a/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js +++ b/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js @@ -33,6 +33,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; } @@ -557,19 +560,41 @@ const response = await originalFetch.apply(this, arguments); - // Main-World GraphQL, Clips, & Feed Ad Filtering & Reel Meta Harvesting - if (url.includes('/graphql') || url.includes('/api/v1/feed') || url.includes('/api/v1/clips/') || url.includes('/clips/')) { + // Main-World GraphQL, Clips, & Feed Ad Filtering & Reel Meta Harvesting & DM Masking + if (url.includes('/graphql') || url.includes('/api/v1/feed') || url.includes('/api/v1/clips/') || url.includes('/clips/') || url.includes('/direct/')) { try { const contentType = response.headers.get('content-type') || ''; - if (contentType.includes('application/json')) { + if (contentType.includes('json') || contentType.includes('javascript')) { const cloned = response.clone(); - const data = await cloned.json(); - 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; + } + } + + let directModified = false; + if (typeof window.__fgProcessDirectMessagesPayload === 'function') { + directModified = window.__fgProcessDirectMessagesPayload(data); + } + + let adModified = false; if (window.__fgFilterConfig && window.__fgFilterConfig.blockAds !== false) { + walkAndFilter(data); + adModified = true; + } + + if (directModified || adModified) { const newHeaders = new Headers(response.headers); newHeaders.delete('content-length'); newHeaders.delete('content-encoding'); - newHeaders.set('content-type', 'application/json; charset=utf-8'); + newHeaders.set('content-type', contentType || 'application/json; charset=utf-8'); return new Response(JSON.stringify(data), { status: response.status, statusText: response.statusText, @@ -2176,6 +2201,9 @@ function isAnyModalOpen() { const path = window.location.pathname.toLowerCase(); + if (path.includes('/accounts/edit')) { + return false; + } // 1. Route-based detection (DM chats, stories, auth, create) if (isAuthRoute(path) || path.includes('/comments') || path.startsWith('/direct/t/') || path.startsWith('/stories/') || path.startsWith('/create')) { @@ -2276,7 +2304,580 @@ return mode; } + /* ========================================================================== + CHAT PRIVACY VEIL & INBOX PREVIEW MASKING + Zero-latency pure black cover over locked chat threads + snippet redaction. + ========================================================================== */ + let chatLockMode = 'individual'; + let chatLockAll = false; + let lockedChatIds = new Set(); + const unlockedThreads = new Set(); + const threadUsernameMap = new Map(); + + function syncGlobalsToWindow() { + const targets = [window]; + if (typeof window.wrappedJSObject !== 'undefined' && window.wrappedJSObject) { + targets.push(window.wrappedJSObject); + } + const lockedArray = Array.from(lockedChatIds); + const unlockedArray = Array.from(unlockedThreads); + const userMapObj = {}; + for (const [k, v] of threadUsernameMap.entries()) { + userMapObj[k] = v; + } + + for (const target of targets) { + try { + target.__fgChatLockMode = chatLockMode; + target.__fgChatLockAll = chatLockAll; + target.__fgLockedChatIds = lockedArray; + target.__fgUnlockedThreads = unlockedArray; + target.__fgThreadUsernameMap = userMapObj; + } catch (_) {} + } + + const syncDetail = { + chatLockMode: chatLockMode, + chatLockAll: chatLockAll, + lockedChatIds: lockedArray, + unlockedThreads: unlockedArray, + threadUsernameMap: userMapObj + }; + try { + window.dispatchEvent(new CustomEvent('focusgram:chat-lock-sync', { detail: syncDetail })); + document.dispatchEvent(new CustomEvent('focusgram:chat-lock-sync', { detail: syncDetail })); + } catch (_) {} + } + + function syncChatLockSettings(settings) { + if (!settings) return; + if (typeof settings.chatLockMode !== 'undefined') { + chatLockMode = String(settings.chatLockMode).toLowerCase(); + } + if (typeof settings.chatLockAll !== 'undefined') { + chatLockAll = !!settings.chatLockAll; + if (chatLockMode === 'whole_dm') { + chatLockAll = true; + } else if (chatLockMode === 'off') { + chatLockAll = false; + } + } else if (chatLockMode === 'whole_dm') { + chatLockAll = true; + } else if (chatLockMode === 'off') { + chatLockAll = false; + } + if (Array.isArray(settings.lockedChatIds)) { + lockedChatIds = new Set(settings.lockedChatIds.map(id => String(id).toLowerCase().trim().replace(/^@/, ''))); + } + if (settings.lockedChatUsernames && typeof settings.lockedChatUsernames === 'object') { + for (const [tid, uname] of Object.entries(settings.lockedChatUsernames)) { + const cleanTid = String(tid).toLowerCase().trim(); + const cleanUname = String(uname).toLowerCase().trim().replace(/^@/, ''); + if (cleanTid && cleanUname) { + threadUsernameMap.set(cleanTid, cleanUname); + threadUsernameMap.set(cleanUname, cleanTid); + if (lockedChatIds.has(cleanTid)) lockedChatIds.add(cleanUname); + if (lockedChatIds.has(cleanUname)) lockedChatIds.add(cleanTid); + } + } + } + + syncGlobalsToWindow(); + evaluateChatLockState(); + maskInboxChatPreviews(); + } + + const handleThreadMatched = (e) => { + const detail = e.detail; + if (detail && detail.threadId) { + const cleanTid = String(detail.threadId).toLowerCase().trim(); + const cleanKey = detail.threadKey ? String(detail.threadKey).toLowerCase().trim() : ''; + if (detail.isLocked) { + lockedChatIds.add(cleanTid); + if (cleanKey) lockedChatIds.add(cleanKey); + } + if (Array.isArray(detail.usernames)) { + for (const u of detail.usernames) { + const cleanUser = String(u).toLowerCase().trim().replace(/^@/, ''); + if (cleanUser) { + threadUsernameMap.set(cleanTid, cleanUser); + threadUsernameMap.set(cleanUser, cleanTid); + if (cleanKey) { + threadUsernameMap.set(cleanKey, cleanUser); + threadUsernameMap.set(cleanUser, cleanKey); + } + if (lockedChatIds.has(cleanUser)) { + lockedChatIds.add(cleanTid); + if (cleanKey) lockedChatIds.add(cleanKey); + } + if (lockedChatIds.has(cleanTid) || (cleanKey && lockedChatIds.has(cleanKey))) { + lockedChatIds.add(cleanUser); + } + } + } + } + postToNative({ + action: 'threadUsernameMapped', + threadId: cleanTid, + threadKey: cleanKey, + username: (detail.usernames && detail.usernames[0]) || '', + usernames: detail.usernames || [], + displayName: detail.displayName || '', + isLocked: !!detail.isLocked + }); + syncGlobalsToWindow(); + evaluateChatLockState(); + maskInboxChatPreviews(); + } + }; + window.addEventListener('focusgram:thread-matched', handleThreadMatched); + document.addEventListener('focusgram:thread-matched', handleThreadMatched); + + function handleUnlockChat(threadId, isAll) { + if (isAll) { + unlockedThreads.add('__all__'); + } + if (threadId) { + const clean = String(threadId).toLowerCase().trim(); + unlockedThreads.add(clean); + const mapped = threadUsernameMap.get(clean); + if (mapped) unlockedThreads.add(mapped); + } + syncGlobalsToWindow(); + removePrivacyVeil(); + evaluateChatLockState(); + maskInboxChatPreviews(); + } + + function handleLockChat(threadId) { + if (threadId) { + const clean = String(threadId).toLowerCase().trim(); + unlockedThreads.delete(clean); + const mapped = threadUsernameMap.get(clean); + if (mapped) unlockedThreads.delete(mapped); + lockedChatIds.add(clean); + if (mapped) lockedChatIds.add(mapped); + } + syncGlobalsToWindow(); + evaluateChatLockState(); + maskInboxChatPreviews(); + } + + function extractCurrentThreadId() { + const path = window.location.pathname.toLowerCase(); + if (!path.startsWith('/direct/t/')) return null; + const parts = path.replace('/direct/t/', '').split('/').filter(Boolean); + return parts[0] ? parts[0].trim() : null; + } + + function isCurrentThreadExplicitlyLocked(threadId) { + if (!threadId) return false; + const cleanId = String(threadId).toLowerCase().trim(); + if (lockedChatIds.has(cleanId)) return true; + const mappedUser = threadUsernameMap.get(cleanId); + if (mappedUser && lockedChatIds.has(mappedUser)) return true; + for (const lockedId of lockedChatIds) { + if (window.location.href.toLowerCase().includes(lockedId)) return true; + } + return false; + } + + function extractThreadParticipants() { + const currentThreadId = extractCurrentThreadId(); + if (!currentThreadId) return null; + + let username = ''; + let displayName = ''; + + // 1. Inspect document.title + const docTitle = document.title || ''; + const titleMatch = docTitle.match(/@([a-zA-Z0-9_\.]+)/); + if (titleMatch && titleMatch[1]) { + username = titleMatch[1].trim(); + } else if (docTitle.includes('โ€ข')) { + const candidate = docTitle.split('โ€ข')[0].trim(); + if (candidate && !['direct', 'messages', 'instagram'].includes(candidate.toLowerCase())) { + displayName = candidate; + } + } + + // 2. Inspect header avatar image alt text + if (!username) { + const headerImgs = document.querySelectorAll('header img[alt], [role="banner"] img[alt], [role="main"] header img[alt]'); + for (const img of headerImgs) { + const alt = img.getAttribute('alt') || ''; + const m = alt.match(/(.+?)'s profile picture/i) || alt.match(/profile picture of (.+)/i); + if (m && m[1]) { + username = m[1].trim().replace(/^@/, ''); + break; + } + } + } + + // 3. Instagram header profile link inspection + if (!username) { + const profileLinks = document.querySelectorAll( + 'header a[aria-label*="profile page of" i], [role="banner"] a[aria-label*="profile page of" i], ' + + '[role="main"] header a[aria-label*="profile page of" i], a[aria-label*="Open the profile page of" i], ' + + 'header a[href^="/"], [role="banner"] a[href^="/"], [role="main"] header a[href^="/"]' + ); + + for (const link of profileLinks) { + const aria = link.getAttribute('aria-label') || ''; + const matchAria = aria.match(/profile page of (.+)/i); + if (matchAria && matchAria[1]) { + username = matchAria[1].trim().replace(/^@/, ''); + break; + } + const href = link.getAttribute('href') || ''; + const cleanHref = href.replace(/^https?:\/\/[^\/]+/, ''); + const matchHref = cleanHref.match(/^\/([a-zA-Z0-9_\.]+)\/?(?:\?.*)?$/); + if (matchHref && matchHref[1]) { + const candidate = matchHref[1].trim(); + const ignored = ['direct', 'explore', 'reels', 'reel', 'stories', 'home', 'accounts', 'p', 'tv']; + if (!ignored.includes(candidate.toLowerCase())) { + username = candidate; + break; + } + } + } + } + + // 4. Header heading / title elements + const headingEl = document.querySelector( + 'header [role="heading"], [role="banner"] [role="heading"], ' + + 'header h1, header h2, header h3, [role="banner"] h1, [role="banner"] h2, ' + + 'header a span, [role="banner"] a span' + ); + if (headingEl) { + const hText = headingEl.textContent.trim().replace(/^@/, ''); + if (!displayName) displayName = hText; + if (!username && hText && !hText.includes(' ') && hText.length < 32) { + username = hText; + } + } + + // 5. In-page bio banner at top of message scroller (View profile button) + if (!username) { + const viewProfileLink = document.querySelector('a[href^="/"][role="link"]:not([href*="/direct/"])'); + if (viewProfileLink && viewProfileLink.textContent.toLowerCase().includes('profile')) { + const href = (viewProfileLink.getAttribute('href') || '').replace(/^https?:\/\/[^\/]+/, ''); + const m = href.match(/^\/([a-zA-Z0-9_\.]+)\/?/); + if (m && m[1]) { + username = m[1].trim(); + } + } + } + + const cleanUser = username ? username.toLowerCase().trim().replace(/^@/, '') : ''; + const effectiveUser = cleanUser || (displayName ? displayName.toLowerCase().trim().replace(/^@/, '') : ''); + + if (effectiveUser) { + threadUsernameMap.set(currentThreadId, effectiveUser); + threadUsernameMap.set(effectiveUser, currentThreadId); + postToNative({ + action: 'threadUsernameMapped', + threadId: currentThreadId, + username: effectiveUser, + usernames: [effectiveUser], + displayName: displayName || effectiveUser + }); + window.__fgActiveThread = { threadId: currentThreadId, username: effectiveUser, displayName: displayName || effectiveUser }; + return { username: effectiveUser, displayName: displayName || effectiveUser }; + } + return null; + } + window.__fgGetThreadParticipant = extractThreadParticipants; + + function isCurrentChatLocked() { + const path = window.location.pathname.toLowerCase(); + if (!path.startsWith('/direct')) return false; + if (chatLockMode === 'off') return false; + if (unlockedThreads.has('__all__')) return false; + + const currentThreadId = extractCurrentThreadId(); + if (currentThreadId) { + if (unlockedThreads.has(currentThreadId)) return false; + const mappedUser = threadUsernameMap.get(currentThreadId); + if (mappedUser && unlockedThreads.has(mappedUser)) return false; + + if (chatLockMode === 'whole_dm' || chatLockAll) return true; + if (isCurrentThreadExplicitlyLocked(currentThreadId)) return true; + + // Search header text as additional safeguard + const header = document.querySelector('header, [role="banner"], [role="main"] header'); + if (header) { + const headerText = header.textContent.toLowerCase(); + for (const lockedId of lockedChatIds) { + if (headerText.includes(lockedId)) { + lockedChatIds.add(currentThreadId); + threadUsernameMap.set(currentThreadId, lockedId); + postToNative({ action: 'threadUsernameMapped', threadId: currentThreadId, usernames: [lockedId], isLocked: true }); + return true; + } + } + } + return false; + } + + if ((chatLockMode === 'whole_dm' || chatLockAll) && (path === '/direct' || path === '/direct/' || path.startsWith('/direct/inbox'))) { + return !unlockedThreads.has('__all__'); + } + + return false; + } + + function applyPrivacyVeil() { + let veil = document.getElementById('focusgram-chat-privacy-veil'); + if (!veil) { + veil = document.createElement('div'); + veil.id = 'focusgram-chat-privacy-veil'; + veil.style.cssText = 'position:fixed!important;top:0!important;left:0!important;width:100vw!important;height:100vh!important;background:#000000!important;z-index:2147483647!important;pointer-events:all!important;display:block!important;'; + (document.body || document.documentElement).appendChild(veil); + } + veil.style.display = 'block'; + } + window.__fgApplyPrivacyVeil = applyPrivacyVeil; + + function removePrivacyVeil() { + const veil = document.getElementById('focusgram-chat-privacy-veil'); + if (veil) { + veil.style.display = 'none'; + } + } + window.__fgRemovePrivacyVeil = removePrivacyVeil; + + function evaluateChatLockState() { + if (isCurrentChatLocked()) { + try { + document.body?.setAttribute('data-fg-chat-locked', 'true'); + document.documentElement?.setAttribute('data-fg-chat-locked', 'true'); + } catch (_) {} + applyPrivacyVeil(); + } else { + try { + document.body?.removeAttribute('data-fg-chat-locked'); + document.documentElement?.removeAttribute('data-fg-chat-locked'); + } catch (_) {} + removePrivacyVeil(); + } + } + + // Capture-phase link click interception for instant pre-render black cover & blocking server queries + document.addEventListener('click', (e) => { + if (chatLockMode === 'off') return; + const link = e.target.closest('a[href*="/direct/t/"]'); + if (!link) return; + const href = (link.getAttribute('href') || '').toLowerCase(); + const match = href.match(/\/direct\/t\/([^\/\?]+)/); + const targetThreadId = match ? match[1].trim() : ''; + if (!targetThreadId) return; + + let shouldLock = (chatLockMode === 'whole_dm' || chatLockAll); + const mapped = threadUsernameMap.get(targetThreadId); + if (!shouldLock) { + if (lockedChatIds.has(targetThreadId)) shouldLock = true; + if (mapped && lockedChatIds.has(mapped)) shouldLock = true; + if (!shouldLock) { + const row = link.closest('[role="listitem"], [role="button"], li') || link; + const rowText = row.textContent.toLowerCase(); + for (const lockedId of lockedChatIds) { + if (rowText.includes(lockedId) || href.includes(lockedId)) { + shouldLock = true; + lockedChatIds.add(targetThreadId); + break; + } + } + } + } + + const isUnlocked = unlockedThreads.has('__all__') || + unlockedThreads.has(targetThreadId) || + (mapped && unlockedThreads.has(mapped)); + + if (shouldLock && !isUnlocked) { + // Immediately BLOCK the browser and React router from querying the server or rendering the locked chat! + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + applyPrivacyVeil(); + + const mappedUser = mapped || ''; + postToNative({ + action: 'requestUnlockChat', + threadId: targetThreadId, + username: mappedUser, + targetUrl: link.href || ('https://www.instagram.com/direct/t/' + targetThreadId + '/') + }); + } + }, true); + + function updateChatMaskCSS() { + let style = document.getElementById('focusgram-chat-mask-style'); + if (!style) { + style = document.createElement('style'); + style.id = 'focusgram-chat-mask-style'; + (document.head || document.documentElement).appendChild(style); + } + + const css = ` + /* FocusGram Chat Lock Veil */ + body[data-fg-chat-locked="true"] [role="list"] [role="row"], + body[data-fg-chat-locked="true"] div[dir="auto"], + body[data-fg-chat-locked="true"] [data-message-id] { + filter: blur(14px) !important; + user-select: none !important; + pointer-events: none !important; + } + `; + style.textContent = css; + } + + function maskInboxChatPreviews() { + updateChatMaskCSS(); + + const path = window.location.pathname.toLowerCase(); + if (!path.startsWith('/direct')) return; + + const isLockOff = (chatLockMode === 'off'); + + const chatLinks = document.querySelectorAll('a[href*="/direct/t/"]'); + for (const link of chatLinks) { + const href = (link.getAttribute('href') || '').toLowerCase(); + const match = href.match(/\/direct\/t\/([^\/\?]+)/); + const threadId = match ? match[1].trim() : ''; + if (!threadId) continue; + + const rowContainer = link.closest('[role="listitem"], [role="button"], li') || link; + + // Strategy A: Avatar image alt inspection + let detectedUser = ''; + let displayName = ''; + const avatarImg = rowContainer.querySelector('img[alt]'); + if (avatarImg) { + const alt = (avatarImg.getAttribute('alt') || '').trim(); + const m = alt.match(/(.+?)'s profile picture/i) || alt.match(/profile picture of (.+)/i); + if (m && m[1]) { + detectedUser = m[1].trim().replace(/^@/, '').toLowerCase(); + displayName = m[1].trim(); + } + } + + // Strategy B: TreeWalker text node for title element + const walker = document.createTreeWalker( + rowContainer, + NodeFilter.SHOW_TEXT, + { + acceptNode: function(node) { + const val = (node.nodeValue || '').trim(); + if (!val || val === '๐Ÿ”’' || val === 'โ€ข' || val === 'ยท') { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + } + ); + + let candidateTitleEl = null; + const firstNode = walker.nextNode(); + if (firstNode) { + candidateTitleEl = firstNode.parentElement; + if (!detectedUser) { + const txt = (firstNode.nodeValue || '').trim(); + detectedUser = txt.replace(/^@/, '').trim().toLowerCase(); + displayName = txt; + } + } + + if (detectedUser && !threadUsernameMap.has(threadId)) { + threadUsernameMap.set(threadId, detectedUser); + threadUsernameMap.set(detectedUser, threadId); + postToNative({ + action: 'threadUsernameMapped', + threadId: threadId, + username: detectedUser, + usernames: [detectedUser], + displayName: displayName || detectedUser + }); + } + + let isLocked = false; + if (!isLockOff) { + if (chatLockMode === 'whole_dm' || chatLockAll) { + isLocked = !unlockedThreads.has('__all__'); + } else { + if (unlockedThreads.has(threadId)) { + isLocked = false; + } else { + if (lockedChatIds.has(threadId)) isLocked = true; + const mappedUser = threadUsernameMap.get(threadId); + if (mappedUser && lockedChatIds.has(mappedUser)) isLocked = true; + if (detectedUser && lockedChatIds.has(detectedUser)) isLocked = true; + if (!isLocked) { + const rowText = rowContainer.textContent.toLowerCase(); + for (const lockedId of lockedChatIds) { + if (rowText.includes(lockedId) || href.includes(lockedId)) { + isLocked = true; + lockedChatIds.add(threadId); + break; + } + } + } + if (mappedUser && unlockedThreads.has(mappedUser)) isLocked = false; + if (detectedUser && unlockedThreads.has(detectedUser)) isLocked = false; + } + } + } + + // Clean up any legacy preview masking classes or attributes + const legacyMasked = rowContainer.querySelectorAll('.fg-masked-snippet-text, [data-fg-mask-preview]'); + for (const el of legacyMasked) { + el.classList.remove('fg-masked-snippet-text'); + el.removeAttribute('data-fg-mask-preview'); + } + + if (isLocked) { + link.setAttribute('data-fg-locked-chat', 'true'); + rowContainer.setAttribute('data-fg-locked-chat', 'true'); + + const titleTarget = candidateTitleEl || rowContainer.querySelector('[role="heading"], h2, h3, span'); + if (titleTarget && !rowContainer.querySelector('.fg-chat-lock-badge')) { + const badge = document.createElement('span'); + badge.className = 'fg-chat-lock-badge'; + badge.style.cssText = 'margin-left: 6px; font-size: 11px; opacity: 0.85; vertical-align: middle; display: inline-block;'; + badge.textContent = '๐Ÿ”’'; + titleTarget.appendChild(badge); + } + } else { + link.removeAttribute('data-fg-locked-chat'); + rowContainer.removeAttribute('data-fg-locked-chat'); + const badge = rowContainer.querySelector('.fg-chat-lock-badge'); + if (badge) badge.remove(); + } + } + } + + // Attach dynamic MutationObserver on direct messaging container to ensure previews are masked in real time + if (!window.__fgInboxObserverInstalled) { + window.__fgInboxObserverInstalled = true; + const inboxObserver = new MutationObserver(() => { + const path = window.location.pathname.toLowerCase(); + if (path.startsWith('/direct')) { + maskInboxChatPreviews(); + } + }); + inboxObserver.observe(document.body || document.documentElement, { + childList: true, + subtree: true + }); + } + function checkStateAndNotify() { + evaluateChatLockState(); + extractThreadParticipants(); + maskInboxChatPreviews(); + const currentUrl = window.location.href; if (currentUrl !== lastReportedUrl) { lastReportedUrl = currentUrl; @@ -2328,6 +2929,9 @@ } window.addEventListener('focusgram:urlchange', () => { + lastInputFocused = false; + postToNative({ action: 'inputFocused', focused: false }); + postToNative({ action: 'bioInputFocused', focused: false }); checkStateAndNotify(); setTimeout(checkStateAndNotify, 100); setTimeout(checkStateAndNotify, 300); @@ -2335,6 +2939,9 @@ }); window.addEventListener('popstate', () => { + lastInputFocused = false; + postToNative({ action: 'inputFocused', focused: false }); + postToNative({ action: 'bioInputFocused', focused: false }); checkStateAndNotify(); setTimeout(checkStateAndNotify, 100); setTimeout(checkStateAndNotify, 300); @@ -2342,6 +2949,9 @@ }); window.addEventListener('hashchange', () => { + lastInputFocused = false; + postToNative({ action: 'inputFocused', focused: false }); + postToNative({ action: 'bioInputFocused', focused: false }); checkStateAndNotify(); setTimeout(checkStateAndNotify, 100); hideNav(); @@ -2849,6 +3459,13 @@ } updateGuardrailsCSS(); syncGhostSettings(); + syncChatLockSettings(msg.settings); + return; + } else if (msg.action === 'unlockChat') { + handleUnlockChat(msg.threadId, msg.all); + return; + } else if (msg.action === 'lockChat') { + handleLockChat(msg.threadId); return; } else if (msg.action === 'navigateSpa' && msg.path) { performNavClick(msg.path); @@ -3038,6 +3655,7 @@ } updateGuardrailsCSS(); syncGhostSettings(); + syncChatLockSettings(response.settings); } }); } @@ -3139,5 +3757,553 @@ } } catch (_) {} + /* ========================================================================== + 8. LIVE BIO FONT STYLIST (KEYBOARD ACCESSORY BAR & REAL-TIME INPUT) + ========================================================================== */ + 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 bioToolbarEl = null; + let bioCounterEl = null; + let activeBioTarget = 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 id = (el.id || '').toLowerCase(); + const placeholder = (el.placeholder || '').toLowerCase(); + const aria = (el.getAttribute('aria-label') || '').toLowerCase(); + if (name === 'biography' || name.includes('bio') || id.includes('bio') || placeholder.includes('bio') || aria.includes('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() { + if (!bioCounterEl || !activeBioTarget) return; + const text = activeBioTarget.value || activeBioTarget.textContent || ''; + const codePoints = Array.from(text).length; + const utf16Length = text.length; + + bioCounterEl.textContent = `${codePoints} / 150`; + if (codePoints > 140 || utf16Length > 140) { + bioCounterEl.style.color = '#ef4444'; + } else if (codePoints > 120) { + bioCounterEl.style.color = '#f59e0b'; + } else { + bioCounterEl.style.color = '#8e8e8e'; + } + } + + function updateBioToolbarPosition() { + if (!bioToolbarEl) return; + if (window.visualViewport) { + const kbHeight = window.innerHeight - (window.visualViewport.offsetTop + window.visualViewport.height); + bioToolbarEl.style.bottom = Math.max(0, Math.round(kbHeight)) + 'px'; + } else { + bioToolbarEl.style.bottom = '0px'; + } + } + + function createBioFontToolbar() { + if (document.getElementById('fg-bio-font-toolbar')) { + return document.getElementById('fg-bio-font-toolbar'); + } + + const toolbar = document.createElement('div'); + toolbar.id = 'fg-bio-font-toolbar'; + toolbar.style.cssText = ` + position: fixed !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + z-index: 2147483647 !important; + background: rgba(18, 18, 18, 0.96) !important; + backdrop-filter: blur(24px) !important; + -webkit-backdrop-filter: blur(24px) !important; + border-top: 1px solid rgba(255, 255, 255, 0.15) !important; + padding: 8px 12px !important; + display: none !important; + align-items: center !important; + justify-content: space-between !important; + gap: 8px !important; + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.7) !important; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important; + user-select: none !important; + -webkit-user-select: none !important; + transition: bottom 0.15s ease-out !important; + `; + + const chipsScroll = document.createElement('div'); + chipsScroll.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; + flex: 1 !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(style => { + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'fg-font-chip'; + chip.dataset.styleId = style.id; + chip.textContent = style.label; + const isSelected = style.id === activeBioStyle; + chip.style.cssText = ` + padding: 6px 14px !important; + border-radius: 16px !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: 13px !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(); + setBioActiveStyle(style.id); + }); + + chipsScroll.appendChild(chip); + }); + + const counter = document.createElement('span'); + counter.id = 'fg-bio-counter'; + counter.style.cssText = ` + font-size: 11px !important; + font-weight: 600 !important; + color: #8e8e8e !important; + white-space: nowrap !important; + padding: 4px 8px !important; + border-radius: 12px !important; + background: rgba(255, 255, 255, 0.05) !important; + border: 1px solid rgba(255, 255, 255, 0.08) !important; + `; + counter.textContent = '0 / 150'; + + toolbar.appendChild(chipsScroll); + toolbar.appendChild(counter); + (document.body || document.documentElement).appendChild(toolbar); + + bioToolbarEl = toolbar; + bioCounterEl = counter; + return toolbar; + } + + function setBioActiveStyle(styleId) { + activeBioStyle = styleId; + if (bioToolbarEl) { + bioToolbarEl.querySelectorAll('.fg-font-chip').forEach(c => { + const isActive = c.dataset.styleId === activeBioStyle; + c.style.border = `1px solid ${isActive ? '#ffffff' : 'rgba(255, 255, 255, 0.15)'}`; + c.style.background = isActive ? '#ffffff' : '#262626'; + c.style.color = isActive ? '#000000' : '#d1d5db'; + c.style.fontWeight = isActive ? '600' : '500'; + }); + } + + const target = activeBioTarget || document.getElementById('pepBio') || document.querySelector('textarea[aria-label="Bio"]'); + if (target) { + 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 selectedText = val.substring(start, end); + const after = val.substring(end); + const converted = convertStringToStyle(selectedText, activeBioStyle); + try { + const proto = window.HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : Object.getPrototypeOf(target); + const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; + if (setter) setter.call(target, before + converted + after); + else target.value = before + converted + after; + target.dispatchEvent(new Event('input', { bubbles: true })); + } catch (_) { + target.value = before + converted + after; + } + target.setSelectionRange(start, start + converted.length); + } + // If no text selected, do NOT alter existing text! + updateBioCharCounter(); + } + } + + window.__fgSetBioStyle = setBioActiveStyle; + + function showBioFontToolbar(target) { + activeBioTarget = target; + createBioFontToolbar(); + if (bioToolbarEl) { + updateBioToolbarPosition(); + bioToolbarEl.style.display = 'flex'; + updateBioCharCounter(); + } + } + + function hideBioFontToolbar() { + if (bioToolbarEl) { + bioToolbarEl.style.display = 'none'; + } + } + + // Intercept keystrokes on document capture level for instant Unicode glyph conversion + document.addEventListener('beforeinput', (e) => { + if (!activeBioTarget || activeBioStyle === 'normal') return; + if (e.data && e.inputType === 'insertText') { + e.preventDefault(); + const converted = convertStringToStyle(e.data, activeBioStyle); + document.execCommand('insertText', false, converted); + updateBioCharCounter(); + } + }, true); + + document.addEventListener('input', (e) => { + if (activeBioTarget && (e.target === activeBioTarget || isBioTarget(e.target))) { + updateBioCharCounter(); + } + }, true); + + // Dynamic focusin detection across document + document.addEventListener('focusin', (e) => { + if (isBioTarget(e.target)) { + showBioFontToolbar(e.target); + postToNative({ action: 'bioInputFocused', focused: true }); + } + }, true); + + document.addEventListener('focusout', () => { + setTimeout(() => { + const active = document.activeElement; + if (!isBioTarget(active) && (!bioToolbarEl || !bioToolbarEl.contains(active))) { + hideBioFontToolbar(); + postToNative({ action: 'bioInputFocused', focused: false }); + } + }, 250); + }, true); + + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', updateBioToolbarPosition); + window.visualViewport.addEventListener('scroll', updateBioToolbarPosition); + } + + // Listen for font style changes from background or native host + if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessage) { + browser.runtime.onMessage.addListener((msg) => { + if (msg.action === 'setBioStyle' && msg.style) { + setBioActiveStyle(msg.style); + } + }); + } + + /* ========================================================================== + 9. NETWORK-TRIGGERED STORY VIEWER SEARCH + - Directly displays the search bar when the /list_reel_media_viewer/ + network request is sent or received. + ========================================================================== */ + let storySearchCardEl = null; + let storyViewersData = []; + + 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) { + 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 ensureStorySearchCard() { + if (document.getElementById('fg-story-search-card')) { + return document.getElementById('fg-story-search-card'); + } + + const card = document.createElement('div'); + card.id = 'fg-story-search-card'; + card.style.cssText = ` + margin: 6px 16px 10px 16px !important; + padding: 0 12px !important; + height: 38px !important; + background: #262626 !important; + border: 1px solid rgba(255, 255, 255, 0.12) !important; + border-radius: 8px !important; + display: flex !important; + align-items: center !important; + gap: 10px !important; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important; + color: #FAFAFA !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 = ``; + + 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); + + const info = findViewersDialog(); + if (info && info.header) { + info.header.after(card); + } + + storySearchCardEl = card; + 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; + + 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++; + }); + + 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; + } + + ensureStorySearchCard(); + } + + 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()); + } + } + } + + // Listen to main-world fetch interceptor custom events + window.addEventListener('focusgram:story-viewers-detected', (e) => { + handleStoryViewersDetected(e.detail); + }); + document.addEventListener('focusgram:story-viewers-detected', (e) => { + handleStoryViewersDetected(e.detail); + }); + + // Listen to WebExtension background script messages + if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessage) { + browser.runtime.onMessage.addListener((msg) => { + if (msg.action === 'storyViewersDetected') { + handleStoryViewersDetected(msg); + } + }); + } + + // Continuous autonomous observer for Viewers modal + const viewersObserver = new MutationObserver(() => { + checkAndMountStorySearch(); + }); + viewersObserver.observe(document.documentElement, { childList: true, subtree: true }); + setInterval(checkAndMountStorySearch, 400); + + // Dismiss search bar on route change away from stories + window.addEventListener('focusgram:urlchange', (e) => { + const url = (e.detail || window.location.href).toLowerCase(); + if (!url.includes('/stories/')) { + if (storySearchCardEl) { + storySearchCardEl.remove(); + storySearchCardEl = null; + } + } + }); + })(); + diff --git a/Ported/app/src/main/assets/scripts/fetch_interceptor.js b/Ported/app/src/main/assets/scripts/fetch_interceptor.js index 18607fc..91d1ec1 100644 --- a/Ported/app/src/main/assets/scripts/fetch_interceptor.js +++ b/Ported/app/src/main/assets/scripts/fetch_interceptor.js @@ -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 = ``; + + 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); diff --git a/Ported/app/src/main/assets/scripts/native_feel.js b/Ported/app/src/main/assets/scripts/native_feel.js index 4934ee3..bd25114 100644 --- a/Ported/app/src/main/assets/scripts/native_feel.js +++ b/Ported/app/src/main/assets/scripts/native_feel.js @@ -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 = `Bio Font`; + + 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.'); })(); diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt index d1d8dce..2d53f0f 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt @@ -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) { diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt index cda8ff9..38b3f9f 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt @@ -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 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 { + if (jsonStr.isNullOrBlank()) return emptyMap() + return try { + val json = JSONObject(jsonStr) + val map = mutableMapOf() + val keys = json.keys() + while (keys.hasNext()) { + val k = keys.next() + map[k] = json.optString(k) + } + map + } catch (_: Exception) { + emptyMap() + } + } + val lastSessionEndTimestampFlow: Flow = 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) { + 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") } } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt index cd834e4..d73ae30 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt @@ -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) -> 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() + 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" diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt index ba57fa7..958ec11 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt @@ -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() diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt index d010c77..d2fef14 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt @@ -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; diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt index 663ac36..0847e46 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt @@ -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(); } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt index 85d7f01..10df1cc 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt @@ -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 = emptySet(), + val lockedChatUsernames: Map = emptyMap(), + val useSeparateChatPin: Boolean = false, + val chatBiometricEnabled: Boolean = true, // Ghost Mode (Privacy & Stealth) val ghostModeEnabled: Boolean = false, diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/IslandMode.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/IslandMode.kt index 3a001c8..af03788 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/IslandMode.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/IslandMode.kt @@ -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 diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/security/EncryptedStorage.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/security/EncryptedStorage.kt index a6d4d13..2efafd8 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/security/EncryptedStorage.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/security/EncryptedStorage.kt @@ -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" diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/AppIconManager.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/AppIconManager.kt new file mode 100644 index 0000000..38d738b --- /dev/null +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/AppIconManager.kt @@ -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() + + /** + * 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 + } + } +} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HistoricalIconManager.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HistoricalIconManager.kt new file mode 100644 index 0000000..185cc4f --- /dev/null +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HistoricalIconManager.kt @@ -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>(emptyMap()) + val downloadedIcons: StateFlow> = _downloadedIcons.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _downloadProgress = MutableStateFlow(0f) + val downloadProgress: StateFlow = _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() + + // 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 + } + } +} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockScreen.kt index 015ca93..bfcb267 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockScreen.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockScreen.kt @@ -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, diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSettingsPage.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSettingsPage.kt index 059f32d..8c42074 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSettingsPage.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSettingsPage.kt @@ -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)) } } } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSetupScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSetupScreen.kt index 4d17531..27b6434 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSetupScreen.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSetupScreen.kt @@ -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 ) diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/ChatLockScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/ChatLockScreen.kt new file mode 100644 index 0000000..60980c4 --- /dev/null +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/ChatLockScreen.kt @@ -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(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 + ) + } + } +} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt index 2171041..edaca7d 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt @@ -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) diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt index f105676..f9dab43 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt @@ -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 diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt index 55d1f30..14f72fc 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt @@ -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 } + ) + } +} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt index d4afa44..28804c5 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt @@ -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(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 diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt index 507c9da..79e7c11 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt @@ -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>() } + var unlockedChatThreadIds by remember { mutableStateOf(emptySet()) } + var isDirectAllUnlocked by remember { mutableStateOf(false) } + var pendingUnlockChat by remember { mutableStateOf(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(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) -> 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() + } + } + ) + } + } } } } diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_abstract.png b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_abstract.png new file mode 100644 index 0000000..d1a59ae Binary files /dev/null and b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_abstract.png differ diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_forest.png b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_forest.png new file mode 100644 index 0000000..c2cca8e Binary files /dev/null and b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_forest.png differ diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_golden.png b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_golden.png new file mode 100644 index 0000000..9f39a14 Binary files /dev/null and b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_golden.png differ diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_metal.png b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_metal.png new file mode 100644 index 0000000..2a850b9 Binary files /dev/null and b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_metal.png differ diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_minimalist.png b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_minimalist.png new file mode 100644 index 0000000..4c7dca8 Binary files /dev/null and b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_minimalist.png differ diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_polaroid.png b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_polaroid.png new file mode 100644 index 0000000..71795e3 Binary files /dev/null and b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_polaroid.png differ diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_redesign.png b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_redesign.png new file mode 100644 index 0000000..5a5d5c2 Binary files /dev/null and b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_redesign.png differ diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_scribble.png b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_scribble.png new file mode 100644 index 0000000..4fb5f72 Binary files /dev/null and b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_scribble.png differ diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_vibrant.png b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_vibrant.png new file mode 100644 index 0000000..0959c06 Binary files /dev/null and b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_vibrant.png differ diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_viewfinder.png b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_viewfinder.png new file mode 100644 index 0000000..ad399aa Binary files /dev/null and b/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_viewfinder.png differ diff --git a/Ported/app/src/main/res/drawable/ic_chat_lock_closed.xml b/Ported/app/src/main/res/drawable/ic_chat_lock_closed.xml new file mode 100644 index 0000000..015a5f0 --- /dev/null +++ b/Ported/app/src/main/res/drawable/ic_chat_lock_closed.xml @@ -0,0 +1,9 @@ + + + diff --git a/Ported/app/src/main/res/drawable/ic_chat_lock_open.xml b/Ported/app/src/main/res/drawable/ic_chat_lock_open.xml new file mode 100644 index 0000000..359a0d8 --- /dev/null +++ b/Ported/app/src/main/res/drawable/ic_chat_lock_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/Ported/app/src/main/res/drawable/ic_launcher_bg_instagram.xml b/Ported/app/src/main/res/drawable/ic_launcher_bg_instagram.xml new file mode 100644 index 0000000..30020ec --- /dev/null +++ b/Ported/app/src/main/res/drawable/ic_launcher_bg_instagram.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/Ported/app/src/main/res/drawable/ic_launcher_fg_all_white.xml b/Ported/app/src/main/res/drawable/ic_launcher_fg_all_white.xml new file mode 100644 index 0000000..119a0a6 --- /dev/null +++ b/Ported/app/src/main/res/drawable/ic_launcher_fg_all_white.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/Ported/app/src/main/res/drawable/ic_launcher_fg_classic.xml b/Ported/app/src/main/res/drawable/ic_launcher_fg_classic.xml new file mode 100644 index 0000000..6de5afe --- /dev/null +++ b/Ported/app/src/main/res/drawable/ic_launcher_fg_classic.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/Ported/app/src/main/res/drawable/ic_launcher_fg_discreet.xml b/Ported/app/src/main/res/drawable/ic_launcher_fg_discreet.xml new file mode 100644 index 0000000..3f6b5ac --- /dev/null +++ b/Ported/app/src/main/res/drawable/ic_launcher_fg_discreet.xml @@ -0,0 +1,41 @@ + + + + + + + + + + diff --git a/Ported/app/src/main/res/drawable/ic_launcher_fg_instagram_official.xml b/Ported/app/src/main/res/drawable/ic_launcher_fg_instagram_official.xml new file mode 100644 index 0000000..f48df9b --- /dev/null +++ b/Ported/app/src/main/res/drawable/ic_launcher_fg_instagram_official.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/Ported/app/src/main/res/drawable/ic_launcher_fg_mono.xml b/Ported/app/src/main/res/drawable/ic_launcher_fg_mono.xml new file mode 100644 index 0000000..c514fe1 --- /dev/null +++ b/Ported/app/src/main/res/drawable/ic_launcher_fg_mono.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/Ported/app/src/main/res/drawable/ic_launcher_fg_oled.xml b/Ported/app/src/main/res/drawable/ic_launcher_fg_oled.xml new file mode 100644 index 0000000..0d6047b --- /dev/null +++ b/Ported/app/src/main/res/drawable/ic_launcher_fg_oled.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/Ported/app/src/main/res/drawable/ic_launcher_fg_pitch_black.xml b/Ported/app/src/main/res/drawable/ic_launcher_fg_pitch_black.xml new file mode 100644 index 0000000..04ad370 --- /dev/null +++ b/Ported/app/src/main/res/drawable/ic_launcher_fg_pitch_black.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_abstract.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_abstract.xml new file mode 100644 index 0000000..013da97 --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_abstract.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_all_white.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_all_white.xml new file mode 100644 index 0000000..da561b8 --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_all_white.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_classic.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_classic.xml new file mode 100644 index 0000000..a27adb2 --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_classic.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_discreet.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_discreet.xml new file mode 100644 index 0000000..47a1717 --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_discreet.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_forest.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_forest.xml new file mode 100644 index 0000000..dd74fc4 --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_forest.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_golden.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_golden.xml new file mode 100644 index 0000000..76f3928 --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_golden.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_instagram_official.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_instagram_official.xml new file mode 100644 index 0000000..308a69b --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_instagram_official.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_metal.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_metal.xml new file mode 100644 index 0000000..eb15908 --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_metal.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_minimalist.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_minimalist.xml new file mode 100644 index 0000000..48da1c2 --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_minimalist.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_mono.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_mono.xml new file mode 100644 index 0000000..af63f2b --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_mono.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_oled.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_oled.xml new file mode 100644 index 0000000..e1b3904 --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_oled.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_pitch_black.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_pitch_black.xml new file mode 100644 index 0000000..a1d1afd --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_pitch_black.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_polaroid.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_polaroid.xml new file mode 100644 index 0000000..66181bb --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_polaroid.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_redesign.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_redesign.xml new file mode 100644 index 0000000..f3e75d6 --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_redesign.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_scribble.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_scribble.xml new file mode 100644 index 0000000..c499b1f --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_scribble.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_vibrant.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_vibrant.xml new file mode 100644 index 0000000..adf939e --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_vibrant.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_viewfinder.xml b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_viewfinder.xml new file mode 100644 index 0000000..2ea0d4b --- /dev/null +++ b/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_viewfinder.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_abstract.png b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_abstract.png new file mode 100644 index 0000000..204d5e5 Binary files /dev/null and b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_abstract.png differ diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_forest.png b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_forest.png new file mode 100644 index 0000000..54f2786 Binary files /dev/null and b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_forest.png differ diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_golden.png b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_golden.png new file mode 100644 index 0000000..598500e Binary files /dev/null and b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_golden.png differ diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_metal.png b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_metal.png new file mode 100644 index 0000000..8ba466a Binary files /dev/null and b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_metal.png differ diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_minimalist.png b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_minimalist.png new file mode 100644 index 0000000..45a7cac Binary files /dev/null and b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_minimalist.png differ diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_polaroid.png b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_polaroid.png new file mode 100644 index 0000000..6ce18f8 Binary files /dev/null and b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_polaroid.png differ diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_redesign.png b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_redesign.png new file mode 100644 index 0000000..3795146 Binary files /dev/null and b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_redesign.png differ diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_scribble.png b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_scribble.png new file mode 100644 index 0000000..3ec288a Binary files /dev/null and b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_scribble.png differ diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_vibrant.png b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_vibrant.png new file mode 100644 index 0000000..36597cb Binary files /dev/null and b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_vibrant.png differ diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_viewfinder.png b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_viewfinder.png new file mode 100644 index 0000000..505eea5 Binary files /dev/null and b/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_viewfinder.png differ diff --git a/Ported/app/src/main/res/values/colors.xml b/Ported/app/src/main/res/values/colors.xml index ae5bd29..03b161f 100644 --- a/Ported/app/src/main/res/values/colors.xml +++ b/Ported/app/src/main/res/values/colors.xml @@ -6,4 +6,19 @@ #121212 #3797EF #FFD700 + #000000 + #000000 + #1C0E18 + #121212 + #1C1C1E + #462220 + #1C1D25 + #6A6151 + #141519 + #313744 + #1F2227 + #121212 + #1A1410 + #000000 + #000000 diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/AdsterraAdFlowTest.kt b/Ported/app/src/test/kotlin/com/ujwal/focusgram/AdsterraAdFlowTest.kt index e800d6f..31dcab5 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/AdsterraAdFlowTest.kt +++ b/Ported/app/src/test/kotlin/com/ujwal/focusgram/AdsterraAdFlowTest.kt @@ -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)) + } } diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/ChatLockAndAppIconTest.kt b/Ported/app/src/test/kotlin/com/ujwal/focusgram/ChatLockAndAppIconTest.kt new file mode 100644 index 0000000..6600cd7 --- /dev/null +++ b/Ported/app/src/test/kotlin/com/ujwal/focusgram/ChatLockAndAppIconTest.kt @@ -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, 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 { + 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")) + } +} + diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/UBlockExtensionAssetsTest.kt b/Ported/app/src/test/kotlin/com/ujwal/focusgram/UBlockExtensionAssetsTest.kt index 9bce81d..537e366 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/UBlockExtensionAssetsTest.kt +++ b/Ported/app/src/test/kotlin/com/ujwal/focusgram/UBlockExtensionAssetsTest.kt @@ -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")) + } } diff --git a/alt-logos/abstract_512x512.png b/alt-logos/abstract_512x512.png new file mode 100644 index 0000000..31e1669 Binary files /dev/null and b/alt-logos/abstract_512x512.png differ diff --git a/alt-logos/forest_512x512.png b/alt-logos/forest_512x512.png new file mode 100644 index 0000000..42d051d Binary files /dev/null and b/alt-logos/forest_512x512.png differ diff --git a/alt-logos/golden_512x512.png b/alt-logos/golden_512x512.png new file mode 100644 index 0000000..8ee1412 Binary files /dev/null and b/alt-logos/golden_512x512.png differ diff --git a/alt-logos/metal_512x512.png b/alt-logos/metal_512x512.png new file mode 100644 index 0000000..3881b54 Binary files /dev/null and b/alt-logos/metal_512x512.png differ diff --git a/alt-logos/redesign_512x512.png b/alt-logos/redesign_512x512.png new file mode 100644 index 0000000..27eee03 Binary files /dev/null and b/alt-logos/redesign_512x512.png differ diff --git a/alt-logos/scribble_512x512.png b/alt-logos/scribble_512x512.png new file mode 100644 index 0000000..66b401f Binary files /dev/null and b/alt-logos/scribble_512x512.png differ diff --git a/bottom-icons/lock-icon-closed.svg b/bottom-icons/lock-icon-closed.svg new file mode 100644 index 0000000..27138ea --- /dev/null +++ b/bottom-icons/lock-icon-closed.svg @@ -0,0 +1,3 @@ + + + diff --git a/bottom-icons/lock-icon-open.svg b/bottom-icons/lock-icon-open.svg new file mode 100644 index 0000000..2127fca --- /dev/null +++ b/bottom-icons/lock-icon-open.svg @@ -0,0 +1,3 @@ + + +