mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-11-03 03:44:37 +01:00
Port(76% Done):
# Problem Fixes - > Fixed Captions and audio banner being cutted off when adblocker is off. > Fixed a bug due to which feed posts were getting blanked out after ~13 posts (due to too tight adblocking) > Fixed Performance bottlenecks > Fixed "Block Reels Swiping in DMs" not working properly and more... # New - > Access watched Reels History easily from settings. > Reels counter [per session(In reels page) and daily(in settings)] > Smoothened Reels scrolling > Increased transparency of bottom nav bar and few more... # Known Problems: > "Download Media" feature doesn't work. > You might see occasional ads (in reels player only) > File Picker doesn't open (hence no picture in DMs, No Posts, No Storied etc). > Clearing reels hitory clears "Reels Watched" too. (could be a feature but i dont want the "Reels Watched" metric to be cleared/changed) > Hide Likes Count doesnt work [ hides like icon instead ;) ] > Homepage & Reels page might ocassionally go blank while scrolling (ad remover sometimes removes whole CSS Container if some keywords match the list of ad trigger) > You can skip cooldowns (intentional, for development testing) there can be more bugs as v3 is incomplete.
This commit is contained in:
1 parent
b709693e1d
commit
fb2a367c83
44 files changed
+3499
-487
No files matched your search
@@ -110,5 +110,7 @@ PRD.md
|
||||
system/
|
||||
Documents/
|
||||
Ghostify/
|
||||
Ghostify
|
||||
inspo/
|
||||
Instagram-Downloader/
|
||||
|
||||
@@ -29,3 +29,7 @@ key.properties
|
||||
Thumbs.db
|
||||
*.log
|
||||
*.hprof
|
||||
|
||||
# Others
|
||||
Ghostify/
|
||||
Instagram-Downloader/
|
||||
@@ -11,6 +11,19 @@
|
||||
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- Media and Storage Permissions for file uploads (DMs, Posts, Stories) and downloads -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".FocusGramApp"
|
||||
android:allowBackup="false"
|
||||
|
||||
@@ -25,7 +25,8 @@ let cachedSettings = {
|
||||
ghostHideStorySeen: true,
|
||||
ghostHideDirectSeen: true,
|
||||
ghostHideTypingIndicator: true,
|
||||
unblurMode: 'tap'
|
||||
unblurMode: 'tap',
|
||||
videoDownloadEnabled: true
|
||||
};
|
||||
|
||||
const BLOCKED_URL_PATTERNS = [
|
||||
@@ -33,9 +34,15 @@ const BLOCKED_URL_PATTERNS = [
|
||||
"*://*.instagram.com/ajax/bz*",
|
||||
"*://graph.instagram.com/*",
|
||||
"*://*.instagram.com/api/v1/ads/*",
|
||||
"*://*.instagram.com/api/v1/ad_*",
|
||||
"*://*.instagram.com/api/v1/feed/saved/ads*",
|
||||
"*://*.instagram.com/api/v1/feed/reels_tray/ads*",
|
||||
"*://*.instagram.com/api/v1/stories/tray/ads*",
|
||||
"*://*.instagram.com/api/v1/stories/ads*",
|
||||
"*://*.instagram.com/api/v1/feed/timeline/ads*",
|
||||
"*://*.instagram.com/api/v1/clips/home/ads*",
|
||||
"*://*.instagram.com/api/v1/clips/ads*",
|
||||
"*://*.instagram.com/api/v1/clips/stream/ads*",
|
||||
"*://*.facebook.com/tr/*",
|
||||
"*://*.facebook.com/tr?*",
|
||||
"*://*.facebook.net/*",
|
||||
@@ -45,14 +52,7 @@ const BLOCKED_URL_PATTERNS = [
|
||||
"*://*.googleadservices.com/*",
|
||||
"*://*.googlesyndication.com/*",
|
||||
"*://*.adservice.google.*/*",
|
||||
"*://*.cdninstagram.com/v/t51.2885-15/e35/*_ad*",
|
||||
"*://*.instagram.com/api/v1/clips/home/ads*",
|
||||
"*://*.instagram.com/api/v1/clips/ads*",
|
||||
"*://*.instagram.com/api/v1/clips/stream/ads*",
|
||||
"*://*.instagram.com/api/v1/ads/*",
|
||||
"*://*.instagram.com/api/v1/feed/saved/ads*",
|
||||
"*://*.instagram.com/api/v1/stories/tray/ads*",
|
||||
"*://*.instagram.com/api/v1/feed/timeline/ads*"
|
||||
"*://*.cdninstagram.com/v/t51.2885-15/e35/*_ad*"
|
||||
];
|
||||
|
||||
const GHOST_BLOCKED_URL_PATTERNS = [
|
||||
@@ -168,15 +168,17 @@ if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessa
|
||||
|
||||
if (nativePort) {
|
||||
try {
|
||||
if (message.type === 'ReelMetadata' || message.action === 'reelViewed') {
|
||||
if (message.type === 'ReelMetadata' || message.action === 'reelViewed' || message.action === 'reelWatched') {
|
||||
const payload = message.payload || message.data || message.reel || message;
|
||||
nativePort.postMessage({
|
||||
action: 'reelViewed',
|
||||
type: 'reelViewed',
|
||||
url: payload.url || '',
|
||||
title: payload.title || 'Instagram Reel',
|
||||
author: payload.author || 'Instagram Creator',
|
||||
thumbnailUrl: payload.thumbnailUrl || ''
|
||||
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);
|
||||
|
||||
@@ -17,13 +17,17 @@
|
||||
div[data-testid="post_ad"],
|
||||
div[aria-label*="Sponsored" i]:not(main):not([role="feed"]),
|
||||
div[aria-label*="sponsored" i]:not(main):not([role="feed"]),
|
||||
div[aria-label="Ad" i]:not(main):not([role="feed"]),
|
||||
article:has([aria-label="Ad" i]),
|
||||
article:has([aria-label*="Sponsored" i]),
|
||||
article:has(a[href*="/ads/"]),
|
||||
article:has(a[href*="/about/ads"]),
|
||||
article:has(a[href*="/ad_"]),
|
||||
article:has(a[href*="ads.instagram.com"]),
|
||||
article[data-ad-filtered="true"] {
|
||||
height: 1px !important;
|
||||
min-height: 1px !important;
|
||||
max-height: 1px !important;
|
||||
display: none !important;
|
||||
height: 0px !important;
|
||||
min-height: 0px !important;
|
||||
max-height: 0px !important;
|
||||
overflow: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
@@ -31,6 +35,10 @@
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
div[data-fg-reel-ad="1"] {
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
a[href*="/ads/"],
|
||||
a[href*="ads.instagram.com"] {
|
||||
display: none !important;
|
||||
@@ -59,12 +67,12 @@
|
||||
const SPONSORED_KEYWORDS = [
|
||||
'sponsored', 'gesponsert', 'sponsorisé', 'sponsorise',
|
||||
'publicidad', 'patrocinado', 'sponsorizzato', 'sponsrad',
|
||||
'paid partnership'
|
||||
'paid partnership', 'werbung', 'anuncio', 'werbeanzeige', 'publicité'
|
||||
];
|
||||
|
||||
const AD_CTA_KEYWORDS = [
|
||||
'learn more', 'shop now', 'install now', 'sign up', 'apply now',
|
||||
'book now', 'download', 'order now', 'get offer', 'subscribe'
|
||||
const STRICT_AD_CTAS = [
|
||||
'shop now', 'install now', 'sign up', 'apply now',
|
||||
'book now', 'order now', 'get offer'
|
||||
];
|
||||
|
||||
function filterDynamicSponsoredPosts() {
|
||||
@@ -77,31 +85,34 @@
|
||||
|
||||
let isAd = false;
|
||||
|
||||
// Check header text (Sponsored, Paid partnership)
|
||||
const spans = article.querySelectorAll('header span, header a, span[dir="auto"], [role="button"] span');
|
||||
for (let j = 0; j < spans.length; j++) {
|
||||
const text = (spans[j].textContent || '').trim().toLowerCase();
|
||||
if (SPONSORED_KEYWORDS.some(kw => text === kw || text.startsWith(kw) || text.includes(kw))) {
|
||||
// Check post header only (Sponsored, Paid partnership)
|
||||
const headerElements = article.querySelectorAll('header span, header [aria-label*="Sponsored" i], header [aria-label="Ad" i]');
|
||||
for (let j = 0; j < headerElements.length; j++) {
|
||||
if (headerElements[j].children.length > 2) continue;
|
||||
const text = (headerElements[j].textContent || '').trim().toLowerCase();
|
||||
if (!text || text.length > 30) continue;
|
||||
const clean = text.replace(/[^a-z]/g, '');
|
||||
if (clean === 'ad' || SPONSORED_KEYWORDS.some(kw => text === kw || clean === kw)) {
|
||||
isAd = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check CTA button text
|
||||
// Check CTA button text specifically on buttons (excluding FocusGram download buttons)
|
||||
if (!isAd) {
|
||||
const buttons = article.querySelectorAll('div[role="button"], a[role="button"]');
|
||||
const buttons = article.querySelectorAll('div[role="button"]:not([data-fg-x-act]):not([data-fg-download-btn]):not(.fg-x-act-btn), a[role="button"]:not([data-fg-x-act]):not([data-fg-download-btn]):not(.fg-x-act-btn), button');
|
||||
for (let k = 0; k < buttons.length; k++) {
|
||||
const btnText = (buttons[k].textContent || '').trim().toLowerCase();
|
||||
if (AD_CTA_KEYWORDS.some(cta => btnText === cta)) {
|
||||
if (STRICT_AD_CTAS.some(cta => btnText === cta)) {
|
||||
isAd = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check ad-specific links (strictly /ads/, /ad_, ads.instagram.com, /about/ads)
|
||||
// Check ad-specific links (strictly /ads/, /ad_, ads.instagram.com)
|
||||
if (!isAd) {
|
||||
if (article.querySelector('a[href*="/ads/"], a[href*="/ad_"], a[href*="/about/ads"], a[href*="ads.instagram.com"]')) {
|
||||
if (article.querySelector('a[href*="/ads/"], a[href*="/ad_"], a[href*="ads.instagram.com"]')) {
|
||||
isAd = true;
|
||||
}
|
||||
}
|
||||
@@ -120,42 +131,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Filter Reels video ads on reels and feed surfaces
|
||||
// Filter Reels video ads on reels surface without ever breaking layout
|
||||
const videos = document.querySelectorAll('video');
|
||||
for (let i = 0; i < videos.length; i++) {
|
||||
const video = videos[i];
|
||||
let container = video.closest('article') || video.closest('section') || video.closest('[role="region"]') || video.parentElement;
|
||||
while (container && container !== document.body && container !== document.documentElement) {
|
||||
if (container.tagName === 'ARTICLE' || container.tagName === 'SECTION' || container.parentElement?.tagName === 'MAIN') break;
|
||||
const r = container.getBoundingClientRect();
|
||||
if (r.height > window.innerHeight * 0.45) break;
|
||||
container = container.parentElement;
|
||||
const card = video.closest('[data-fg-reel-card="1"]') || video.closest('article');
|
||||
if (!card || card.dataset.adFiltered || card.dataset.fgReelAd) continue;
|
||||
|
||||
let isSponsored = !!card.querySelector('a[href*="/ads/"], a[href*="/ad_"], a[href*="ads.instagram.com"], a[href*="/about/ads"]') ||
|
||||
!!card.querySelector('[aria-label*="Sponsored" i], [aria-label="Ad" i], [data-ad-preview]');
|
||||
|
||||
if (!isSponsored) {
|
||||
const badges = card.querySelectorAll('span, div[role="button"], button');
|
||||
for (let s = 0; s < badges.length; s++) {
|
||||
const el = badges[s];
|
||||
if (el.children.length > 2) continue;
|
||||
const raw = (el.textContent || '').trim();
|
||||
if (!raw || raw.length > 15) continue;
|
||||
if (/^[\u{1F300}-\u{1F9FF}\s]*ad$/iu.test(raw) || /^sponsored$/i.test(raw) || /^paid\s+partnership$/i.test(raw)) {
|
||||
if (!el.closest('span[dir="auto"]')) {
|
||||
isSponsored = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!container || container.dataset.adFiltered) continue;
|
||||
|
||||
const text = (container.textContent || '').trim().toLowerCase();
|
||||
const isSponsored = SPONSORED_KEYWORDS.some(kw => text.includes(kw)) ||
|
||||
!!container.querySelector('a[href*="/ads/"], a[href*="/ad_"], a[href*="/about/ads"], a[href*="ads.instagram.com"]') ||
|
||||
!!container.querySelector('[aria-label*="Sponsored" i], [aria-label*="sponsored" i], [data-ad-preview]');
|
||||
|
||||
const hasAdCTA = !!container.querySelector('div[role="button"], a[role="button"]') &&
|
||||
AD_CTA_KEYWORDS.some(cta => text.includes(cta));
|
||||
|
||||
if (isSponsored || hasAdCTA) {
|
||||
if (isSponsored) {
|
||||
video.muted = true;
|
||||
try { video.pause(); } catch (_) {}
|
||||
container.style.setProperty('display', 'none', 'important');
|
||||
container.style.setProperty('height', '0px', 'important');
|
||||
container.style.setProperty('opacity', '0', 'important');
|
||||
container.style.setProperty('pointer-events', 'none', 'important');
|
||||
container.dataset.adFiltered = 'true';
|
||||
card.setAttribute('data-fg-reel-ad', '1');
|
||||
card.dataset.adFiltered = 'true';
|
||||
card.style.setProperty('visibility', 'hidden', 'important');
|
||||
card.style.setProperty('pointer-events', 'none', 'important');
|
||||
|
||||
// Skip ad reel automatically if currently visible
|
||||
const r = container.getBoundingClientRect();
|
||||
const r = card.getBoundingClientRect();
|
||||
if (r.top >= -100 && r.top <= window.innerHeight * 0.8) {
|
||||
const evt = new KeyboardEvent('keydown', { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40, which: 40, bubbles: true });
|
||||
window.dispatchEvent(evt);
|
||||
document.dispatchEvent(evt);
|
||||
const scroller = card.closest('[data-fg-reel-scroller]') || document.querySelector('[data-fg-reel-scroller="1"]');
|
||||
if (scroller && scroller !== window) {
|
||||
try { scroller.scrollBy({ top: scroller.clientHeight || window.innerHeight, behavior: 'instant' }); } catch (_) {}
|
||||
} else {
|
||||
try { window.scrollBy({ top: window.innerHeight, behavior: 'instant' }); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,6 +186,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (adBlockingEnabled) {
|
||||
filterDynamicSponsoredPosts();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
function init() {
|
||||
updateCosmeticFilters();
|
||||
const root = document.body || document.documentElement;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -22,17 +22,6 @@
|
||||
"persistent": true
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"*://*.instagram.com/*"
|
||||
],
|
||||
"js": [
|
||||
"ghost.js"
|
||||
],
|
||||
"run_at": "document_start",
|
||||
"world": "MAIN",
|
||||
"all_frames": true
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.instagram.com/*"
|
||||
@@ -45,6 +34,9 @@
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
"ghost.js"
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "uBlock0@raymondhill.net",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
let nativePort = null;
|
||||
|
||||
function getPort() {
|
||||
if (!nativePort) {
|
||||
try {
|
||||
if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.connectNative) {
|
||||
nativePort = browser.runtime.connectNative('reelhistory');
|
||||
nativePort.onDisconnect.addListener(() => {
|
||||
nativePort = null;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
nativePort = null;
|
||||
}
|
||||
}
|
||||
return nativePort;
|
||||
}
|
||||
|
||||
browser.runtime.onMessage.addListener((m) => {
|
||||
if (!m || m.t !== 'fg-watched') return;
|
||||
if (!m.code || !/^[A-Za-z0-9_.-]{3,}$/.test(m.code)) return;
|
||||
|
||||
const payload = {
|
||||
type: m.type === 'meta' ? 'meta' : 'watched',
|
||||
code: m.code,
|
||||
author: String(m.author || ''),
|
||||
caption: String(m.caption || ''),
|
||||
};
|
||||
|
||||
try {
|
||||
const port = getPort();
|
||||
if (port) {
|
||||
port.postMessage(payload);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "FocusGram Reel History",
|
||||
"version": "1.0",
|
||||
"browser_specific_settings": { "gecko": { "id": "reelhistory@focusgram.app" } },
|
||||
"permissions": ["nativeMessaging", "*://*.instagram.com/*"],
|
||||
"background": { "scripts": ["bg.js"] },
|
||||
"content_scripts": [{
|
||||
"matches": ["*://*.instagram.com/*"],
|
||||
"js": ["reels.js"],
|
||||
"run_at": "document_start"
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
const DWELL_MS = 1000; // a reel counts after 1 second on screen
|
||||
const REEL_RE = /^\/reels?\/([A-Za-z0-9_-]{8,})\/?/;
|
||||
const CODE_RE = /^[A-Za-z0-9_.-]{3,}$/;
|
||||
|
||||
// ---- In-Page API Interception via Injected Script ----
|
||||
// Content scripts run in an isolated world where exportFunction may not exist.
|
||||
// Injecting a DOM script into the page context guarantees direct access to fetch and XMLHttpRequest.
|
||||
(function injectApiHook() {
|
||||
const SCRIPT_ID = 'fg-reel-api-hook';
|
||||
if (document.getElementById(SCRIPT_ID)) return;
|
||||
const s = document.createElement('script');
|
||||
s.id = SCRIPT_ID;
|
||||
s.textContent = `
|
||||
(function() {
|
||||
const CODE_RE = /^[A-Za-z0-9_-]{8,}$/;
|
||||
window.__fg_media_cache = window.__fg_media_cache || new Map();
|
||||
|
||||
function harvest(obj, depth) {
|
||||
if (!obj || typeof obj !== 'object' || depth > 14) return;
|
||||
if (Array.isArray(obj)) { for (const x of obj) harvest(x, depth + 1); return; }
|
||||
if (typeof obj.code === 'string' && CODE_RE.test(obj.code)) {
|
||||
const cap = obj.caption && typeof obj.caption.text === 'string' ? obj.caption.text : '';
|
||||
const author = obj.user && typeof obj.user.username === 'string' ? obj.user.username : '';
|
||||
|
||||
let videoUrl = '';
|
||||
if (Array.isArray(obj.video_versions) && obj.video_versions.length > 0) {
|
||||
videoUrl = obj.video_versions[0].url || '';
|
||||
}
|
||||
let imageUrl = '';
|
||||
if (obj.image_versions2 && Array.isArray(obj.image_versions2.candidates) && obj.image_versions2.candidates.length > 0) {
|
||||
imageUrl = obj.image_versions2.candidates[0].url || '';
|
||||
}
|
||||
|
||||
if (videoUrl || imageUrl) {
|
||||
window.__fg_media_cache.set(obj.code, {
|
||||
code: obj.code,
|
||||
videoUrl: videoUrl,
|
||||
imageUrl: imageUrl,
|
||||
isVideo: !!videoUrl,
|
||||
author: author,
|
||||
caption: cap.slice(0, 300)
|
||||
});
|
||||
while (window.__fg_media_cache.size > 200) {
|
||||
window.__fg_media_cache.delete(window.__fg_media_cache.keys().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
if (author) {
|
||||
window.dispatchEvent(new CustomEvent('fg-api-reel', {
|
||||
detail: { code: obj.code, author: author, caption: cap.slice(0, 300) }
|
||||
}));
|
||||
}
|
||||
}
|
||||
for (const k in obj) harvest(obj[k], depth + 1);
|
||||
}
|
||||
|
||||
function remember(text) {
|
||||
try { harvest(JSON.parse(text), 0); } catch (_) {}
|
||||
}
|
||||
|
||||
window.__fg_fetch_post_media = async function(shortcode) {
|
||||
if (!shortcode) return null;
|
||||
if (window.__fg_media_cache && window.__fg_media_cache.has(shortcode)) {
|
||||
return window.__fg_media_cache.get(shortcode);
|
||||
}
|
||||
try {
|
||||
const apiURL = new URL('/graphql/query', window.location.origin);
|
||||
const params = new URLSearchParams({
|
||||
fb_api_caller_class: 'RelayModern',
|
||||
fb_api_req_friendly_name: 'PolarisPostRootQuery',
|
||||
doc_id: '27852811784380813',
|
||||
variables: JSON.stringify({
|
||||
shortcode: shortcode,
|
||||
__relay_internal__pv__PolarisShortDramaEnabledrelayprovider: false,
|
||||
__relay_internal__pv__PolarisMultiCaptionCarouselEnabledrelayprovider: false,
|
||||
}),
|
||||
server_timestamps: 'true',
|
||||
});
|
||||
const res = await window.fetch(apiURL.href, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'x-fb-friendly-name': 'PolarisPostRootQuery',
|
||||
'x-ig-app-id': '936619743392459',
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
},
|
||||
body: params.toString(),
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const item = data?.data?.xdt_api__v1__media__shortcode__web_info?.items?.[0];
|
||||
if (item) {
|
||||
let videoUrl = (item.video_versions && item.video_versions[0]?.url) || '';
|
||||
let imageUrl = (item.image_versions2?.candidates && item.image_versions2.candidates[0]?.url) || '';
|
||||
const result = {
|
||||
code: shortcode,
|
||||
videoUrl: videoUrl,
|
||||
imageUrl: imageUrl,
|
||||
isVideo: !!videoUrl,
|
||||
author: item.user?.username || '',
|
||||
caption: item.caption?.text || ''
|
||||
};
|
||||
window.__fg_media_cache.set(shortcode, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
};
|
||||
|
||||
window.addEventListener('fg-request-media', async (e) => {
|
||||
const code = e?.detail?.code;
|
||||
const reqId = e?.detail?.reqId;
|
||||
if (!code || !reqId) return;
|
||||
let media = window.__fg_media_cache && window.__fg_media_cache.get(code);
|
||||
if (!media && typeof window.__fg_fetch_post_media === 'function') {
|
||||
media = await window.__fg_fetch_post_media(code);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('fg-response-media-' + reqId, {
|
||||
detail: media || null
|
||||
}));
|
||||
});
|
||||
|
||||
const isApi = (u) => typeof u === 'string' && (u.includes('graphql') || u.includes('/api/v1/') || u.includes('/clips/'));
|
||||
|
||||
const origFetch = window.fetch;
|
||||
if (origFetch) {
|
||||
window.fetch = async function(input, init) {
|
||||
const p = origFetch.apply(this, arguments);
|
||||
try {
|
||||
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
if (isApi(url)) {
|
||||
p.then(res => {
|
||||
try { res.clone().text().then(remember).catch(() => {}); } catch (_) {}
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (_) {}
|
||||
return p;
|
||||
};
|
||||
}
|
||||
|
||||
const XP = window.XMLHttpRequest && window.XMLHttpRequest.prototype;
|
||||
if (XP) {
|
||||
const origOpen = XP.open;
|
||||
const origSend = XP.send;
|
||||
XP.open = function(method, url) {
|
||||
this.__fg_url = String(url);
|
||||
return origOpen.apply(this, arguments);
|
||||
};
|
||||
XP.send = function() {
|
||||
try {
|
||||
if (this.__fg_url && isApi(this.__fg_url)) {
|
||||
this.addEventListener('load', function() {
|
||||
try {
|
||||
if (!this.responseType || this.responseType === 'text') remember(this.responseText);
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
return origSend.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
})();
|
||||
`;
|
||||
(document.head || document.documentElement).appendChild(s);
|
||||
})();
|
||||
|
||||
// ---- Source 2: Store harvested API metadata ----
|
||||
const apiMeta = new Map(); // code -> { author, caption }
|
||||
const authorToCode = new Map(); // lowercase username -> code
|
||||
|
||||
window.addEventListener('fg-api-reel', (e) => {
|
||||
if (e && e.detail) {
|
||||
const d = e.detail;
|
||||
apiMeta.set(d.code, { author: d.author, caption: d.caption });
|
||||
if (d.author) authorToCode.set(d.author.toLowerCase(), d.code);
|
||||
while (apiMeta.size > 400) apiMeta.delete(apiMeta.keys().next().value);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Source 1 (primary): the <meta> tags in the page head ----
|
||||
function fromHead(code) {
|
||||
const get = (sel, attr) => {
|
||||
const e = document.querySelector(sel);
|
||||
return e ? (e.getAttribute(attr) || '') : '';
|
||||
};
|
||||
const canon = get('link[rel="canonical"]', 'href');
|
||||
const ogUrl = get('meta[property="og:url"]', 'content');
|
||||
const alUrl = get('meta[property="al:android:url"]', 'content');
|
||||
if (![canon, ogUrl, alUrl].some((u) => u.includes(code))) return null;
|
||||
|
||||
let author = '';
|
||||
let m = new RegExp('instagram\\.com/([A-Za-z0-9._]+)/reels?/' + code).exec(ogUrl);
|
||||
if (m) author = m[1];
|
||||
if (!author) {
|
||||
m = /\(@([A-Za-z0-9._]+)\)/.exec(get('meta[name="twitter:title"]', 'content'));
|
||||
if (m) author = m[1];
|
||||
}
|
||||
m = /^[\s\S]*? on Instagram: "([\s\S]*)"\s*$/.exec(get('meta[property="og:title"]', 'content'));
|
||||
const caption = m ? m[1].slice(0, 300) : '';
|
||||
return author || caption ? { author, caption } : null;
|
||||
}
|
||||
|
||||
// Head first, DOM second, API third, field by field.
|
||||
function lookup(code) {
|
||||
const a = apiMeta.get(code) || {};
|
||||
const h = fromHead(code) || {};
|
||||
let author = a.author || h.author || '';
|
||||
let caption = a.caption || h.caption || '';
|
||||
|
||||
if (!author || !caption) {
|
||||
const videos = Array.from(document.querySelectorAll('video'));
|
||||
for (const v of videos) {
|
||||
let card = v.closest('article') || v.closest('section') || v.parentElement;
|
||||
while (card && card !== document.body && card !== document.documentElement) {
|
||||
if (card.tagName === 'ARTICLE' || card.tagName === 'SECTION') break;
|
||||
const r = card.getBoundingClientRect();
|
||||
if (r.height > window.innerHeight * 0.5) break;
|
||||
card = card.parentElement;
|
||||
}
|
||||
if (!card) continue;
|
||||
const link = card.querySelector('a[href*="/reel/"], a[href*="/p/"], a[href*="/reels/"]');
|
||||
if (link && link.href && link.href.includes(code)) {
|
||||
if (!author) {
|
||||
const aEl = card.querySelector('header a[role="link"], a[role="link"][href^="/"]:not([href*="/audio/"]):not([href*="/reel"])');
|
||||
if (aEl) author = aEl.textContent.trim().replace(/^@/, '');
|
||||
}
|
||||
if (!caption) {
|
||||
const textCandidates = Array.from(card.querySelectorAll('h1, h2, span[dir="auto"], div[dir="auto"]'));
|
||||
for (const el of textCandidates) {
|
||||
if (el.closest('a[href*="/audio/"]') ||
|
||||
el.closest('header') ||
|
||||
el.closest('button') ||
|
||||
el.closest('[role="button"]') ||
|
||||
el.closest('a[role="link"]')) {
|
||||
continue;
|
||||
}
|
||||
const txt = (el.textContent || '').trim();
|
||||
const lower = txt.toLowerCase();
|
||||
if (txt.length >= 2 &&
|
||||
txt !== author &&
|
||||
!lower.includes('follow') &&
|
||||
!lower.startsWith('liked by') &&
|
||||
!/^\d+[\d,.]*[KkMm]?$/.test(txt) &&
|
||||
!lower.includes('original audio') &&
|
||||
!['ad', 'sponsored'].includes(lower)) {
|
||||
caption = txt.slice(0, 300);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!author && code.startsWith('reel_')) {
|
||||
author = code.replace(/^reel_/, '');
|
||||
}
|
||||
return { author: author || '', caption: caption || '' };
|
||||
}
|
||||
|
||||
// ---- Detect which reel is on screen and report it ----
|
||||
let cur = null;
|
||||
let timer = null;
|
||||
const unfilled = new Set();
|
||||
|
||||
function report(type, code) {
|
||||
const m = lookup(code);
|
||||
browser.runtime.sendMessage({
|
||||
t: 'fg-watched', type, code, author: m.author, caption: m.caption,
|
||||
}).catch(() => {});
|
||||
return m;
|
||||
}
|
||||
|
||||
function getActiveReelCode() {
|
||||
const m = REEL_RE.exec(location.pathname);
|
||||
if (m) return m[1];
|
||||
|
||||
const videos = Array.from(document.querySelectorAll('video'));
|
||||
const activeVideo = videos.find(v => !v.paused && v.getBoundingClientRect().height > 200) ||
|
||||
videos.find(v => {
|
||||
const r = v.getBoundingClientRect();
|
||||
return r.top >= -50 && r.top < window.innerHeight * 0.6;
|
||||
}) || videos[0];
|
||||
if (!activeVideo) return null;
|
||||
|
||||
let card = activeVideo.closest('article') || activeVideo.closest('section') || activeVideo.parentElement;
|
||||
while (card && card !== document.body && card !== document.documentElement) {
|
||||
if (card.tagName === 'ARTICLE' || card.tagName === 'SECTION') break;
|
||||
const r = card.getBoundingClientRect();
|
||||
if (r.height > window.innerHeight * 0.5) break;
|
||||
card = card.parentElement;
|
||||
}
|
||||
if (!card) return null;
|
||||
|
||||
// 1. Direct link to reel
|
||||
const link = card.querySelector('a[href*="/reel/"], a[href*="/p/"], a[href*="/reels/"]');
|
||||
if (link && link.href) {
|
||||
const lm = /\/(?:reel|reels|p)\/([A-Za-z0-9_-]{8,})/.exec(link.href);
|
||||
if (lm) return lm[1];
|
||||
}
|
||||
|
||||
// 2. Identify author
|
||||
const authorEl = card.querySelector('header a[role="link"], a[role="link"][href^="/"]:not([href*="/audio/"]):not([href*="/reel"]), a[href^="/"]:not([href*="/audio/"]):not([href*="/reel"])');
|
||||
if (authorEl) {
|
||||
let username = authorEl.textContent.trim().replace(/^@/, '').toLowerCase();
|
||||
if (!username && authorEl.getAttribute('href')) {
|
||||
const match = authorEl.getAttribute('href').match(/^\/([A-Za-z0-9._]+)\/?/);
|
||||
if (match) username = match[1].toLowerCase();
|
||||
}
|
||||
if (username) {
|
||||
return 'reel_' + username;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const code = getActiveReelCode();
|
||||
if (code === cur) return;
|
||||
cur = code;
|
||||
clearTimeout(timer);
|
||||
if (!code) return;
|
||||
timer = setTimeout(() => {
|
||||
if (cur !== code || document.visibilityState !== 'visible') return;
|
||||
const found = report('watched', code);
|
||||
if (!found.author || !found.caption) unfilled.add(code);
|
||||
}, DWELL_MS);
|
||||
}
|
||||
|
||||
function fill() {
|
||||
unfilled.forEach((code) => {
|
||||
const m = lookup(code);
|
||||
if (m.author || m.caption) {
|
||||
report('meta', code);
|
||||
if (m.author && m.caption) unfilled.delete(code);
|
||||
}
|
||||
});
|
||||
if (unfilled.size > 50) unfilled.delete(unfilled.values().next().value);
|
||||
}
|
||||
|
||||
setInterval(tick, 300);
|
||||
setInterval(fill, 1000);
|
||||
@@ -249,16 +249,53 @@
|
||||
`;
|
||||
}
|
||||
|
||||
/* Instagram Reels Layout & Overlay Lift (Always active, regardless of ad blocker) */
|
||||
css += `
|
||||
:root {
|
||||
--fg-reel-bottom-offset: 16px;
|
||||
--fg-reel-scrubber-offset: 2px;
|
||||
}
|
||||
|
||||
[data-fg-reel-scroller],
|
||||
div[style*="position: fixed"][style*="overflow-y: scroll"],
|
||||
div[style*="position: fixed"][style*="overflow-y: auto"],
|
||||
div[style*="position: fixed"][style*="overflow: scroll"] {
|
||||
bottom: 0px !important;
|
||||
height: 100% !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
[data-fg-reel-overlay] {
|
||||
bottom: var(--fg-reel-bottom-offset, 88px) !important;
|
||||
margin-bottom: 0px !important;
|
||||
z-index: 25 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
[data-fg-reel-actions] {
|
||||
bottom: var(--fg-reel-bottom-offset, 88px) !important;
|
||||
z-index: 25 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
[data-fg-reel-scrubber] {
|
||||
bottom: var(--fg-reel-scrubber-offset, 76px) !important;
|
||||
z-index: 26 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
if (config.noAds !== false) {
|
||||
css += `
|
||||
article:has(a[href*="/ad_"]),
|
||||
article:has(a[href*="/ads/"]),
|
||||
article:has(a[href*="/about/ads"]),
|
||||
article:has(a[href*="ads.instagram.com"]),
|
||||
article:has([aria-label="Ad" i]),
|
||||
article:has([aria-label*="Sponsored" i]),
|
||||
article[data-fg-ad-filtered="true"] {
|
||||
height: 1px !important;
|
||||
min-height: 1px !important;
|
||||
max-height: 1px !important;
|
||||
display: none !important;
|
||||
height: 0px !important;
|
||||
min-height: 0px !important;
|
||||
max-height: 0px !important;
|
||||
overflow: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
@@ -266,9 +303,14 @@
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
div[data-fg-reel-ad="1"] {
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
div[data-ad-preview],
|
||||
div[data-testid="post_ad"],
|
||||
div[data-testid="suggested-users"] {
|
||||
div[data-testid="suggested-users"],
|
||||
div[aria-label="Ad" i]:not(main):not([role="feed"]) {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
@@ -291,23 +333,36 @@
|
||||
try {
|
||||
// Filter Sponsored Ads
|
||||
if (config.noAds !== false) {
|
||||
const SPONSORED_KW = ['sponsored', 'gesponsert', 'sponsorisé', 'publicidad', 'patrocinado', 'sponsorizzato', 'paid partnership'];
|
||||
const AD_CTA = ['learn more', 'shop now', 'install now', 'sign up', 'apply now', 'book now', 'download', 'order now', 'get offer', 'subscribe', 'contact us'];
|
||||
const SPONSORED_KW = ['sponsored', 'gesponsert', 'sponsorisé', 'sponsorise', 'publicidad', 'patrocinado', 'sponsorizzato', 'sponsrad', 'paid partnership', 'werbung', 'anuncio', 'werbeanzeige'];
|
||||
const AD_CTA = ['shop now', 'install now', 'sign up', 'apply now', 'book now', 'order now', 'get offer'];
|
||||
// Strictly target individual article elements to avoid hiding feed wrappers
|
||||
document.querySelectorAll('article').forEach((article) => {
|
||||
if (article.dataset.fgAdFiltered) return;
|
||||
let isAd = false;
|
||||
const spans = article.querySelectorAll('header span, header a, div[role="button"] span, a[role="button"]');
|
||||
for (let i = 0; i < spans.length; i++) {
|
||||
const t = (spans[i].textContent || '').trim().toLowerCase();
|
||||
if (SPONSORED_KW.some(kw => t === kw || t.startsWith(kw)) || AD_CTA.some(cta => t === cta)) {
|
||||
const headerElements = article.querySelectorAll('header span, header [aria-label*="Sponsored" i], header [aria-label="Ad" i]');
|
||||
for (let i = 0; i < headerElements.length; i++) {
|
||||
if (headerElements[i].children.length > 2) continue;
|
||||
const t = (headerElements[i].textContent || '').trim().toLowerCase();
|
||||
if (!t || t.length > 30) continue;
|
||||
const clean = t.replace(/[^a-z]/g, '');
|
||||
if (clean === 'ad' || SPONSORED_KW.some(kw => t === kw || clean === kw)) {
|
||||
isAd = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isAd && article.querySelector('a[href*="/ad_"], a[href*="/ads/"], a[href*="/about/ads"], a[href*="ads.instagram.com"]')) {
|
||||
if (!isAd && article.querySelector('a[href*="/ad_"], a[href*="/ads/"], a[href*="ads.instagram.com"]')) {
|
||||
isAd = true;
|
||||
}
|
||||
if (!isAd) {
|
||||
const ctaButtons = article.querySelectorAll('div[role="button"]:not([data-fg-x-act]):not([data-fg-download-btn]):not(.fg-x-act-btn), a[role="button"]:not([data-fg-x-act]):not([data-fg-download-btn]):not(.fg-x-act-btn), button');
|
||||
for (let b = 0; b < ctaButtons.length; b++) {
|
||||
const bText = (ctaButtons[b].textContent || '').trim().toLowerCase();
|
||||
if (AD_CTA.some(cta => bText === cta)) {
|
||||
isAd = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isAd) {
|
||||
article.style.setProperty('height', '1px', 'important');
|
||||
article.style.setProperty('min-height', '1px', 'important');
|
||||
@@ -322,37 +377,46 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Filter Reels video ads on reels and feed
|
||||
// Filter Reels video ads on reels surface without ever breaking layout
|
||||
document.querySelectorAll('video').forEach((video) => {
|
||||
let container = video.closest('article') || video.closest('section') || video.closest('[role="region"]') || video.parentElement;
|
||||
while (container && container !== document.body && container !== document.documentElement) {
|
||||
if (container.tagName === 'ARTICLE' || container.tagName === 'SECTION' || container.parentElement?.tagName === 'MAIN') break;
|
||||
const r = container.getBoundingClientRect();
|
||||
if (r.height > window.innerHeight * 0.45) break;
|
||||
container = container.parentElement;
|
||||
const card = video.closest('[data-fg-reel-card="1"]') || video.closest('article');
|
||||
if (!card || card.dataset.fgAdFiltered || card.dataset.fgReelAd) return;
|
||||
|
||||
let isSponsored = !!card.querySelector('a[href*="/ad_"], a[href*="/ads/"], a[href*="ads.instagram.com"], a[href*="/about/ads"]') ||
|
||||
!!card.querySelector('[aria-label*="Sponsored" i], [aria-label="Ad" i], [data-ad-preview]');
|
||||
|
||||
if (!isSponsored) {
|
||||
const badges = card.querySelectorAll('span, div[role="button"], button');
|
||||
for (let s = 0; s < badges.length; s++) {
|
||||
const el = badges[s];
|
||||
if (el.children.length > 2) continue;
|
||||
const raw = (el.textContent || '').trim();
|
||||
if (!raw || raw.length > 15) continue;
|
||||
if (/^[\u{1F300}-\u{1F9FF}\s]*ad$/iu.test(raw) || /^sponsored$/i.test(raw) || /^paid\s+partnership$/i.test(raw)) {
|
||||
if (!el.closest('span[dir="auto"]')) {
|
||||
isSponsored = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!container || container.dataset.fgAdFiltered) return;
|
||||
|
||||
const t = (container.textContent || '').trim().toLowerCase();
|
||||
const isSponsored = SPONSORED_KW.some(kw => t.includes(kw)) ||
|
||||
!!container.querySelector('a[href*="/ad_"], a[href*="/ads/"], a[href*="/about/ads"], a[href*="ads.instagram.com"]') ||
|
||||
!!container.querySelector('[aria-label*="Sponsored" i], [aria-label*="sponsored" i], [data-ad-preview]');
|
||||
|
||||
const hasAdCTA = !!container.querySelector('div[role="button"], a[role="button"]') &&
|
||||
AD_CTA.some(cta => t.includes(cta));
|
||||
|
||||
if (isSponsored || hasAdCTA) {
|
||||
if (isSponsored) {
|
||||
video.muted = true;
|
||||
try { video.pause(); } catch (_) {}
|
||||
container.style.setProperty('display', 'none', 'important');
|
||||
container.style.setProperty('height', '0px', 'important');
|
||||
container.style.setProperty('opacity', '0', 'important');
|
||||
container.style.setProperty('pointer-events', 'none', 'important');
|
||||
container.dataset.fgAdFiltered = 'true';
|
||||
card.setAttribute('data-fg-reel-ad', '1');
|
||||
card.dataset.fgAdFiltered = 'true';
|
||||
card.style.setProperty('visibility', 'hidden', 'important');
|
||||
card.style.setProperty('pointer-events', 'none', 'important');
|
||||
|
||||
const r = container.getBoundingClientRect();
|
||||
const r = card.getBoundingClientRect();
|
||||
if (r.top >= -100 && r.top <= window.innerHeight * 0.8) {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40, which: 40, bubbles: true }));
|
||||
const scroller = card.closest('[data-fg-reel-scroller]') || document.querySelector('[data-fg-reel-scroller="1"]');
|
||||
if (scroller && scroller !== window) {
|
||||
try { scroller.scrollBy({ top: scroller.clientHeight || window.innerHeight, behavior: 'instant' }); } catch (_) {}
|
||||
} else {
|
||||
try { window.scrollBy({ top: window.innerHeight, behavior: 'instant' }); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -363,7 +427,7 @@
|
||||
// Strictly target individual article elements or specific suggested-users widgets
|
||||
document.querySelectorAll('article').forEach((article) => {
|
||||
if (article.dataset.fgSuggestedHidden) return;
|
||||
const headers = article.querySelectorAll('header span, header h2, header h3, h2, h3, h4, span');
|
||||
const headers = article.querySelectorAll('header span, header h2, header h3, h2, h3, h4');
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const text = (headers[i].textContent || '').trim().toLowerCase();
|
||||
if (text === 'suggested for you' || text === 'suggested posts' || text === 'suggested post' || text === 'suggestions for you') {
|
||||
@@ -435,6 +499,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Always tag and lift Reels bottom metadata & action buttons above the floating nav bar
|
||||
tagReelsOverlay();
|
||||
|
||||
// Minimal Mode in-page SPA redirect guard
|
||||
if (config.minimalMode) {
|
||||
const curPath = (window.location.pathname || '').toLowerCase();
|
||||
@@ -445,6 +512,110 @@
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function tagReelsOverlay() {
|
||||
const path = (window.location.pathname || '').toLowerCase();
|
||||
if (!path.startsWith('/reels') && !path.startsWith('/reel')) return;
|
||||
const vh = window.innerHeight;
|
||||
if (!vh) return;
|
||||
|
||||
const firstVideo = document.querySelector('video');
|
||||
if (firstVideo) {
|
||||
let el = firstVideo.parentElement;
|
||||
while (el && el !== document.body && el !== document.documentElement) {
|
||||
const cs = window.getComputedStyle(el);
|
||||
if (cs.position === 'fixed' || cs.overflowY === 'scroll' || cs.overflowY === 'auto') {
|
||||
if (!el.hasAttribute('data-fg-reel-scroller')) {
|
||||
el.setAttribute('data-fg-reel-scroller', '1');
|
||||
}
|
||||
el.style.setProperty('bottom', '0px', 'important');
|
||||
el.style.setProperty('height', '100%', 'important');
|
||||
el.style.setProperty('max-height', 'none', 'important');
|
||||
break;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('video').forEach(v => {
|
||||
let card = v.closest('article') || v.closest('section') || v.closest('[role="region"]') || v.parentElement;
|
||||
while (card && card !== document.body && card !== document.documentElement) {
|
||||
if (card.tagName === 'ARTICLE' || card.tagName === 'SECTION') break;
|
||||
if (card.getBoundingClientRect().height > vh * 0.5) break;
|
||||
card = card.parentElement;
|
||||
}
|
||||
if (!card || card.tagName === 'MAIN' || card.id === 'react-root' || card.querySelectorAll('video').length > 1) return;
|
||||
|
||||
let overlay = null;
|
||||
const audioLink = card.querySelector('a[href*="/audio/"]');
|
||||
if (audioLink) {
|
||||
let p = audioLink.parentElement;
|
||||
while (p && p !== card && p !== document.body) {
|
||||
const hasAuthorOrCaption = p.querySelector('a[role="link"]:not([href*="/audio/"])') || p.querySelector('span[dir="auto"]');
|
||||
if (hasAuthorOrCaption) {
|
||||
overlay = p;
|
||||
let candidate = p;
|
||||
while (candidate && candidate.parentElement && candidate.parentElement !== card) {
|
||||
const cs = window.getComputedStyle(candidate);
|
||||
if (cs.position === 'absolute' || cs.position === 'fixed') {
|
||||
overlay = candidate;
|
||||
break;
|
||||
}
|
||||
candidate = candidate.parentElement;
|
||||
}
|
||||
break;
|
||||
}
|
||||
p = p.parentElement;
|
||||
}
|
||||
if (!overlay) overlay = audioLink.closest('div[style*="bottom"]') || audioLink.parentElement;
|
||||
}
|
||||
|
||||
if (!overlay) {
|
||||
const captions = card.querySelectorAll('span[dir="auto"], div[dir="auto"]');
|
||||
for (let i = 0; i < captions.length; i++) {
|
||||
const c = captions[i];
|
||||
if (c.textContent && c.textContent.trim().length > 0) {
|
||||
let p = c.closest('div');
|
||||
while (p && p !== card && p !== document.body) {
|
||||
const cs = window.getComputedStyle(p);
|
||||
if (cs.position === 'absolute' || cs.position === 'fixed' || p.parentElement === card) {
|
||||
overlay = p;
|
||||
break;
|
||||
}
|
||||
p = p.parentElement;
|
||||
}
|
||||
if (overlay) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (overlay && overlay !== card) {
|
||||
overlay.setAttribute('data-fg-reel-overlay', '1');
|
||||
overlay.style.setProperty('bottom', 'var(--fg-reel-bottom-offset, 16px)', 'important');
|
||||
overlay.style.removeProperty('padding-bottom');
|
||||
overlay.style.setProperty('margin-bottom', '0px', 'important');
|
||||
overlay.style.setProperty('z-index', '25', 'important');
|
||||
overlay.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
|
||||
const likeIcon = card.querySelector('svg[aria-label="Like"], svg[aria-label="Unlike"], svg[aria-label="Liked"]');
|
||||
if (likeIcon) {
|
||||
let actionRail = likeIcon.closest('div[role="button"]') || likeIcon.parentElement;
|
||||
while (actionRail && actionRail !== card && actionRail !== document.body) {
|
||||
const cs = window.getComputedStyle(actionRail);
|
||||
if (cs.position === 'absolute' || cs.position === 'fixed' || (actionRail.parentElement === card)) break;
|
||||
if (actionRail.parentElement && (actionRail.parentElement.querySelector('a[href*="/audio/"]') || actionRail.parentElement.querySelector('video'))) break;
|
||||
actionRail = actionRail.parentElement;
|
||||
}
|
||||
if (actionRail && actionRail !== card) {
|
||||
actionRail.setAttribute('data-fg-reel-actions', '1');
|
||||
actionRail.style.setProperty('bottom', 'var(--fg-reel-bottom-offset, 16px)', 'important');
|
||||
actionRail.style.setProperty('z-index', '25', 'important');
|
||||
actionRail.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Feed Path Monitoring & Unblur Interaction Engine ---
|
||||
function updateFeedAttr() {
|
||||
const path = window.location.pathname || '';
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* FocusGram Unified Network Ad & Content Filter via Main-World Fetch Interception
|
||||
* Injected at document_start in the page execution context.
|
||||
*
|
||||
* Intercepts Instagram GraphQL and REST API responses (Feed, Reels, Stories)
|
||||
* and purges ad/sponsored objects before React receives them.
|
||||
* This guarantees:
|
||||
* 1. Ads are 100% blocked before rendering.
|
||||
* 2. React never builds DOM elements for ads.
|
||||
* 3. Scroll-snap and virtual scroller remain continuous and never collapse or go blank.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
if (window.__fgFetchInterceptorInstalled) return;
|
||||
window.__fgFetchInterceptorInstalled = true;
|
||||
|
||||
window.__fgFilterConfig = {
|
||||
blockAds: true,
|
||||
blockSponsored: true,
|
||||
blockSuggested: false,
|
||||
blockVideos: false,
|
||||
blockAutoplay: false,
|
||||
blockGraphQLQueryWhenFeedPosts: false
|
||||
};
|
||||
|
||||
const AD_KEYWORDS = [
|
||||
'sponsored', 'gesponsert', 'sponsorisé', 'sponsorise', 'publicidad',
|
||||
'patrocinado', 'sponsorizzato', 'sponsrad', 'paid partnership', 'werbung',
|
||||
'anuncio', 'werbeanzeige', 'publicité', 'ad_id', 'ad_tracking', 'sponsor_tags'
|
||||
];
|
||||
|
||||
function textHasAdSignal(value) {
|
||||
if (!value) return false;
|
||||
const s = String(value).toLowerCase();
|
||||
return AD_KEYWORDS.some(kw => s.includes(kw));
|
||||
}
|
||||
|
||||
function isAdNode(node) {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
const typename = String(node.__typename || '');
|
||||
const adText = JSON.stringify({
|
||||
organic_tracking_token: node.organic_tracking_token,
|
||||
sponsor_tags: node.sponsor_tags,
|
||||
social_context: node.social_context,
|
||||
title: node.title,
|
||||
header: node.header,
|
||||
label: node.label,
|
||||
overlay_text: node.overlay_text
|
||||
});
|
||||
|
||||
return !!(
|
||||
node.is_ad ||
|
||||
node.is_paid_partnership ||
|
||||
(node.sponsor_tags && node.sponsor_tags.length > 0) ||
|
||||
node.ad_tracking_token ||
|
||||
node.ad_action_link ||
|
||||
node.ad_id ||
|
||||
node.ad_impression_token ||
|
||||
node.ad_metadata ||
|
||||
node.commerciality_status === 'commercial' ||
|
||||
(node.product_type && node.product_type === 'ad') ||
|
||||
(node.ad_header_style && node.ad_header_style !== 'none') ||
|
||||
typename === 'GraphAdStory' ||
|
||||
typename.includes('Ad') ||
|
||||
textHasAdSignal(adText)
|
||||
);
|
||||
}
|
||||
|
||||
function isSponsoredNode(node) {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
return !!(
|
||||
node.is_paid_partnership ||
|
||||
(node.sponsor_tags && node.sponsor_tags.length > 0) ||
|
||||
(node.ad_action_link && node.ad_action_link.href) ||
|
||||
(node.ad_header_style && node.ad_header_style !== 'none') ||
|
||||
textHasAdSignal(JSON.stringify(node.social_context || node.header || node.label || ''))
|
||||
);
|
||||
}
|
||||
|
||||
function isSuggestedNode(node) {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
const typename = String(node.__typename || '');
|
||||
const reason = JSON.stringify({
|
||||
reason: node.suggested_reason,
|
||||
social_context: node.social_context,
|
||||
title: node.title,
|
||||
header: node.header,
|
||||
label: node.label
|
||||
}).toLowerCase();
|
||||
|
||||
return !!(
|
||||
node.is_suggested ||
|
||||
node.is_suggested_for_you ||
|
||||
node.is_recommendation ||
|
||||
node.suggested_users ||
|
||||
node.suggested_media ||
|
||||
node.suggested_content ||
|
||||
node.recommendation_source ||
|
||||
typename.includes('Suggested') ||
|
||||
typename.includes('Recommendation') ||
|
||||
reason.includes('suggested') ||
|
||||
reason.includes('recommend')
|
||||
);
|
||||
}
|
||||
|
||||
function shouldFilterNode(node) {
|
||||
const config = window.__fgFilterConfig;
|
||||
if (!config || !node || typeof node !== 'object') return false;
|
||||
|
||||
// Filter ads
|
||||
if (config.blockAds !== false && isAdNode(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Filter sponsored posts
|
||||
if (config.blockSponsored !== false && isSponsoredNode(node) && !isAdNode(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Filter suggested posts if enabled
|
||||
if (config.blockSuggested && isSuggestedNode(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function unwrapNode(item) {
|
||||
if (!item || typeof item !== 'object') return item;
|
||||
return item.media || item.node || item.post || item;
|
||||
}
|
||||
|
||||
function filterList(list) {
|
||||
if (!Array.isArray(list)) return list;
|
||||
return list.filter(item => {
|
||||
if (!item) return false;
|
||||
const target = unwrapNode(item);
|
||||
return !shouldFilterNode(target);
|
||||
});
|
||||
}
|
||||
|
||||
function walkAndFilter(obj, visited = new Set()) {
|
||||
if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
|
||||
visited.add(obj);
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (let i = 0; i < obj.length; i++) {
|
||||
walkAndFilter(obj[i], visited);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (obj.edges && Array.isArray(obj.edges)) {
|
||||
obj.edges = filterList(obj.edges);
|
||||
}
|
||||
if (obj.items && Array.isArray(obj.items)) {
|
||||
obj.items = filterList(obj.items);
|
||||
}
|
||||
if (obj.feed_items && Array.isArray(obj.feed_items)) {
|
||||
obj.feed_items = filterList(obj.feed_items);
|
||||
}
|
||||
if (obj.tray && Array.isArray(obj.tray)) {
|
||||
obj.tray = filterList(obj.tray);
|
||||
}
|
||||
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key) && key !== '__typename') {
|
||||
const val = obj[key];
|
||||
if (val && typeof val === 'object') {
|
||||
walkAndFilter(val, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override fetch in the main world
|
||||
const _fetch = window.fetch.bind(window);
|
||||
|
||||
window.fetch = async function (input, init) {
|
||||
const url = typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input?.url ?? '';
|
||||
|
||||
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/')) {
|
||||
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')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const data = await cloned.json();
|
||||
walkAndFilter(data);
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers
|
||||
});
|
||||
} catch (_) {
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'fetch', {
|
||||
value: window.fetch,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
window.__fgSetFilterConfig = function (cfg) {
|
||||
if (typeof cfg === 'object' && cfg) {
|
||||
Object.assign(window.__fgFilterConfig, cfg);
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -45,8 +45,8 @@
|
||||
user-select: text !important;
|
||||
}
|
||||
|
||||
/* 4. Enforce hardware-accelerated momentum touch scrolling */
|
||||
* {
|
||||
/* 4. Enforce hardware-accelerated momentum touch scrolling on scrollable containers */
|
||||
html, body, [data-fg-reel-scroller], div[style*="overflow"], main {
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,56 @@
|
||||
package com.ujwal.focusgram
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.ujwal.focusgram.core.ui.theme.FocusGramTheme
|
||||
|
||||
import com.ujwal.focusgram.ui.FocusGramApp
|
||||
|
||||
/**
|
||||
* MainActivity
|
||||
* Single-Activity host for Jetpack Compose UI and GeckoView container.
|
||||
* Requests native 120Hz/high display refresh rate for fluid scrolling.
|
||||
*/
|
||||
class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
applyHighRefreshRate()
|
||||
setContent {
|
||||
FocusGramTheme {
|
||||
FocusGramApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyHighRefreshRate() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val display = display
|
||||
val modes = display?.supportedModes ?: emptyArray()
|
||||
val maxMode = modes.maxByOrNull { it.refreshRate }
|
||||
if (maxMode != null) {
|
||||
val params = window.attributes
|
||||
params.preferredDisplayModeId = maxMode.modeId
|
||||
params.preferredRefreshRate = maxMode.refreshRate
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
params.setFrameRateBoostOnTouchEnabled(true)
|
||||
}
|
||||
window.attributes = params
|
||||
}
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@Suppress("DEPRECATION")
|
||||
val modes = window.windowManager.defaultDisplay.supportedModes
|
||||
val maxMode = modes.maxByOrNull { it.refreshRate }
|
||||
if (maxMode != null) {
|
||||
val params = window.attributes
|
||||
params.preferredDisplayModeId = maxMode.modeId
|
||||
params.preferredRefreshRate = maxMode.refreshRate
|
||||
window.attributes = params
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,30 @@ interface ScreenTimeDao {
|
||||
}
|
||||
}
|
||||
|
||||
@Query("UPDATE screen_time_records SET reelsWatched = reelsWatched + :delta, lastUpdated = :timestamp WHERE date = :date")
|
||||
suspend fun incrementReelsWatched(date: String, delta: Int = 1, timestamp: Long = System.currentTimeMillis())
|
||||
|
||||
@Query("UPDATE screen_time_records SET reelsWatched = :count, lastUpdated = :timestamp WHERE date = :date")
|
||||
suspend fun setReelsWatched(date: String, count: Int, timestamp: Long = System.currentTimeMillis())
|
||||
|
||||
suspend fun recordReelWatched(date: String) {
|
||||
val existing = getForDateSync(date)
|
||||
if (existing == null) {
|
||||
insertOrUpdate(ScreenTimeRecord(date = date, seconds = 0, reelsSeconds = 0, reelsWatched = 1))
|
||||
} else {
|
||||
incrementReelsWatched(date = date, delta = 1)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun syncReelsWatched(date: String, count: Int) {
|
||||
val existing = getForDateSync(date)
|
||||
if (existing == null) {
|
||||
insertOrUpdate(ScreenTimeRecord(date = date, seconds = 0, reelsSeconds = 0, reelsWatched = count))
|
||||
} else {
|
||||
setReelsWatched(date = date, count = count)
|
||||
}
|
||||
}
|
||||
|
||||
@Query("DELETE FROM screen_time_records WHERE date < :cutoffDate")
|
||||
suspend fun pruneOlderThan(cutoffDate: String)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ data class ScreenTimeRecord(
|
||||
val date: String, // Format: YYYY-MM-DD
|
||||
val seconds: Int,
|
||||
val reelsSeconds: Int = 0,
|
||||
val reelsWatched: Int = 0,
|
||||
val lastUpdated: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import androidx.room.RoomDatabase
|
||||
SessionRecord::class,
|
||||
SnapshotItem::class
|
||||
],
|
||||
version = 2,
|
||||
version = 3,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class FocusGramDatabase : RoomDatabase() {
|
||||
|
||||
@@ -74,7 +74,7 @@ class SettingsRepository(private val context: Context) {
|
||||
isFirstRun = prefs[KEY_IS_FIRST_RUN] ?: false,
|
||||
|
||||
startupPage = prefs[KEY_STARTUP_PAGE] ?: "home",
|
||||
videoDownloadEnabled = prefs[KEY_VIDEO_DOWNLOAD_ENABLED] ?: false,
|
||||
videoDownloadEnabled = prefs[KEY_VIDEO_DOWNLOAD_ENABLED] ?: true,
|
||||
sanitizeLinks = prefs[KEY_SANITIZE_LINKS] ?: true,
|
||||
notifySessionEnd = prefs[KEY_NOTIFY_SESSION_END] ?: false,
|
||||
notifyPersistent = prefs[KEY_NOTIFY_PERSISTENT] ?: false,
|
||||
|
||||
@@ -32,6 +32,8 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
var onModalOrChatActive: ((Boolean) -> Unit)? = null
|
||||
var onThemeChanged: ((String) -> Unit)? = null
|
||||
var onReelViewed: ((url: String, title: String, author: String, thumbnail: String) -> Unit)? = null
|
||||
var onReelMeta: ((url: String, title: String, author: String) -> Unit)? = null
|
||||
var onDownloadRequested: ((url: String, filename: String) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Installs and activates built-in content blocking and FocusGram controller from APK assets.
|
||||
@@ -45,6 +47,11 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
.await()
|
||||
|
||||
if (extension != null) {
|
||||
try {
|
||||
runtime.webExtensionController
|
||||
.enable(extension, org.mozilla.geckoview.WebExtensionController.EnableSource.USER)
|
||||
.await()
|
||||
} catch (_: Exception) {}
|
||||
contentBlockerExtension = extension
|
||||
_isContentBlockerActive.value = true
|
||||
setupMessageDelegate(extension)
|
||||
@@ -136,19 +143,48 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
Log.d(TAG, "Received themeChanged from Instagram web: $theme")
|
||||
mainHandler.post { onThemeChanged?.invoke(theme) }
|
||||
}
|
||||
"reelViewed" -> {
|
||||
"reelViewed", "reelWatched" -> {
|
||||
val reelObj = json.optJSONObject("reel")
|
||||
val url = reelObj?.optString("url")?.takeIf { it.isNotBlank() } ?: json.optString("url", "")
|
||||
val title = reelObj?.optString("title")?.takeIf { it.isNotBlank() } ?: json.optString("title", "Instagram Reel")
|
||||
val author = reelObj?.optString("author")?.takeIf { it.isNotBlank() } ?: json.optString("author", "Instagram Creator")
|
||||
var code = json.optString("code", "").trim()
|
||||
if (code.isBlank() && reelObj != null) {
|
||||
code = reelObj.optString("code", "").trim()
|
||||
}
|
||||
var url = reelObj?.optString("url")?.takeIf { it.isNotBlank() } ?: json.optString("url", "")
|
||||
val rawCaption = reelObj?.optString("caption")?.takeIf { it.isNotBlank() } ?: json.optString("caption", "")
|
||||
val rawTitle = reelObj?.optString("title")?.takeIf { it.isNotBlank() } ?: json.optString("title", "")
|
||||
val title = when {
|
||||
rawCaption.isNotBlank() -> rawCaption
|
||||
rawTitle.isNotBlank() && !rawTitle.equals("Instagram Reel", ignoreCase = true) -> rawTitle
|
||||
else -> ""
|
||||
}
|
||||
val rawAuthor = reelObj?.optString("author")?.takeIf { it.isNotBlank() } ?: json.optString("author", "")
|
||||
val author = if (rawAuthor.equals("Instagram Creator", ignoreCase = true)) "" else rawAuthor
|
||||
val thumbnail = reelObj?.optString("thumbnailUrl")?.takeIf { it.isNotBlank() } ?: json.optString("thumbnailUrl", "")
|
||||
|
||||
if (url.isBlank() && code.isNotBlank()) {
|
||||
url = if (code.startsWith("reel_")) "https://www.instagram.com/${code.removePrefix("reel_")}/" else "https://www.instagram.com/reel/$code/"
|
||||
}
|
||||
|
||||
if (url.isNotBlank()) {
|
||||
Log.d(TAG, "Received reelViewed: url=$url, title=$title, author=$author")
|
||||
val isMeta = json.optString("type") == "meta"
|
||||
Log.d(TAG, "Received reelWatched: code=$code, url=$url, author=$author, title=$title, isMeta=$isMeta")
|
||||
mainHandler.post {
|
||||
onReelViewed?.invoke(url, title, author, thumbnail)
|
||||
if (isMeta) {
|
||||
onReelMeta?.invoke(url, title, author)
|
||||
} else {
|
||||
onReelViewed?.invoke(url, title, author, thumbnail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"downloadMedia" -> {
|
||||
val mediaUrl = json.optString("url")
|
||||
val filename = json.optString("filename")
|
||||
Log.d(TAG, "Received downloadMedia from web: url=$mediaUrl, filename=$filename")
|
||||
mainHandler.post {
|
||||
onDownloadRequested?.invoke(mediaUrl, filename)
|
||||
}
|
||||
}
|
||||
"getSettings" -> {
|
||||
activePort?.let { sendSettingsToPort(it, cachedSettings) }
|
||||
}
|
||||
@@ -182,6 +218,7 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
put("noAutoplay", settings.noAutoplay)
|
||||
put("grayscale", settings.grayscaleEnabled)
|
||||
put("minimalMode", settings.minimalModeEnabled)
|
||||
put("videoDownloadEnabled", settings.videoDownloadEnabled)
|
||||
put("ghostModeEnabled", settings.ghostModeEnabled)
|
||||
put("ghostHideStorySeen", settings.ghostHideStorySeen)
|
||||
put("ghostHideDirectSeen", settings.ghostHideDirectSeen)
|
||||
@@ -289,28 +326,29 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
}
|
||||
|
||||
suspend fun setContentBlockerEnabled(enabled: Boolean) {
|
||||
val extension = contentBlockerExtension ?: return
|
||||
try {
|
||||
if (enabled) {
|
||||
runtime.webExtensionController
|
||||
.enable(extension, org.mozilla.geckoview.WebExtensionController.EnableSource.USER)
|
||||
.await()
|
||||
_isContentBlockerActive.value = true
|
||||
Log.i(TAG, "Content ad-blocker enabled")
|
||||
} else {
|
||||
runtime.webExtensionController
|
||||
.disable(extension, org.mozilla.geckoview.WebExtensionController.EnableSource.USER)
|
||||
.await()
|
||||
_isContentBlockerActive.value = false
|
||||
Log.i(TAG, "Content ad-blocker disabled")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to toggle content ad-blocker state: ${e.message}", e)
|
||||
}
|
||||
_isContentBlockerActive.value = enabled
|
||||
cachedSettings = cachedSettings.copy(noAds = enabled)
|
||||
broadcastSettings(cachedSettings)
|
||||
Log.i(TAG, "Content ad-blocker dynamic state updated: noAds=$enabled")
|
||||
}
|
||||
|
||||
suspend fun setUBlockEnabled(enabled: Boolean) = setContentBlockerEnabled(enabled)
|
||||
|
||||
/**
|
||||
* Sends request to in-page content script to extract and report active Reel video URL for downloading.
|
||||
*/
|
||||
fun triggerReelDownload() {
|
||||
try {
|
||||
val msg = JSONObject().apply {
|
||||
put("action", "requestReelDownload")
|
||||
}
|
||||
activePort?.postMessage(msg)
|
||||
Log.d(TAG, "Sent requestReelDownload to WebExtension port")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send requestReelDownload to port: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ExtensionManager"
|
||||
const val CONTENT_BLOCKER_EXTENSION_ID = "uBlock0@raymondhill.net"
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.ujwal.focusgram.core.engine
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.mozilla.geckoview.GeckoResult
|
||||
import org.mozilla.geckoview.GeckoSession
|
||||
|
||||
/**
|
||||
* FocusGramPermissionDelegate
|
||||
* Handles web content and Android runtime permission requests (Camera, Microphone, Media)
|
||||
* from GeckoView sessions while maintaining user privacy and strict origin isolation.
|
||||
*/
|
||||
class FocusGramPermissionDelegate(
|
||||
private val context: Context,
|
||||
private val onAndroidPermissionRequest: ((Array<String>, (Boolean) -> Unit) -> Unit)? = null
|
||||
) : GeckoSession.PermissionDelegate {
|
||||
|
||||
override fun onAndroidPermissionsRequest(
|
||||
session: GeckoSession,
|
||||
permissions: Array<out String>?,
|
||||
callback: GeckoSession.PermissionDelegate.Callback
|
||||
) {
|
||||
val permsList = permissions?.filterNotNull() ?: emptyList()
|
||||
Log.d(TAG, "onAndroidPermissionsRequest: ${permsList.joinToString()}")
|
||||
val ungranted = permsList.filter {
|
||||
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
if (ungranted.isEmpty()) {
|
||||
callback.grant()
|
||||
return
|
||||
}
|
||||
|
||||
if (onAndroidPermissionRequest != null) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
onAndroidPermissionRequest.invoke(ungranted.toTypedArray()) { allGranted ->
|
||||
if (allGranted) callback.grant() else callback.reject()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error requesting Android permissions: ${e.message}", e)
|
||||
callback.reject()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
callback.grant()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onContentPermissionRequest(
|
||||
session: GeckoSession,
|
||||
perm: GeckoSession.PermissionDelegate.ContentPermission
|
||||
): GeckoResult<Int> {
|
||||
Log.d(TAG, "onContentPermissionRequest: uri=${perm.uri}, perm=${perm.permission}")
|
||||
val uri = perm.uri?.lowercase() ?: ""
|
||||
return if (uri.contains("instagram.com") || uri.contains("cdninstagram.com")) {
|
||||
GeckoResult.fromValue(GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW)
|
||||
} else {
|
||||
GeckoResult.fromValue(GeckoSession.PermissionDelegate.ContentPermission.VALUE_DENY)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaPermissionRequest(
|
||||
session: GeckoSession,
|
||||
uri: String,
|
||||
video: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
|
||||
audio: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
|
||||
callback: GeckoSession.PermissionDelegate.MediaCallback
|
||||
) {
|
||||
Log.d(TAG, "onMediaPermissionRequest: uri=$uri, video=${video?.size}, audio=${audio?.size}")
|
||||
val videoSource = video?.firstOrNull()
|
||||
val audioSource = audio?.firstOrNull()
|
||||
if (videoSource != null || audioSource != null) {
|
||||
callback.grant(videoSource, audioSource)
|
||||
} else {
|
||||
callback.reject()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PermissionDelegate"
|
||||
}
|
||||
}
|
||||
+19
-8
@@ -2,6 +2,8 @@ package com.ujwal.focusgram.core.engine
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import org.mozilla.geckoview.AllowOrDeny
|
||||
import org.mozilla.geckoview.Autocomplete
|
||||
@@ -41,17 +43,26 @@ class FocusGramPromptDelegate(
|
||||
|
||||
val result = GeckoResult<GeckoSession.PromptDelegate.PromptResponse>()
|
||||
|
||||
handler.onPickFiles(mimeTypes, isMultiple) { uris ->
|
||||
// CRITICAL: GeckoView calls onFilePrompt on a background thread.
|
||||
// ActivityResultLauncher.launch() MUST be called on the main Looper thread.
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
if (uris.isEmpty()) {
|
||||
result.complete(prompt.dismiss())
|
||||
} else if (isMultiple) {
|
||||
result.complete(prompt.confirm(context, uris.toTypedArray()))
|
||||
} else {
|
||||
result.complete(prompt.confirm(context, uris.first()))
|
||||
handler.onPickFiles(mimeTypes, isMultiple) { uris ->
|
||||
try {
|
||||
if (uris.isEmpty()) {
|
||||
result.complete(prompt.dismiss())
|
||||
} else if (isMultiple) {
|
||||
result.complete(prompt.confirm(context, uris.toTypedArray()))
|
||||
} else {
|
||||
result.complete(prompt.confirm(context, uris.first()))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error confirming file prompt: ${e.message}", e)
|
||||
result.complete(prompt.dismiss())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error confirming file prompt: ${e.message}", e)
|
||||
Log.e(TAG, "Error initiating file picker on UI thread: ${e.message}", e)
|
||||
result.complete(prompt.dismiss())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class GeckoRuntimeManager private constructor(context: Context) {
|
||||
|
||||
val runtime: GeckoRuntime
|
||||
val extensionManager: ExtensionManager
|
||||
val reelHistory: com.ujwal.focusgram.feature.history.ReelHistory
|
||||
|
||||
init {
|
||||
val appContext = context.applicationContext
|
||||
@@ -45,6 +46,7 @@ class GeckoRuntimeManager private constructor(context: Context) {
|
||||
Log.i(TAG, "Initializing Mozilla GeckoRuntime with strict privacy & WebExtension runtime...")
|
||||
runtime = GeckoRuntime.create(appContext, runtimeSettings)
|
||||
extensionManager = ExtensionManager(runtime)
|
||||
reelHistory = com.ujwal.focusgram.feature.history.ReelHistory(java.io.File(appContext.filesDir, "reel_history.json"))
|
||||
|
||||
runtime.webExtensionController
|
||||
.ensureBuiltIn("resource://android/assets/reellock/", "reellock@focusgram.app")
|
||||
@@ -52,6 +54,16 @@ class GeckoRuntimeManager private constructor(context: Context) {
|
||||
{ Log.d(TAG, "✓ Reel lock WebExtension installed") },
|
||||
{ e -> Log.e(TAG, "Reel lock WebExtension installation failed", e) }
|
||||
)
|
||||
|
||||
runtime.webExtensionController
|
||||
.ensureBuiltIn("resource://android/assets/reelhistory/", "reelhistory@focusgram.app")
|
||||
.accept(
|
||||
{ ext ->
|
||||
ext?.setMessageDelegate(com.ujwal.focusgram.feature.history.ReelMessageDelegate(reelHistory), "reelhistory")
|
||||
Log.d(TAG, "✓ Reel history WebExtension installed and delegate registered")
|
||||
},
|
||||
{ e -> Log.e(TAG, "Reel history WebExtension installation failed", e) }
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -49,7 +49,8 @@ class UserScriptManager(private val context: Context) {
|
||||
hideShopTab: ${settings.hideShopTab},
|
||||
blockAutoplay: ${settings.noAutoplay},
|
||||
grayscale: ${settings.grayscaleEnabled},
|
||||
minimalMode: ${settings.minimalModeEnabled}
|
||||
minimalMode: ${settings.minimalModeEnabled},
|
||||
videoDownloadEnabled: ${settings.videoDownloadEnabled}
|
||||
};
|
||||
window.__FOCUSGRAM_GHOST_CONFIG__ = {
|
||||
enabled: ${settings.ghostModeEnabled},
|
||||
@@ -69,6 +70,13 @@ class UserScriptManager(private val context: Context) {
|
||||
if (window.__fgSetGrayscale) {
|
||||
window.__fgSetGrayscale(${settings.grayscaleEnabled});
|
||||
}
|
||||
if (window.__fgSetFilterConfig) {
|
||||
window.__fgSetFilterConfig({
|
||||
blockAds: ${settings.noAds},
|
||||
blockSponsored: ${settings.noAds},
|
||||
blockSuggested: ${settings.hideSuggestedPosts}
|
||||
});
|
||||
}
|
||||
document.documentElement.setAttribute('data-fg-block-dm-reels', '${settings.blockDmReelsSwiping}');
|
||||
window.postMessage({
|
||||
source: "GHOSTIFY_EXTENSION",
|
||||
@@ -108,6 +116,7 @@ class UserScriptManager(private val context: Context) {
|
||||
|
||||
fun injectAll(session: GeckoSession, settings: FocusSettings) {
|
||||
val bundle = StringBuilder()
|
||||
bundle.append(loadScript("fetch_interceptor.js")).append("\n")
|
||||
bundle.append(loadScript("content_hider.js")).append("\n")
|
||||
bundle.append(loadScript("autoplay_blocker.js")).append("\n")
|
||||
bundle.append(loadScript("grayscale.js")).append("\n")
|
||||
|
||||
@@ -58,7 +58,7 @@ data class FocusSettings(
|
||||
|
||||
// Extras & Notifications
|
||||
val startupPage: String = "home",
|
||||
val videoDownloadEnabled: Boolean = false,
|
||||
val videoDownloadEnabled: Boolean = true,
|
||||
val sanitizeLinks: Boolean = true,
|
||||
val notifySessionEnd: Boolean = false,
|
||||
val notifyPersistent: Boolean = false,
|
||||
|
||||
+45
-32
@@ -1,7 +1,9 @@
|
||||
package com.ujwal.focusgram.core.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.os.Build
|
||||
import android.os.VibrationAttributes
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
@@ -91,22 +93,7 @@ fun CupertinoWheelPicker(
|
||||
snapshotFlow { currentCenteredIndex }
|
||||
.collect { index ->
|
||||
if (index in items.indices) {
|
||||
try {
|
||||
val performed = view.performHapticFeedback(
|
||||
HapticFeedbackConstants.CLOCK_TICK,
|
||||
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING
|
||||
)
|
||||
if (!performed) {
|
||||
val vibrator = getVibrator(view.context)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator?.vibrate(10L)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
performWheelHaptic(view.context, view)
|
||||
onItemSelected(index)
|
||||
}
|
||||
}
|
||||
@@ -273,22 +260,7 @@ fun <T> CupertinoOptionWheelPicker(
|
||||
snapshotFlow { currentCenteredIndex }
|
||||
.collect { index ->
|
||||
if (index in options.indices) {
|
||||
try {
|
||||
val performed = view.performHapticFeedback(
|
||||
HapticFeedbackConstants.CLOCK_TICK,
|
||||
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING
|
||||
)
|
||||
if (!performed) {
|
||||
val vibrator = getVibrator(view.context)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator?.vibrate(10L)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
performWheelHaptic(view.context, view)
|
||||
onOptionSelected(index, options[index])
|
||||
}
|
||||
}
|
||||
@@ -408,3 +380,44 @@ private fun getVibrator(context: Context): Vibrator? {
|
||||
context.getSystemService(Vibrator::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform crisp haptic tick even when user has system "touch feedback" disabled.
|
||||
* Uses both View ignore flags, and falls back to physical Vibrator with USAGE_ALARM.
|
||||
*/
|
||||
private fun performWheelHaptic(context: Context, view: android.view.View) {
|
||||
try {
|
||||
// 1. Try View haptics with ignore flags
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING or HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
|
||||
}
|
||||
val performed = view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK, flags)
|
||||
if (performed) return
|
||||
|
||||
// 2. Hardware Vibrator fallback with USAGE_ALARM to bypass system touch vibration disablement
|
||||
val vibrator = getVibrator(context) ?: return
|
||||
if (!vibrator.hasVibrator()) return
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val attributes = VibrationAttributes.Builder()
|
||||
.setUsage(VibrationAttributes.USAGE_ALARM)
|
||||
.build()
|
||||
val effect = VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)
|
||||
vibrator.vibrate(effect, attributes)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||
.build()
|
||||
val effect = VibrationEffect.createOneShot(12L, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(effect, audioAttributes)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(10L)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
+20
-2
@@ -20,6 +20,7 @@ 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.filled.Info
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -210,6 +211,7 @@ fun IOSSettingsRow(
|
||||
subtitle: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
iconUrl: String? = null,
|
||||
iconRes: Int? = null,
|
||||
iconBgColor: Color = IOSBlue,
|
||||
checked: Boolean? = null,
|
||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
||||
@@ -232,10 +234,26 @@ fun IOSSettingsRow(
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (iconUrl != null && icon != null) {
|
||||
if (iconRes != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clip(RoundedCornerShape(7.dp))
|
||||
.background(iconBgColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = androidx.compose.ui.res.painterResource(id = iconRes),
|
||||
contentDescription = null,
|
||||
tint = Color.Unspecified,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
} else if (iconUrl != null) {
|
||||
DynamicNetworkIcon(
|
||||
imageUrl = iconUrl,
|
||||
fallbackIcon = icon,
|
||||
fallbackIcon = icon ?: Icons.Default.Info,
|
||||
iconBgColor = iconBgColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.ujwal.focusgram.core.utils
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/**
|
||||
* MediaDownloadHelper
|
||||
* Native client-side media downloader using Android System DownloadManager & MediaStore.
|
||||
* Compliant with on-device architecture: downloads directly to user's public Downloads directory.
|
||||
*/
|
||||
object MediaDownloadHelper {
|
||||
private const val TAG = "MediaDownloadHelper"
|
||||
|
||||
fun download(context: Context, url: String, filename: String = "") {
|
||||
if (url.isBlank()) {
|
||||
Toast.makeText(context, "No media URL found to download", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val isVideo = url.contains(".mp4") || url.contains("video") || url.contains("/v/") || url.startsWith("data:video")
|
||||
val extension = if (isVideo) "mp4" else "jpg"
|
||||
val targetFilename = if (filename.isNotBlank()) {
|
||||
if (filename.contains(".")) filename else "$filename.$extension"
|
||||
} else {
|
||||
"FocusGram_${System.currentTimeMillis()}.$extension"
|
||||
}
|
||||
|
||||
if (url.startsWith("data:")) {
|
||||
val commaIndex = url.indexOf(',')
|
||||
if (commaIndex != -1) {
|
||||
val base64Data = url.substring(commaIndex + 1)
|
||||
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
|
||||
saveBytesToDownloads(context, bytes, targetFilename, isVideo)
|
||||
Toast.makeText(context, "Saved to Downloads: $targetFilename", Toast.LENGTH_SHORT).show()
|
||||
Log.i(TAG, "Saved data URI to Downloads: $targetFilename")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val uri = Uri.parse(url)
|
||||
val request = DownloadManager.Request(uri).apply {
|
||||
setTitle(targetFilename)
|
||||
setDescription("FocusGram Media Download")
|
||||
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, targetFilename)
|
||||
setAllowedOverMetered(true)
|
||||
setAllowedOverRoaming(true)
|
||||
addRequestHeader("User-Agent", "Mozilla/5.0 (Linux; Android 14; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36")
|
||||
addRequestHeader("Referer", "https://www.instagram.com/")
|
||||
addRequestHeader("Accept", "*/*")
|
||||
}
|
||||
|
||||
val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
|
||||
if (manager != null) {
|
||||
manager.enqueue(request)
|
||||
Toast.makeText(context, "Download started: $targetFilename", Toast.LENGTH_SHORT).show()
|
||||
Log.i(TAG, "Download enqueued for $targetFilename from $url")
|
||||
} else {
|
||||
Toast.makeText(context, "Download service unavailable", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to download media: ${e.message}", e)
|
||||
Toast.makeText(context, "Download failed: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveBytesToDownloads(context: Context, bytes: ByteArray, filename: String, isVideo: Boolean) {
|
||||
val mimeType = if (isVideo) "video/mp4" else "image/jpeg"
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
}
|
||||
val collection = if (isVideo) MediaStore.Video.Media.EXTERNAL_CONTENT_URI else MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
val itemUri = context.contentResolver.insert(collection, values)
|
||||
if (itemUri != null) {
|
||||
context.contentResolver.openOutputStream(itemUri)?.use { out ->
|
||||
out.write(bytes)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
val file = File(downloadsDir, filename)
|
||||
FileOutputStream(file).use { out ->
|
||||
out.write(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.ujwal.focusgram.feature.history
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
data class ReelEntry(
|
||||
val code: String,
|
||||
val author: String, // may be empty if no source had it
|
||||
val caption: String, // empty is normal, many reels have no caption
|
||||
val lastSeen: Long, // epoch millis
|
||||
val views: Int,
|
||||
) {
|
||||
val url: String get() = "https://www.instagram.com/reel/${code.removePrefix("reel_")}/"
|
||||
}
|
||||
|
||||
class ReelHistory(private val file: File) {
|
||||
private val lock = Any()
|
||||
private val io = Executors.newSingleThreadExecutor()
|
||||
private val sessionCodes = HashSet<String>()
|
||||
private var entries: List<ReelEntry> = load() // newest first
|
||||
|
||||
var onNewReelWatched: ((String) -> Unit)? = null
|
||||
|
||||
private fun getTodayStartMillis(): Long {
|
||||
val cal = java.util.Calendar.getInstance().apply {
|
||||
set(java.util.Calendar.HOUR_OF_DAY, 0)
|
||||
set(java.util.Calendar.MINUTE, 0)
|
||||
set(java.util.Calendar.SECOND, 0)
|
||||
set(java.util.Calendar.MILLISECOND, 0)
|
||||
}
|
||||
return cal.timeInMillis
|
||||
}
|
||||
|
||||
private fun calculateTodayCount(list: List<ReelEntry>): Int {
|
||||
val start = getTodayStartMillis()
|
||||
return list.count { it.lastSeen >= start }
|
||||
}
|
||||
|
||||
private val _items = MutableStateFlow(entries)
|
||||
val items: StateFlow<List<ReelEntry>> = _items
|
||||
|
||||
private val _sessionCount = MutableStateFlow(0)
|
||||
val sessionCount: StateFlow<Int> = _sessionCount // unique reels since app start
|
||||
|
||||
private val _todayCount = MutableStateFlow(calculateTodayCount(entries))
|
||||
val todayCount: StateFlow<Int> = _todayCount // unique reels watched today
|
||||
|
||||
companion object {
|
||||
private val IGNORED_CODES = setOf(
|
||||
"home", "explore", "reels", "reel", "direct", "accounts",
|
||||
"stories", "p", "about", "legal", "videos", "audio", "clips",
|
||||
"trending", "foryou", "following", "live"
|
||||
)
|
||||
private val GENERIC_AUTHORS = setOf(
|
||||
"home", "explore", "reels", "reel", "direct", "instagram",
|
||||
"instagram creator", "instagram user", "instagram video", "creator", "user", ""
|
||||
)
|
||||
private val GENERIC_CAPTIONS = setOf(
|
||||
"instagram reel", "reel", "reels", "instagram", "instagram video", "video", ""
|
||||
)
|
||||
}
|
||||
|
||||
private fun sanitizeCaption(rawCaption: String, cleanAuthor: String): String {
|
||||
val clean = rawCaption.trim()
|
||||
val lower = clean.lowercase()
|
||||
val isGeneric = lower.isBlank() ||
|
||||
lower in GENERIC_CAPTIONS ||
|
||||
lower.startsWith("instagram reel") ||
|
||||
lower.startsWith("reel by @") ||
|
||||
lower.startsWith("reel by ") ||
|
||||
(cleanAuthor.isNotBlank() && lower == cleanAuthor.lowercase()) ||
|
||||
lower.endsWith("· original audio") ||
|
||||
lower.endsWith("original audio")
|
||||
return if (isGeneric) "" else clean
|
||||
}
|
||||
|
||||
private fun isCaptionInvalid(caption: String, author: String): Boolean {
|
||||
return sanitizeCaption(caption, author).isBlank()
|
||||
}
|
||||
|
||||
fun onWatched(code: String, author: String, caption: String): Int = synchronized(lock) {
|
||||
val cleanCode = code.trim().removePrefix("/").removeSuffix("/")
|
||||
val lowerCode = cleanCode.lowercase()
|
||||
if (cleanCode.length < 5 || lowerCode in IGNORED_CODES) {
|
||||
return _sessionCount.value
|
||||
}
|
||||
val finalCode = if (cleanCode.startsWith("reel_")) cleanCode.removePrefix("reel_") else cleanCode
|
||||
if (finalCode.length < 5 || finalCode.lowercase() in IGNORED_CODES) {
|
||||
return _sessionCount.value
|
||||
}
|
||||
|
||||
val cleanAuthor = author.trim().removePrefix("@")
|
||||
val lowerAuthor = cleanAuthor.lowercase()
|
||||
val isGenericAuthor = lowerAuthor in GENERIC_AUTHORS || lowerAuthor.startsWith("instagram")
|
||||
val sanitizedAuthor = if (isGenericAuthor) "" else cleanAuthor
|
||||
|
||||
val sanitizedCaption = sanitizeCaption(caption, sanitizedAuthor)
|
||||
|
||||
val isNew = sessionCodes.add(finalCode)
|
||||
val old = entries.firstOrNull { it.code == finalCode }
|
||||
|
||||
val resolvedAuthor = sanitizedAuthor.ifEmpty { old?.author ?: "" }
|
||||
val resolvedCaption = sanitizedCaption.ifEmpty {
|
||||
val oldCaption = old?.caption ?: ""
|
||||
if (!isCaptionInvalid(oldCaption, resolvedAuthor)) oldCaption else ""
|
||||
}
|
||||
|
||||
val updated = ReelEntry(
|
||||
code = finalCode,
|
||||
author = resolvedAuthor,
|
||||
caption = resolvedCaption,
|
||||
lastSeen = System.currentTimeMillis(),
|
||||
views = (old?.views ?: 0) + 1,
|
||||
)
|
||||
commit(listOf(updated) + entries.filter { it.code != finalCode })
|
||||
_sessionCount.value = sessionCodes.size
|
||||
if (isNew) {
|
||||
onNewReelWatched?.invoke(finalCode)
|
||||
}
|
||||
sessionCodes.size
|
||||
}
|
||||
|
||||
fun onMeta(code: String, author: String, caption: String) {
|
||||
synchronized(lock) {
|
||||
val cleanCode = if (code.startsWith("reel_")) code.removePrefix("reel_") else code
|
||||
if (entries.none { it.code == cleanCode }) return
|
||||
|
||||
val cleanAuthor = author.trim().removePrefix("@")
|
||||
val lowerAuthor = cleanAuthor.lowercase()
|
||||
val isGenericAuthor = lowerAuthor in GENERIC_AUTHORS || lowerAuthor.startsWith("instagram")
|
||||
val sanitizedAuthor = if (isGenericAuthor) "" else cleanAuthor
|
||||
|
||||
val sanitizedCaption = sanitizeCaption(caption, sanitizedAuthor)
|
||||
|
||||
commit(entries.map {
|
||||
if (it.code == cleanCode) {
|
||||
val resolvedAuthor = sanitizedAuthor.ifEmpty { it.author }
|
||||
val currentCaptionInvalid = isCaptionInvalid(it.caption, resolvedAuthor)
|
||||
val resolvedCaption = if (sanitizedCaption.isNotEmpty()) {
|
||||
sanitizedCaption
|
||||
} else if (!currentCaptionInvalid) {
|
||||
it.caption
|
||||
} else {
|
||||
""
|
||||
}
|
||||
it.copy(
|
||||
author = resolvedAuthor,
|
||||
caption = resolvedCaption
|
||||
)
|
||||
} else it
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(code: String) = synchronized(lock) { commit(entries.filter { it.code != code }) }
|
||||
|
||||
fun clear() = synchronized(lock) { commit(emptyList()) }
|
||||
|
||||
fun resetSession() = synchronized(lock) {
|
||||
sessionCodes.clear()
|
||||
_sessionCount.value = 0
|
||||
}
|
||||
|
||||
private fun commit(list: List<ReelEntry>) {
|
||||
entries = list.take(500)
|
||||
_items.value = entries
|
||||
_todayCount.value = calculateTodayCount(entries)
|
||||
val snapshot = entries
|
||||
io.execute { save(snapshot) }
|
||||
}
|
||||
|
||||
private fun save(list: List<ReelEntry>) {
|
||||
try {
|
||||
val arr = JSONArray()
|
||||
list.forEach {
|
||||
arr.put(
|
||||
JSONObject()
|
||||
.put("code", it.code)
|
||||
.put("author", it.author)
|
||||
.put("caption", it.caption)
|
||||
.put("lastSeen", it.lastSeen)
|
||||
.put("views", it.views)
|
||||
)
|
||||
}
|
||||
val tmp = File(file.parentFile, file.name + ".tmp")
|
||||
tmp.writeText(arr.toString())
|
||||
tmp.renameTo(file)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun load(): List<ReelEntry> = try {
|
||||
if (!file.exists()) emptyList() else {
|
||||
val arr = JSONArray(file.readText())
|
||||
(0 until arr.length()).map {
|
||||
val o = arr.getJSONObject(it)
|
||||
ReelEntry(
|
||||
o.getString("code"),
|
||||
o.optString("author"),
|
||||
o.optString("caption"),
|
||||
o.optLong("lastSeen"),
|
||||
o.optInt("views", 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ujwal.focusgram.feature.history
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.mozilla.geckoview.GeckoResult
|
||||
import org.mozilla.geckoview.WebExtension
|
||||
|
||||
class ReelMessageDelegate(private val history: ReelHistory) : WebExtension.MessageDelegate {
|
||||
private val codeRe = Regex("^[A-Za-z0-9_.-]{3,}$")
|
||||
|
||||
override fun onConnect(port: WebExtension.Port) {
|
||||
port.setDelegate(object : WebExtension.PortDelegate {
|
||||
override fun onPortMessage(message: Any, p: WebExtension.Port) {
|
||||
handlePayload(message)
|
||||
}
|
||||
override fun onDisconnect(p: WebExtension.Port) {}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
nativeApp: String,
|
||||
message: Any,
|
||||
sender: WebExtension.MessageSender,
|
||||
): GeckoResult<Any>? {
|
||||
val count = handlePayload(message) ?: return null
|
||||
return GeckoResult.fromValue(JSONObject().put("count", count))
|
||||
}
|
||||
|
||||
private fun handlePayload(message: Any): Int? {
|
||||
val m = when (message) {
|
||||
is JSONObject -> message
|
||||
is String -> try { JSONObject(message) } catch (_: Exception) { null }
|
||||
else -> null
|
||||
} ?: return null
|
||||
|
||||
val code = m.optString("code")
|
||||
if (!codeRe.matches(code)) return null
|
||||
val author = m.optString("author")
|
||||
val caption = m.optString("caption")
|
||||
|
||||
return when (m.optString("type")) {
|
||||
"watched" -> history.onWatched(code, author, caption)
|
||||
"meta" -> {
|
||||
history.onMeta(code, author, caption)
|
||||
history.sessionCount.value
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-43
@@ -48,7 +48,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.ujwal.focusgram.core.database.ReelsHistoryItem
|
||||
import com.ujwal.focusgram.core.ui.components.FocusGramCard
|
||||
import com.ujwal.focusgram.core.ui.components.FocusGramTopBar
|
||||
import com.ujwal.focusgram.core.ui.theme.AlertRed
|
||||
@@ -60,7 +59,6 @@ import com.ujwal.focusgram.core.ui.theme.PureBlack
|
||||
import com.ujwal.focusgram.core.ui.theme.TextMuted
|
||||
import com.ujwal.focusgram.core.ui.theme.TextPrimary
|
||||
import com.ujwal.focusgram.core.ui.theme.TextSecondary
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
@@ -72,7 +70,7 @@ import java.util.Locale
|
||||
*/
|
||||
@Composable
|
||||
fun ReelsHistoryScreen(
|
||||
repository: ReelsHistoryRepository,
|
||||
reelHistory: ReelHistory,
|
||||
onBack: () -> Unit,
|
||||
onOpenReel: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -82,13 +80,20 @@ fun ReelsHistoryScreen(
|
||||
}
|
||||
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
val scope = rememberCoroutineScope()
|
||||
var showClearDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val reels by if (searchQuery.isBlank()) {
|
||||
repository.allReels.collectAsState(initial = emptyList())
|
||||
} else {
|
||||
repository.searchReels(searchQuery).collectAsState(initial = emptyList())
|
||||
val allReels by reelHistory.items.collectAsState()
|
||||
val reels = remember(allReels, searchQuery) {
|
||||
if (searchQuery.isBlank()) {
|
||||
allReels
|
||||
} else {
|
||||
val q = searchQuery.trim().lowercase()
|
||||
allReels.filter {
|
||||
it.author.lowercase().contains(q) ||
|
||||
it.caption.lowercase().contains(q) ||
|
||||
it.code.lowercase().contains(q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -101,7 +106,7 @@ fun ReelsHistoryScreen(
|
||||
title = "Reels History",
|
||||
onBack = onBack,
|
||||
actions = {
|
||||
if (reels.isNotEmpty()) {
|
||||
if (allReels.isNotEmpty()) {
|
||||
IconButton(onClick = { showClearDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
@@ -113,32 +118,12 @@ fun ReelsHistoryScreen(
|
||||
}
|
||||
)
|
||||
|
||||
// Privacy indicator banner
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
tint = TextMuted,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "${reels.size} reels stored locally on-device only",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
|
||||
// Local search filter field
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
placeholder = { Text("Search by title or URL...", color = TextMuted) },
|
||||
placeholder = { Text("Search by author or caption...", color = TextMuted) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Search, contentDescription = null, tint = TextMuted)
|
||||
},
|
||||
@@ -186,7 +171,7 @@ fun ReelsHistoryScreen(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Watched reels will automatically record metadata here.",
|
||||
text = "Watched reels will automatically record author and caption here.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextMuted
|
||||
)
|
||||
@@ -199,11 +184,11 @@ fun ReelsHistoryScreen(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(reels, key = { it.id }) { item ->
|
||||
items(reels, key = { it.code }) { item ->
|
||||
ReelHistoryCard(
|
||||
item = item,
|
||||
onClick = { onOpenReel(item.url) },
|
||||
onDelete = { scope.launch { repository.deleteReel(item.id) } }
|
||||
onDelete = { reelHistory.delete(item.code) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -216,7 +201,7 @@ fun ReelsHistoryScreen(
|
||||
title = { Text("Clear Reels History?", color = TextPrimary) },
|
||||
text = {
|
||||
Text(
|
||||
"This removes all locally recorded reel visit history from SQLite.",
|
||||
"This removes all locally recorded reel visit history.",
|
||||
color = TextSecondary
|
||||
)
|
||||
},
|
||||
@@ -224,7 +209,7 @@ fun ReelsHistoryScreen(
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch { repository.clearHistory() }
|
||||
reelHistory.clear()
|
||||
showClearDialog = false
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = AlertRed)
|
||||
@@ -243,7 +228,7 @@ fun ReelsHistoryScreen(
|
||||
|
||||
@Composable
|
||||
private fun ReelHistoryCard(
|
||||
item: ReelsHistoryItem,
|
||||
item: ReelEntry,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
@@ -276,19 +261,31 @@ private fun ReelHistoryCard(
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (item.author.isNotBlank()) {
|
||||
Text(
|
||||
text = "@${item.author}",
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = InstagramBlue,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
|
||||
text = if (item.caption.isNotBlank()) item.caption else "Reel /${item.code}",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Normal),
|
||||
color = TextPrimary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = formatRelativeTime(item.visitedAt),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp),
|
||||
color = TextMuted
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = formatRelativeTime(item.lastSeen),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp),
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onDelete) {
|
||||
|
||||
+4
-4
@@ -148,20 +148,20 @@ fun NativeBottomNavBar(
|
||||
}
|
||||
val islandBackground by animateColorAsState(
|
||||
targetValue = if (islandMode == IslandMode.FLOATING) {
|
||||
if (isLight) Color(0xF2FFFFFF) else Color(0xD9101012)
|
||||
if (isLight) Color(0x9CFFFFFF) else Color(0x8B101014)
|
||||
} else {
|
||||
if (isLight) Color(0xF5FFFFFF) else Color(0xF2141416)
|
||||
if (isLight) Color(0x9EFFFFFF) else Color(0x9B141418)
|
||||
},
|
||||
animationSpec = tween(300),
|
||||
label = "islandBackground"
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = if (isLight) Color(0x1F000000) else Color(0x33FFFFFF),
|
||||
targetValue = if (isLight) Color(0x2B000000) else Color(0x4DFFFFFF),
|
||||
animationSpec = tween(300),
|
||||
label = "borderColor"
|
||||
)
|
||||
val selectedTabBackground by animateColorAsState(
|
||||
targetValue = if (isLight) Color(0xFFE5E5EA) else Color(0xFF2C2C30),
|
||||
targetValue = if (isLight) Color(0x1F000000) else Color(0x33FFFFFF),
|
||||
animationSpec = tween(300),
|
||||
label = "selectedTabBackground"
|
||||
)
|
||||
|
||||
@@ -25,16 +25,29 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.ujwal.focusgram.R
|
||||
|
||||
/**
|
||||
* ReelsTopBar
|
||||
* Minimal Instagram Reels top header matching modern Instagram:
|
||||
* - "Reels" (Bold white header)
|
||||
* Minimal Instagram Reels top header:
|
||||
* - "Reels" (Bold white header on left)
|
||||
* - Session watched counter (pill in middle)
|
||||
* - Download icon button (on right, opposite side of Reels)
|
||||
* - Subtle vertical scrim gradient for legibility over video frames.
|
||||
*/
|
||||
@Composable
|
||||
fun ReelsTopBar(
|
||||
isVisible: Boolean,
|
||||
watchedCount: Int = 0,
|
||||
isDownloadEnabled: Boolean = false,
|
||||
onReelsClick: () -> Unit,
|
||||
onDownloadClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
@@ -61,7 +74,7 @@ fun ReelsTopBar(
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Reels",
|
||||
@@ -74,6 +87,45 @@ fun ReelsTopBar(
|
||||
onClick = onReelsClick
|
||||
)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color(0x40000000))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "$watchedCount watched",
|
||||
color = Color(0xEEFFFFFF),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
if (isDownloadEnabled) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0x26FFFFFF))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDownloadClick
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_download_fill),
|
||||
contentDescription = "Download Reel",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.size(36.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+258
-54
@@ -1,5 +1,6 @@
|
||||
package com.ujwal.focusgram.feature.screentime
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
@@ -39,6 +40,8 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import com.ujwal.focusgram.feature.history.ReelHistory
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -46,6 +49,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.ujwal.focusgram.core.database.ScreenTimeDao
|
||||
@@ -62,20 +66,29 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.DateRangePicker
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDateRangePickerState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlin.math.max
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
enum class DailyBreakdownRange(val label: String) {
|
||||
PAST_7_DAYS("7 Days"),
|
||||
PAST_14_DAYS("14 Days"),
|
||||
PAST_30_DAYS("30 Days"),
|
||||
CUSTOM("Custom")
|
||||
}
|
||||
|
||||
/**
|
||||
* ScreenTimeScreen
|
||||
* Visualizes user digital wellness trends, daily screen time, and session limit adherence.
|
||||
* Designed with authentic iOS Screen Time aesthetics and on-device Room SQLite records.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ScreenTimeScreen(
|
||||
screenTimeDao: ScreenTimeDao,
|
||||
dailyLimitMinutes: Int = 60,
|
||||
onBack: () -> Unit,
|
||||
reelHistory: ReelHistory? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
BackHandler {
|
||||
@@ -87,35 +100,31 @@ fun ScreenTimeScreen(
|
||||
val dayLabelFormat = remember { SimpleDateFormat("EEE", Locale.US) }
|
||||
val todayStr = remember { dateFormat.format(Date()) }
|
||||
|
||||
var liveOffset by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(records) {
|
||||
liveOffset = 0
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(1000L)
|
||||
liveOffset++
|
||||
}
|
||||
}
|
||||
|
||||
val todayRecord = records.firstOrNull { it.date == todayStr }
|
||||
val todaySeconds = (todayRecord?.seconds ?: 0) + liveOffset
|
||||
val todayWatchedFromHistory by (reelHistory?.todayCount ?: flowOf(0)).collectAsState(initial = 0)
|
||||
val todaySeconds = todayRecord?.seconds ?: 0
|
||||
val todayReelsSeconds = todayRecord?.reelsSeconds ?: 0
|
||||
val todayMinutes = todaySeconds / 60
|
||||
val todayReelsMinutes = todayReelsSeconds / 60
|
||||
val todayReelsWatched = if (reelHistory != null) todayWatchedFromHistory else (todayRecord?.reelsWatched ?: 0)
|
||||
val limitFraction = (todayMinutes.toFloat() / max(1, dailyLimitMinutes)).coerceIn(0f, 1f)
|
||||
|
||||
var selectedRange by remember { mutableStateOf(DailyBreakdownRange.PAST_7_DAYS) }
|
||||
var customStartDateMillis by remember { mutableStateOf<Long?>(null) }
|
||||
var customEndDateMillis by remember { mutableStateOf<Long?>(null) }
|
||||
var showCustomPicker by remember { mutableStateOf(false) }
|
||||
|
||||
val todayUsageText = when {
|
||||
todaySeconds < 60 -> "${todaySeconds}s"
|
||||
todaySeconds % 60 == 0 -> "$todayMinutes min"
|
||||
else -> "$todayMinutes min ${todaySeconds % 60}s"
|
||||
todaySeconds % 60 == 0 -> "${todayMinutes}m"
|
||||
else -> "${todayMinutes}m ${todaySeconds % 60}s"
|
||||
}
|
||||
|
||||
val todayReelsText = when {
|
||||
todayReelsSeconds == 0 -> "0 min"
|
||||
todayReelsSeconds == 0 -> "0m"
|
||||
todayReelsSeconds < 60 -> "${todayReelsSeconds}s"
|
||||
todayReelsSeconds % 60 == 0 -> "$todayReelsMinutes min"
|
||||
else -> "$todayReelsMinutes min ${todayReelsSeconds % 60}s"
|
||||
todayReelsSeconds % 60 == 0 -> "${todayReelsMinutes}m"
|
||||
else -> "${todayReelsMinutes}m ${todayReelsSeconds % 60}s"
|
||||
}
|
||||
|
||||
// Compute past 7 days breakdown for bar chart
|
||||
@@ -139,6 +148,110 @@ fun ScreenTimeScreen(
|
||||
val weeklyTotalMinutes = last7Days.sumOf { it.second }
|
||||
val weeklyDailyAverage = weeklyTotalMinutes / 7
|
||||
|
||||
val displayedRecords = remember(records, selectedRange, customStartDateMillis, customEndDateMillis) {
|
||||
when (selectedRange) {
|
||||
DailyBreakdownRange.PAST_7_DAYS -> {
|
||||
val cutoffCal = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -6) }
|
||||
val cutoffStr = dateFormat.format(cutoffCal.time)
|
||||
records.filter { it.date >= cutoffStr && it.date <= todayStr }.sortedByDescending { it.date }
|
||||
}
|
||||
DailyBreakdownRange.PAST_14_DAYS -> {
|
||||
val cutoffCal = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -13) }
|
||||
val cutoffStr = dateFormat.format(cutoffCal.time)
|
||||
records.filter { it.date >= cutoffStr && it.date <= todayStr }.sortedByDescending { it.date }
|
||||
}
|
||||
DailyBreakdownRange.PAST_30_DAYS -> {
|
||||
val cutoffCal = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -29) }
|
||||
val cutoffStr = dateFormat.format(cutoffCal.time)
|
||||
records.filter { it.date >= cutoffStr && it.date <= todayStr }.sortedByDescending { it.date }
|
||||
}
|
||||
DailyBreakdownRange.CUSTOM -> {
|
||||
val startStr = customStartDateMillis?.let { dateFormat.format(Date(it)) } ?: "0000-00-00"
|
||||
val endStr = customEndDateMillis?.let { dateFormat.format(Date(it)) } ?: "9999-99-99"
|
||||
records.filter { it.date in startStr..endStr }.sortedByDescending { it.date }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val distinctYears = remember(displayedRecords) {
|
||||
displayedRecords.mapNotNull { it.date.split("-").getOrNull(0) }.distinct()
|
||||
}
|
||||
val distinctMonths = remember(displayedRecords) {
|
||||
displayedRecords.mapNotNull {
|
||||
val parts = it.date.split("-")
|
||||
if (parts.size >= 2) "${parts[0]}-${parts[1]}" else null
|
||||
}.distinct()
|
||||
}
|
||||
val hasMultipleYears = distinctYears.size > 1
|
||||
val hasMultipleMonths = distinctMonths.size > 1
|
||||
|
||||
val yesterdayStr = remember {
|
||||
val yCal = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -1) }
|
||||
dateFormat.format(yCal.time)
|
||||
}
|
||||
val sevenDaysAgoStr = remember {
|
||||
val sCal = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -6) }
|
||||
dateFormat.format(sCal.time)
|
||||
}
|
||||
|
||||
val fullDayFormat = remember { SimpleDateFormat("EEEE", Locale.US) }
|
||||
val monthDayFormat = remember { SimpleDateFormat("MMM d", Locale.US) }
|
||||
val monthDayYearFormat = remember { SimpleDateFormat("MMM d, yyyy", Locale.US) }
|
||||
|
||||
fun formatRecordDate(dateStr: String): String {
|
||||
if (dateStr == todayStr) return "Today"
|
||||
if (dateStr == yesterdayStr) return "Yesterday"
|
||||
val parsed = try { dateFormat.parse(dateStr) } catch (_: Exception) { null } ?: return dateStr
|
||||
|
||||
// Within past 7 days: show day of the week
|
||||
if (dateStr >= sevenDaysAgoStr && dateStr <= todayStr) {
|
||||
return fullDayFormat.format(parsed)
|
||||
}
|
||||
|
||||
// Only show month & year if it includes multiple months and years
|
||||
return when {
|
||||
hasMultipleYears -> monthDayYearFormat.format(parsed)
|
||||
hasMultipleMonths -> monthDayFormat.format(parsed)
|
||||
else -> fullDayFormat.format(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCustomPicker) {
|
||||
val pickerState = rememberDateRangePickerState()
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showCustomPicker = false },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val start = pickerState.selectedStartDateMillis
|
||||
val end = pickerState.selectedEndDateMillis ?: start
|
||||
if (start != null) {
|
||||
customStartDateMillis = start
|
||||
customEndDateMillis = end
|
||||
selectedRange = DailyBreakdownRange.CUSTOM
|
||||
}
|
||||
showCustomPicker = false
|
||||
}
|
||||
) {
|
||||
Text("Apply", color = IOSBlue)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showCustomPicker = false }) {
|
||||
Text("Cancel", color = IOSTextSecondary)
|
||||
}
|
||||
}
|
||||
) {
|
||||
DateRangePicker(
|
||||
state = pickerState,
|
||||
title = { Text("Select Date Range", modifier = Modifier.padding(16.dp), color = IOSTextPrimary) },
|
||||
headline = { },
|
||||
showModeToggle = false,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -183,10 +296,13 @@ fun ScreenTimeScreen(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(IOSTeal.copy(alpha = 0.15f)),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -195,30 +311,36 @@ fun ScreenTimeScreen(
|
||||
imageVector = Icons.Default.HourglassTop,
|
||||
contentDescription = null,
|
||||
tint = IOSTeal,
|
||||
modifier = Modifier.size(22.dp)
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Today's Usage",
|
||||
text = "Usage",
|
||||
color = IOSTextSecondary,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = todayUsageText,
|
||||
color = IOSTextPrimary,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1.05f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(IOSPurple.copy(alpha = 0.15f)),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -227,22 +349,63 @@ fun ScreenTimeScreen(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = IOSPurple,
|
||||
modifier = Modifier.size(22.dp)
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Reels Time",
|
||||
color = IOSTextSecondary,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = todayReelsText,
|
||||
color = IOSTextPrimary,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.weight(0.95f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(IOSOrange.copy(alpha = 0.15f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Insights,
|
||||
contentDescription = null,
|
||||
tint = IOSOrange,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Watched",
|
||||
color = IOSTextSecondary,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = "$todayReelsWatched",
|
||||
color = IOSTextPrimary,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -396,29 +559,62 @@ fun ScreenTimeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Recent Daily Log
|
||||
// Recent Daily Log with customizable ranges
|
||||
item {
|
||||
Text(
|
||||
text = "DAILY BREAKDOWN",
|
||||
color = IOSTextSecondary,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 4.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 4.dp, end = 4.dp, top = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "DAILY BREAKDOWN",
|
||||
color = IOSTextSecondary,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
DailyBreakdownRange.values().forEach { range ->
|
||||
val isSelected = selectedRange == range
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(if (isSelected) IOSBlue else Color(0xFF2C2C2E))
|
||||
.clickable {
|
||||
if (range == DailyBreakdownRange.CUSTOM) {
|
||||
showCustomPicker = true
|
||||
} else {
|
||||
selectedRange = range
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 7.dp, vertical = 3.dp)
|
||||
) {
|
||||
Text(
|
||||
text = range.label,
|
||||
color = if (isSelected) Color.White else IOSTextSecondary,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
IOSGroupedCard {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
if (records.isEmpty()) {
|
||||
if (displayedRecords.isEmpty()) {
|
||||
Text(
|
||||
text = "No screen time recorded yet today.",
|
||||
text = "No screen time recorded for this period.",
|
||||
color = IOSTextSecondary,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
)
|
||||
} else {
|
||||
records.take(7).forEachIndexed { idx, record ->
|
||||
displayedRecords.forEachIndexed { idx, record ->
|
||||
val mins = record.seconds / 60
|
||||
val reelsM = record.reelsSeconds / 60
|
||||
val reelsS = record.reelsSeconds % 60
|
||||
@@ -432,14 +628,22 @@ fun ScreenTimeScreen(
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = if (record.date == todayStr) "Today" else record.date,
|
||||
text = formatRecordDate(record.date),
|
||||
color = IOSTextPrimary,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
val subtitleParts = mutableListOf<String>()
|
||||
if (record.reelsSeconds > 0) {
|
||||
val reelsLabel = if (record.reelsSeconds < 60) "${reelsS}s reels" else "${reelsM}m reels"
|
||||
subtitleParts.add(reelsLabel)
|
||||
}
|
||||
val watchedCount = if (record.date == todayStr && reelHistory != null) todayReelsWatched else record.reelsWatched
|
||||
if (watchedCount > 0) {
|
||||
subtitleParts.add("$watchedCount watched")
|
||||
}
|
||||
if (subtitleParts.isNotEmpty()) {
|
||||
Text(
|
||||
text = reelsLabel,
|
||||
text = subtitleParts.joinToString(" • "),
|
||||
color = IOSPurple,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
@@ -453,7 +657,7 @@ fun ScreenTimeScreen(
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
if (idx < records.take(7).lastIndex) {
|
||||
if (idx < displayedRecords.lastIndex) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ujwal.focusgram.feature.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.ujwal.focusgram.R
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
@@ -92,6 +93,7 @@ import com.ujwal.focusgram.core.ui.components.IOSSettingsRow
|
||||
import com.ujwal.focusgram.core.ui.components.IOSTeal
|
||||
import com.ujwal.focusgram.core.ui.components.IOSTextPrimary
|
||||
import com.ujwal.focusgram.core.ui.components.IOSTextSecondary
|
||||
import com.ujwal.focusgram.feature.history.ReelHistory
|
||||
import com.ujwal.focusgram.feature.session.AppSessionPicker
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -126,6 +128,7 @@ fun SettingsScreen(
|
||||
onWatchAd: () -> Unit = {},
|
||||
onNavigateUrl: (String) -> Unit = {},
|
||||
screenTimeDao: ScreenTimeDao? = null,
|
||||
reelHistory: ReelHistory? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val settings by repository.settingsFlow.collectAsState(initial = FocusSettings())
|
||||
@@ -325,7 +328,8 @@ fun SettingsScreen(
|
||||
onWatchAd = onWatchAd,
|
||||
onShowLegal = { showLegalDialog = true },
|
||||
onShowDeveloper = { showDeveloperDialog = true },
|
||||
screenTimeDao = screenTimeDao
|
||||
screenTimeDao = screenTimeDao,
|
||||
reelHistory = reelHistory
|
||||
)
|
||||
}
|
||||
SettingsSubpage.FOCUS -> {
|
||||
@@ -378,26 +382,18 @@ private fun MainSettingsContent(
|
||||
onWatchAd: () -> Unit,
|
||||
onShowLegal: () -> Unit,
|
||||
onShowDeveloper: () -> Unit,
|
||||
screenTimeDao: ScreenTimeDao? = null
|
||||
screenTimeDao: ScreenTimeDao? = null,
|
||||
reelHistory: ReelHistory? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.US) }
|
||||
val todayStr = remember { dateFormat.format(Date()) }
|
||||
val todayRecord by (screenTimeDao?.getForDate(todayStr) ?: flowOf(null)).collectAsState(initial = null)
|
||||
val todayWatchedFromHistory by (reelHistory?.todayCount ?: flowOf(0)).collectAsState(initial = 0)
|
||||
|
||||
var liveOffset by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(todayRecord) {
|
||||
liveOffset = 0
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(1000L)
|
||||
liveOffset++
|
||||
}
|
||||
}
|
||||
|
||||
val todaySeconds = (todayRecord?.seconds ?: (settings.instaMinutes * 60)) + liveOffset
|
||||
val todaySeconds = todayRecord?.seconds ?: (settings.instaMinutes * 60)
|
||||
val todayReelsSeconds = todayRecord?.reelsSeconds ?: (settings.reelsMinutes * 60)
|
||||
val todayReelsWatched = if (reelHistory != null) todayWatchedFromHistory else (todayRecord?.reelsWatched ?: 0)
|
||||
|
||||
val appTimeText = when {
|
||||
todaySeconds < 60 -> "${todaySeconds}s"
|
||||
@@ -539,7 +535,7 @@ private fun MainSettingsContent(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -552,6 +548,11 @@ private fun MainSettingsContent(
|
||||
Text(text = reelsTimeText, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Text(text = "Reels Time", color = Color(0xFF8E8E93), fontSize = 11.sp)
|
||||
}
|
||||
Box(modifier = Modifier.width(1.dp).height(24.dp).background(Color(0xFF2C2C2E)))
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = "$todayReelsWatched", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Text(text = "Reels Watched", color = Color(0xFF8E8E93), fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1607,9 +1608,8 @@ private fun LicensesSubpage() {
|
||||
IOSSettingsRow(
|
||||
title = "Mozilla GeckoView",
|
||||
subtitle = "Mozilla Public License 2.0 · Independent privacy-focused Android browser engine",
|
||||
icon = Icons.Default.Security,
|
||||
iconUrl = "https://avatars.githubusercontent.com/u/131524?s=120",
|
||||
iconBgColor = IOSOrange,
|
||||
iconRes = R.drawable.ic_credit_geckoview,
|
||||
iconBgColor = Color(0xFF008787),
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://mozilla.github.io/geckoview/"))
|
||||
context.startActivity(intent)
|
||||
@@ -1618,9 +1618,8 @@ private fun LicensesSubpage() {
|
||||
IOSSettingsRow(
|
||||
title = "uBlock Origin",
|
||||
subtitle = "GPLv3 · High-performance content filtering and declarative rule evaluation",
|
||||
icon = Icons.Default.Block,
|
||||
iconUrl = "https://avatars.githubusercontent.com/u/585534?s=120",
|
||||
iconBgColor = IOSRed,
|
||||
iconRes = R.drawable.ic_credit_ublock,
|
||||
iconBgColor = Color(0xFF800000),
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/gorhill/uBlock"))
|
||||
context.startActivity(intent)
|
||||
@@ -1630,27 +1629,35 @@ private fun LicensesSubpage() {
|
||||
title = "Ghostify",
|
||||
subtitle = "MIT License · Groundbreaking research & telemetry suppression architecture",
|
||||
icon = Icons.Default.VisibilityOff,
|
||||
iconUrl = "https://avatars.githubusercontent.com/u/131580970?s=120",
|
||||
iconUrl = "https://lh3.googleusercontent.com/or_aQHbJ4j41UjtaQ9rsNZk93tPH9Xw_YASD3l-27SW3A_z8Qu1va8JOEcRKyDCRpcVdo1MncJta70iMqSBTmypRbtQ=s60",
|
||||
iconBgColor = IOSPurple,
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Hendrizzzz/Ghostify"))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
IOSSettingsRow(
|
||||
title = "Iconify",
|
||||
subtitle = "Apache 2.0 · Universal icon framework powering bottom nav and system icons",
|
||||
iconRes = R.drawable.ic_credit_iconify,
|
||||
iconBgColor = Color(0xFF026C9C),
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://icon-sets.iconify.design/"))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
IOSSettingsRow(
|
||||
title = "Android Jetpack & Compose",
|
||||
subtitle = "Apache 2.0 · Declarative UI framework, Navigation, and Lifecycle libraries",
|
||||
icon = Icons.Default.Settings,
|
||||
iconUrl = "https://avatars.githubusercontent.com/u/32689599?s=120",
|
||||
iconBgColor = IOSGreen,
|
||||
iconRes = R.drawable.ic_credit_jetpack_compose,
|
||||
iconBgColor = Color(0xFF1C1C1E),
|
||||
onClick = {}
|
||||
)
|
||||
IOSSettingsRow(
|
||||
title = "Room Database & SQLite",
|
||||
subtitle = "Apache 2.0 · On-device SQLite persistence and screen time tracking",
|
||||
icon = Icons.Default.Timer,
|
||||
iconUrl = "https://avatars.githubusercontent.com/u/1715421?s=120",
|
||||
iconBgColor = IOSTeal,
|
||||
iconRes = R.drawable.ic_credit_sqlite,
|
||||
iconBgColor = Color(0xFFE8F4FC),
|
||||
onClick = {}
|
||||
)
|
||||
IOSSettingsRow(
|
||||
|
||||
@@ -23,8 +23,11 @@ import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.ujwal.focusgram.MainActivity
|
||||
import com.ujwal.focusgram.R
|
||||
import com.ujwal.focusgram.core.utils.MediaDownloadHelper
|
||||
import com.ujwal.focusgram.feature.screentime.ScreenTimeService
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
@@ -64,6 +67,7 @@ import kotlinx.coroutines.withContext
|
||||
import com.ujwal.focusgram.core.database.FocusGramDatabase
|
||||
import com.ujwal.focusgram.core.datastore.SettingsRepository
|
||||
import com.ujwal.focusgram.core.engine.FocusGramNavigationDelegate
|
||||
import com.ujwal.focusgram.core.engine.FocusGramPermissionDelegate
|
||||
import com.ujwal.focusgram.core.engine.FocusGramPromptDelegate
|
||||
import com.ujwal.focusgram.core.engine.GeckoRuntimeManager
|
||||
import com.ujwal.focusgram.core.engine.GeckoSessionManager
|
||||
@@ -131,6 +135,8 @@ fun FocusGramApp() {
|
||||
val geckoManager = remember { GeckoRuntimeManager.getInstance(context) }
|
||||
val runtime = geckoManager.runtime
|
||||
val extensionManager = geckoManager.extensionManager
|
||||
val reelHistory = remember { geckoManager.reelHistory }
|
||||
val reelsWatchedSessionCount by reelHistory.sessionCount.collectAsState()
|
||||
val userScriptManager = remember { UserScriptManager(context) }
|
||||
val sessionManager = remember {
|
||||
GeckoSessionManager(runtime).apply {
|
||||
@@ -179,27 +185,49 @@ fun FocusGramApp() {
|
||||
var adExtensionsUsed by remember { mutableIntStateOf(0) }
|
||||
var isSessionExhausted by remember { mutableStateOf(false) }
|
||||
|
||||
// Evaluates whether any barrier, lock, breath pause, effort friction, cooldown, or intention picker is active
|
||||
val isGateActive = shouldShowLock ||
|
||||
((isDailyLimitExhausted || isSessionExhausted) && destination != AppDestination.ADSTERRA_REWARDED) ||
|
||||
(remainingCooldownSeconds > 0 && !hasPassedCooldownGate) ||
|
||||
(settings.showBreathGate && settings.breathGateSeconds > 0 && !hasPassedBreathGate) ||
|
||||
(settings.effortFrictionEnabled && settings.wordChallengeCount > 0 && !hasPassedEffortFriction) ||
|
||||
!hasPassedSessionPicker
|
||||
|
||||
// System File Chooser / Gallery Launchers for Post & Story uploads
|
||||
var pendingFilePickerCallback by remember { mutableStateOf<((List<Uri>) -> Unit)?>(null) }
|
||||
|
||||
val multiFilePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenMultipleDocuments()
|
||||
) { uris: List<Uri> ->
|
||||
val filePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
val uris = mutableListOf<Uri>()
|
||||
val data = result.data
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK && data != null) {
|
||||
val clipData = data.clipData
|
||||
if (clipData != null) {
|
||||
for (i in 0 until clipData.itemCount) {
|
||||
clipData.getItemAt(i).uri?.let { uris.add(it) }
|
||||
}
|
||||
} else {
|
||||
data.data?.let { uris.add(it) }
|
||||
}
|
||||
}
|
||||
pendingFilePickerCallback?.invoke(uris)
|
||||
pendingFilePickerCallback = null
|
||||
}
|
||||
|
||||
val singleFilePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri: Uri? ->
|
||||
pendingFilePickerCallback?.invoke(if (uri != null) listOf(uri) else emptyList())
|
||||
pendingFilePickerCallback = null
|
||||
// Android Runtime Permissions Launcher (Camera, Audio, Storage)
|
||||
var pendingPermissionCallback by remember { mutableStateOf<((Boolean) -> Unit)?>(null) }
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { results ->
|
||||
val allGranted = results.values.all { it }
|
||||
pendingPermissionCallback?.invoke(allGranted)
|
||||
pendingPermissionCallback = null
|
||||
}
|
||||
|
||||
val filePickerHandler = remember {
|
||||
object : FocusGramPromptDelegate.FilePickerHandler {
|
||||
var launchMulti: ((Array<String>) -> Unit)? = null
|
||||
var launchSingle: ((Array<String>) -> Unit)? = null
|
||||
var launchChooser: ((Array<String>, Boolean) -> Unit)? = null
|
||||
|
||||
override fun onPickFiles(
|
||||
mimeTypes: Array<String>,
|
||||
@@ -207,13 +235,8 @@ fun FocusGramApp() {
|
||||
onResult: (List<Uri>) -> Unit
|
||||
) {
|
||||
pendingFilePickerCallback = onResult
|
||||
val safeMimes = if (mimeTypes.isEmpty()) arrayOf("*/*") else mimeTypes
|
||||
try {
|
||||
if (allowMultiple) {
|
||||
launchMulti?.invoke(safeMimes) ?: onResult(emptyList())
|
||||
} else {
|
||||
launchSingle?.invoke(safeMimes) ?: onResult(emptyList())
|
||||
}
|
||||
launchChooser?.invoke(mimeTypes, allowMultiple) ?: onResult(emptyList())
|
||||
} catch (e: Exception) {
|
||||
Log.e("FocusGramApp", "Error launching file picker: ${e.message}", e)
|
||||
onResult(emptyList())
|
||||
@@ -221,8 +244,55 @@ fun FocusGramApp() {
|
||||
}
|
||||
}
|
||||
}
|
||||
filePickerHandler.launchMulti = { mimes -> multiFilePickerLauncher.launch(mimes) }
|
||||
filePickerHandler.launchSingle = { mimes -> singleFilePickerLauncher.launch(mimes) }
|
||||
|
||||
filePickerHandler.launchChooser = { mimes, allowMultiple ->
|
||||
val mimeMap = android.webkit.MimeTypeMap.getSingleton()
|
||||
val cleanMimes = mimes
|
||||
.flatMap { it.split(",") }
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.mapNotNull { token ->
|
||||
if (token.startsWith(".")) {
|
||||
mimeMap.getMimeTypeFromExtension(token.removePrefix("."))
|
||||
} else if (token.contains("/")) {
|
||||
token
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
.toTypedArray()
|
||||
|
||||
val hasImages = cleanMimes.any { it.startsWith("image/") } || cleanMimes.isEmpty()
|
||||
val hasVideos = cleanMimes.any { it.startsWith("video/") }
|
||||
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_GET_CONTENT).apply {
|
||||
addCategory(android.content.Intent.CATEGORY_OPENABLE)
|
||||
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
if (allowMultiple) {
|
||||
putExtra(android.content.Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
}
|
||||
if (hasImages && !hasVideos) {
|
||||
type = "image/*"
|
||||
if (cleanMimes.isNotEmpty() && !cleanMimes.contains("image/*")) {
|
||||
putExtra(android.content.Intent.EXTRA_MIME_TYPES, cleanMimes)
|
||||
}
|
||||
} else if (hasVideos && !hasImages) {
|
||||
type = "video/*"
|
||||
if (cleanMimes.isNotEmpty() && !cleanMimes.contains("video/*")) {
|
||||
putExtra(android.content.Intent.EXTRA_MIME_TYPES, cleanMimes)
|
||||
}
|
||||
} else {
|
||||
type = "*/*"
|
||||
val fallbackTypes = if (cleanMimes.isNotEmpty()) cleanMimes else arrayOf("image/*", "video/*")
|
||||
putExtra(android.content.Intent.EXTRA_MIME_TYPES, fallbackTypes)
|
||||
}
|
||||
}
|
||||
val chooser = android.content.Intent.createChooser(intent, "Select Media").apply {
|
||||
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
filePickerLauncher.launch(chooser)
|
||||
}
|
||||
|
||||
// Pre-warm Rewarded Ad container and cache in background (isolated from GeckoView)
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -316,9 +386,9 @@ fun FocusGramApp() {
|
||||
}
|
||||
}
|
||||
|
||||
// Media pausing: pause media when leaving webview, resume when returning
|
||||
LaunchedEffect(destination) {
|
||||
if (destination != AppDestination.MAIN_WEBVIEW) {
|
||||
// Media pausing: pause media when leaving webview or when barriers/gates are active, resume when returning
|
||||
LaunchedEffect(destination, isGateActive) {
|
||||
if (destination != AppDestination.MAIN_WEBVIEW || isGateActive) {
|
||||
sessionManager.pauseMedia()
|
||||
} else {
|
||||
sessionManager.resumeMedia()
|
||||
@@ -339,7 +409,7 @@ fun FocusGramApp() {
|
||||
}
|
||||
} else if (event == Lifecycle.Event.ON_START) {
|
||||
isAppInForeground = true
|
||||
if (destination == AppDestination.MAIN_WEBVIEW) {
|
||||
if (destination == AppDestination.MAIN_WEBVIEW && !isGateActive) {
|
||||
sessionManager.resumeMedia()
|
||||
}
|
||||
}
|
||||
@@ -378,10 +448,48 @@ fun FocusGramApp() {
|
||||
sessionManager.updateTheme(theme)
|
||||
}
|
||||
extensionManager.onReelViewed = { url, title, author, thumb ->
|
||||
val code = if (url.contains("/reel/")) {
|
||||
url.substringAfter("/reel/").substringBefore("/").substringBefore("?")
|
||||
} else if (url.contains("/reels/")) {
|
||||
url.substringAfter("/reels/").substringBefore("/").substringBefore("?")
|
||||
} else {
|
||||
url.trimEnd('/').substringAfterLast('/')
|
||||
}
|
||||
val cleanCode = code.trim().removePrefix("/").removeSuffix("/")
|
||||
val ignoredCodes = setOf("reels", "reel", "videos", "audio", "clips", "home", "explore", "direct", "stories")
|
||||
if (cleanCode.length >= 5 && cleanCode.lowercase() !in ignoredCodes) {
|
||||
reelHistory.onWatched(cleanCode, author, title)
|
||||
}
|
||||
scope.launch {
|
||||
reelsRepository.addReel(url, title, thumb)
|
||||
}
|
||||
}
|
||||
extensionManager.onReelMeta = { url, title, author ->
|
||||
val code = if (url.contains("/reel/")) {
|
||||
url.substringAfter("/reel/").substringBefore("/").substringBefore("?")
|
||||
} else if (url.contains("/reels/")) {
|
||||
url.substringAfter("/reels/").substringBefore("/").substringBefore("?")
|
||||
} else {
|
||||
url.trimEnd('/').substringAfterLast('/')
|
||||
}
|
||||
val cleanCode = code.trim().removePrefix("/").removeSuffix("/")
|
||||
val ignoredCodes = setOf("reels", "reel", "videos", "audio", "clips", "home", "explore", "direct", "stories")
|
||||
if (cleanCode.length >= 5 && cleanCode.lowercase() !in ignoredCodes) {
|
||||
if (title.isNotBlank() || author.isNotBlank()) {
|
||||
reelHistory.onMeta(cleanCode, author, title)
|
||||
}
|
||||
}
|
||||
}
|
||||
extensionManager.onDownloadRequested = { url, filename ->
|
||||
MediaDownloadHelper.download(context, url, filename)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(reelHistory, database) {
|
||||
val todayStr = dateFormat.format(java.util.Date())
|
||||
reelHistory.todayCount.collect { count ->
|
||||
database.screenTimeDao().syncReelsWatched(todayStr, count)
|
||||
}
|
||||
}
|
||||
|
||||
// Mount Content Blocker extension and configure delegates
|
||||
@@ -406,6 +514,14 @@ fun FocusGramApp() {
|
||||
filePickerHandler = filePickerHandler
|
||||
)
|
||||
|
||||
sessionManager.session.permissionDelegate = FocusGramPermissionDelegate(
|
||||
context = context,
|
||||
onAndroidPermissionRequest = { perms, callback ->
|
||||
pendingPermissionCallback = callback
|
||||
permissionLauncher.launch(perms)
|
||||
}
|
||||
)
|
||||
|
||||
val initialUrl = when {
|
||||
settings.minimalModeEnabled -> "https://www.instagram.com/direct/inbox/"
|
||||
settings.startupPage == "following" -> "https://www.instagram.com/?variant=following"
|
||||
@@ -446,87 +562,22 @@ fun FocusGramApp() {
|
||||
previousReloadSettings = settings
|
||||
}
|
||||
|
||||
// 1. App Lock Enforcement Gate
|
||||
if (shouldShowLock) {
|
||||
AppLockScreen(
|
||||
encryptedStorage = encryptedStorage,
|
||||
onUnlockSuccess = { isAppUnlocked = true },
|
||||
isBiometricAllowed = settings.biometricEnabled
|
||||
)
|
||||
return
|
||||
// When any barrier, gate, lock, or intention picker is active, pressing back exits the app cleanly
|
||||
BackHandler(enabled = isGateActive && destination != AppDestination.ADSTERRA_REWARDED) {
|
||||
activity?.finish()
|
||||
}
|
||||
|
||||
// 2. Time limit reached (Daily limit exhausted or Session exhausted)
|
||||
if ((isDailyLimitExhausted || isSessionExhausted) && destination != AppDestination.ADSTERRA_REWARDED) {
|
||||
val elapsedMins = max(sessionDurationMinutes, (totalElapsedSeconds + 59) / 60)
|
||||
val extensionsLeft = if (isDailyLimitExhausted) {
|
||||
if (settings.dailyLimitAdClaimedDate == today) 0 else 1
|
||||
} else {
|
||||
(3 - adExtensionsUsed).coerceAtLeast(0)
|
||||
}
|
||||
SessionExhaustedScreen(
|
||||
elapsedMinutes = elapsedMins,
|
||||
extensionsRemaining = extensionsLeft,
|
||||
onExit = { activity?.finish() },
|
||||
onWatchAdToExtend = {
|
||||
if (extensionsLeft > 0) {
|
||||
destination = AppDestination.ADSTERRA_REWARDED
|
||||
}
|
||||
// Predictive back gesture: when at the root destination and cannot go back in WebView,
|
||||
// disabling BackHandler allows Android's predictive back-to-home animation to trigger cleanly.
|
||||
val canHandleBack = !isGateActive && (
|
||||
destination != AppDestination.MAIN_WEBVIEW ||
|
||||
sessionManager.canGoBack.value ||
|
||||
sessionManager.currentUrl.value.lowercase().let {
|
||||
it.contains("/direct") || (!it.endsWith("instagram.com/") && !it.endsWith("instagram.com"))
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
)
|
||||
|
||||
// 3. Cooldown Interval Gate between sessions
|
||||
if (remainingCooldownSeconds > 0 && !hasPassedCooldownGate) {
|
||||
CooldownGateScreen(
|
||||
remainingSecondsInitial = remainingCooldownSeconds,
|
||||
onCooldownExpired = { hasPassedCooldownGate = true },
|
||||
onExit = { activity?.finish() }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Mindfulness Breath Gate
|
||||
if (settings.showBreathGate && settings.breathGateSeconds > 0 && !hasPassedBreathGate) {
|
||||
BreathGateScreen(
|
||||
durationSeconds = settings.breathGateSeconds,
|
||||
onFinish = { hasPassedBreathGate = true }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Effort Friction Challenge
|
||||
if (settings.effortFrictionEnabled && settings.wordChallengeCount > 0 && !hasPassedEffortFriction) {
|
||||
EffortFrictionGate(
|
||||
sessionType = "Instagram",
|
||||
remainingCreditMinutes = settings.instaMinutes,
|
||||
onProceed = { hasPassedEffortFriction = true },
|
||||
onWatchAd = {
|
||||
destination = AppDestination.ADSTERRA_REWARDED
|
||||
},
|
||||
onCancel = { activity?.finish() }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 6. Cold-start Conscious Session Picker (Set your intention)
|
||||
if (!hasPassedSessionPicker) {
|
||||
AppSessionPicker(
|
||||
onSessionSelected = { minutes ->
|
||||
sessionDurationMinutes = minutes
|
||||
sessionRemainingSeconds = minutes * 60
|
||||
totalElapsedSeconds = 0
|
||||
adExtensionsUsed = 0
|
||||
isSessionExhausted = false
|
||||
hasPassedSessionPicker = true
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Back button handling: going back from subpages ALWAYS takes to Settings main screen
|
||||
BackHandler(enabled = true) {
|
||||
BackHandler(enabled = canHandleBack) {
|
||||
when {
|
||||
destination == AppDestination.APP_LOCK_SETUP -> {
|
||||
destination = AppDestination.APP_LOCK_SETTINGS
|
||||
@@ -558,12 +609,10 @@ fun FocusGramApp() {
|
||||
}
|
||||
else -> {
|
||||
val currentUrlStr = sessionManager.currentUrl.value.lowercase()
|
||||
if (currentUrlStr.contains("/direct") || !currentUrlStr.endsWith("instagram.com/")) {
|
||||
if (currentUrlStr.contains("/direct") || (!currentUrlStr.endsWith("instagram.com/") && !currentUrlStr.endsWith("instagram.com"))) {
|
||||
isWebInputFocused = false
|
||||
isModalActive = false
|
||||
sessionManager.goBack()
|
||||
} else {
|
||||
activity?.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -610,20 +659,30 @@ fun FocusGramApp() {
|
||||
)
|
||||
)
|
||||
|
||||
// Instagram Reels Native Top Header matching inspiration
|
||||
// Instagram Reels Native Top Header with watched counter and media download
|
||||
ReelsTopBar(
|
||||
isVisible = destination == AppDestination.MAIN_WEBVIEW &&
|
||||
isVisible = !isGateActive &&
|
||||
destination == AppDestination.MAIN_WEBVIEW &&
|
||||
islandMode == IslandMode.FLOATING &&
|
||||
!isModalActive &&
|
||||
!isWebInputFocused,
|
||||
watchedCount = reelsWatchedSessionCount,
|
||||
isDownloadEnabled = settings.videoDownloadEnabled,
|
||||
onReelsClick = {
|
||||
sessionManager.refreshReels()
|
||||
},
|
||||
onDownloadClick = {
|
||||
extensionManager.triggerReelDownload()
|
||||
sessionManager.userScriptManager?.evaluate(
|
||||
sessionManager.session,
|
||||
"window.dispatchEvent(new CustomEvent('focusgram:download-reel'));"
|
||||
)
|
||||
},
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
|
||||
// Ambient background behind the floating nav bar on Reels (seamless gradient, no harsh border line)
|
||||
if (destination == AppDestination.MAIN_WEBVIEW && islandMode == IslandMode.FLOATING && !isKeyboardOrInputActive) {
|
||||
if (!isGateActive && destination == AppDestination.MAIN_WEBVIEW && islandMode == IslandMode.FLOATING && !isKeyboardOrInputActive) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
@@ -651,7 +710,7 @@ fun FocusGramApp() {
|
||||
}
|
||||
|
||||
// Native Bottom Navigation Bar (OLED floating island)
|
||||
if (destination == AppDestination.MAIN_WEBVIEW) {
|
||||
if (!isGateActive && destination == AppDestination.MAIN_WEBVIEW) {
|
||||
val instagramTheme by sessionManager.instagramTheme.collectAsState()
|
||||
NativeBottomNavBar(
|
||||
currentUrl = currentUrl,
|
||||
@@ -684,7 +743,7 @@ fun FocusGramApp() {
|
||||
|
||||
// Sub-screen Animated Overlay (Settings, Reels History, App Lock, etc.)
|
||||
AnimatedVisibility(
|
||||
visible = destination != AppDestination.MAIN_WEBVIEW,
|
||||
visible = !isGateActive && destination != AppDestination.MAIN_WEBVIEW,
|
||||
enter = slideInHorizontally(animationSpec = tween(280)) { it } + fadeIn(animationSpec = tween(250)),
|
||||
exit = slideOutHorizontally(animationSpec = tween(280)) { it } + fadeOut(animationSpec = tween(220))
|
||||
) {
|
||||
@@ -721,13 +780,14 @@ fun FocusGramApp() {
|
||||
destination = AppDestination.MAIN_WEBVIEW
|
||||
sessionManager.navigateTo(url)
|
||||
},
|
||||
screenTimeDao = database.screenTimeDao()
|
||||
screenTimeDao = database.screenTimeDao(),
|
||||
reelHistory = reelHistory
|
||||
)
|
||||
}
|
||||
|
||||
AppDestination.REELS_HISTORY -> {
|
||||
ReelsHistoryScreen(
|
||||
repository = reelsRepository,
|
||||
reelHistory = reelHistory,
|
||||
onBack = { destination = AppDestination.SETTINGS },
|
||||
onOpenReel = { url ->
|
||||
destination = AppDestination.MAIN_WEBVIEW
|
||||
@@ -751,7 +811,8 @@ fun FocusGramApp() {
|
||||
ScreenTimeScreen(
|
||||
screenTimeDao = database.screenTimeDao(),
|
||||
dailyLimitMinutes = settings.dailyLimitMinutes,
|
||||
onBack = { destination = AppDestination.SETTINGS }
|
||||
onBack = { destination = AppDestination.SETTINGS },
|
||||
reelHistory = reelHistory
|
||||
)
|
||||
}
|
||||
|
||||
@@ -822,6 +883,77 @@ fun FocusGramApp() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen Gate & Mindful Session Picker Overlays
|
||||
// Rendered on top of GeckoView so Instagram preloads seamlessly in the background.
|
||||
if (isGateActive) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PureBlack)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {}
|
||||
) {
|
||||
if (shouldShowLock) {
|
||||
AppLockScreen(
|
||||
encryptedStorage = encryptedStorage,
|
||||
onUnlockSuccess = { isAppUnlocked = true },
|
||||
isBiometricAllowed = settings.biometricEnabled
|
||||
)
|
||||
} else if ((isDailyLimitExhausted || isSessionExhausted) && destination != AppDestination.ADSTERRA_REWARDED) {
|
||||
val elapsedMins = max(sessionDurationMinutes, (totalElapsedSeconds + 59) / 60)
|
||||
val extensionsLeft = if (isDailyLimitExhausted) {
|
||||
if (settings.dailyLimitAdClaimedDate == today) 0 else 1
|
||||
} else {
|
||||
(3 - adExtensionsUsed).coerceAtLeast(0)
|
||||
}
|
||||
SessionExhaustedScreen(
|
||||
elapsedMinutes = elapsedMins,
|
||||
extensionsRemaining = extensionsLeft,
|
||||
onExit = { activity?.finish() },
|
||||
onWatchAdToExtend = {
|
||||
if (extensionsLeft > 0) {
|
||||
destination = AppDestination.ADSTERRA_REWARDED
|
||||
}
|
||||
}
|
||||
)
|
||||
} else if (remainingCooldownSeconds > 0 && !hasPassedCooldownGate) {
|
||||
CooldownGateScreen(
|
||||
remainingSecondsInitial = remainingCooldownSeconds,
|
||||
onCooldownExpired = { hasPassedCooldownGate = true },
|
||||
onExit = { activity?.finish() }
|
||||
)
|
||||
} else if (settings.showBreathGate && settings.breathGateSeconds > 0 && !hasPassedBreathGate) {
|
||||
BreathGateScreen(
|
||||
durationSeconds = settings.breathGateSeconds,
|
||||
onFinish = { hasPassedBreathGate = true }
|
||||
)
|
||||
} else if (settings.effortFrictionEnabled && settings.wordChallengeCount > 0 && !hasPassedEffortFriction) {
|
||||
EffortFrictionGate(
|
||||
sessionType = "Instagram",
|
||||
remainingCreditMinutes = settings.instaMinutes,
|
||||
onProceed = { hasPassedEffortFriction = true },
|
||||
onWatchAd = {
|
||||
destination = AppDestination.ADSTERRA_REWARDED
|
||||
},
|
||||
onCancel = { activity?.finish() }
|
||||
)
|
||||
} else if (!hasPassedSessionPicker) {
|
||||
AppSessionPicker(
|
||||
onSessionSelected = { minutes ->
|
||||
sessionDurationMinutes = minutes
|
||||
sessionRemainingSeconds = minutes * 60
|
||||
totalElapsedSeconds = 0
|
||||
adExtensionsUsed = 0
|
||||
isSessionExhausted = false
|
||||
hasPassedSessionPicker = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="1560"
|
||||
android:viewportHeight="1560">
|
||||
<path
|
||||
android:fillColor="#FF008787"
|
||||
android:pathData="M778,56 A724,724 0 1,0 778,1504 A724,724 0 1,0 778,56 Z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M1083,674a104,104 0 0,0 -104,104c0,13 2,24 6,34L821,977V639a104,104 0 1,0 -84,0v338L573,812c3,-10 6,-22 6,-33a104,104 0 1,0 -63,95l233,233a42,42 0 0,0 60,0l233,-233a104,104 0 1,0 41,-200"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group
|
||||
android:pivotX="12"
|
||||
android:pivotY="12"
|
||||
android:scaleX="0.85"
|
||||
android:scaleY="0.85">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M2.8571,19.4286V9.6423c0.232,-0.06 0.4583,-0.1577 0.6703,-0.2949L5.143,8.3023v9.9834h4.5714v2.2857H4c-0.6309,0 -1.1429,-0.512 -1.1429,-1.1428Zm7.4286,-8.5714c0,-0.9463 0.768,-1.7143 1.7143,-1.7143 0.9463,0 1.7143,0.768 1.7143,1.7143 0,0.9511 -0.7751,1.7142 -1.7143,1.7142 -0.9392,0 -1.7143,-0.7631 -1.7143,-1.7142zm-5.1428,-8V1.1429C5.1429,0.512 5.6549,0 6.2857,0c0.6309,0 1.1429,0.512 1.1429,1.1429v0.2354zm9.1428,15.4285h4.5715V8.3023l1.6154,1.0451c0.212,0.1372 0.4383,0.2349 0.6703,0.2949v9.7863c0,0.6308 -0.512,1.1429 -1.1429,1.1429h-5.7143Zm-0.0034,-17.0274 8.0531,5.2109c0.5292,0.3428 0.6812,1.0508 0.3383,1.5805 -0.3428,0.5292 -1.0508,0.6812 -1.5805,0.3383L13.052,3.1846l0.1897,-0.1229c0.64,-0.4143 1.004,-1.0954 1.0406,-1.8034ZM11.3789,0.1834c0.5297,-0.3428 1.2377,-0.1908 1.5805,0.3383 0.3429,0.5297 0.1909,1.2377 -0.3383,1.5806L2.907,8.388c-0.5298,0.3429 -1.2378,0.1909 -1.5806,-0.3383 -0.3429,-0.5297 -0.1909,-1.2377 0.3383,-1.5805Zm2.6005,22.1023H22c0.4732,0 0.8572,0.384 0.8572,0.8572A0.8575,0.8575 0 0,1 22,24H2a0.8575,0.8575 0 0,1 -0.8571,-0.8571c0,-0.4732 0.384,-0.8572 0.8571,-0.8572h8.0206c0.3954,0.6829 1.1343,1.1429 1.9794,1.1429s1.584,-0.46 1.9794,-1.1429zm-0.8365,-8.8097v7.6669c0,0.6308 -0.512,1.1428 -1.1429,1.1428s-1.1429,-0.512 -1.1429,-1.1428V13.476a2.8453,2.8453 0 0,0 1.143,0.2383c0.4062,0 0.7925,-0.0851 1.1428,-0.2383z"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF4285F4"
|
||||
android:pathData="M12.135,0.003a2.577,2.577 0 0,0 -1.42,0.344L2.55,5.06a2.577,2.577 0 0,0 -0.953,0.967l3.957,2.17a1.765,1.765 0 0,1 0.603,-0.588l4.951,-2.86a1.761,1.761 0 0,1 1.76,0l4.951,2.86a1.753,1.753 0 0,1 0.526,0.46l3.921,-2.265a2.577,2.577 0 0,0 -0.816,-0.744L13.291,0.347a2.577,2.577 0 0,0 -1.152,-0.34Zm-0.145,4.912a1.36,1.36 0 0,0 -0.68,0.182L6.36,7.953a1.345,1.345 0 0,0 -0.454,0.435l1.92,1.055a1.05,1.05 0 0,1 0.31,-0.275l3.34,-1.928a1.054,1.054 0 0,1 1.051,0l3.336,1.928a1.066,1.066 0 0,1 0.238,0.199l0.018,-0.01 -0.002,-0.002 1.877,-1.08a1.353,1.353 0 0,0 -0.373,-0.322l-4.95,-2.86a1.357,1.357 0 0,0 -0.68,-0.178Zm10.432,1.149 -3.92,2.26a1.757,1.757 0 0,1 0.2,0.812v5.715c0,0.627 -0.332,1.21 -0.88,1.528l-4.95,2.86a1.761,1.761 0 0,1 -0.64,0.214v4.524a2.577,2.577 0 0,0 1.059,-0.313l8.162,-4.713a2.577,2.577 0 0,0 1.289,-2.23V7.297a2.577,2.577 0 0,0 -0.318,-1.228Zm-20.937,0.238a2.577,2.577 0 0,0 -0.227,0.99v9.43c0,0.918 0.491,1.767 1.293,2.229l8.162,4.713a2.57,2.57 0 0,0 1.225,0.336v-4.531a1.761,1.761 0 0,1 -0.833,-0.235L6.156,16.38a1.765,1.765 0 0,1 -0.877,-1.528V9.136a1.776,1.776 0 0,1 0.131,-0.676Zm16.67,2.223 -1.89,1.092a1.058,1.058 0 0,1 0.124,0.46v3.856c0,0.378 -0.2,0.72 -0.526,0.907l-3.336,1.93a1.04,1.04 0 0,1 -0.295,0.107v2.17c0.152,-0.027 0.304,-0.078 0.44,-0.157l4.949,-2.859a1.36,1.36 0 0,0 0.68,-1.18V9.136a1.36,1.36 0 0,0 -0.147,-0.607Zm-12.387,0.13a1.36,1.36 0 0,0 -0.088,0.481v5.715a1.36,1.36 0 0,0 0.68,1.18l4.95,2.86c0.194,0.112 0.412,0.166 0.627,0.177v-2.174a1.054,1.054 0 0,1 -0.46,-0.123L8.14,14.843a1.054,1.054 0 0,1 -0.532,-0.908v-3.853a0.99,0.99 0 0,1 0.077,-0.38l-0.034,-0.013z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="128"
|
||||
android:viewportHeight="128">
|
||||
<!-- Blue SQLite block -->
|
||||
<path
|
||||
android:fillColor="#FF0F80CC"
|
||||
android:pathData="M16,8 H112 A8,8 0 0,1 120,16 V112 A8,8 0 0,1 112,120 H16 A8,8 0 0,1 8,112 V16 A8,8 0 0,1 16,8 Z"/>
|
||||
<!-- High-contrast White Feather -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M115.95,2.781c-5.5,-4.906 -12.164,-2.933 -18.734,2.899a44.347,44.347 0 0,0 -2.914,2.859c-11.25,11.926 -21.684,34.023 -24.926,50.895 1.262,2.563 2.25,5.832 2.894,8.328 0.168,0.64 0.32,1.242 0.442,1.754 0.285,1.207 0.437,1.996 0.437,1.996s-0.101,-0.383 -0.515,-1.582c-0.078,-0.23 -0.168,-0.484 -0.27,-0.773 -0.043,-0.125 -0.105,-0.274 -0.172,-0.434 -0.734,-1.703 -2.765,-5.305 -3.656,-6.867 -0.762,2.25 -1.437,4.36 -2.004,6.265 2.578,4.715 4.149,12.797 4.149,12.797s-0.137,-0.523 -0.782,-2.347c-0.578,-1.621 -3.441,-6.64 -4.117,-7.809 -1.164,4.281 -1.625,7.172 -1.207,7.88 0.809,1.362 1.574,3.722 2.25,6.323 1.524,5.867 2.586,13.012 2.586,13.012s0.031,0.469 0.094,1.2a118.653,118.653 0 0,0 0.297,14.651c0.504,6.11 1.453,11.363 2.664,14.172l0.828,-0.449c-1.781,-5.535 -2.504,-12.793 -2.188,-21.156 0.48,-12.793 3.422,-28.215 8.856,-44.289 9.191,-24.27 21.938,-43.738 33.602,-53.035 -10.633,9.602 -25.023,40.684 -29.332,52.195 -4.82,12.891 -8.238,24.984 -10.301,36.574 3.55,-10.863 15.047,-15.53 15.047,-15.53s5.637,-6.958 12.227,-16.888c-3.95,0.903 -10.43,2.442 -12.598,3.352 -3.2,1.344 -4.067,1.8 -4.067,1.8s10.371,-6.312 19.27,-9.171c12.234,-19.27 25.562,-46.648 12.141,-58.621"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,17 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<!-- Crisp White Shield Background -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M16,1 C10.2,4.8 8.2,4.8 2.5,4.8 C2.5,23.5 2.5,23.5 16,31 C29.5,23.5 29.5,23.5 29.5,4.8 C23.8,4.8 21.8,4.8 16,1 Z"/>
|
||||
<!-- uBlock Stylized 'u' and emblem in deep red -->
|
||||
<path
|
||||
android:fillColor="#FF800000"
|
||||
android:pathData="M7.943,9.964h2.016v6.047c0,1.76 0.249,2.011 2.015,2.011 1.767,0 2.016,-0.256 2.016,-2.016v-6.041h2.011v1.020c0.869,-0.661 1.932,-1.020 3.027,-1.020 2.776,0.009 5.025,2.26 5.031,5.041 -0.011,2.776 -2.26,5.025 -5.036,5.031 -1.349,-0.005 -2.641,-0.547 -3.584,-1.509 -0.615,1.025 -1.749,1.509 -3.464,1.509 -2.771,0 -4.031,-1.26 -4.031,-4.031z"/>
|
||||
<path
|
||||
android:fillColor="#FF800000"
|
||||
android:pathData="M19.021,11.979c-1.677,-0.009 -3.032,1.349 -3.021,3.027 -0.005,1.672 1.355,3.025 3.027,3.016 1.671,0.009 3.025,-1.349 3.015,-3.027 0.011,-1.672 -1.348,-3.025 -3.020,-3.016z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,26 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!-- Bottom horizontal bar -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M5,18h14a1,1 0 0,1 1,1v0a1,1 0 0,1 -1,1h-14a1,1 0 0,1 -1,-1v0a1,1 0 0,1 1,-1z" />
|
||||
<!-- Left vertical edge -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M4,15h2a1,1 0 0,1 1,1v3a1,1 0 0,1 -1,1h-2a1,1 0 0,1 -1,-1v-3a1,1 0 0,1 1,-1z" />
|
||||
<!-- Right vertical edge -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M18,15h2a1,1 0 0,1 1,1v3a1,1 0 0,1 -1,1h-2a1,1 0 0,1 -1,-1v-3a1,1 0 0,1 1,-1z" />
|
||||
<!-- Down arrow head -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,15a1,1 0 0,1 -0.58,-0.18l-4,-2.82a1,1 0 0,1 -0.24,-1.39a1,1 0 0,1 1.4,-0.24L12,12.76l3.4,-2.56a1,1 0 0,1 1.2,1.6l-4,3a1,1 0 0,1 -0.6,0.2z" />
|
||||
<!-- Arrow shaft -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,13a1,1 0 0,1 -1,-1V4a1,1 0 0,1 2,0v8a1,1 0 0,1 -1,1z" />
|
||||
</vector>
|
||||
@@ -64,4 +64,56 @@ class ReelsHistoryLogicTest {
|
||||
val averagePerDay = recentCount / 7.0
|
||||
assertEquals(2.0, averagePerDay, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReelHistoryOperations() {
|
||||
val tempFile = java.io.File.createTempFile("reel_test", ".json")
|
||||
try {
|
||||
val history = com.ujwal.focusgram.feature.history.ReelHistory(tempFile)
|
||||
assertEquals(0, history.sessionCount.value)
|
||||
assertEquals(0, history.items.value.size)
|
||||
|
||||
// Watch reel 1
|
||||
history.onWatched("abc12345", "photographer", "Nice view")
|
||||
assertEquals(1, history.sessionCount.value)
|
||||
assertEquals(1, history.items.value.size)
|
||||
assertEquals("abc12345", history.items.value[0].code)
|
||||
assertEquals("photographer", history.items.value[0].author)
|
||||
assertEquals("Nice view", history.items.value[0].caption)
|
||||
assertEquals(1, history.items.value[0].views)
|
||||
|
||||
// Watch reel 1 again (views increment, session count remains 1 unique)
|
||||
history.onWatched("abc12345", "", "")
|
||||
assertEquals(1, history.sessionCount.value)
|
||||
assertEquals(1, history.items.value.size)
|
||||
assertEquals(2, history.items.value[0].views)
|
||||
assertEquals("photographer", history.items.value[0].author)
|
||||
|
||||
// Meta update
|
||||
history.onMeta("abc12345", "photographer_pro", "Updated caption")
|
||||
assertEquals("photographer_pro", history.items.value[0].author)
|
||||
assertEquals("Updated caption", history.items.value[0].caption)
|
||||
|
||||
// Watch reel 2
|
||||
history.onWatched("def67890", "artist", "Artwork")
|
||||
assertEquals(2, history.sessionCount.value)
|
||||
assertEquals(2, history.items.value.size)
|
||||
|
||||
// Delete reel 1
|
||||
history.delete("abc12345")
|
||||
assertEquals(1, history.items.value.size)
|
||||
assertEquals("def67890", history.items.value[0].code)
|
||||
|
||||
// Clear history
|
||||
history.clear()
|
||||
assertEquals(0, history.items.value.size)
|
||||
assertEquals(2, history.sessionCount.value)
|
||||
|
||||
// Reset session
|
||||
history.resetSession()
|
||||
assertEquals(0, history.sessionCount.value)
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<rect width="16" height="2" x="4" y="18" fill="currentColor" rx="1" ry="1" />
|
||||
<rect width="4" height="2" x="3" y="17" fill="currentColor" rx="1" ry="1" transform="rotate(-90 5 18)" />
|
||||
<rect width="4" height="2" x="17" y="17" fill="currentColor" rx="1" ry="1" transform="rotate(-90 19 18)" />
|
||||
<path fill="currentColor" d="M12 15a1 1 0 0 1-.58-.18l-4-2.82a1 1 0 0 1-.24-1.39a1 1 0 0 1 1.4-.24L12 12.76l3.4-2.56a1 1 0 0 1 1.2 1.6l-4 3a1 1 0 0 1-.6.2" />
|
||||
<path fill="currentColor" d="M12 13a1 1 0 0 1-1-1V4a1 1 0 0 1 2 0v8a1 1 0 0 1-1 1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 610 B |
Reference in new issue
Block a user