mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-11-03 03:44:37 +01:00
Port(82% Done):
# Problem Fixes - > "Download Media" feature works completely for all media types (Posts, Stories, Reels) > Fixed a bug due to which file Picker wasn't opening > Fixed Performance bottlenecks > Fixed "Block Reels Swiping in DMs" not working properly and more... # New - > Lock App in potrait mode. > New dowload animations and UI. > Bottom nav bar has got that PREMIUM look now. and few more... # Known Problems: > Hide Likes Count doesnt work. there can be more bugs as v3 is incomplete. NOW COMES TOTALLY NEW FEATURES IN NEXT COMMITS ;)
This commit is contained in:
1 parent
fb2a367c83
commit
9b00435d72
36 files changed
+3603
-732
No files matched your search
@@ -126,6 +126,7 @@ dependencies {
|
||||
// Security & Biometric
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.biometric)
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
|
||||
// Coroutines
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
@@ -158,6 +159,8 @@ configurations.all {
|
||||
"androidx.lifecycle:lifecycle-common-java8:2.8.7",
|
||||
"androidx.core:core:1.15.0",
|
||||
"androidx.core:core-ktx:1.15.0",
|
||||
"androidx.fragment:fragment:1.8.6",
|
||||
"androidx.fragment:fragment-ktx:1.8.6",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib:2.0.21",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.0.21",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.0.21",
|
||||
|
||||
@@ -3,18 +3,15 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<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" />
|
||||
@@ -24,6 +21,33 @@
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||
|
||||
<!-- Package Visibility Queries for Android 11+ (API 30+) Intent Resolution -->
|
||||
<queries>
|
||||
<!-- System Document and Media Pickers -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.GET_CONTENT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.OPEN_DOCUMENT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PICK" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.provider.action.PICK_IMAGES" />
|
||||
</intent>
|
||||
<!-- Camera capture intents -->
|
||||
<intent>
|
||||
<action android:name="android.media.action.IMAGE_CAPTURE" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.media.action.VIDEO_CAPTURE" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".FocusGramApp"
|
||||
android:allowBackup="false"
|
||||
@@ -43,6 +67,7 @@
|
||||
android:exported="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -80,6 +105,17 @@
|
||||
android:value="FocusGram Digital Wellness Session & Screen Time Tracker" />
|
||||
</service>
|
||||
|
||||
<!-- FileProvider for temporary camera captures (Story, Post, DMs, Profile) -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -26,9 +26,11 @@ let cachedSettings = {
|
||||
ghostHideDirectSeen: true,
|
||||
ghostHideTypingIndicator: true,
|
||||
unblurMode: 'tap',
|
||||
videoDownloadEnabled: true
|
||||
videoDownloadEnabled: false
|
||||
};
|
||||
|
||||
let userIsAuthenticated = false;
|
||||
|
||||
const BLOCKED_URL_PATTERNS = [
|
||||
"*://*.instagram.com/logging_client_events*",
|
||||
"*://*.instagram.com/ajax/bz*",
|
||||
@@ -66,8 +68,15 @@ const GHOST_BLOCKED_URL_PATTERNS = [
|
||||
if (typeof browser !== 'undefined' && browser.webRequest && browser.webRequest.onBeforeRequest) {
|
||||
browser.webRequest.onBeforeRequest.addListener(
|
||||
function (details) {
|
||||
if (cachedSettings.noAds === false) {
|
||||
// User turned ad blocker off: do not cancel requests
|
||||
if (cachedSettings.noAds === false || !userIsAuthenticated) {
|
||||
// User turned ad blocker off or user is not logged in: do not cancel requests
|
||||
return { cancel: false };
|
||||
}
|
||||
const url = (details.url || '').toLowerCase();
|
||||
// NEVER block anything on or related to authentication, login, signup, or security checks
|
||||
if (url.includes('/accounts/') || url.includes('/login') || url.includes('/signup') ||
|
||||
url.includes('facebook.net') || url.includes('facebook.com') || url.includes('/ajax/bz') ||
|
||||
url.includes('/api/v1/web/accounts/') || url.includes('/api/v1/accounts/')) {
|
||||
return { cancel: false };
|
||||
}
|
||||
console.log('[Content Blocker] Intercepted and blocked network ad request:', details.url);
|
||||
@@ -157,6 +166,10 @@ if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessa
|
||||
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message) return;
|
||||
|
||||
if (message.action === 'authStateChanged') {
|
||||
userIsAuthenticated = !!message.isAuthenticated;
|
||||
}
|
||||
|
||||
if (message.action === 'getSettings') {
|
||||
sendResponse({ settings: cachedSettings });
|
||||
return true;
|
||||
|
||||
@@ -11,23 +11,40 @@
|
||||
const COSMETIC_STYLE_ID = 'focusgram-cosmetic-filters';
|
||||
let adBlockingEnabled = true;
|
||||
|
||||
function isAuthPage() {
|
||||
try {
|
||||
const path = (window.location.pathname || '').toLowerCase();
|
||||
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) {
|
||||
return true;
|
||||
}
|
||||
const cookies = document.cookie || '';
|
||||
const hasAuthCookie = cookies.includes('sessionid=') || cookies.includes('ds_user_id=');
|
||||
if ((path === '/' || path === '') && !hasAuthCookie) {
|
||||
return true;
|
||||
}
|
||||
if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) {
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAuthPage()) {
|
||||
const existing = document.getElementById(COSMETIC_STYLE_ID);
|
||||
if (existing) existing.remove();
|
||||
return; // NEVER run on auth, login, or signup pages
|
||||
}
|
||||
|
||||
const CSS_RULES = `
|
||||
/* Hide sponsored feed articles and containers without breaking Infinite Scroll IntersectionObserver */
|
||||
/* Hide sponsored feed articles without breaking Infinite Scroll IntersectionObserver or feed containers */
|
||||
div[data-ad-preview],
|
||||
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*="/ad_"]),
|
||||
article:has(a[href*="ads.instagram.com"]),
|
||||
article[data-ad-filtered="true"] {
|
||||
display: none !important;
|
||||
height: 0px !important;
|
||||
min-height: 0px !important;
|
||||
max-height: 0px !important;
|
||||
height: 1px !important;
|
||||
min-height: 1px !important;
|
||||
max-height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
@@ -71,7 +88,7 @@
|
||||
];
|
||||
|
||||
const STRICT_AD_CTAS = [
|
||||
'shop now', 'install now', 'sign up', 'apply now',
|
||||
'shop now', 'install now', 'apply now',
|
||||
'book now', 'order now', 'get offer'
|
||||
];
|
||||
|
||||
@@ -127,7 +144,6 @@
|
||||
article.style.setProperty('margin', '0', 'important');
|
||||
article.style.setProperty('padding', '0', 'important');
|
||||
article.dataset.adFiltered = 'true';
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "FocusGram Content Engine",
|
||||
"version": "1.61.0",
|
||||
"version": "2.1.0",
|
||||
"description": "Privacy-preserving network-level content blocking, stealth protection, and cosmetic filtering for FocusGram.",
|
||||
"author": "FocusGram Project",
|
||||
"homepage_url": "https://github.com/Ujwal223/FocusGram",
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
function isAuthPage() {
|
||||
try {
|
||||
var path = (window.location.pathname || '').toLowerCase();
|
||||
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true;
|
||||
var c = document.cookie || '';
|
||||
if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true;
|
||||
if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true;
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAuthPage()) return; // Leave login/signup completely untouched
|
||||
|
||||
if (window.__fgAutoplayPatched) return;
|
||||
window.__fgAutoplayPatched = true;
|
||||
|
||||
|
||||
@@ -13,6 +13,23 @@
|
||||
(function () {
|
||||
const STYLE_ID = 'focusgram-dynamic-guardrails-styles';
|
||||
|
||||
function isAuthPage() {
|
||||
try {
|
||||
var path = (window.location.pathname || '').toLowerCase();
|
||||
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true;
|
||||
var c = document.cookie || '';
|
||||
if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true;
|
||||
if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true;
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAuthPage()) {
|
||||
var existing = document.getElementById(STYLE_ID);
|
||||
if (existing) existing.remove();
|
||||
return; // Leave login/signup completely untouched
|
||||
}
|
||||
|
||||
// 1. Client-Side SPA Navigation Helper
|
||||
window.__focusgramNavigate = function (path) {
|
||||
if (!path) return;
|
||||
@@ -286,16 +303,13 @@
|
||||
|
||||
if (config.noAds !== false) {
|
||||
css += `
|
||||
article:has(a[href*="/ad_"]),
|
||||
article:has(a[href*="/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"] {
|
||||
display: none !important;
|
||||
height: 0px !important;
|
||||
min-height: 0px !important;
|
||||
max-height: 0px !important;
|
||||
height: 1px !important;
|
||||
min-height: 1px !important;
|
||||
max-height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
@@ -309,8 +323,7 @@
|
||||
}
|
||||
div[data-ad-preview],
|
||||
div[data-testid="post_ad"],
|
||||
div[data-testid="suggested-users"],
|
||||
div[aria-label="Ad" i]:not(main):not([role="feed"]) {
|
||||
div[data-testid="suggested-users"] {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
@@ -373,7 +386,6 @@
|
||||
article.style.setProperty('margin', '0', 'important');
|
||||
article.style.setProperty('padding', '0', 'important');
|
||||
article.dataset.fgAdFiltered = 'true';
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,19 @@
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
function isAuthPage() {
|
||||
try {
|
||||
var path = (window.location.pathname || '').toLowerCase();
|
||||
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true;
|
||||
var c = document.cookie || '';
|
||||
if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true;
|
||||
if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true;
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAuthPage()) return; // Leave login/signup completely untouched
|
||||
|
||||
if (window.__fgFetchInterceptorInstalled) return;
|
||||
window.__fgFetchInterceptorInstalled = true;
|
||||
|
||||
@@ -27,7 +40,7 @@
|
||||
const AD_KEYWORDS = [
|
||||
'sponsored', 'gesponsert', 'sponsorisé', 'sponsorise', 'publicidad',
|
||||
'patrocinado', 'sponsorizzato', 'sponsrad', 'paid partnership', 'werbung',
|
||||
'anuncio', 'werbeanzeige', 'publicité', 'ad_id', 'ad_tracking', 'sponsor_tags'
|
||||
'anuncio', 'werbeanzeige', 'publicité'
|
||||
];
|
||||
|
||||
function textHasAdSignal(value) {
|
||||
@@ -39,69 +52,55 @@
|
||||
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
|
||||
});
|
||||
if (node.is_ad || node.is_paid_partnership || (Array.isArray(node.sponsor_tags) && node.sponsor_tags.length > 0)) {
|
||||
return true;
|
||||
}
|
||||
if (node.ad_id || node.ad_action_link || node.ad_impression_token || node.ad_metadata) {
|
||||
return true;
|
||||
}
|
||||
if (node.commerciality_status === 'commercial' || (node.product_type && node.product_type === 'ad')) {
|
||||
return true;
|
||||
}
|
||||
if (node.ad_header_style && node.ad_header_style !== 'none') {
|
||||
return true;
|
||||
}
|
||||
if (typename === 'GraphAdStory' || typename.includes('Ad')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
const values = [node.social_context, node.title, node.header, node.label, node.overlay_text]
|
||||
.filter(Boolean).map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(' ');
|
||||
|
||||
return textHasAdSignal(values);
|
||||
}
|
||||
|
||||
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 || ''))
|
||||
);
|
||||
if (node.is_paid_partnership || (Array.isArray(node.sponsor_tags) && node.sponsor_tags.length > 0)) {
|
||||
return true;
|
||||
}
|
||||
if (node.ad_action_link && node.ad_action_link.href) {
|
||||
return true;
|
||||
}
|
||||
if (node.ad_header_style && node.ad_header_style !== 'none') {
|
||||
return true;
|
||||
}
|
||||
const text = [node.social_context, node.header, node.label].filter(Boolean).map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(' ');
|
||||
return textHasAdSignal(text);
|
||||
}
|
||||
|
||||
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')
|
||||
);
|
||||
if (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')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const text = [node.suggested_reason, node.social_context, node.title, node.header, node.label]
|
||||
.filter(Boolean).map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(' ').toLowerCase();
|
||||
return text.includes('suggested') || text.includes('recommend');
|
||||
}
|
||||
|
||||
function shouldFilterNode(node) {
|
||||
@@ -128,7 +127,7 @@
|
||||
|
||||
function unwrapNode(item) {
|
||||
if (!item || typeof item !== 'object') return item;
|
||||
return item.media || item.node || item.post || item;
|
||||
return item.media_or_ad || item.media || item.node || item.post || item;
|
||||
}
|
||||
|
||||
function filterList(list) {
|
||||
@@ -165,6 +164,8 @@
|
||||
}
|
||||
|
||||
for (const key in obj) {
|
||||
// Never filter pagination or connection metadata
|
||||
if (key === 'page_info' || key === 'pagination_info' || key === 'paging_info') continue;
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key) && key !== '__typename') {
|
||||
const val = obj[key];
|
||||
if (val && typeof val === 'object') {
|
||||
@@ -174,6 +175,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
window.__fg_media_harvest = window.__fg_media_harvest || {};
|
||||
window.__fg_recent_media = window.__fg_recent_media || [];
|
||||
|
||||
function harvestMediaFromData(obj, depth = 0) {
|
||||
if (!obj || typeof obj !== 'object' || depth > 12) return;
|
||||
if (Array.isArray(obj)) {
|
||||
for (let i = 0; i < obj.length; i++) harvestMediaFromData(obj[i], depth + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const code = obj.code || (obj.shortcode ? obj.shortcode : null);
|
||||
if (code && (obj.video_versions || obj.image_versions2 || obj.carousel_media)) {
|
||||
const isVideo = !!(obj.is_video || (obj.video_versions && obj.video_versions.length > 0));
|
||||
const videoUrl = (obj.video_versions && obj.video_versions[0]?.url) || '';
|
||||
const imageUrl = (obj.image_versions2?.candidates && obj.image_versions2.candidates[0]?.url) || '';
|
||||
|
||||
const carousel = [];
|
||||
if (Array.isArray(obj.carousel_media)) {
|
||||
for (let c = 0; c < obj.carousel_media.length; c++) {
|
||||
const cItem = obj.carousel_media[c];
|
||||
const cIsVideo = !!(cItem.is_video || (cItem.video_versions && cItem.video_versions.length > 0));
|
||||
const cVideoUrl = (cItem.video_versions && cItem.video_versions[0]?.url) || '';
|
||||
const cImageUrl = (cItem.image_versions2?.candidates && cItem.image_versions2.candidates[0]?.url) || '';
|
||||
carousel.push({
|
||||
url: cIsVideo && cVideoUrl ? cVideoUrl : cImageUrl,
|
||||
videoUrl: cVideoUrl,
|
||||
imageUrl: cImageUrl,
|
||||
isVideo: cIsVideo && !!cVideoUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const entry = {
|
||||
code: code,
|
||||
videoUrl: videoUrl,
|
||||
imageUrl: imageUrl,
|
||||
url: isVideo && videoUrl ? videoUrl : (imageUrl || videoUrl),
|
||||
isVideo: isVideo && !!videoUrl,
|
||||
carousel: carousel,
|
||||
author: obj.user?.username || obj.owner?.username || ''
|
||||
};
|
||||
|
||||
window.__fg_media_harvest[code] = entry;
|
||||
window.__fg_recent_media.unshift(entry);
|
||||
if (window.__fg_recent_media.length > 100) {
|
||||
window.__fg_recent_media.pop();
|
||||
}
|
||||
}
|
||||
|
||||
for (const k in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, k) && k !== '__typename' && typeof obj[k] === 'object') {
|
||||
harvestMediaFromData(obj[k], depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override fetch in the main world
|
||||
const _fetch = window.fetch.bind(window);
|
||||
|
||||
@@ -204,12 +261,20 @@
|
||||
}
|
||||
|
||||
const data = await cloned.json();
|
||||
harvestMediaFromData(data);
|
||||
walkAndFilter(data);
|
||||
|
||||
const newHeaders = new Headers(response.headers);
|
||||
// CRITICAL: Must delete content-length and content-encoding so the uncompressed
|
||||
// JSON body is not truncated or corrupted by the browser HTTP layer
|
||||
newHeaders.delete('content-length');
|
||||
newHeaders.delete('content-encoding');
|
||||
newHeaders.set('content-type', 'application/json; charset=utf-8');
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers
|
||||
headers: newHeaders
|
||||
});
|
||||
} catch (_) {
|
||||
return response;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,19 @@
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
function isAuthPage() {
|
||||
try {
|
||||
var path = (window.location.pathname || '').toLowerCase();
|
||||
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true;
|
||||
var c = document.cookie || '';
|
||||
if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true;
|
||||
if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true;
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAuthPage()) return; // Leave login/signup completely untouched
|
||||
|
||||
if (window.__fgNativeFeelInitialized) return;
|
||||
window.__fgNativeFeelInitialized = true;
|
||||
|
||||
@@ -104,17 +117,112 @@
|
||||
}
|
||||
}
|
||||
|
||||
function enhanceFileInputsForVideoSupport() {
|
||||
function patchInput(input) {
|
||||
if (!input || input.tagName !== 'INPUT' || input.type !== 'file') return;
|
||||
// Never patch profile avatar uploads
|
||||
const path = window.location.pathname.toLowerCase();
|
||||
if (path.includes('/accounts/edit')) return;
|
||||
|
||||
const currentAccept = input.getAttribute('accept') || '';
|
||||
if (currentAccept && currentAccept.includes('image') && !currentAccept.includes('video')) {
|
||||
// Broaden accept attribute to allow video formats (MP4, MOV, WebM, etc.)
|
||||
input.setAttribute('accept', currentAccept + ',video/mp4,video/quicktime,video/webm,video/*');
|
||||
} else if (!currentAccept) {
|
||||
input.setAttribute('accept', 'image/*,video/*');
|
||||
}
|
||||
}
|
||||
|
||||
// Patch any existing inputs immediately
|
||||
document.querySelectorAll('input[type="file"]').forEach(patchInput);
|
||||
|
||||
// Intercept pointer/click before it triggers onFilePrompt to guarantee accept attribute is expanded
|
||||
document.addEventListener('pointerdown', function(e) {
|
||||
const target = e.target;
|
||||
if (!target) return;
|
||||
const trigger = target.closest('button, [role="button"], a, div[role="menuitem"], label');
|
||||
const fileInput = (target.tagName === 'INPUT' && target.type === 'file') ? target :
|
||||
(trigger ? (trigger.querySelector('input[type="file"]') || (trigger.parentElement ? trigger.parentElement.querySelector('input[type="file"]') : null)) : null);
|
||||
if (fileInput) {
|
||||
patchInput(fileInput);
|
||||
}
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
// Observe dynamically created upload dialogs (e.g. DM media buttons, Story camera, New Post)
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
for (let i = 0; i < mutations.length; i++) {
|
||||
const added = mutations[i].addedNodes;
|
||||
for (let j = 0; j < added.length; j++) {
|
||||
const node = added[j];
|
||||
if (node.nodeType === 1) {
|
||||
if (node.tagName === 'INPUT' && node.type === 'file') {
|
||||
patchInput(node);
|
||||
} else if (node.querySelectorAll) {
|
||||
node.querySelectorAll('input[type="file"]').forEach(patchInput);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
function setupMediaUploadBridge() {
|
||||
// Ensures user taps on Instagram media upload triggers (Story +, Post +, DMs photo, Profile photo)
|
||||
// reliably trigger the underlying input[type="file"] element.
|
||||
document.addEventListener('click', function(e) {
|
||||
const target = e.target;
|
||||
if (!target) return;
|
||||
if (target.tagName === 'INPUT' && target.type === 'file') return;
|
||||
|
||||
const trigger = target.closest('button, [role="button"], a, div[role="menuitem"], label');
|
||||
if (!trigger) return;
|
||||
|
||||
const fileInput = trigger.querySelector('input[type="file"]') ||
|
||||
(trigger.parentElement ? trigger.parentElement.querySelector('input[type="file"]') : null);
|
||||
|
||||
if (fileInput && fileInput !== target) {
|
||||
try {
|
||||
fileInput.click();
|
||||
} catch (_) {}
|
||||
}
|
||||
}, { capture: true, passive: true });
|
||||
}
|
||||
|
||||
function preventLandscapeOrientationLock() {
|
||||
// Blocks web pages / HTML5 video players from forcing device orientation into landscape
|
||||
// when portrait-only mode is active in FocusGram settings.
|
||||
try {
|
||||
if (screen.orientation && typeof screen.orientation.lock === 'function') {
|
||||
const origLock = screen.orientation.lock.bind(screen.orientation);
|
||||
screen.orientation.lock = function(orientation) {
|
||||
const isPortraitOnly = !window.__FOCUSGRAM_CONFIG__ || window.__FOCUSGRAM_CONFIG__.portraitOnly !== false;
|
||||
if (isPortraitOnly && orientation && typeof orientation === 'string' && orientation.includes('landscape')) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return origLock(orientation);
|
||||
};
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Initialize immediately or upon DOM ready
|
||||
injectNativeStyles();
|
||||
applyScrollSmoothing();
|
||||
setupKeyboardViewportFix();
|
||||
setupMediaUploadBridge();
|
||||
enhanceFileInputsForVideoSupport();
|
||||
preventLandscapeOrientationLock();
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
injectNativeStyles();
|
||||
applyScrollSmoothing();
|
||||
setupMediaUploadBridge();
|
||||
enhanceFileInputsForVideoSupport();
|
||||
preventLandscapeOrientationLock();
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[FocusGram] Native Feel & Touch Smoothing active.');
|
||||
console.log('[FocusGram] Native Feel, Touch Smoothing, Media Upload Bridge & Video Support active.');
|
||||
})();
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ujwal.focusgram
|
||||
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
@@ -16,6 +17,7 @@ import com.ujwal.focusgram.ui.FocusGramApp
|
||||
class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
enableEdgeToEdge()
|
||||
applyHighRefreshRate()
|
||||
setContent {
|
||||
|
||||
@@ -74,10 +74,11 @@ 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] ?: true,
|
||||
videoDownloadEnabled = prefs[KEY_VIDEO_DOWNLOAD_ENABLED] ?: false,
|
||||
sanitizeLinks = prefs[KEY_SANITIZE_LINKS] ?: true,
|
||||
notifySessionEnd = prefs[KEY_NOTIFY_SESSION_END] ?: false,
|
||||
notifyPersistent = prefs[KEY_NOTIFY_PERSISTENT] ?: false,
|
||||
portraitOnly = prefs[KEY_PORTRAIT_ONLY] ?: true,
|
||||
|
||||
ghostModeEnabled = prefs[KEY_GHOST_MODE_ENABLED] ?: false,
|
||||
ghostHideStorySeen = prefs[KEY_GHOST_HIDE_STORY_SEEN] ?: true,
|
||||
@@ -154,6 +155,7 @@ class SettingsRepository(private val context: Context) {
|
||||
suspend fun setSanitizeLinks(enabled: Boolean) = setBoolean(KEY_SANITIZE_LINKS, enabled)
|
||||
suspend fun setNotifySessionEnd(enabled: Boolean) = setBoolean(KEY_NOTIFY_SESSION_END, enabled)
|
||||
suspend fun setNotifyPersistent(enabled: Boolean) = setBoolean(KEY_NOTIFY_PERSISTENT, enabled)
|
||||
suspend fun setPortraitOnly(enabled: Boolean) = setBoolean(KEY_PORTRAIT_ONLY, enabled)
|
||||
|
||||
suspend fun addInstaMinutes(minutes: Int) {
|
||||
context.dataStore.edit { prefs ->
|
||||
@@ -286,6 +288,7 @@ class SettingsRepository(private val context: Context) {
|
||||
val KEY_SANITIZE_LINKS = booleanPreferencesKey("set_sanitize_links")
|
||||
val KEY_NOTIFY_SESSION_END = booleanPreferencesKey("set_notify_session_end")
|
||||
val KEY_NOTIFY_PERSISTENT = booleanPreferencesKey("set_notify_persistent")
|
||||
val KEY_PORTRAIT_ONLY = booleanPreferencesKey("portrait_only")
|
||||
|
||||
val KEY_GHOST_MODE_ENABLED = booleanPreferencesKey("ghost_mode_enabled")
|
||||
val KEY_GHOST_HIDE_STORY_SEEN = booleanPreferencesKey("ghost_hide_story_seen")
|
||||
|
||||
@@ -34,6 +34,9 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
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
|
||||
var onDownloadError: ((error: String) -> Unit)? = null
|
||||
var onDownloadStarted: ((filename: String) -> Unit)? = null
|
||||
var onAuthStateChanged: ((isAuthenticated: Boolean, isAuthPage: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Installs and activates built-in content blocking and FocusGram controller from APK assets.
|
||||
@@ -42,9 +45,16 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
contentBlockerExtension?.let { return Result.success(it) }
|
||||
return try {
|
||||
Log.i(TAG, "Mounting built-in Content Blocker WebExtension from assets...")
|
||||
val extension = runtime.webExtensionController
|
||||
.ensureBuiltIn(CONTENT_BLOCKER_ASSET_URI, CONTENT_BLOCKER_EXTENSION_ID)
|
||||
.await()
|
||||
val extension = try {
|
||||
runtime.webExtensionController
|
||||
.installBuiltIn(CONTENT_BLOCKER_ASSET_URI)
|
||||
.await()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "installBuiltIn fallback to ensureBuiltIn: ${e.message}")
|
||||
runtime.webExtensionController
|
||||
.ensureBuiltIn(CONTENT_BLOCKER_ASSET_URI, CONTENT_BLOCKER_EXTENSION_ID)
|
||||
.await()
|
||||
}
|
||||
|
||||
if (extension != null) {
|
||||
try {
|
||||
@@ -177,12 +187,32 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
}
|
||||
}
|
||||
}
|
||||
"downloadStarted" -> {
|
||||
val filename = json.optString("filename")
|
||||
Log.d(TAG, "Received downloadStarted from web: filename=$filename")
|
||||
mainHandler.post {
|
||||
onDownloadStarted?.invoke(filename)
|
||||
}
|
||||
}
|
||||
"downloadMedia" -> {
|
||||
val mediaUrl = json.optString("url")
|
||||
val filename = json.optString("filename")
|
||||
Log.d(TAG, "Received downloadMedia from web: url=$mediaUrl, filename=$filename")
|
||||
val error = json.optString("error")
|
||||
Log.d(TAG, "Received downloadMedia from web: url=$mediaUrl, filename=$filename, error=$error")
|
||||
mainHandler.post {
|
||||
onDownloadRequested?.invoke(mediaUrl, filename)
|
||||
if (mediaUrl.isNotBlank()) {
|
||||
onDownloadRequested?.invoke(mediaUrl, filename)
|
||||
} else if (error.isNotBlank()) {
|
||||
onDownloadError?.invoke(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
"authStateChanged" -> {
|
||||
val isAuthenticated = json.optBoolean("isAuthenticated", true)
|
||||
val isAuthPage = json.optBoolean("isAuthPage", false)
|
||||
Log.d(TAG, "Received authStateChanged from web: isAuthenticated=$isAuthenticated, isAuthPage=$isAuthPage")
|
||||
mainHandler.post {
|
||||
onAuthStateChanged?.invoke(isAuthenticated, isAuthPage)
|
||||
}
|
||||
}
|
||||
"getSettings" -> {
|
||||
@@ -219,6 +249,7 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
|
||||
put("grayscale", settings.grayscaleEnabled)
|
||||
put("minimalMode", settings.minimalModeEnabled)
|
||||
put("videoDownloadEnabled", settings.videoDownloadEnabled)
|
||||
put("portraitOnly", settings.portraitOnly)
|
||||
put("ghostModeEnabled", settings.ghostModeEnabled)
|
||||
put("ghostHideStorySeen", settings.ghostHideStorySeen)
|
||||
put("ghostHideDirectSeen", settings.ghostHideDirectSeen)
|
||||
|
||||
+18
@@ -38,6 +38,23 @@ class FocusGramNavigationDelegate(
|
||||
return GeckoResult.fromValue(AllowOrDeny.DENY)
|
||||
}
|
||||
|
||||
// 2b. Intercept native media download requests from in-page client scripts
|
||||
if (scheme == "focusgram-download") {
|
||||
val mediaUrl = uri.getQueryParameter("url") ?: ""
|
||||
val filename = uri.getQueryParameter("filename") ?: ""
|
||||
val error = uri.getQueryParameter("error") ?: ""
|
||||
Log.d(TAG, "Intercepted focusgram-download: url=$mediaUrl, filename=$filename, error=$error")
|
||||
val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
mainHandler.post {
|
||||
if (mediaUrl.isNotBlank()) {
|
||||
com.ujwal.focusgram.core.utils.MediaDownloadHelper.download(context, mediaUrl, filename)
|
||||
} else if (error.isNotBlank()) {
|
||||
android.widget.Toast.makeText(context, error, android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
return GeckoResult.fromValue(AllowOrDeny.DENY)
|
||||
}
|
||||
|
||||
// 3. Intercept navigation trap to suggested accounts (/explore/people) from Favorites
|
||||
val path = uri.path?.lowercase() ?: ""
|
||||
if (path.contains("/explore/people")) {
|
||||
@@ -82,6 +99,7 @@ class FocusGramNavigationDelegate(
|
||||
hasUserGesture: Boolean
|
||||
) {
|
||||
url?.let {
|
||||
if (it.startsWith("focusgram-download:")) return
|
||||
if (it.lowercase().contains("/explore/people")) {
|
||||
Log.i(TAG, "Location redirected away from /explore/people to home")
|
||||
sessionManager.loadUrl("https://www.instagram.com/")
|
||||
|
||||
+17
-4
@@ -30,7 +30,18 @@ class FocusGramPermissionDelegate(
|
||||
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
if (ungranted.isEmpty()) {
|
||||
// On Android 13+ (API 33+), READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE are deprecated
|
||||
// and automatically denied by the OS. System file pickers do not require storage permissions.
|
||||
val actionableUngranted = if (android.os.Build.VERSION.SDK_INT >= 33) {
|
||||
ungranted.filter {
|
||||
it != android.Manifest.permission.READ_EXTERNAL_STORAGE &&
|
||||
it != android.Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
}
|
||||
} else {
|
||||
ungranted
|
||||
}
|
||||
|
||||
if (actionableUngranted.isEmpty()) {
|
||||
callback.grant()
|
||||
return
|
||||
}
|
||||
@@ -38,12 +49,14 @@ class FocusGramPermissionDelegate(
|
||||
if (onAndroidPermissionRequest != null) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
onAndroidPermissionRequest.invoke(ungranted.toTypedArray()) { allGranted ->
|
||||
if (allGranted) callback.grant() else callback.reject()
|
||||
onAndroidPermissionRequest.invoke(actionableUngranted.toTypedArray()) { granted ->
|
||||
// Always grant to GeckoView so it proceeds to onFilePrompt() where
|
||||
// native ActivityResultContracts (Photo Picker / SAF) handle file access securely.
|
||||
callback.grant()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error requesting Android permissions: ${e.message}", e)
|
||||
callback.reject()
|
||||
callback.grant()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
+9
-2
@@ -24,6 +24,7 @@ class FocusGramPromptDelegate(
|
||||
fun onPickFiles(
|
||||
mimeTypes: Array<String>,
|
||||
allowMultiple: Boolean,
|
||||
capture: Int,
|
||||
onResult: (List<Uri>) -> Unit
|
||||
)
|
||||
}
|
||||
@@ -34,12 +35,15 @@ class FocusGramPromptDelegate(
|
||||
): GeckoResult<GeckoSession.PromptDelegate.PromptResponse> {
|
||||
val handler = filePickerHandler
|
||||
if (handler == null) {
|
||||
Log.d(TAG, "No FilePickerHandler attached. Dismissing file prompt.")
|
||||
Log.w(TAG, "No FilePickerHandler attached. Dismissing file prompt.")
|
||||
return GeckoResult.fromValue(prompt.dismiss())
|
||||
}
|
||||
|
||||
val mimeTypes = prompt.mimeTypes ?: arrayOf("*/*")
|
||||
val isMultiple = prompt.type == GeckoSession.PromptDelegate.FilePrompt.Type.MULTIPLE
|
||||
val capture = prompt.capture
|
||||
|
||||
Log.i(TAG, "onFilePrompt triggered: type=${prompt.type}, isMultiple=$isMultiple, capture=$capture, mimeTypes=${mimeTypes.joinToString()}")
|
||||
|
||||
val result = GeckoResult<GeckoSession.PromptDelegate.PromptResponse>()
|
||||
|
||||
@@ -47,13 +51,16 @@ class FocusGramPromptDelegate(
|
||||
// ActivityResultLauncher.launch() MUST be called on the main Looper thread.
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
handler.onPickFiles(mimeTypes, isMultiple) { uris ->
|
||||
handler.onPickFiles(mimeTypes, isMultiple, capture) { uris ->
|
||||
try {
|
||||
if (uris.isEmpty()) {
|
||||
Log.i(TAG, "File picker dismissed or canceled by user.")
|
||||
result.complete(prompt.dismiss())
|
||||
} else if (isMultiple) {
|
||||
Log.i(TAG, "Confirming multiple files (${uris.size}) to GeckoView: ${uris.joinToString()}")
|
||||
result.complete(prompt.confirm(context, uris.toTypedArray()))
|
||||
} else {
|
||||
Log.i(TAG, "Confirming single file to GeckoView: ${uris.first()}")
|
||||
result.complete(prompt.confirm(context, uris.first()))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -44,6 +44,21 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
|
||||
val instagramTheme: StateFlow<String> = _instagramTheme.asStateFlow()
|
||||
|
||||
private var isOpen = false
|
||||
var isAuthenticated: Boolean = false
|
||||
private set
|
||||
|
||||
fun setAuthenticated(auth: Boolean) {
|
||||
val changed = isAuthenticated != auth
|
||||
isAuthenticated = auth
|
||||
if (auth && changed) {
|
||||
val url = _currentUrl.value.lowercase()
|
||||
val isAuth = url.contains("/accounts") || url.contains("/login") || url.contains("/signup")
|
||||
if (!isAuth) {
|
||||
injectBottomBarTrimmer()
|
||||
userScriptManager?.injectAll(session, currentSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
val settings = GeckoSessionSettings.Builder()
|
||||
@@ -51,6 +66,7 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
|
||||
.suspendMediaWhenInactive(true)
|
||||
.allowJavascript(true)
|
||||
.userAgentMode(GeckoSessionSettings.USER_AGENT_MODE_MOBILE)
|
||||
.userAgentOverride(IOS_USER_AGENT)
|
||||
.usePrivateMode(false) // Retain Instagram authentication cookies locally
|
||||
.build()
|
||||
|
||||
@@ -62,9 +78,11 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
|
||||
session.progressDelegate = object : GeckoSession.ProgressDelegate {
|
||||
override fun onPageStart(session: GeckoSession, url: String) {
|
||||
Log.d(TAG, "Page started loading: $url")
|
||||
_isLoading.value = true
|
||||
_currentUrl.value = url
|
||||
_progress.value = 10
|
||||
if (!url.startsWith("focusgram-download:") && !url.startsWith("javascript:")) {
|
||||
_isLoading.value = true
|
||||
_currentUrl.value = url
|
||||
_progress.value = 10
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageStop(session: GeckoSession, success: Boolean) {
|
||||
@@ -72,8 +90,12 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
|
||||
_isLoading.value = false
|
||||
_progress.value = 100
|
||||
if (success) {
|
||||
injectBottomBarTrimmer()
|
||||
userScriptManager?.injectAll(session, currentSettings)
|
||||
val url = _currentUrl.value.lowercase()
|
||||
val isAuth = url.contains("/accounts") || url.contains("/login") || url.contains("/signup")
|
||||
if (isAuthenticated && !isAuth) {
|
||||
injectBottomBarTrimmer()
|
||||
userScriptManager?.injectAll(session, currentSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +167,9 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
|
||||
|
||||
fun updateSettings(settings: FocusSettings) {
|
||||
currentSettings = settings
|
||||
userScriptManager?.injectConfig(session, settings)
|
||||
if (isAuthenticated) {
|
||||
userScriptManager?.injectConfig(session, settings)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCurrentUrl(url: String) {
|
||||
@@ -275,7 +299,11 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
|
||||
fun injectBottomBarTrimmer() {
|
||||
val js = """javascript:(function() {
|
||||
try {
|
||||
if (window.location.pathname.startsWith('/direct')) return;
|
||||
var p = (window.location.pathname || '').toLowerCase();
|
||||
if (p.startsWith('/direct') || p.startsWith('/accounts') || p.startsWith('/login') || p.startsWith('/signup')) return;
|
||||
var c = document.cookie || '';
|
||||
if ((p === '/' || p === '') && !c.includes('sessionid=') && !c.includes('ds_user_id=')) return;
|
||||
if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return;
|
||||
document.querySelectorAll('a[href="/"], a[href="/?variant=home"], svg[aria-label="Home"], a[href*="/explore/"]').forEach(function(target) {
|
||||
var el = target.closest('a') || target;
|
||||
while (el && el !== document.body && el !== document.documentElement) {
|
||||
|
||||
@@ -50,7 +50,8 @@ class UserScriptManager(private val context: Context) {
|
||||
blockAutoplay: ${settings.noAutoplay},
|
||||
grayscale: ${settings.grayscaleEnabled},
|
||||
minimalMode: ${settings.minimalModeEnabled},
|
||||
videoDownloadEnabled: ${settings.videoDownloadEnabled}
|
||||
videoDownloadEnabled: ${settings.videoDownloadEnabled},
|
||||
portraitOnly: ${settings.portraitOnly}
|
||||
};
|
||||
window.__FOCUSGRAM_GHOST_CONFIG__ = {
|
||||
enabled: ${settings.ghostModeEnabled},
|
||||
@@ -121,6 +122,7 @@ class UserScriptManager(private val context: Context) {
|
||||
bundle.append(loadScript("autoplay_blocker.js")).append("\n")
|
||||
bundle.append(loadScript("grayscale.js")).append("\n")
|
||||
bundle.append(loadScript("native_feel.js")).append("\n")
|
||||
bundle.append(loadScript("media_downloader.js")).append("\n")
|
||||
evaluate(session, bundle.toString())
|
||||
injectConfig(session, settings)
|
||||
}
|
||||
|
||||
@@ -58,10 +58,11 @@ data class FocusSettings(
|
||||
|
||||
// Extras & Notifications
|
||||
val startupPage: String = "home",
|
||||
val videoDownloadEnabled: Boolean = true,
|
||||
val videoDownloadEnabled: Boolean = false,
|
||||
val sanitizeLinks: Boolean = true,
|
||||
val notifySessionEnd: Boolean = false,
|
||||
val notifyPersistent: Boolean = false,
|
||||
val portraitOnly: Boolean = true,
|
||||
|
||||
// Ghost Mode (Privacy & Stealth)
|
||||
val ghostModeEnabled: Boolean = false,
|
||||
|
||||
+5
-35
@@ -8,6 +8,7 @@ import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.view.HapticFeedbackConstants
|
||||
import com.ujwal.focusgram.core.utils.HapticHelper
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -383,41 +384,10 @@ private fun getVibrator(context: Context): Vibrator? {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Uses direct physical vibration (35ms pulse) to ensure tactile feedback on all devices
|
||||
* (both ERM and LRA motors, like MX Player), without relying on unsupported predefined effects.
|
||||
*/
|
||||
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) {}
|
||||
HapticHelper.performWheelTick(context, view)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.ujwal.focusgram.core.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
|
||||
/**
|
||||
* HapticHelper
|
||||
* Universal haptic feedback and vibration utility for FocusGram.
|
||||
*
|
||||
* Guarantees physical vibration across all hardware motors (ERM & LRA),
|
||||
* regardless of whether system "Touch feedback" is disabled in user Settings.
|
||||
* Emulates the proven direct-hardware approach of media apps like MX Player
|
||||
* by pulsing the physical vibrator directly with sufficient duration (35ms)
|
||||
* to overcome motor inertia without relying on unsupported predefined effects.
|
||||
*/
|
||||
object HapticHelper {
|
||||
|
||||
private const val TAG = "HapticHelper"
|
||||
private var lastWheelTickTime = 0L
|
||||
|
||||
/**
|
||||
* Retrieves the system Vibrator service using modern, non-deprecated APIs.
|
||||
*/
|
||||
fun getVibrator(context: Context): Vibrator? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager = context.getSystemService(VibratorManager::class.java)
|
||||
vibratorManager?.defaultVibrator
|
||||
} else {
|
||||
context.getSystemService(Vibrator::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a crisp, responsive drum-wheel scroll tick.
|
||||
* Direct hardware pulse of 35ms ensures ERM motors overcome inertia and
|
||||
* rotate to produce an unmistakable tactile click, while LRA motors give a crisp bump.
|
||||
* Debounced at 35ms to ensure smooth performance during rapid flings.
|
||||
*/
|
||||
fun performWheelTick(context: Context, view: View? = null) {
|
||||
val now = SystemClock.uptimeMillis()
|
||||
if (now - lastWheelTickTime < 35L) return
|
||||
lastWheelTickTime = now
|
||||
|
||||
val vibrator = getVibrator(context)
|
||||
if (vibrator != null && vibrator.hasVibrator()) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val effect = VibrationEffect.createOneShot(35L, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
vibrator.vibrate(effect)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(35L)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Hardware wheel tick failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Companion View haptic for devices that integrate with platform accessibility
|
||||
try {
|
||||
view?.performHapticFeedback(
|
||||
HapticFeedbackConstants.CLOCK_TICK,
|
||||
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an alert vibration pattern when a focus session timer expires,
|
||||
* daily limit is exhausted, or the lock screen activates.
|
||||
* Pattern: Buzz 280ms -> Pause 140ms -> Buzz 280ms.
|
||||
*/
|
||||
fun performSessionCompletionAlert(context: Context) {
|
||||
val vibrator = getVibrator(context) ?: return
|
||||
if (!vibrator.hasVibrator()) return
|
||||
|
||||
try {
|
||||
val pattern = longArrayOf(0, 280, 140, 280)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val effect = VibrationEffect.createWaveform(pattern, -1)
|
||||
vibrator.vibrate(effect)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(pattern, -1)
|
||||
}
|
||||
Log.d(TAG, "Session completion vibration triggered successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to vibrate for session completion: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a tactile click pulse (25ms) when a tab or button is tapped.
|
||||
* Uses direct motor pulse so devices with ERM or LRA motors vibrate regardless of
|
||||
* system touch-feedback toggles, matching media apps like MX Player.
|
||||
*/
|
||||
fun performClick(context: Context, view: View? = null) {
|
||||
val vibrator = getVibrator(context)
|
||||
if (vibrator != null && vibrator.hasVibrator()) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val effect = VibrationEffect.createOneShot(25L, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
vibrator.vibrate(effect)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(25L)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Hardware click vibration failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
try {
|
||||
view?.performHapticFeedback(
|
||||
HapticFeedbackConstants.VIRTUAL_KEY,
|
||||
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a tactile long-press pulse (60ms) when long-pressing an element.
|
||||
*/
|
||||
fun performLongPress(context: Context, view: View? = null) {
|
||||
val vibrator = getVibrator(context)
|
||||
if (vibrator != null && vibrator.hasVibrator()) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val effect = VibrationEffect.createOneShot(60L, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
vibrator.vibrate(effect)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(60L)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Hardware long-press vibration failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
try {
|
||||
view?.performHapticFeedback(
|
||||
HapticFeedbackConstants.LONG_PRESS,
|
||||
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a single medium confirmation pulse (60ms) when completing
|
||||
* a mindful breath gate or cooldown waiting period.
|
||||
*/
|
||||
fun performSuccessPulse(context: Context) {
|
||||
val vibrator = getVibrator(context) ?: return
|
||||
if (!vibrator.hasVibrator()) return
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val effect = VibrationEffect.createOneShot(60L, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
vibrator.vibrate(effect)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(60L)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to vibrate success pulse: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,25 @@ import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
data class DownloadProgressState(
|
||||
val isDownloading: Boolean = false,
|
||||
val progress: Float = 0f, // 0f to 1f
|
||||
val filename: String = "",
|
||||
val isCompleted: Boolean = false,
|
||||
val isFailed: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* MediaDownloadHelper
|
||||
* Native client-side media downloader using Android System DownloadManager & MediaStore.
|
||||
@@ -21,20 +37,154 @@ import java.io.FileOutputStream
|
||||
object MediaDownloadHelper {
|
||||
private const val TAG = "MediaDownloadHelper"
|
||||
|
||||
private val _downloadProgressFlow = MutableStateFlow(DownloadProgressState())
|
||||
val downloadProgressFlow: StateFlow<DownloadProgressState> = _downloadProgressFlow.asStateFlow()
|
||||
|
||||
private val helperScope = CoroutineScope(Dispatchers.IO)
|
||||
private var progressJob: Job? = null
|
||||
|
||||
private var lastDownloadUrl: String = ""
|
||||
private var lastDownloadTimestamp: Long = 0L
|
||||
|
||||
/**
|
||||
* Determines whether media is a video based on explicit URL and filename markers.
|
||||
* Note: NEVER treat "/v/" in CDN URLs as video because all Meta CDN photos contain "/v/".
|
||||
*/
|
||||
fun detectIsVideo(url: String, filename: String = ""): Boolean {
|
||||
val lowerUrl = url.lowercase()
|
||||
val lowerFile = filename.lowercase()
|
||||
if (lowerUrl.contains(".jpg") || lowerUrl.contains(".jpeg") || lowerUrl.contains(".png") ||
|
||||
lowerUrl.contains(".webp") || lowerUrl.contains("dst-jpg") || lowerUrl.contains("dst-png") ||
|
||||
lowerUrl.contains("dst-webp") || lowerUrl.startsWith("data:image") ||
|
||||
lowerFile.endsWith(".jpg") || lowerFile.endsWith(".jpeg") ||
|
||||
lowerFile.endsWith(".png") || lowerFile.endsWith(".webp")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (lowerUrl.contains(".mp4") || lowerUrl.contains(".webm") || lowerUrl.contains("video_dashinit") ||
|
||||
lowerUrl.contains("/v/t50.") || lowerUrl.contains("/v/t16.") ||
|
||||
lowerUrl.startsWith("data:video") || lowerFile.endsWith(".mp4")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats target filename according to FocusGram specification:
|
||||
* Author name (first 10-13 characters) followed by ' - Focusgram.<ext>'
|
||||
* E.g. '@ujwal - Focusgram.mp4' or '@ujwal - Focusgram.jpg'.
|
||||
*/
|
||||
fun formatTargetFilename(rawFilename: String, isVideo: Boolean): String {
|
||||
val extension = if (isVideo) "mp4" else "jpg"
|
||||
var clean = rawFilename.trim()
|
||||
|
||||
// Strip known extension if present
|
||||
if (clean.endsWith(".mp4", ignoreCase = true) || clean.endsWith(".jpg", ignoreCase = true) ||
|
||||
clean.endsWith(".jpeg", ignoreCase = true) || clean.endsWith(".png", ignoreCase = true)
|
||||
) {
|
||||
clean = clean.substringBeforeLast('.')
|
||||
}
|
||||
|
||||
// Handle slide suffix (e.g. _2 for carousel slides)
|
||||
var slideSuffix = ""
|
||||
val slideMatch = Regex("_(\\d+)$").find(clean)
|
||||
if (slideMatch != null) {
|
||||
slideSuffix = "_" + slideMatch.groupValues[1]
|
||||
clean = clean.removeSuffix(slideSuffix)
|
||||
}
|
||||
|
||||
// Extract author prefix
|
||||
val authorName = when {
|
||||
clean.startsWith("@") -> clean.removePrefix("@").substringBefore(" - ").substringBefore('_').trim()
|
||||
clean.contains(" - Focusgram", ignoreCase = true) -> {
|
||||
val idx = clean.indexOf(" - Focusgram", ignoreCase = true)
|
||||
if (idx != -1) clean.substring(0, idx).trim().removePrefix("@") else ""
|
||||
}
|
||||
clean.startsWith("instagram_", ignoreCase = true) -> ""
|
||||
clean.startsWith("Focusgram", ignoreCase = true) -> ""
|
||||
clean.isNotBlank() -> clean.substringBefore('_').trim().removePrefix("@")
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val sanitizedAuthor = authorName
|
||||
.replace(Regex("[^a-zA-Z0-9._-]"), "")
|
||||
.take(13)
|
||||
.ifBlank { "Focusgram" }
|
||||
|
||||
val prefix = if (sanitizedAuthor.equals("Focusgram", ignoreCase = true)) "@Focusgram" else "@$sanitizedAuthor"
|
||||
return "$prefix - Focusgram$slideSuffix.$extension"
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts visual perimeter progress in the bottom nav bar while URL is resolving.
|
||||
* Starts at 0.05f without fake timer loops so visual pace reflects actual network download.
|
||||
*/
|
||||
fun startIndeterminateProgress(filename: String = "") {
|
||||
progressJob?.cancel()
|
||||
_downloadProgressFlow.value = DownloadProgressState(
|
||||
isDownloading = true,
|
||||
progress = 0.05f,
|
||||
filename = filename,
|
||||
isCompleted = false,
|
||||
isFailed = false
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverses progress bar from current position back to start in red/dark-red and cancels animation.
|
||||
*/
|
||||
fun triggerDownloadFailure(context: Context, message: String = "Download failed") {
|
||||
progressJob?.cancel()
|
||||
val startProgress = _downloadProgressFlow.value.progress.coerceAtLeast(0.12f)
|
||||
progressJob = helperScope.launch {
|
||||
_downloadProgressFlow.value = DownloadProgressState(
|
||||
isDownloading = true,
|
||||
progress = startProgress,
|
||||
filename = "",
|
||||
isCompleted = false,
|
||||
isFailed = true
|
||||
)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
val steps = 20
|
||||
for (i in steps downTo 0) {
|
||||
delay(20L) // 20 * 20ms = ~400ms smooth reverse animation
|
||||
_downloadProgressFlow.value = _downloadProgressFlow.value.copy(
|
||||
progress = startProgress * (i.toFloat() / steps)
|
||||
)
|
||||
}
|
||||
delay(60L)
|
||||
_downloadProgressFlow.value = DownloadProgressState(
|
||||
isDownloading = false,
|
||||
progress = 0f,
|
||||
filename = "",
|
||||
isCompleted = false,
|
||||
isFailed = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun download(context: Context, url: String, filename: String = "") {
|
||||
if (url.isBlank()) {
|
||||
Toast.makeText(context, "No media URL found to download", Toast.LENGTH_SHORT).show()
|
||||
triggerDownloadFailure(context, "No media URL found to download")
|
||||
return
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (url == lastDownloadUrl && (now - lastDownloadTimestamp) < 1500L) {
|
||||
Log.d(TAG, "Ignoring duplicate download trigger within debounce window")
|
||||
return
|
||||
}
|
||||
lastDownloadUrl = url
|
||||
lastDownloadTimestamp = now
|
||||
|
||||
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"
|
||||
}
|
||||
val isVideo = detectIsVideo(url, filename)
|
||||
val targetFilename = formatTargetFilename(filename, isVideo)
|
||||
|
||||
progressJob?.cancel()
|
||||
|
||||
if (url.startsWith("data:")) {
|
||||
val commaIndex = url.indexOf(',')
|
||||
@@ -42,8 +192,18 @@ object MediaDownloadHelper {
|
||||
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")
|
||||
|
||||
progressJob = helperScope.launch {
|
||||
_downloadProgressFlow.value = DownloadProgressState(isDownloading = true, progress = 0.5f, filename = targetFilename)
|
||||
delay(150L)
|
||||
_downloadProgressFlow.value = DownloadProgressState(isDownloading = false, progress = 1.0f, filename = targetFilename, isCompleted = true)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
delay(1200L)
|
||||
_downloadProgressFlow.value = DownloadProgressState(isDownloading = false, progress = 0f, filename = "", isCompleted = false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -63,27 +223,216 @@ object MediaDownloadHelper {
|
||||
|
||||
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")
|
||||
val downloadId = try {
|
||||
manager.enqueue(request)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "DownloadManager.enqueue failed, falling back to direct HTTP: ${e.message}")
|
||||
downloadDirectHttp(context, url, targetFilename, isVideo)
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(TAG, "Download enqueued for $targetFilename (id=$downloadId) from $url")
|
||||
|
||||
_downloadProgressFlow.value = DownloadProgressState(
|
||||
isDownloading = true,
|
||||
progress = 0.08f,
|
||||
filename = targetFilename,
|
||||
isCompleted = false,
|
||||
isFailed = false
|
||||
)
|
||||
|
||||
progressJob = helperScope.launch {
|
||||
var lastProgress = 0.08f
|
||||
var isDone = false
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
while (!isDone && (System.currentTimeMillis() - startTime < 60000L)) {
|
||||
delay(120L)
|
||||
try {
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
val cursor = manager.query(query)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
val bytesCol = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
val totalCol = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
val statusCol = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val reasonCol = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
|
||||
|
||||
val bytesDownloaded = if (bytesCol != -1) cursor.getLong(bytesCol) else 0L
|
||||
val totalBytes = if (totalCol != -1) cursor.getLong(totalCol) else -1L
|
||||
val status = if (statusCol != -1) cursor.getInt(statusCol) else -1
|
||||
val reason = if (reasonCol != -1) cursor.getInt(reasonCol) else -1
|
||||
cursor.close()
|
||||
|
||||
when (status) {
|
||||
DownloadManager.STATUS_SUCCESSFUL -> {
|
||||
isDone = true
|
||||
_downloadProgressFlow.value = DownloadProgressState(
|
||||
isDownloading = false,
|
||||
progress = 1.0f,
|
||||
filename = targetFilename,
|
||||
isCompleted = true,
|
||||
isFailed = false
|
||||
)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
delay(1200L)
|
||||
_downloadProgressFlow.value = DownloadProgressState(
|
||||
isDownloading = false,
|
||||
progress = 0f,
|
||||
filename = "",
|
||||
isCompleted = false
|
||||
)
|
||||
}
|
||||
DownloadManager.STATUS_FAILED -> {
|
||||
isDone = true
|
||||
Log.w(TAG, "DownloadManager failed with reason $reason, retrying via direct HTTP")
|
||||
downloadDirectHttp(context, url, targetFilename, isVideo)
|
||||
}
|
||||
else -> {
|
||||
val computed = if (totalBytes > 0) {
|
||||
(bytesDownloaded.toFloat() / totalBytes.toFloat()).coerceIn(0.08f, 0.98f)
|
||||
} else {
|
||||
(lastProgress + 0.02f).coerceAtMost(0.85f)
|
||||
}
|
||||
lastProgress = computed
|
||||
_downloadProgressFlow.value = DownloadProgressState(
|
||||
isDownloading = true,
|
||||
progress = computed,
|
||||
filename = targetFilename,
|
||||
isCompleted = false,
|
||||
isFailed = false
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cursor?.close()
|
||||
if (lastProgress >= 0.7f) {
|
||||
isDone = true
|
||||
_downloadProgressFlow.value = DownloadProgressState(
|
||||
isDownloading = false,
|
||||
progress = 1.0f,
|
||||
filename = targetFilename,
|
||||
isCompleted = true,
|
||||
isFailed = false
|
||||
)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
delay(1200L)
|
||||
_downloadProgressFlow.value = DownloadProgressState(
|
||||
isDownloading = false,
|
||||
progress = 0f,
|
||||
filename = "",
|
||||
isCompleted = false
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error checking download progress: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(context, "Download service unavailable", Toast.LENGTH_SHORT).show()
|
||||
Log.w(TAG, "DownloadManager unavailable, falling back to direct HTTP")
|
||||
downloadDirectHttp(context, url, targetFilename, isVideo)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to download media: ${e.message}", e)
|
||||
Toast.makeText(context, "Download failed: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
triggerDownloadFailure(context, "Download failed: ${e.message ?: "Unknown error"}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadDirectHttp(context: Context, url: String, targetFilename: String, isVideo: Boolean) {
|
||||
helperScope.launch {
|
||||
try {
|
||||
Log.i(TAG, "Starting direct HTTP download for $targetFilename from $url")
|
||||
_downloadProgressFlow.value = DownloadProgressState(
|
||||
isDownloading = true,
|
||||
progress = 0.08f,
|
||||
filename = targetFilename,
|
||||
isCompleted = false,
|
||||
isFailed = false
|
||||
)
|
||||
val connection = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = 15000
|
||||
readTimeout = 30000
|
||||
setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 14; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36")
|
||||
setRequestProperty("Referer", "https://www.instagram.com/")
|
||||
setRequestProperty("Accept", "*/*")
|
||||
instanceFollowRedirects = true
|
||||
}
|
||||
connection.connect()
|
||||
val code = connection.responseCode
|
||||
if (code in 200..299) {
|
||||
val contentLength = connection.contentLengthLong
|
||||
val bytesOut = java.io.ByteArrayOutputStream()
|
||||
connection.inputStream.use { input ->
|
||||
val buffer = ByteArray(8192)
|
||||
var read: Int
|
||||
var downloaded = 0L
|
||||
while (input.read(buffer).also { read = it } != -1) {
|
||||
bytesOut.write(buffer, 0, read)
|
||||
downloaded += read
|
||||
if (contentLength > 0) {
|
||||
val prog = (downloaded.toFloat() / contentLength.toFloat()).coerceIn(0.08f, 0.98f)
|
||||
_downloadProgressFlow.value = DownloadProgressState(
|
||||
isDownloading = true,
|
||||
progress = prog,
|
||||
filename = targetFilename,
|
||||
isCompleted = false,
|
||||
isFailed = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val data = bytesOut.toByteArray()
|
||||
saveBytesToDownloads(context, data, targetFilename, isVideo)
|
||||
_downloadProgressFlow.value = DownloadProgressState(
|
||||
isDownloading = false,
|
||||
progress = 1.0f,
|
||||
filename = targetFilename,
|
||||
isCompleted = true,
|
||||
isFailed = false
|
||||
)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
delay(1200L)
|
||||
_downloadProgressFlow.value = DownloadProgressState(isDownloading = false, progress = 0f)
|
||||
} else {
|
||||
Log.e(TAG, "Direct HTTP download returned status $code")
|
||||
triggerDownloadFailure(context, "Download failed (HTTP $code)")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Direct HTTP download error: ${e.message}", e)
|
||||
triggerDownloadFailure(context, "Download failed: ${e.message ?: "Network error"}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveBytesToDownloads(context: Context, bytes: ByteArray, filename: String, isVideo: Boolean) {
|
||||
val mimeType = if (isVideo) "video/mp4" else "image/jpeg"
|
||||
// Inspect magic bytes to verify whether bytes are an image or a video
|
||||
val isActuallyImage = bytes.size >= 4 && (
|
||||
(bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte()) || // JPEG
|
||||
(bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && bytes[2] == 0x4E.toByte() && bytes[3] == 0x47.toByte()) // PNG
|
||||
)
|
||||
val resolvedIsVideo = if (isActuallyImage) false else isVideo
|
||||
val resolvedFilename = if (isActuallyImage && filename.endsWith(".mp4", ignoreCase = true)) {
|
||||
filename.substringBeforeLast('.') + ".jpg"
|
||||
} else {
|
||||
filename
|
||||
}
|
||||
val mimeType = if (resolvedIsVideo) "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.DISPLAY_NAME, resolvedFilename)
|
||||
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 collection = if (resolvedIsVideo) 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 ->
|
||||
@@ -93,7 +442,7 @@ object MediaDownloadHelper {
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
val file = File(downloadsDir, filename)
|
||||
val file = File(downloadsDir, resolvedFilename)
|
||||
FileOutputStream(file).use { out ->
|
||||
out.write(bytes)
|
||||
}
|
||||
|
||||
+245
-17
@@ -5,6 +5,7 @@ import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
@@ -21,6 +22,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -42,6 +44,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -59,11 +62,13 @@ import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ujwal.focusgram.core.model.FocusSettings
|
||||
import com.ujwal.focusgram.core.model.IslandMode
|
||||
import com.ujwal.focusgram.core.utils.HapticHelper
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -102,6 +107,12 @@ fun NativeBottomNavBar(
|
||||
instagramTheme: String = "auto",
|
||||
isWebInputFocused: Boolean = false,
|
||||
isModalActive: Boolean = false,
|
||||
downloadProgress: Float = 0f,
|
||||
isDownloading: Boolean = false,
|
||||
isDownloadCompleted: Boolean = false,
|
||||
isDownloadFailed: Boolean = false,
|
||||
isAuthenticated: Boolean = false,
|
||||
isAuthPage: Boolean = false,
|
||||
onNavClick: ((String) -> Unit)? = null,
|
||||
onNavigate: (String) -> Unit,
|
||||
onOpenProfile: () -> Unit,
|
||||
@@ -110,6 +121,7 @@ fun NativeBottomNavBar(
|
||||
onRefreshReels: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -137,7 +149,8 @@ fun NativeBottomNavBar(
|
||||
} else {
|
||||
isInDirect
|
||||
}
|
||||
val isVisible = !isImeVisible && !shouldHideInDirect && !isWebInputFocused && !isModalActive && islandMode != IslandMode.HIDDEN
|
||||
val isAuth = isAuthPage || !isAuthenticated || lowerUrl.contains("/accounts") || lowerUrl.contains("/login") || lowerUrl.contains("/signup")
|
||||
val isVisible = !isAuth && !isImeVisible && !shouldHideInDirect && !isWebInputFocused && !isModalActive && islandMode != IslandMode.HIDDEN
|
||||
|
||||
// Real-time animated color palette matching Instagram's web theme
|
||||
val isSystemDark = isSystemInDarkTheme()
|
||||
@@ -148,18 +161,35 @@ fun NativeBottomNavBar(
|
||||
}
|
||||
val islandBackground by animateColorAsState(
|
||||
targetValue = if (islandMode == IslandMode.FLOATING) {
|
||||
if (isLight) Color(0x9CFFFFFF) else Color(0x8B101014)
|
||||
if (isLight) Color(0xB0FFFFFF) else Color(0x9E101014)
|
||||
} else {
|
||||
if (isLight) Color(0x9EFFFFFF) else Color(0x9B141418)
|
||||
if (isLight) Color(0xB2FFFFFF) else Color(0xAE141418)
|
||||
},
|
||||
animationSpec = tween(300),
|
||||
label = "islandBackground"
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = if (isLight) Color(0x2B000000) else Color(0x4DFFFFFF),
|
||||
targetValue = if (isLight) Color(0x35000000) else Color(0x57FFFFFF),
|
||||
animationSpec = tween(300),
|
||||
label = "borderColor"
|
||||
)
|
||||
val glassBorderBrush = remember(isLight) {
|
||||
if (isLight) {
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color(0x3E000000), // Top specular rim (+4% glassy)
|
||||
Color(0x20000000) // Bottom diffused rim
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color(0x60FFFFFF), // Top specular glass rim (+4% glassy)
|
||||
Color(0x36FFFFFF) // Bottom subtle glass rim
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
val selectedTabBackground by animateColorAsState(
|
||||
targetValue = if (isLight) Color(0x1F000000) else Color(0x33FFFFFF),
|
||||
animationSpec = tween(300),
|
||||
@@ -183,18 +213,80 @@ fun NativeBottomNavBar(
|
||||
// Long press perimeter progress animation state
|
||||
val longPressProgressAnim = remember { Animatable(0f) }
|
||||
|
||||
// Determine currently active tab index
|
||||
val selectedTabIndex = activeTabs.indexOfFirst { tab ->
|
||||
// Animated download progress state (smooth sweep)
|
||||
val animatedDownloadProgress by animateFloatAsState(
|
||||
targetValue = if (isDownloading || downloadProgress > 0f) downloadProgress else 0f,
|
||||
animationSpec = if (isDownloadFailed) {
|
||||
tween(durationMillis = 60, easing = LinearEasing)
|
||||
} else if (downloadProgress >= 1f) {
|
||||
tween(durationMillis = 200, easing = LinearEasing)
|
||||
} else if (downloadProgress == 0f) {
|
||||
tween(durationMillis = 350, easing = FastOutSlowInEasing)
|
||||
} else {
|
||||
tween(durationMillis = 180, easing = LinearEasing)
|
||||
},
|
||||
label = "animatedDownloadProgress"
|
||||
)
|
||||
|
||||
// Glow animation for download completion (glow out and glow in 2 times)
|
||||
val glowAnim = remember { Animatable(0f) }
|
||||
androidx.compose.runtime.LaunchedEffect(isDownloadCompleted) {
|
||||
if (isDownloadCompleted) {
|
||||
// Pulse 1: glow out (0f -> 1f) and glow in (1f -> 0f)
|
||||
glowAnim.animateTo(1f, animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing))
|
||||
glowAnim.animateTo(0f, animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing))
|
||||
// Pulse 2: glow out (0f -> 1f) and glow in (1f -> 0f)
|
||||
glowAnim.animateTo(1f, animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing))
|
||||
glowAnim.animateTo(0f, animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing))
|
||||
} else {
|
||||
glowAnim.snapTo(0f)
|
||||
}
|
||||
}
|
||||
|
||||
// Retain last valid active tab index so custom schemes/downloads never jump to Profile
|
||||
var lastValidTabIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
val cleanPath = remember(currentUrl) {
|
||||
val afterScheme = if (currentUrl.contains("://")) currentUrl.substringAfter("://") else currentUrl
|
||||
val pathPart = if (afterScheme.contains('/')) afterScheme.substringAfter('/') else ""
|
||||
pathPart.substringBefore('?').substringBefore('#').trim('/')
|
||||
}
|
||||
val isHome = cleanPath.isEmpty()
|
||||
|
||||
val rawTabIndex = activeTabs.indexOfFirst { tab ->
|
||||
when (tab) {
|
||||
NavTab.HOME -> lowerUrl == "https://www.instagram.com/" || lowerUrl.endsWith("instagram.com/") || lowerUrl == "/"
|
||||
NavTab.HOME -> isHome || lowerUrl.startsWith("https://www.instagram.com/?") || lowerUrl.startsWith("https://instagram.com/?") || lowerUrl == "/"
|
||||
NavTab.SEARCH -> lowerUrl.contains("/explore")
|
||||
NavTab.REELS -> lowerUrl.contains("/reel")
|
||||
NavTab.MESSAGES -> lowerUrl.contains("/direct") || lowerUrl.contains("/inbox")
|
||||
NavTab.PROFILE -> !lowerUrl.contains("/explore") && !lowerUrl.contains("/reel") && !lowerUrl.contains("/direct") &&
|
||||
(lowerUrl.contains("/profile") || (!lowerUrl.endsWith("instagram.com/") && !lowerUrl.endsWith("instagram.com") && lowerUrl != "/"))
|
||||
NavTab.PROFILE -> {
|
||||
val isInstagram = lowerUrl.contains("instagram.com") || lowerUrl.startsWith("/")
|
||||
val isExcluded = isHome ||
|
||||
lowerUrl.contains("/explore") ||
|
||||
lowerUrl.contains("/reel") ||
|
||||
lowerUrl.contains("/direct") ||
|
||||
lowerUrl.contains("/inbox") ||
|
||||
lowerUrl.contains("/p/") ||
|
||||
lowerUrl.contains("/stories/") ||
|
||||
lowerUrl.startsWith("focusgram-download:") ||
|
||||
lowerUrl.startsWith("javascript:")
|
||||
|
||||
val segments = cleanPath.trim('/').split('/').filter { it.isNotEmpty() }
|
||||
val isSingleUserSegment = segments.size == 1
|
||||
|
||||
!isHome && isInstagram && !isExcluded &&
|
||||
(lowerUrl.contains("/profile") || isSingleUserSegment)
|
||||
}
|
||||
NavTab.SETTINGS -> false
|
||||
}
|
||||
}.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
val selectedTabIndex = if (rawTabIndex >= 0) {
|
||||
lastValidTabIndex = rawTabIndex
|
||||
rawTabIndex
|
||||
} else {
|
||||
lastValidTabIndex.coerceIn(0, activeTabs.lastIndex)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
@@ -219,15 +311,46 @@ fun NativeBottomNavBar(
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.shadow(
|
||||
elevation = if (isLight) 8.dp else 4.dp,
|
||||
elevation = if (glowAnim.value > 0f) (8.dp + 12.dp * glowAnim.value) else (if (isLight) 8.dp else 4.dp),
|
||||
shape = navShape,
|
||||
spotColor = if (isLight) Color(0x33000000) else Color.Transparent
|
||||
spotColor = if (glowAnim.value > 0f) Color(0xFF00D2FF).copy(alpha = 0.75f * glowAnim.value) else (if (isLight) Color(0x33000000) else Color.Transparent)
|
||||
)
|
||||
.clip(navShape)
|
||||
.background(islandBackground)
|
||||
.border(BorderStroke(1.dp, borderColor), navShape),
|
||||
.border(BorderStroke(1.dp, glassBorderBrush), navShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 0. Frosted Glass Blur Background Layer (~13% more blur)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(navShape)
|
||||
.blur(20.dp)
|
||||
.background(islandBackground)
|
||||
)
|
||||
|
||||
// 0.1 Specular Glass Sheen Gradient (+4% glass reflection)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(navShape)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = if (isLight) {
|
||||
listOf(
|
||||
Color(0x24FFFFFF),
|
||||
Color(0x08FFFFFF),
|
||||
Color.Transparent
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Color(0x1CFFFFFF),
|
||||
Color(0x06FFFFFF),
|
||||
Color.Transparent
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
val totalWidthPx = constraints.maxWidth.toFloat()
|
||||
val totalHeightPx = constraints.maxHeight.toFloat()
|
||||
val tabCount = activeTabs.size
|
||||
@@ -298,7 +421,7 @@ fun NativeBottomNavBar(
|
||||
)
|
||||
)
|
||||
settingsOpened = true
|
||||
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
HapticHelper.performLongPress(context, view)
|
||||
longPressProgressAnim.snapTo(0f)
|
||||
onOpenSettings()
|
||||
} catch (_: CancellationException) {
|
||||
@@ -313,7 +436,7 @@ fun NativeBottomNavBar(
|
||||
animJob.cancel()
|
||||
scope.launch { longPressProgressAnim.snapTo(0f) }
|
||||
if (released) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
HapticHelper.performClick(context, view)
|
||||
onOpenProfile()
|
||||
onNavClick?.invoke("profile")
|
||||
}
|
||||
@@ -329,7 +452,7 @@ fun NativeBottomNavBar(
|
||||
detectTapGestures(
|
||||
onTap = {
|
||||
val now = SystemClock.uptimeMillis()
|
||||
view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
HapticHelper.performClick(context, view)
|
||||
|
||||
when (tab) {
|
||||
NavTab.HOME -> {
|
||||
@@ -453,6 +576,111 @@ fun NativeBottomNavBar(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Media Download Clockwise Perimeter Progress Animation & 2x Completion Glow
|
||||
val currentDlProgress = animatedDownloadProgress
|
||||
val currentGlow = glowAnim.value
|
||||
if (currentDlProgress > 0f || currentGlow > 0f) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val fullPath = Path().apply {
|
||||
moveTo(startXPx, 0f)
|
||||
lineTo(totalWidthPx - cornerRadiusPx, 0f)
|
||||
arcTo(
|
||||
rect = Rect(totalWidthPx - 2 * cornerRadiusPx, 0f, totalWidthPx, 2 * cornerRadiusPx),
|
||||
startAngleDegrees = 270f,
|
||||
sweepAngleDegrees = 90f,
|
||||
forceMoveTo = false
|
||||
)
|
||||
lineTo(totalWidthPx, totalHeightPx - cornerRadiusPx)
|
||||
arcTo(
|
||||
rect = Rect(totalWidthPx - 2 * cornerRadiusPx, totalHeightPx - 2 * cornerRadiusPx, totalWidthPx, totalHeightPx),
|
||||
startAngleDegrees = 0f,
|
||||
sweepAngleDegrees = 90f,
|
||||
forceMoveTo = false
|
||||
)
|
||||
lineTo(cornerRadiusPx, totalHeightPx)
|
||||
arcTo(
|
||||
rect = Rect(0f, totalHeightPx - 2 * cornerRadiusPx, 2 * cornerRadiusPx, totalHeightPx),
|
||||
startAngleDegrees = 90f,
|
||||
sweepAngleDegrees = 90f,
|
||||
forceMoveTo = false
|
||||
)
|
||||
lineTo(0f, cornerRadiusPx)
|
||||
arcTo(
|
||||
rect = Rect(0f, 0f, 2 * cornerRadiusPx, 2 * cornerRadiusPx),
|
||||
startAngleDegrees = 180f,
|
||||
sweepAngleDegrees = 90f,
|
||||
forceMoveTo = false
|
||||
)
|
||||
lineTo(startXPx, 0f)
|
||||
close()
|
||||
}
|
||||
|
||||
val blueGradient = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF00E5FF), // Bright Cyan
|
||||
Color(0xFF0091FF), // Electric Sky Blue
|
||||
Color(0xFF0055FF), // Deep Vivid Blue
|
||||
Color(0xFF00D2FF) // Cyan-Blue
|
||||
)
|
||||
)
|
||||
val failureGradient = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFFF3B30), // Bright Red
|
||||
Color(0xFFCC0000), // Dark Red
|
||||
Color(0xFF8B0000), // Very Dark Crimson
|
||||
Color(0xFFFF3B30) // Bright Red
|
||||
)
|
||||
)
|
||||
|
||||
// 4a. If downloading or failed: Clockwise perimeter sweep starting above Home icon
|
||||
if (currentDlProgress > 0f && currentGlow == 0f) {
|
||||
val pathMeasure = PathMeasure()
|
||||
pathMeasure.setPath(fullPath, false)
|
||||
val totalLen = pathMeasure.length
|
||||
val segmentPath = Path()
|
||||
pathMeasure.getSegment(0f, totalLen * currentDlProgress.coerceIn(0f, 1f), segmentPath, true)
|
||||
|
||||
drawPath(
|
||||
path = segmentPath,
|
||||
brush = if (isDownloadFailed) failureGradient else blueGradient,
|
||||
style = Stroke(
|
||||
width = 3.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 4b. If download completed: 2x glowing outer halo + luminous neon perimeter rim
|
||||
if (currentGlow > 0f) {
|
||||
// Outer diffused glow halo
|
||||
drawPath(
|
||||
path = fullPath,
|
||||
color = Color(0xFF00D2FF).copy(alpha = 0.6f * currentGlow),
|
||||
style = Stroke(
|
||||
width = (4.dp + 5.dp * currentGlow).toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
)
|
||||
// Core luminous neon rim
|
||||
drawPath(
|
||||
path = fullPath,
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF80EEFF).copy(alpha = currentGlow),
|
||||
Color(0xFF00B4FF).copy(alpha = currentGlow),
|
||||
Color(0xFF0066FF).copy(alpha = currentGlow),
|
||||
Color(0xFF80EEFF).copy(alpha = currentGlow)
|
||||
)
|
||||
),
|
||||
style = Stroke(
|
||||
width = 3.2.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-13
@@ -11,6 +11,8 @@ import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import android.content.pm.ServiceInfo
|
||||
import com.ujwal.focusgram.FocusGramApp
|
||||
import com.ujwal.focusgram.MainActivity
|
||||
import com.ujwal.focusgram.R
|
||||
@@ -45,6 +47,27 @@ class ScreenTimeService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
val initialNotification = buildNotification("Focus Session Active", "Tracking active session time...")
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
NOTIFICATION_ID,
|
||||
initialNotification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, initialNotification)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Primary startForeground failed in onCreate: ${e.message}", e)
|
||||
try {
|
||||
startForeground(NOTIFICATION_ID, initialNotification)
|
||||
} catch (fallbackEx: Exception) {
|
||||
Log.e(TAG, "Fallback startForeground failed: ${fallbackEx.message}", fallbackEx)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -68,6 +91,14 @@ class ScreenTimeService : Service() {
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
stopTracking()
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
@@ -80,8 +111,7 @@ class ScreenTimeService : Service() {
|
||||
sessionStartTime = System.currentTimeMillis()
|
||||
sessionSeconds = 0
|
||||
|
||||
val initialNotification = buildNotification("Focus Session Active", "Tracking active session time...")
|
||||
startForeground(NOTIFICATION_ID, initialNotification)
|
||||
updateNotification("Focus Session Active", "Tracking active session time...")
|
||||
|
||||
trackingJob = serviceScope.launch {
|
||||
val app = FocusGramApp.instance
|
||||
@@ -192,7 +222,7 @@ class ScreenTimeService : Service() {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(content)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
@@ -201,8 +231,12 @@ class ScreenTimeService : Service() {
|
||||
}
|
||||
|
||||
private fun updateNotification(title: String, content: String) {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(NOTIFICATION_ID, buildNotification(title, content))
|
||||
try {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(NOTIFICATION_ID, buildNotification(title, content))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to update notification: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -224,14 +258,18 @@ class ScreenTimeService : Service() {
|
||||
const val EXTRA_IS_REELS = "extra_is_reels"
|
||||
|
||||
fun startSession(context: Context, isReels: Boolean = false) {
|
||||
val intent = Intent(context, ScreenTimeService::class.java).apply {
|
||||
action = ACTION_START
|
||||
putExtra(EXTRA_IS_REELS, isReels)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
try {
|
||||
val intent = Intent(context, ScreenTimeService::class.java).apply {
|
||||
action = ACTION_START
|
||||
putExtra(EXTRA_IS_REELS, isReels)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to startSession: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -28,16 +28,19 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.ujwal.focusgram.core.ui.theme.PureBlack
|
||||
import com.ujwal.focusgram.core.utils.HapticHelper
|
||||
|
||||
/**
|
||||
* SessionExhaustedScreen
|
||||
@@ -54,6 +57,13 @@ fun SessionExhaustedScreen(
|
||||
onWatchAdToExtend: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Trigger physical session completion vibration alert (double buzz)
|
||||
LaunchedEffect(Unit) {
|
||||
HapticHelper.performSessionCompletionAlert(context)
|
||||
}
|
||||
|
||||
// Lock down back navigation: back button will exit the app, never return to webview
|
||||
BackHandler(enabled = true) {
|
||||
onExit()
|
||||
|
||||
+248
-65
@@ -2,6 +2,11 @@ package com.ujwal.focusgram.feature.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.ujwal.focusgram.R
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
@@ -49,6 +54,7 @@ import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.ShoppingBag
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StayCurrentPortrait
|
||||
import androidx.compose.material.icons.filled.Timer
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
@@ -126,9 +132,12 @@ fun SettingsScreen(
|
||||
onNavigateScreenTime: () -> Unit,
|
||||
onNavigateAppLock: () -> Unit,
|
||||
onWatchAd: () -> Unit = {},
|
||||
onWatchAdWithReward: (((suspend () -> Unit) -> Unit))? = null,
|
||||
onNavigateUrl: (String) -> Unit = {},
|
||||
screenTimeDao: ScreenTimeDao? = null,
|
||||
reelHistory: ReelHistory? = null,
|
||||
currentSubpage: SettingsSubpage = SettingsSubpage.MAIN,
|
||||
onSubpageChange: (SettingsSubpage) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val settings by repository.settingsFlow.collectAsState(initial = FocusSettings())
|
||||
@@ -136,14 +145,28 @@ fun SettingsScreen(
|
||||
val context = LocalContext.current
|
||||
|
||||
val mainListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() }
|
||||
var currentSubpage by remember { mutableStateOf(SettingsSubpage.MAIN) }
|
||||
var activeSubpage by remember(currentSubpage) { mutableStateOf(currentSubpage) }
|
||||
val setSubpage: (SettingsSubpage) -> Unit = { page ->
|
||||
activeSubpage = page
|
||||
onSubpageChange(page)
|
||||
}
|
||||
var showLegalDialog by remember { mutableStateOf(false) }
|
||||
var showDeveloperDialog by remember { mutableStateOf(false) }
|
||||
|
||||
var pendingRewardAction by remember { mutableStateOf<(suspend () -> Unit)?>(null) }
|
||||
var adDialogReason by remember { mutableStateOf<String?>(null) }
|
||||
var showSupportAdDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val onRequestAdReward: (action: suspend () -> Unit, reason: String) -> Unit = { action, reason ->
|
||||
pendingRewardAction = action
|
||||
adDialogReason = reason
|
||||
showSupportAdDialog = true
|
||||
}
|
||||
|
||||
// Intercept back presses: go back to MAIN subpage first, or pop screen
|
||||
BackHandler {
|
||||
if (currentSubpage != SettingsSubpage.MAIN) {
|
||||
currentSubpage = SettingsSubpage.MAIN
|
||||
if (activeSubpage != SettingsSubpage.MAIN) {
|
||||
setSubpage(SettingsSubpage.MAIN)
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
@@ -271,6 +294,78 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Support Ad Dialog
|
||||
if (showSupportAdDialog && adDialogReason != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showSupportAdDialog = false
|
||||
pendingRewardAction = null
|
||||
},
|
||||
containerColor = Color(0xFF1C1C1E),
|
||||
title = {
|
||||
Text(
|
||||
text = "Support FocusGram ❤️",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = adDialogReason ?: "Please watch an ad in order to toggle this setting.",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
Text(
|
||||
text = "Your 20 seconds can provide a huge contribution to the continuous development and improvement of FocusGram.\n\nThank you for supporting this project! ❤️",
|
||||
color = Color(0xFF8E8E93),
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
showSupportAdDialog = false
|
||||
val action = pendingRewardAction
|
||||
pendingRewardAction = null
|
||||
if (action != null) {
|
||||
if (onWatchAdWithReward != null) {
|
||||
onWatchAdWithReward(action)
|
||||
} else {
|
||||
scope.launch { action() }
|
||||
onWatchAd()
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
contentColor = Color.Black
|
||||
),
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
) {
|
||||
Text("Watch Ad & Continue", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
showSupportAdDialog = false
|
||||
pendingRewardAction = null
|
||||
},
|
||||
border = BorderStroke(1.dp, Color(0xFF3A3A3C)),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -285,8 +380,8 @@ fun SettingsScreen(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
if (currentSubpage != SettingsSubpage.MAIN) {
|
||||
currentSubpage = SettingsSubpage.MAIN
|
||||
if (activeSubpage != SettingsSubpage.MAIN) {
|
||||
setSubpage(SettingsSubpage.MAIN)
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
@@ -298,7 +393,7 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = when (currentSubpage) {
|
||||
text = when (activeSubpage) {
|
||||
SettingsSubpage.MAIN -> "Settings and activity"
|
||||
SettingsSubpage.FOCUS -> "Focus Mode"
|
||||
SettingsSubpage.GUARDRAILS -> "Time Control & Guardrails"
|
||||
@@ -316,12 +411,12 @@ fun SettingsScreen(
|
||||
}
|
||||
|
||||
// Subpage Router
|
||||
when (currentSubpage) {
|
||||
when (activeSubpage) {
|
||||
SettingsSubpage.MAIN -> {
|
||||
MainSettingsContent(
|
||||
settings = settings,
|
||||
listState = mainListState,
|
||||
onNavigateSubpage = { currentSubpage = it },
|
||||
onNavigateSubpage = { setSubpage(it) },
|
||||
onNavigateAppLock = onNavigateAppLock,
|
||||
onNavigateScreenTime = onNavigateScreenTime,
|
||||
onNavigateReelsHistory = onNavigateReelsHistory,
|
||||
@@ -333,7 +428,11 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
SettingsSubpage.FOCUS -> {
|
||||
FocusModeSubpage(repository = repository, settings = settings)
|
||||
FocusModeSubpage(
|
||||
repository = repository,
|
||||
settings = settings,
|
||||
onRequestAdReward = onRequestAdReward
|
||||
)
|
||||
}
|
||||
SettingsSubpage.GUARDRAILS -> {
|
||||
GuardrailsSubpage(
|
||||
@@ -347,6 +446,7 @@ fun SettingsScreen(
|
||||
ExtrasSubpage(
|
||||
repository = repository,
|
||||
settings = settings,
|
||||
onRequestAdReward = onRequestAdReward,
|
||||
onNavigateUrl = { url ->
|
||||
onNavigateUrl(url)
|
||||
onBack()
|
||||
@@ -585,7 +685,7 @@ private fun MainSettingsContent(
|
||||
IOSGroupedCard {
|
||||
IOSSettingsRow(
|
||||
title = "Extras",
|
||||
subtitle = "Startup launch page, download media",
|
||||
subtitle = "Startup launch page, media downloader",
|
||||
icon = Icons.Default.Star,
|
||||
iconBgColor = IOSBlue,
|
||||
onClick = { onNavigateSubpage(SettingsSubpage.EXTRAS) },
|
||||
@@ -713,7 +813,8 @@ private fun MainSettingsContent(
|
||||
@Composable
|
||||
private fun FocusModeSubpage(
|
||||
repository: SettingsRepository,
|
||||
settings: FocusSettings
|
||||
settings: FocusSettings,
|
||||
onRequestAdReward: (action: suspend () -> Unit, reason: String) -> Unit = { _, _ -> }
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -887,7 +988,16 @@ private fun FocusModeSubpage(
|
||||
icon = Icons.Default.Block,
|
||||
iconBgColor = IOSGreen,
|
||||
checked = settings.noAds,
|
||||
onCheckedChange = { scope.launch { repository.setNoAds(it) } },
|
||||
onCheckedChange = { willEnable ->
|
||||
if (willEnable) {
|
||||
onRequestAdReward(
|
||||
{ repository.setNoAds(true) },
|
||||
"Please watch an ad in order to re-enable Ad Blocking."
|
||||
)
|
||||
} else {
|
||||
scope.launch { repository.setNoAds(false) }
|
||||
}
|
||||
},
|
||||
showDivider = false
|
||||
)
|
||||
}
|
||||
@@ -1347,9 +1457,11 @@ private fun GhostModeSubpage(
|
||||
private fun ExtrasSubpage(
|
||||
repository: SettingsRepository,
|
||||
settings: FocusSettings,
|
||||
onRequestAdReward: (action: suspend () -> Unit, reason: String) -> Unit = { _, _ -> },
|
||||
onNavigateUrl: (String) -> Unit = {}
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var showLaunchPageModal by remember { mutableStateOf(false) }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
@@ -1360,56 +1472,38 @@ private fun ExtrasSubpage(
|
||||
item {
|
||||
IOSSectionTitle(title = "STARTUP")
|
||||
IOSGroupedCard {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Launch Page",
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Choose which page opens when you launch FocusGram.",
|
||||
color = Color(0xFF8E8E93),
|
||||
fontSize = 13.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
||||
val pages = listOf(
|
||||
"home" to "Home Feed",
|
||||
"following" to "Following Feed",
|
||||
"favorites" to "Favorites Feed",
|
||||
"direct" to "Direct Messages"
|
||||
)
|
||||
|
||||
pages.forEach { (key, label) ->
|
||||
val isSelected = settings.startupPage == key
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (isSelected) Color.White.copy(alpha = 0.1f) else Color.Transparent)
|
||||
.clickable { scope.launch { repository.setStartupPage(key) } }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = if (isSelected) Color.White else Color(0xFF8E8E93),
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
if (isSelected) {
|
||||
Text("✓", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
val isCustomLaunch = settings.startupPage != "home"
|
||||
IOSSettingsRow(
|
||||
title = "Launch Page",
|
||||
subtitle = if (isCustomLaunch) "Start on ${when (settings.startupPage) { "following" -> "Following Feed"; "favorites" -> "Favorites Feed"; "direct" -> "Direct Messages"; else -> "Home Feed" }}" else "Opens default Instagram Home",
|
||||
icon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
iconBgColor = IOSBlue,
|
||||
checked = isCustomLaunch,
|
||||
onCheckedChange = { isChecked ->
|
||||
scope.launch {
|
||||
if (isChecked) {
|
||||
repository.setStartupPage("following")
|
||||
showLaunchPageModal = true
|
||||
} else {
|
||||
repository.setStartupPage("home")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
showDivider = isCustomLaunch
|
||||
)
|
||||
if (isCustomLaunch) {
|
||||
IOSSettingsRow(
|
||||
title = "Selected Target",
|
||||
subtitle = "Choose screen to open upon launch",
|
||||
trailingText = when (settings.startupPage) {
|
||||
"following" -> "Following Feed"
|
||||
"favorites" -> "Favorites Feed"
|
||||
"direct" -> "Direct Messages"
|
||||
else -> "Home Feed"
|
||||
},
|
||||
onClick = { showLaunchPageModal = true },
|
||||
showDivider = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1419,12 +1513,38 @@ private fun ExtrasSubpage(
|
||||
IOSSectionTitle(title = "MEDIA")
|
||||
IOSGroupedCard {
|
||||
IOSSettingsRow(
|
||||
title = "Download Media (Feed + Reels)",
|
||||
subtitle = "Adds a download option on posts and reels",
|
||||
title = "Media Downloader",
|
||||
subtitle = "Download media from Feed, Reels, and Profiles",
|
||||
icon = Icons.Default.Star,
|
||||
iconBgColor = IOSOrange,
|
||||
checked = settings.videoDownloadEnabled,
|
||||
onCheckedChange = { scope.launch { repository.setVideoDownloadEnabled(it) } },
|
||||
onCheckedChange = { newState ->
|
||||
val actionDesc = if (newState) "enable" else "disable"
|
||||
onRequestAdReward(
|
||||
{ repository.setVideoDownloadEnabled(newState) },
|
||||
"Please watch an ad in order to $actionDesc the Media Downloader."
|
||||
)
|
||||
},
|
||||
showDivider = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DISPLAY
|
||||
item {
|
||||
IOSSectionTitle(title = "DISPLAY")
|
||||
IOSGroupedCard {
|
||||
IOSSettingsRow(
|
||||
title = "Portrait Only",
|
||||
subtitle = "Lock app orientation to portrait mode",
|
||||
icon = Icons.Default.StayCurrentPortrait,
|
||||
iconBgColor = IOSBlue,
|
||||
checked = settings.portraitOnly,
|
||||
onCheckedChange = { isChecked ->
|
||||
scope.launch {
|
||||
repository.setPortraitOnly(isChecked)
|
||||
}
|
||||
},
|
||||
showDivider = false
|
||||
)
|
||||
}
|
||||
@@ -1451,6 +1571,31 @@ private fun ExtrasSubpage(
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (showLaunchPageModal) {
|
||||
val launchPages = remember {
|
||||
listOf(
|
||||
"home" to "Home Feed",
|
||||
"following" to "Following Feed",
|
||||
"favorites" to "Favorites Feed",
|
||||
"direct" to "Direct Messages"
|
||||
)
|
||||
}
|
||||
val currentIdx = remember(settings.startupPage) {
|
||||
launchPages.indexOfFirst { it.first == settings.startupPage }.coerceAtLeast(0)
|
||||
}
|
||||
WheelPickerModalDialog(
|
||||
title = "Launch Page",
|
||||
subtitle = "Choose which page opens when you launch FocusGram",
|
||||
options = launchPages,
|
||||
initialIndex = currentIdx,
|
||||
onDismiss = { showLaunchPageModal = false },
|
||||
onConfirm = { option ->
|
||||
scope.launch { repository.setStartupPage(option.first) }
|
||||
},
|
||||
labelProvider = { it.second }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── APPEARANCE SUBPAGE ─────────────────────────────────────────────────────
|
||||
@@ -1496,6 +1641,26 @@ private fun NotificationsSubpage(
|
||||
settings: FocusSettings
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { _ -> }
|
||||
|
||||
val checkAndRequestNotificationPermission = { onGranted: () -> Unit ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.POST_NOTIFICATIONS
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
permissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
onGranted()
|
||||
}
|
||||
} else {
|
||||
onGranted()
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
@@ -1528,7 +1693,16 @@ private fun NotificationsSubpage(
|
||||
icon = Icons.Default.Notifications,
|
||||
iconBgColor = IOSTeal,
|
||||
checked = settings.notifySessionEnd,
|
||||
onCheckedChange = { scope.launch { repository.setNotifySessionEnd(it) } }
|
||||
onCheckedChange = { willEnable ->
|
||||
if (willEnable) {
|
||||
checkAndRequestNotificationPermission {
|
||||
scope.launch { repository.setNotifySessionEnd(true) }
|
||||
}
|
||||
scope.launch { repository.setNotifySessionEnd(true) }
|
||||
} else {
|
||||
scope.launch { repository.setNotifySessionEnd(false) }
|
||||
}
|
||||
}
|
||||
)
|
||||
IOSSettingsRow(
|
||||
title = "Persistent Status Notification",
|
||||
@@ -1536,7 +1710,16 @@ private fun NotificationsSubpage(
|
||||
icon = Icons.Default.Timer,
|
||||
iconBgColor = IOSBlue,
|
||||
checked = settings.notifyPersistent,
|
||||
onCheckedChange = { scope.launch { repository.setNotifyPersistent(it) } },
|
||||
onCheckedChange = { willEnable ->
|
||||
if (willEnable) {
|
||||
checkAndRequestNotificationPermission {
|
||||
scope.launch { repository.setNotifyPersistent(true) }
|
||||
}
|
||||
scope.launch { repository.setNotifyPersistent(true) }
|
||||
} else {
|
||||
scope.launch { repository.setNotifyPersistent(false) }
|
||||
}
|
||||
},
|
||||
showDivider = false
|
||||
)
|
||||
}
|
||||
|
||||
+10
-3
@@ -157,9 +157,10 @@ fun AdsterraAdScreen(
|
||||
var adsClicked by remember { mutableIntStateOf(0) }
|
||||
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
val isSettingToggle = sessionType == "setting_toggle"
|
||||
val timerDone = elapsedSeconds >= requiredSeconds
|
||||
val bothClicked = adsClicked >= 2
|
||||
val isComplete = timerDone && bothClicked
|
||||
val isComplete = if (isSettingToggle) timerDone else (timerDone && bothClicked)
|
||||
|
||||
// 20s Timer Loop - restarts cleanly on timerCycle increment
|
||||
LaunchedEffect(timerCycle) {
|
||||
@@ -171,7 +172,8 @@ fun AdsterraAdScreen(
|
||||
}
|
||||
|
||||
val statusText = when {
|
||||
isComplete -> "Reward Unlocked! Tap below to claim focus minutes."
|
||||
isComplete -> if (isSettingToggle) "Ad viewed! Tap below to apply your setting." else "Reward Unlocked! Tap below to claim focus minutes."
|
||||
isSettingToggle -> "Viewing ad (${requiredSeconds - elapsedSeconds}s remaining)..."
|
||||
bothClicked -> "Both ads clicked! Waiting for countdown (${requiredSeconds - elapsedSeconds}s)..."
|
||||
timerDone -> "Timer completed. Please tap both ads ($adsClicked/2) to continue."
|
||||
else -> "Tap BOTH ads below to earn reward credits ($adsClicked/2 clicked)"
|
||||
@@ -400,8 +402,13 @@ fun AdsterraAdScreen(
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
val buttonLabel = when {
|
||||
!isComplete -> if (isSettingToggle) "Viewing Ad (${(requiredSeconds - elapsedSeconds).coerceAtLeast(0)}s)" else "Complete Ad Actions to Unlock"
|
||||
isSettingToggle -> "Continue & Apply Setting"
|
||||
else -> "Continue & Earn Reward (+2 min)"
|
||||
}
|
||||
Text(
|
||||
text = if (isComplete) "Continue & Earn Reward (+2 min)" else "Complete Ad Actions to Unlock",
|
||||
text = buttonLabel,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -42,6 +43,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.ujwal.focusgram.core.ui.theme.InstagramBlue
|
||||
import com.ujwal.focusgram.core.ui.theme.PureBlack
|
||||
import com.ujwal.focusgram.core.utils.HapticHelper
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -54,6 +56,7 @@ fun BreathGateScreen(
|
||||
durationSeconds: Int = 10,
|
||||
onFinish: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val initialSeconds = durationSeconds.coerceIn(3, 60)
|
||||
var secondsRemaining by remember { mutableIntStateOf(initialSeconds) }
|
||||
val canContinue = secondsRemaining <= 0
|
||||
@@ -64,6 +67,7 @@ fun BreathGateScreen(
|
||||
delay(1000L)
|
||||
secondsRemaining--
|
||||
}
|
||||
HapticHelper.performSuccessPulse(context)
|
||||
}
|
||||
|
||||
// Mindful Breathing Expansion/Contraction Animation
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -43,6 +44,7 @@ import com.ujwal.focusgram.core.ui.theme.DarkCard
|
||||
import com.ujwal.focusgram.core.ui.theme.PureBlack
|
||||
import com.ujwal.focusgram.core.ui.theme.TextMuted
|
||||
import com.ujwal.focusgram.core.ui.theme.TextSecondary
|
||||
import com.ujwal.focusgram.core.utils.HapticHelper
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -55,6 +57,7 @@ fun CooldownGateScreen(
|
||||
onCooldownExpired: () -> Unit,
|
||||
onExit: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var remainingSeconds by remember { mutableIntStateOf(remainingSecondsInitial.coerceAtLeast(0)) }
|
||||
|
||||
val quotes = remember {
|
||||
@@ -71,6 +74,7 @@ fun CooldownGateScreen(
|
||||
|
||||
LaunchedEffect(remainingSeconds) {
|
||||
if (remainingSeconds <= 0) {
|
||||
HapticHelper.performSuccessPulse(context)
|
||||
onCooldownExpired()
|
||||
} else {
|
||||
delay(1000L)
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -18,13 +19,21 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import com.ujwal.focusgram.MainActivity
|
||||
import com.ujwal.focusgram.R
|
||||
import com.ujwal.focusgram.core.utils.HapticHelper
|
||||
import com.ujwal.focusgram.core.utils.MediaDownloadHelper
|
||||
import com.ujwal.focusgram.feature.screentime.ScreenTimeService
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -52,6 +61,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -122,7 +132,14 @@ enum class AppDestination {
|
||||
@Composable
|
||||
fun FocusGramApp() {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
val activity = remember(context) {
|
||||
var ctx = context
|
||||
while (ctx is ContextWrapper) {
|
||||
if (ctx is Activity) break
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
ctx as? Activity
|
||||
}
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -177,6 +194,8 @@ fun FocusGramApp() {
|
||||
|
||||
var isWebInputFocused by remember { mutableStateOf(false) }
|
||||
var isModalActive by remember { mutableStateOf(false) }
|
||||
var isUserAuthenticated by remember { mutableStateOf(false) }
|
||||
var isAuthPage by remember { mutableStateOf(false) }
|
||||
|
||||
// Session intention countdown timer state
|
||||
var sessionDurationMinutes by remember { mutableIntStateOf(15) }
|
||||
@@ -184,6 +203,8 @@ fun FocusGramApp() {
|
||||
var totalElapsedSeconds by remember { mutableIntStateOf(0) }
|
||||
var adExtensionsUsed by remember { mutableIntStateOf(0) }
|
||||
var isSessionExhausted by remember { mutableStateOf(false) }
|
||||
var adRewardAction by remember { mutableStateOf<(suspend () -> Unit)?>(null) }
|
||||
var settingsSubpage by rememberSaveable { mutableStateOf(com.ujwal.focusgram.feature.settings.SettingsSubpage.MAIN) }
|
||||
|
||||
// Evaluates whether any barrier, lock, breath pause, effort friction, cooldown, or intention picker is active
|
||||
val isGateActive = shouldShowLock ||
|
||||
@@ -193,59 +214,118 @@ fun FocusGramApp() {
|
||||
(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) }
|
||||
// System File Chooser / Camera / Gallery Launchers for Story, Post, DMs & Profile Uploads
|
||||
val pendingFilePickerCallback = remember { AtomicReference<((List<Uri>) -> Unit)?>(null) }
|
||||
val pendingCameraCaptureUri = remember { AtomicReference<Uri?>(null) }
|
||||
|
||||
val filePickerLauncher = rememberLauncherForActivityResult(
|
||||
// 1. Android Modern Photo & Video Pickers (Official AndroidX contracts, zero permissions needed)
|
||||
val singlePhotoPickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickVisualMedia()
|
||||
) { uri ->
|
||||
val callback = pendingFilePickerCallback.getAndSet(null)
|
||||
Log.i("FocusGramApp", "Single PhotoPicker result: $uri")
|
||||
callback?.invoke(if (uri != null) listOf(uri) else emptyList())
|
||||
}
|
||||
|
||||
val multiPhotoPickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickMultipleVisualMedia()
|
||||
) { uris ->
|
||||
val callback = pendingFilePickerCallback.getAndSet(null)
|
||||
Log.i("FocusGramApp", "Multi PhotoPicker result: ${uris.size} URIs: ${uris.joinToString()}")
|
||||
callback?.invoke(uris)
|
||||
}
|
||||
|
||||
// 2. Direct Camera Capture Launcher (Used when HTML input requests camera capture)
|
||||
val takePictureLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.TakePicture()
|
||||
) { success ->
|
||||
val callback = pendingFilePickerCallback.getAndSet(null)
|
||||
val uri = pendingCameraCaptureUri.getAndSet(null)
|
||||
Log.i("FocusGramApp", "TakePicture result: success=$success, uri=$uri")
|
||||
if (success && uri != null) {
|
||||
callback?.invoke(listOf(uri))
|
||||
} else {
|
||||
callback?.invoke(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
// 3. System Document/Gallery Fallback Launcher
|
||||
val fallbackFilePickerLauncher = 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) {
|
||||
val callback = pendingFilePickerCallback.getAndSet(null)
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val data = result.data
|
||||
val clipData = data?.clipData
|
||||
val dataUri = data?.data
|
||||
if (clipData != null && clipData.itemCount > 0) {
|
||||
for (i in 0 until clipData.itemCount) {
|
||||
clipData.getItemAt(i).uri?.let { uris.add(it) }
|
||||
}
|
||||
} else {
|
||||
data.data?.let { uris.add(it) }
|
||||
} else if (dataUri != null) {
|
||||
uris.add(dataUri)
|
||||
}
|
||||
}
|
||||
pendingFilePickerCallback?.invoke(uris)
|
||||
pendingFilePickerCallback = null
|
||||
Log.i("FocusGramApp", "Fallback file picker result code: ${result.resultCode}, selected: ${uris.size} URIs")
|
||||
callback?.invoke(uris)
|
||||
}
|
||||
|
||||
// Android Runtime Permissions Launcher (Camera, Audio, Storage)
|
||||
var pendingPermissionCallback by remember { mutableStateOf<((Boolean) -> Unit)?>(null) }
|
||||
val pendingPermissionCallback = remember { AtomicReference<((Boolean) -> Unit)?>(null) }
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { results ->
|
||||
val allGranted = results.values.all { it }
|
||||
pendingPermissionCallback?.invoke(allGranted)
|
||||
pendingPermissionCallback = null
|
||||
val callback = pendingPermissionCallback.getAndSet(null)
|
||||
callback?.invoke(allGranted)
|
||||
}
|
||||
|
||||
val filePickerHandler = remember {
|
||||
object : FocusGramPromptDelegate.FilePickerHandler {
|
||||
var launchChooser: ((Array<String>, Boolean) -> Unit)? = null
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
Log.i("FocusGramApp", "Notification permission result: $isGranted")
|
||||
}
|
||||
|
||||
override fun onPickFiles(
|
||||
mimeTypes: Array<String>,
|
||||
allowMultiple: Boolean,
|
||||
onResult: (List<Uri>) -> Unit
|
||||
) {
|
||||
pendingFilePickerCallback = onResult
|
||||
try {
|
||||
launchChooser?.invoke(mimeTypes, allowMultiple) ?: onResult(emptyList())
|
||||
} catch (e: Exception) {
|
||||
Log.e("FocusGramApp", "Error launching file picker: ${e.message}", e)
|
||||
onResult(emptyList())
|
||||
LaunchedEffect(settings.notifyPersistent, settings.notifySessionEnd) {
|
||||
if (settings.notifyPersistent || settings.notifySessionEnd) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.POST_NOTIFICATIONS
|
||||
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filePickerHandler.launchChooser = { mimes, allowMultiple ->
|
||||
val filePickerHandler = remember {
|
||||
object : FocusGramPromptDelegate.FilePickerHandler {
|
||||
var launchChooser: ((Array<String>, Boolean, Int) -> Unit)? = null
|
||||
|
||||
override fun onPickFiles(
|
||||
mimeTypes: Array<String>,
|
||||
allowMultiple: Boolean,
|
||||
capture: Int,
|
||||
onResult: (List<Uri>) -> Unit
|
||||
) {
|
||||
pendingFilePickerCallback.set(onResult)
|
||||
try {
|
||||
launchChooser?.invoke(mimeTypes, allowMultiple, capture) ?: run {
|
||||
Log.w("FocusGramApp", "launchChooser was null, returning empty list")
|
||||
pendingFilePickerCallback.getAndSet(null)?.invoke(emptyList())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FocusGramApp", "Error invoking launchChooser: ${e.message}", e)
|
||||
pendingFilePickerCallback.getAndSet(null)?.invoke(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filePickerHandler.launchChooser = { mimes, allowMultiple, capture ->
|
||||
val mimeMap = android.webkit.MimeTypeMap.getSingleton()
|
||||
val cleanMimes = mimes
|
||||
.flatMap { it.split(",") }
|
||||
@@ -263,35 +343,120 @@ fun FocusGramApp() {
|
||||
.distinct()
|
||||
.toTypedArray()
|
||||
|
||||
val hasImages = cleanMimes.any { it.startsWith("image/") } || cleanMimes.isEmpty()
|
||||
val hasVideos = cleanMimes.any { it.startsWith("video/") }
|
||||
Log.i("FocusGramApp", "Launching media picker for cleanMimes: ${cleanMimes.joinToString()}, allowMultiple: $allowMultiple, capture: $capture")
|
||||
|
||||
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 isAnyMedia = cleanMimes.isEmpty() || cleanMimes.contains("*/*")
|
||||
val hasImages = isAnyMedia || cleanMimes.any { it.startsWith("image/") }
|
||||
val hasVideos = isAnyMedia || cleanMimes.any { it.startsWith("video/") }
|
||||
|
||||
val currentUrlLower = sessionManager.currentUrl.value.lowercase()
|
||||
val isProfilePictureEdit = currentUrlLower.contains("/accounts/edit")
|
||||
|
||||
// In Instagram, DMs (/direct/), Stories, and Feed Posts all support videos (MP4, MOV, WebM).
|
||||
// Unless explicitly updating an account avatar/profile picture, always allow both Photos and Videos.
|
||||
val allowsVideos = hasVideos || (!isProfilePictureEdit && hasImages)
|
||||
val allowsImages = hasImages || isAnyMedia
|
||||
|
||||
var launched = false
|
||||
|
||||
// 1. Direct Camera Capture Mode (Explicitly requested by web page via capture attribute)
|
||||
if (capture != org.mozilla.geckoview.GeckoSession.PromptDelegate.FilePrompt.Capture.NONE) {
|
||||
try {
|
||||
val cameraDir = File(context.cacheDir, "camera").apply { mkdirs() }
|
||||
val photoFile = File(cameraDir, "camera_capture.jpg")
|
||||
if (photoFile.exists()) photoFile.delete()
|
||||
val photoUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
photoFile
|
||||
)
|
||||
pendingCameraCaptureUri.set(photoUri)
|
||||
takePictureLauncher.launch(photoUri)
|
||||
launched = true
|
||||
} catch (eCamera: Exception) {
|
||||
Log.w("FocusGramApp", "Direct camera launch failed: ${eCamera.message}, trying photo picker", eCamera)
|
||||
}
|
||||
}
|
||||
val chooser = android.content.Intent.createChooser(intent, "Select Media").apply {
|
||||
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
// 2. Primary Media Picker: Modern Android Photo & Video Picker (PickVisualMedia)
|
||||
// Works seamlessly on Android 11+ and Samsung Galaxy OneUI without any permissions
|
||||
if (!launched) {
|
||||
try {
|
||||
val visualMediaType = when {
|
||||
allowsImages && !allowsVideos -> ActivityResultContracts.PickVisualMedia.ImageOnly
|
||||
allowsVideos && !allowsImages -> ActivityResultContracts.PickVisualMedia.VideoOnly
|
||||
else -> ActivityResultContracts.PickVisualMedia.ImageAndVideo
|
||||
}
|
||||
val request = PickVisualMediaRequest.Builder()
|
||||
.setMediaType(visualMediaType)
|
||||
.build()
|
||||
|
||||
if (allowMultiple) {
|
||||
multiPhotoPickerLauncher.launch(request)
|
||||
} else {
|
||||
singlePhotoPickerLauncher.launch(request)
|
||||
}
|
||||
launched = true
|
||||
} catch (ePhotoPicker: Exception) {
|
||||
Log.w("FocusGramApp", "PickVisualMedia failed: ${ePhotoPicker.message}, attempting ACTION_GET_CONTENT", ePhotoPicker)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback 1: ACTION_GET_CONTENT (with CATEGORY_OPENABLE for Samsung & AOSP DocumentsUI)
|
||||
if (!launched) {
|
||||
try {
|
||||
val getContentIntent = Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
if (allowMultiple) {
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
}
|
||||
if (allowsImages && !allowsVideos) {
|
||||
type = "image/*"
|
||||
if (cleanMimes.isNotEmpty() && !cleanMimes.contains("image/*")) {
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, cleanMimes)
|
||||
}
|
||||
} else if (allowsVideos && !allowsImages) {
|
||||
type = "video/*"
|
||||
if (cleanMimes.isNotEmpty() && !cleanMimes.contains("video/*")) {
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, cleanMimes)
|
||||
}
|
||||
} else {
|
||||
type = "*/*"
|
||||
val fallbackTypes = if (cleanMimes.isNotEmpty() && allowsVideos && !hasVideos) {
|
||||
(cleanMimes.toList() + listOf("video/*", "video/mp4")).toTypedArray()
|
||||
} else if (cleanMimes.isNotEmpty()) {
|
||||
cleanMimes
|
||||
} else {
|
||||
arrayOf("image/*", "video/*")
|
||||
}
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, fallbackTypes)
|
||||
}
|
||||
}
|
||||
fallbackFilePickerLauncher.launch(getContentIntent)
|
||||
launched = true
|
||||
} catch (eGetContent: Exception) {
|
||||
Log.w("FocusGramApp", "ACTION_GET_CONTENT failed: ${eGetContent.message}, attempting ACTION_PICK", eGetContent)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback 2: ACTION_PICK from MediaStore
|
||||
if (!launched) {
|
||||
try {
|
||||
val pickIntent = if (allowsVideos && !allowsImages) {
|
||||
Intent(Intent.ACTION_PICK, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
|
||||
} else {
|
||||
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
|
||||
}.apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
fallbackFilePickerLauncher.launch(pickIntent)
|
||||
launched = true
|
||||
} catch (ePick: Exception) {
|
||||
Log.e("FocusGramApp", "All file picker fallbacks failed: ${ePick.message}", ePick)
|
||||
pendingFilePickerCallback.getAndSet(null)?.invoke(emptyList())
|
||||
}
|
||||
}
|
||||
filePickerLauncher.launch(chooser)
|
||||
}
|
||||
|
||||
// Pre-warm Rewarded Ad container and cache in background (isolated from GeckoView)
|
||||
@@ -343,6 +508,7 @@ fun FocusGramApp() {
|
||||
if (totalTodaySeconds >= settings.dailyLimitMinutes * 60) {
|
||||
isSessionExhausted = true
|
||||
settingsRepository.recordSessionEnd()
|
||||
HapticHelper.performSessionCompletionAlert(context)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -351,6 +517,7 @@ fun FocusGramApp() {
|
||||
if (sessionRemainingSeconds <= 0) {
|
||||
isSessionExhausted = true
|
||||
settingsRepository.recordSessionEnd()
|
||||
HapticHelper.performSessionCompletionAlert(context)
|
||||
if (settings.notifySessionEnd) {
|
||||
postSessionExpiredNotification(context)
|
||||
}
|
||||
@@ -480,9 +647,20 @@ fun FocusGramApp() {
|
||||
}
|
||||
}
|
||||
}
|
||||
extensionManager.onDownloadStarted = { filename ->
|
||||
MediaDownloadHelper.startIndeterminateProgress(filename)
|
||||
}
|
||||
extensionManager.onDownloadRequested = { url, filename ->
|
||||
MediaDownloadHelper.download(context, url, filename)
|
||||
}
|
||||
extensionManager.onDownloadError = { error ->
|
||||
MediaDownloadHelper.triggerDownloadFailure(context, error)
|
||||
}
|
||||
extensionManager.onAuthStateChanged = { authenticated, authPage ->
|
||||
isUserAuthenticated = authenticated
|
||||
isAuthPage = authPage
|
||||
sessionManager.setAuthenticated(authenticated)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(reelHistory, database) {
|
||||
@@ -501,6 +679,10 @@ fun FocusGramApp() {
|
||||
sessionManager = sessionManager,
|
||||
onRouteChanged = { newUrl ->
|
||||
val mode = IslandMode.fromUrl(newUrl)
|
||||
val lower = newUrl.lowercase()
|
||||
if (lower.contains("/accounts") || lower.contains("/login") || lower.contains("/signup")) {
|
||||
isAuthPage = true
|
||||
}
|
||||
sessionManager.updateCurrentUrl(newUrl)
|
||||
sessionManager.updateIslandMode(mode)
|
||||
extensionManager.setIslandMode(mode)
|
||||
@@ -517,7 +699,7 @@ fun FocusGramApp() {
|
||||
sessionManager.session.permissionDelegate = FocusGramPermissionDelegate(
|
||||
context = context,
|
||||
onAndroidPermissionRequest = { perms, callback ->
|
||||
pendingPermissionCallback = callback
|
||||
pendingPermissionCallback.set(callback)
|
||||
permissionLauncher.launch(perms)
|
||||
}
|
||||
)
|
||||
@@ -562,6 +744,15 @@ fun FocusGramApp() {
|
||||
previousReloadSettings = settings
|
||||
}
|
||||
|
||||
// Dynamic screen orientation lock: enforce portrait-only when enabled
|
||||
LaunchedEffect(settings.portraitOnly, activity) {
|
||||
activity?.requestedOrientation = if (settings.portraitOnly) {
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
} else {
|
||||
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -672,10 +863,11 @@ fun FocusGramApp() {
|
||||
sessionManager.refreshReels()
|
||||
},
|
||||
onDownloadClick = {
|
||||
MediaDownloadHelper.startIndeterminateProgress("Preparing Reel download...")
|
||||
extensionManager.triggerReelDownload()
|
||||
sessionManager.userScriptManager?.evaluate(
|
||||
sessionManager.session,
|
||||
"window.dispatchEvent(new CustomEvent('focusgram:download-reel'));"
|
||||
"if (typeof window.__fgDownloadActiveReel === 'function') { window.__fgDownloadActiveReel(); } else { window.dispatchEvent(new CustomEvent('focusgram:download-reel', { bubbles: true })); }"
|
||||
)
|
||||
},
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
@@ -688,6 +880,7 @@ fun FocusGramApp() {
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.height(navBarTotalHeight)
|
||||
.blur(18.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = if (isAppLight) {
|
||||
@@ -712,6 +905,7 @@ fun FocusGramApp() {
|
||||
// Native Bottom Navigation Bar (OLED floating island)
|
||||
if (!isGateActive && destination == AppDestination.MAIN_WEBVIEW) {
|
||||
val instagramTheme by sessionManager.instagramTheme.collectAsState()
|
||||
val downloadState by MediaDownloadHelper.downloadProgressFlow.collectAsState()
|
||||
NativeBottomNavBar(
|
||||
currentUrl = currentUrl,
|
||||
settings = settings,
|
||||
@@ -719,6 +913,12 @@ fun FocusGramApp() {
|
||||
instagramTheme = instagramTheme,
|
||||
isWebInputFocused = isWebInputFocused,
|
||||
isModalActive = isModalActive,
|
||||
downloadProgress = downloadState.progress,
|
||||
isDownloading = downloadState.isDownloading,
|
||||
isDownloadCompleted = downloadState.isCompleted,
|
||||
isDownloadFailed = downloadState.isFailed,
|
||||
isAuthenticated = isUserAuthenticated,
|
||||
isAuthPage = isAuthPage,
|
||||
onNavClick = { target ->
|
||||
extensionManager.go(target)
|
||||
},
|
||||
@@ -776,12 +976,18 @@ fun FocusGramApp() {
|
||||
onNavigateScreenTime = { destination = AppDestination.SCREEN_TIME },
|
||||
onNavigateAppLock = { destination = AppDestination.APP_LOCK_SETTINGS },
|
||||
onWatchAd = { destination = AppDestination.ADSTERRA_REWARDED },
|
||||
onWatchAdWithReward = { action ->
|
||||
adRewardAction = action
|
||||
destination = AppDestination.ADSTERRA_REWARDED
|
||||
},
|
||||
onNavigateUrl = { url ->
|
||||
destination = AppDestination.MAIN_WEBVIEW
|
||||
sessionManager.navigateTo(url)
|
||||
},
|
||||
screenTimeDao = database.screenTimeDao(),
|
||||
reelHistory = reelHistory
|
||||
reelHistory = reelHistory,
|
||||
currentSubpage = settingsSubpage,
|
||||
onSubpageChange = { settingsSubpage = it }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -850,9 +1056,14 @@ fun FocusGramApp() {
|
||||
|
||||
AppDestination.ADSTERRA_REWARDED -> {
|
||||
AdsterraAdScreen(
|
||||
sessionType = if (isDailyLimitExhausted || isSessionExhausted) "session_extend" else "voluntary",
|
||||
sessionType = if (adRewardAction != null) "setting_toggle" else if (isDailyLimitExhausted || isSessionExhausted) "session_extend" else "voluntary",
|
||||
onRewardClaimed = {
|
||||
if (isDailyLimitExhausted) {
|
||||
val pendingAction = adRewardAction
|
||||
adRewardAction = null
|
||||
if (pendingAction != null) {
|
||||
scope.launch { pendingAction.invoke() }
|
||||
destination = AppDestination.SETTINGS
|
||||
} else if (isDailyLimitExhausted) {
|
||||
scope.launch { settingsRepository.extendDailyLimit5Minutes(today) }
|
||||
sessionRemainingSeconds += 5 * 60
|
||||
sessionDurationMinutes += 5
|
||||
@@ -872,6 +1083,7 @@ fun FocusGramApp() {
|
||||
}
|
||||
},
|
||||
onCancel = {
|
||||
adRewardAction = null
|
||||
if (isDailyLimitExhausted || isSessionExhausted) {
|
||||
destination = AppDestination.MAIN_WEBVIEW
|
||||
} else {
|
||||
@@ -960,6 +1172,9 @@ fun FocusGramApp() {
|
||||
|
||||
private fun postSessionExpiredNotification(context: Context) {
|
||||
try {
|
||||
// Direct physical vibration ensures the user feels the alert regardless of notification channel mutes
|
||||
HapticHelper.performSessionCompletionAlert(context)
|
||||
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channelId = "focusgram_session_alerts"
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@@ -988,7 +1203,7 @@ private fun postSessionExpiredNotification(context: Context) {
|
||||
val notification = NotificationCompat.Builder(context, channelId)
|
||||
.setContentTitle("Focus Session Expired")
|
||||
.setContentText("Your scheduled time limit has finished. Take a breath and step away.")
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setDefaults(NotificationCompat.DEFAULT_ALL)
|
||||
.setAutoCancel(true)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07 -1.63,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="camera_photos" path="camera/" />
|
||||
<external-cache-path name="ext_camera_photos" path="camera/" />
|
||||
</paths>
|
||||
@@ -108,6 +108,21 @@ class FocusFeaturesEnhancementsTest {
|
||||
assertTrue("Must use Vibrator class on pre-31", source.contains("context.getSystemService(Vibrator::class.java)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHapticHelperImplementsUniversalDirectHardwareVibration() {
|
||||
val source = getFile("src/main/kotlin/com/ujwal/focusgram/core/utils/HapticHelper.kt").readText()
|
||||
assertFalse("Must not use deprecated Context.VIBRATOR_SERVICE", source.contains("Context.VIBRATOR_SERVICE"))
|
||||
assertTrue("Must use VibratorManager on API 31+", source.contains("VibratorManager::class.java"))
|
||||
assertTrue("Must use Vibrator class on pre-31", source.contains("context.getSystemService(Vibrator::class.java)"))
|
||||
assertTrue("Must pulse physical vibrator directly with >= 30ms duration to overcome ERM motor inertia",
|
||||
source.contains("createOneShot(35L") || source.contains("createOneShot(30L"))
|
||||
assertTrue("Must provide session completion waveform pattern", source.contains("createWaveform(pattern"))
|
||||
|
||||
val sessionExhaustedSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/session/SessionExhaustedScreen.kt").readText()
|
||||
assertTrue("SessionExhaustedScreen must trigger session completion alert vibration",
|
||||
sessionExhaustedSource.contains("HapticHelper.performSessionCompletionAlert"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAdsterraDirectContainerAndPreloader() {
|
||||
val source = getFile("src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt").readText()
|
||||
@@ -192,4 +207,281 @@ class FocusFeaturesEnhancementsTest {
|
||||
assertTrue("Native bottom nav bar in minimal mode must only display MESSAGES and PROFILE",
|
||||
navBarSource.contains("listOf(NavTab.MESSAGES, NavTab.PROFILE)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFilePickerManifestAndProviderConfiguration() {
|
||||
val manifestSource = getFile("src/main/AndroidManifest.xml").readText()
|
||||
assertTrue("Manifest must declare FileProvider",
|
||||
manifestSource.contains("androidx.core.content.FileProvider"))
|
||||
assertTrue("Manifest must declare intent queries for PICK_IMAGES",
|
||||
manifestSource.contains("android.provider.action.PICK_IMAGES"))
|
||||
assertTrue("Manifest must declare intent queries for GET_CONTENT",
|
||||
manifestSource.contains("android.intent.action.GET_CONTENT"))
|
||||
assertTrue("Manifest must declare camera capture intents in queries",
|
||||
manifestSource.contains("android.media.action.IMAGE_CAPTURE"))
|
||||
|
||||
val pathsSource = getFile("src/main/res/xml/file_paths.xml").readText()
|
||||
assertTrue("file_paths.xml must configure cache-path for camera",
|
||||
pathsSource.contains("cache-path name=\"camera_photos\" path=\"camera/\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNativeBottomNavBarBlurAndGlassStyling() {
|
||||
val navBarSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt").readText()
|
||||
assertTrue("NavBar must include frosted glass blur layer",
|
||||
navBarSource.contains(".blur(20.dp)"))
|
||||
assertTrue("NavBar must include glassBorderBrush",
|
||||
navBarSource.contains("glassBorderBrush"))
|
||||
assertTrue("NavBar must integrate HapticHelper for device-compatible vibrations",
|
||||
navBarSource.contains("HapticHelper.performHapticFeedback") || navBarSource.contains("HapticHelper"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFragment186VersionEnforcement() {
|
||||
val buildGradle = getFile("build.gradle.kts").readText()
|
||||
assertTrue("build.gradle.kts must force androidx.fragment:fragment:1.8.6",
|
||||
buildGradle.contains("androidx.fragment:fragment:1.8.6"))
|
||||
assertTrue("build.gradle.kts must force androidx.fragment:fragment-ktx:1.8.6",
|
||||
buildGradle.contains("androidx.fragment:fragment-ktx:1.8.6"))
|
||||
|
||||
val libsToml = getFile("../gradle/libs.versions.toml").readText()
|
||||
assertTrue("libs.versions.toml must declare fragment = \"1.8.6\"",
|
||||
libsToml.contains("fragment = \"1.8.6\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFilePickerMimeParsingAndMediaRouting() {
|
||||
fun resolveMediaTypes(mimes: Array<String>, isProfilePictureEdit: Boolean = false): Triple<Boolean, Boolean, String> {
|
||||
val cleanMimes = mimes
|
||||
.flatMap { it.split(",") }
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.mapNotNull { token ->
|
||||
if (token.startsWith(".jpg") || token.startsWith(".jpeg")) "image/jpeg"
|
||||
else if (token.startsWith(".png")) "image/png"
|
||||
else if (token.startsWith(".mp4")) "video/mp4"
|
||||
else if (token.contains("/")) token
|
||||
else null
|
||||
}
|
||||
.distinct()
|
||||
.toTypedArray()
|
||||
|
||||
val isAnyMedia = cleanMimes.isEmpty() || cleanMimes.contains("*/*")
|
||||
val hasImages = isAnyMedia || cleanMimes.any { it.startsWith("image/") }
|
||||
val hasVideos = isAnyMedia || cleanMimes.any { it.startsWith("video/") }
|
||||
|
||||
val allowsVideos = hasVideos || (!isProfilePictureEdit && hasImages)
|
||||
val allowsImages = hasImages || isAnyMedia
|
||||
|
||||
val visualMediaType = when {
|
||||
allowsImages && !allowsVideos -> "ImageOnly"
|
||||
allowsVideos && !allowsImages -> "VideoOnly"
|
||||
else -> "ImageAndVideo"
|
||||
}
|
||||
return Triple(allowsImages, allowsVideos, visualMediaType)
|
||||
}
|
||||
|
||||
// 1. Profile Picture: /accounts/edit screen strictly image only
|
||||
val (profileImg, profileVid, profileType) = resolveMediaTypes(arrayOf("image/*"), isProfilePictureEdit = true)
|
||||
assertTrue(profileImg)
|
||||
assertFalse(profileVid)
|
||||
assertEquals("ImageOnly", profileType)
|
||||
|
||||
// 2. DM / Story upload: Instagram sends only image MIME types, but we must allow both Images and Videos!
|
||||
val (dmImg, dmVid, dmType) = resolveMediaTypes(arrayOf("image/avif, image/jpeg, image/png"), isProfilePictureEdit = false)
|
||||
assertTrue(dmImg)
|
||||
assertTrue(dmVid)
|
||||
assertEquals("ImageAndVideo", dmType)
|
||||
|
||||
val (storyImg, storyVid, storyType) = resolveMediaTypes(arrayOf("image/png, image/jpeg"), isProfilePictureEdit = false)
|
||||
assertTrue(storyImg)
|
||||
assertTrue(storyVid)
|
||||
assertEquals("ImageAndVideo", storyType)
|
||||
|
||||
// 3. Reels: Video only
|
||||
val (reelsImg, reelsVid, reelsType) = resolveMediaTypes(arrayOf("video/mp4, video/quicktime"), isProfilePictureEdit = false)
|
||||
assertFalse(reelsImg)
|
||||
assertTrue(reelsVid)
|
||||
assertEquals("VideoOnly", reelsType)
|
||||
|
||||
// 4. Fallback: Empty array or */*
|
||||
val (emptyImg, emptyVid, emptyType) = resolveMediaTypes(emptyArray(), isProfilePictureEdit = false)
|
||||
assertTrue(emptyImg)
|
||||
assertTrue(emptyVid)
|
||||
assertEquals("ImageAndVideo", emptyType)
|
||||
|
||||
val (starImg, starVid, starType) = resolveMediaTypes(arrayOf("*/*"), isProfilePictureEdit = false)
|
||||
assertTrue(starImg)
|
||||
assertTrue(starVid)
|
||||
assertEquals("ImageAndVideo", starType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNativeFeelIncludesVideoSupportBridge() {
|
||||
val script = getFile("src/main/assets/scripts/native_feel.js").readText()
|
||||
assertTrue("native_feel.js must include enhanceFileInputsForVideoSupport",
|
||||
script.contains("enhanceFileInputsForVideoSupport"))
|
||||
assertTrue("native_feel.js must broaden accept attribute to include video formats",
|
||||
script.contains("video/mp4,video/quicktime,video/webm,video/*"))
|
||||
assertTrue("native_feel.js must exclude /accounts/edit from video patching",
|
||||
script.contains("/accounts/edit"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReelsTopBarWatchedCount() {
|
||||
val appSource = getFile("src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt").readText()
|
||||
assertTrue("FocusGramApp must pass reelsWatchedSessionCount directly to ReelsTopBar",
|
||||
appSource.contains("watchedCount = reelsWatchedSessionCount"))
|
||||
|
||||
val topBarSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/navigation/ReelsTopBar.kt").readText()
|
||||
assertTrue("ReelsTopBar must default watchedCount to 0",
|
||||
topBarSource.contains("watchedCount: Int = 0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDownloadProgressStateAndIndeterminateProgress() {
|
||||
val defaultState = com.ujwal.focusgram.core.utils.DownloadProgressState()
|
||||
assertFalse(defaultState.isDownloading)
|
||||
assertFalse(defaultState.isCompleted)
|
||||
assertEquals(0f, defaultState.progress, 0.001f)
|
||||
|
||||
com.ujwal.focusgram.core.utils.MediaDownloadHelper.startIndeterminateProgress("test_reel")
|
||||
val activeState = com.ujwal.focusgram.core.utils.MediaDownloadHelper.downloadProgressFlow.value
|
||||
assertTrue("isDownloading must be true after startIndeterminateProgress", activeState.isDownloading)
|
||||
assertFalse("isCompleted must be false while downloading", activeState.isCompleted)
|
||||
assertTrue("progress should be >= 0.05f", activeState.progress >= 0.05f)
|
||||
assertEquals("test_reel", activeState.filename)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHomeVsProfileTabResolutionWithQueryParams() {
|
||||
// Simulates the exact logic in NativeBottomNavBar
|
||||
fun getCleanPath(url: String): String {
|
||||
val afterScheme = if (url.contains("://")) url.substringAfter("://") else url
|
||||
val pathPart = if (afterScheme.contains('/')) afterScheme.substringAfter('/') else ""
|
||||
return pathPart.substringBefore('?').substringBefore('#').trim('/')
|
||||
}
|
||||
|
||||
fun resolveIsHome(url: String): Boolean {
|
||||
val cleanPath = getCleanPath(url)
|
||||
val lowerUrl = url.lowercase()
|
||||
val isHome = cleanPath.isEmpty()
|
||||
return isHome || lowerUrl.startsWith("https://www.instagram.com/?") || lowerUrl.startsWith("https://instagram.com/?") || lowerUrl == "/"
|
||||
}
|
||||
|
||||
fun resolveIsProfile(url: String): Boolean {
|
||||
val cleanPath = getCleanPath(url)
|
||||
val lowerUrl = url.lowercase()
|
||||
val isHome = cleanPath.isEmpty()
|
||||
val isInstagram = lowerUrl.contains("instagram.com") || lowerUrl.startsWith("/")
|
||||
val isExcluded = isHome ||
|
||||
lowerUrl.contains("/explore") ||
|
||||
lowerUrl.contains("/reel") ||
|
||||
lowerUrl.contains("/direct") ||
|
||||
lowerUrl.contains("/inbox") ||
|
||||
lowerUrl.contains("/p/") ||
|
||||
lowerUrl.contains("/stories/") ||
|
||||
lowerUrl.startsWith("focusgram-download:") ||
|
||||
lowerUrl.startsWith("javascript:")
|
||||
|
||||
val segments = cleanPath.trim('/').split('/').filter { it.isNotEmpty() }
|
||||
val isSingleUserSegment = segments.size == 1
|
||||
|
||||
return !isHome && isInstagram && !isExcluded &&
|
||||
(lowerUrl.contains("/profile") || isSingleUserSegment)
|
||||
}
|
||||
|
||||
// Home URLs with parameters must always resolve to Home, NEVER Profile
|
||||
assertTrue(resolveIsHome("https://www.instagram.com/"))
|
||||
assertFalse(resolveIsProfile("https://www.instagram.com/"))
|
||||
|
||||
assertTrue(resolveIsHome("https://www.instagram.com/?variant=home"))
|
||||
assertFalse(resolveIsProfile("https://www.instagram.com/?variant=home"))
|
||||
|
||||
assertTrue(resolveIsHome("https://www.instagram.com/?theme=dark"))
|
||||
assertFalse(resolveIsProfile("https://www.instagram.com/?theme=dark"))
|
||||
|
||||
// Profile URLs
|
||||
assertFalse(resolveIsHome("https://www.instagram.com/zuck/"))
|
||||
assertTrue(resolveIsProfile("https://www.instagram.com/zuck/"))
|
||||
|
||||
assertFalse(resolveIsHome("https://www.instagram.com/profile"))
|
||||
assertTrue(resolveIsProfile("https://www.instagram.com/profile"))
|
||||
|
||||
// Excluded pages (Explore, Reels, DMs, Posts)
|
||||
assertFalse(resolveIsProfile("https://www.instagram.com/explore/"))
|
||||
assertFalse(resolveIsProfile("https://www.instagram.com/reel/C7x90/"))
|
||||
assertFalse(resolveIsProfile("https://www.instagram.com/p/C7x90/"))
|
||||
assertFalse(resolveIsProfile("https://www.instagram.com/direct/inbox/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFocusGramContentAntiFlashDoesNotCollapseFeed() {
|
||||
val script = getFile("src/main/assets/extensions/ublock/focusgram_content.js").readText()
|
||||
assertFalse(
|
||||
"focusgram_content.js must NOT have overly broad div:has(> a[href='/']) that caused feed blanking",
|
||||
script.contains("div:has(> a[href=\"/\"]):has(a[href*=\"/explore/\"])")
|
||||
)
|
||||
assertFalse(
|
||||
"focusgram_content.js must NOT have div:has(> div > a[href='/'])",
|
||||
script.contains("div:has(> div > a[href=\"/\"]):has(a[href*=\"/explore/\"])")
|
||||
)
|
||||
assertTrue(
|
||||
"focusgram_content.js must restrict anti-flash hiding to navigation/footer roles",
|
||||
script.contains("nav[role=\"navigation\"]:has(a[href*=\"/explore/\"])")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMediaDownloaderDoesNotUseIframeToPreventCspViolations() {
|
||||
val script = getFile("src/main/assets/scripts/media_downloader.js").readText()
|
||||
assertFalse(
|
||||
"media_downloader.js must not create iframes with custom schemes which violate Instagram frame-src CSP",
|
||||
script.contains("document.createElement('iframe')")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMediaDownloaderAccidentalScrollGuard() {
|
||||
val scriptUser = getFile("src/main/assets/scripts/media_downloader.js").readText()
|
||||
assertTrue(
|
||||
"media_downloader.js must guard against accidental download triggers during feed scrolling with hold timer",
|
||||
scriptUser.contains("isGlobalScrolling") && scriptUser.contains("touchmove") && scriptUser.contains("holdThresholdPassed")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMediaDownloaderPreventsFalseVideoHijackingOnPhotos() {
|
||||
val scriptUser = getFile("src/main/assets/scripts/media_downloader.js").readText()
|
||||
assertTrue(
|
||||
"media_downloader.js must check hasVideoElement before inspecting video streams from performance timings",
|
||||
scriptUser.contains("hasVideoElement")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMediaDownloadHelperPhotoVsVideoDetectionAndFilename() {
|
||||
val helper = com.ujwal.focusgram.core.utils.MediaDownloadHelper
|
||||
val photoUrl = "https://instagram.fktm1-1.fna.fbcdn.net/v/t51.82787-15/841260402_18131269318760808_4130815482121662762_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=107"
|
||||
val videoUrl = "https://instagram.fktm1-1.fna.fbcdn.net/v/t50.28834-16/123456_n.mp4"
|
||||
|
||||
assertFalse("Photo URL with /v/t51 and dst-jpg must NOT be detected as video", helper.detectIsVideo(photoUrl, "instagram_xyz.mp4"))
|
||||
assertTrue("Video URL with /v/t50 and .mp4 must be detected as video", helper.detectIsVideo(videoUrl, "instagram_xyz.mp4"))
|
||||
|
||||
val formattedPhotoName = helper.formatTargetFilename("@ujwal - Focusgram.mp4", false)
|
||||
assertEquals("@ujwal - Focusgram.jpg", formattedPhotoName)
|
||||
|
||||
val formattedVideoName = helper.formatTargetFilename("@ujwal - Focusgram.jpg", true)
|
||||
assertEquals("@ujwal - Focusgram.mp4", formattedVideoName)
|
||||
|
||||
val fallbackFormatted = helper.formatTargetFilename("instagram_DehM0IhkVm0.jpg", false)
|
||||
assertEquals("@Focusgram - Focusgram.jpg", fallbackFormatted)
|
||||
|
||||
val normalAuthorFormatted = helper.formatTargetFilename("@ujwal", true)
|
||||
assertEquals("@ujwal - Focusgram.mp4", normalAuthorFormatted)
|
||||
|
||||
val longAuthorFormatted = helper.formatTargetFilename("ujwaldeveloper12345", true)
|
||||
assertEquals("@ujwaldevelope - Focusgram.mp4", longAuthorFormatted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ class FocusSettingsTest {
|
||||
// Initial user level
|
||||
assertEquals(1, settings.userLevel)
|
||||
assertEquals(0, settings.userXP)
|
||||
// Portrait only enabled by default
|
||||
assertTrue(settings.portraitOnly)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -12,6 +12,7 @@ room = "2.6.1"
|
||||
datastore = "1.1.2"
|
||||
securityCrypto = "1.1.0-alpha06"
|
||||
biometric = "1.2.0-alpha05"
|
||||
fragment = "1.8.6"
|
||||
geckoview = "157.0.20260924084938"
|
||||
coroutines = "1.10.1"
|
||||
junit = "4.13.2"
|
||||
@@ -47,6 +48,7 @@ androidx-datastore-preferences = { group = "androidx.datastore", name = "datasto
|
||||
# Security & Biometrics
|
||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
|
||||
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" }
|
||||
|
||||
# Coroutines
|
||||
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||
|
||||
Reference in new issue
Block a user