From f3be791465a4ddcf6151aa7edd9d531a9914f970 Mon Sep 17 00:00:00 2001 From: Lav-developer <170819619+Lav-developer@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:54:52 +0000 Subject: [PATCH 01/13] Android app v1.3.0: fully self-contained app + native FCM push Self-contained app (no browser/website fallback for normal features): - Side drawer (hamburger) wiring every feature to in-app screens: Upload paper, Study tools, Contributors, Links, About (+ existing tabs) - In-app Upload: same validation/throttle, gofile storage, pendingUploads Firestore queue and 10-point reward as the website; Android file picker; on-device images-to-PDF assembly (hand-rolled minimal PDF writer) - Study tools (CGPA/attendance/planner) fully on-device; request-a-tool keeps its legit t.me external intent - Contributors via ONE cached /api/contributors call (24h SWR); join card routes to in-app upload - Links: the 14 portals rendered statically in-app; only the tapped portal opens externally - PDF: "Open PDF" launches the new native PdfViewerActivity (PdfRenderer, lazy pages, pinch zoom/pan, progress/error states, temp-cache only, never the Worker); failure falls back to the system with the SAME direct URL; Drive/mediafire landing pages stay external intents - Google sign-in now native (Credential Manager -> accounts:signInWithIdp, same dsmnru-data project, nonce-protected); unconfigured builds get an in-app explainer + email/password, never a website hand-off - Audit: removed every website redirect (home shortcuts, profile website item, paper report -> in-app feedback sheet, unresolvable slugs -> in-app prefilled search); about.js documents the remaining external list - docs/GOOGLE_SIGNIN_SETUP.md for the out-of-repo console config Native FCM push (same dsmnru-data project): - FcmService: token rotation handling, version-gated `all_users` topic subscription (one FCM call per install/update, none per launch), token kept device-local only (no Firestore), foreground rendering behind the permission check, tap intent = ACTION_VIEW data URL on MainActivity - Manifest: POST_NOTIFICATIONS, FCM service + MESSAGING_EVENT filter, default channel/icon/color meta-data; Gradle: firebase-messaging dep, google-services stays conditional (builds without the json degrade silently); MainActivity: channel creation + REAL system permission dialog once per install ~9s into the first session (13+ only, never re-asked, skipped when Firebase is unconfigured) - Sender side (admin -> Worker -> FCM) does not exist yet in the repo; docs/PUSH_NOTIFICATIONS.md specifies it exactly (no invented backend) - .gitignore: google-services.json + keystores are never committed Tests: npm test = 46 tests / 44 pass / 0 fail / 2 skip (jsdom smoke runs in CI). New: features.test.mjs (17 static+unit), app-native-bridge.test.mjs (jsdom with a fake Capacitor bridge; skipped in this sandbox), fcm.test.mjs (manifest/Gradle/service/permission/payload-contract audits). Existing tests untouched and green. versionCode 4, versionName 1.3.0. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- android-app/README.md | 181 +++-- android-app/android/.gitignore | 5 + android-app/android/app/build.gradle | 10 +- .../android/app/src/main/AndroidManifest.xml | 57 +- .../java/com/dsmnru/pyq/DsmnruAppPlugin.java | 202 ++++- .../main/java/com/dsmnru/pyq/FcmService.java | 225 ++++++ .../java/com/dsmnru/pyq/MainActivity.java | 76 +- .../com/dsmnru/pyq/PdfViewerActivity.java | 757 ++++++++++++++++++ .../src/main/res/drawable/ic_stat_dsmnru.xml | 15 + .../src/main/res/drawable/pdf_viewer_btn.xml | 6 + .../app/src/main/res/values/strings.xml | 15 + android-app/docs/GOOGLE_SIGNIN_SETUP.md | 112 +++ android-app/docs/PUSH_NOTIFICATIONS.md | 158 ++++ android-app/test/app-frontend-smoke.test.mjs | 163 ++++ android-app/test/app-native-bridge.test.mjs | 207 +++++ android-app/test/fcm.test.mjs | 186 +++++ android-app/test/features.test.mjs | 501 ++++++++++++ android-app/www/css/app.css | 205 +++++ android-app/www/index.html | 4 + android-app/www/js/api.js | 11 + android-app/www/js/app.js | 72 +- android-app/www/js/auth.js | 63 +- android-app/www/js/authui.js | 91 ++- android-app/www/js/drawer.js | 146 ++++ android-app/www/js/linkdata.js | 56 ++ android-app/www/js/native.js | 66 +- android-app/www/js/toolscore.js | 148 ++++ android-app/www/js/ui.js | 22 + android-app/www/js/uploadcore.js | 340 ++++++++ android-app/www/js/views/about.js | 65 ++ android-app/www/js/views/contributors.js | 99 +++ android-app/www/js/views/home.js | 14 +- android-app/www/js/views/links.js | 71 ++ android-app/www/js/views/paper.js | 129 ++- android-app/www/js/views/profile.js | 12 +- android-app/www/js/views/tools.js | 398 +++++++++ android-app/www/js/views/upload.js | 321 ++++++++ 37 files changed, 5072 insertions(+), 137 deletions(-) create mode 100644 android-app/android/app/src/main/java/com/dsmnru/pyq/FcmService.java create mode 100644 android-app/android/app/src/main/java/com/dsmnru/pyq/PdfViewerActivity.java create mode 100644 android-app/android/app/src/main/res/drawable/ic_stat_dsmnru.xml create mode 100644 android-app/android/app/src/main/res/drawable/pdf_viewer_btn.xml create mode 100644 android-app/docs/GOOGLE_SIGNIN_SETUP.md create mode 100644 android-app/docs/PUSH_NOTIFICATIONS.md create mode 100644 android-app/test/app-native-bridge.test.mjs create mode 100644 android-app/test/fcm.test.mjs create mode 100644 android-app/test/features.test.mjs create mode 100644 android-app/www/js/drawer.js create mode 100644 android-app/www/js/linkdata.js create mode 100644 android-app/www/js/toolscore.js create mode 100644 android-app/www/js/uploadcore.js create mode 100644 android-app/www/js/views/about.js create mode 100644 android-app/www/js/views/contributors.js create mode 100644 android-app/www/js/views/links.js create mode 100644 android-app/www/js/views/tools.js create mode 100644 android-app/www/js/views/upload.js diff --git a/android-app/README.md b/android-app/README.md index f692eab..b907001 100644 --- a/android-app/README.md +++ b/android-app/README.md @@ -2,53 +2,71 @@ A **real, app-specific Android interface** for the DSMNRU PYQ / Syllabus Archive — *not* a WebView wrapper around the website. The app bundles its own -mobile-first front-end in `www/` (bottom navigation, app-designed screens, -touch-sized cards and sheets) while sharing the website's existing backends -verbatim: +mobile-first front-end in `www/` (side drawer, bottom navigation, +app-designed screens, touch-sized cards and sheets) while sharing the +website's existing backends verbatim: ``` Android app (bundled UI) ├── Cloudflare Worker API https://dsmnru-pyq-api.kush210431-cloudflare.workers.dev/api/* │ └── same KV search index / pagination / cache the website uses - └── Firebase Auth (project `dsmnru-data`, same accounts as the site) - └── Identity Toolkit REST for in-app email/password sign-in + ├── Firebase Auth (project `dsmnru-data`, same accounts as the site) + │ └── Identity Toolkit REST for email/password + native Google sign-in + └── gofile.io + Firestore pendingUploads (the SAME public upload pipeline + the website uses — driven from the in-app Upload screen) ``` There is **no second backend, no second database, no duplicate PYQ storage** and no app-specific business data. Public archive data never reads Firestore; -the only Firestore touch-point in the entire app is the one-time -`users/{uid}` profile-row sync immediately after a manual sign-in (the same -record the website's `ensureUserDocumentSynced` creates). +the only Firestore touch-points are the one-time `users/{uid}` profile-row +sync after a manual sign-in and the same `pendingUploads` / `feedback` +writes the website's own forms perform — all owner-scoped and rule-validated. +Every product feature (upload, tools, contributors, links, PDF viewing, +sign-in) is handled **inside the app**; the browser is never a fallback for +normal functionality. ## Contents | Path | Purpose | | --- | --- | -| `www/index.html` | App shell: app bar, routed view, bottom nav, sheets/toasts | +| `www/index.html` | App shell: app bar (+ drawer menu button), routed view, bottom nav, sheets/toasts | | `www/css/app.css` | The app's own dark "DSMNRU academic" design system (safe-area aware, 44px+ targets, no blur/animation bloat) | -| `www/js/app.js` | Shell: view stack router, Android back button, network banner, deep-link handoff, auth gates | +| `www/js/app.js` | Shell: view stack router, side drawer wiring, Android back button, network banner, deep-link handoff, auth gates, in-app PDF open policy | +| `www/js/drawer.js` | Android side drawer: tabs + Upload / Tools / Contributors / Links / About (never opens the website) | | `www/js/api.js` | Worker API client: TTL cache + persisted layer + in-flight dedupe + SWR + abort/timeout | -| `www/js/auth.js` | Firebase Authentication via Identity Toolkit REST (same project as the website) | -| `www/js/authui.js` | Sign-in / sign-up / reset / email-verification sheets | +| `www/js/auth.js` | Firebase Authentication via Identity Toolkit REST (same project) + Google credential exchange (`signInWithIdp`) | +| `www/js/authui.js` | Sign-in / sign-up / reset / email-verification sheets + native Google flow | | `www/js/store.js` | On-device persistence: saved papers, recent views, recent searches | | `www/js/slug.js` | Canonical-slug mirror of the Worker's allocator + deep-link URL parser | -| `www/js/views/` | home · search · browse(courses) · course drill-down · paper · saved · profile | -| `www/js/native.js` | Wrapper for the app's own Java plugin (share sheet, downloads, external intents, launch link) | -| `android/app/src/main/java/com/dsmnru/pyq/DsmnruAppPlugin.java` | The custom plugin: ACTION_VIEW / ACTION_SEND / DownloadManager / launch deep-link | -| `android/app/src/main/java/com/dsmnru/pyq/MainActivity.java` | BridgeActivity: registers the plugin, forwards warm-start deep links to the JS router | +| `www/js/uploadcore.js` | Pure upload logic: website-parity validation, throttle, gofile URL, `pendingUploads` doc shape, image→PDF assembly | +| `www/js/toolscore.js` | Pure tool logic: CGPA math, attendance stats, planner (website parity, on-device) | +| `www/js/linkdata.js` | The curated university/government portal list (same data as the site's Links page, shipped statically) | +| `www/js/views/` | home · search · browse(courses) · course · paper · saved · profile · **upload · tools · contributors · links · about** | +| `www/js/native.js` | Wrapper for the app's own Java plugin (in-app PDF viewer, Google credential chooser, share sheet, downloads, external intents, launch link) | +| `android/app/src/main/java/com/dsmnru/pyq/DsmnruAppPlugin.java` | The custom plugin: in-app PDF viewer launch, Credential-Manager Google sign-in, ACTION_VIEW / ACTION_SEND / DownloadManager / launch deep-link | +| `android/app/src/main/java/com/dsmnru/pyq/PdfViewerActivity.java` | Native in-app PDF viewer screen (PdfRenderer, pinch zoom + pan, lazy page rendering, progress/error states, temporary cache only) | +| `android/app/src/main/java/com/dsmnru/pyq/MainActivity.java` | BridgeActivity: registers the plugin, forwards warm-start deep links to the JS router, FCM bootstrap + the one-time POST_NOTIFICATIONS ask | +| `android/app/src/main/java/com/dsmnru/pyq/FcmService.java` | FCM receiver: `all_users` topic subscription (version-gated), foreground notification rendering, deep-link tap intents | | `android/` | Generated + customized Capacitor Android project (Gradle wrapper included) | -| `test/` | Node unit tests + jsdom integration smoke test of the app UI (`npm test`) | +| `docs/GOOGLE_SIGNIN_SETUP.md` | Exact console configuration for native Google sign-in | +| `docs/PUSH_NOTIFICATIONS.md` | FCM implementation notes, message contract, sender-side (Worker/admin) requirements | +| `test/` | Node unit tests + jsdom integration smoke tests of the app UI (`npm test`) | ## Screens & navigation -Bottom tabs: **Home · Search · Courses · Saved · Profile**. Paper detail and -course drill-downs are pushed onto an in-app view stack (Android back pops it; -at a tab root, back goes to Home; at Home, back exits — standard behavior). +**Side drawer** (hamburger in the app bar at every tab root): Home · Search · +Courses · Saved · Profile & settings — Upload Paper · Study Tools · +Contributors · Links — About. Drawer items push the matching in-app screen; +the drawer never opens the website. Bottom tabs: **Home · Search · Courses · +Saved · Profile** (unchanged). Paper detail, course drill-downs and all +drawer screens live on an in-app view stack (Android back pops it; at a tab +root, back goes to Home; at Home, back exits — with drawer/sheet-first +precedence). * **Home** — brand hero + search launcher, archive stats, quick-access course grid, "pick up where you left off" (device history), recently added and - trending rails, and shortcuts (upload/tools/contributors open the live site - externally). Fed by **one** cached `GET /api/homepage` call. + trending rails, and in-app shortcuts (upload/tools/contributors/links + screens). Fed by **one** cached `GET /api/homepage` call. * **Search** — server-side `GET /api/pyqs/search` (title/subject/course/ semester/session), 350 ms debounce, previous in-flight query aborted via `AbortController`, filter + sort chips, paged results (20/page), full @@ -56,12 +74,30 @@ at a tab root, back goes to Home; at Home, back exits — standard behavior). * **Courses** — app-native course grid (catalog + live paper counts) → semester/session chips + in-course subject search → paged papers. * **Paper** — app-designed detail: title, course, semester, session, branch, - subject, views, date, document id; **Open PDF / Server 2 / Download / Save / - Share** actions; metadata table; related papers (one filtered request); - "Open on website" for comments/report flows. + subject, views, date, document id; **Open PDF (in-app viewer first) / + Server 2 / Download / Save / Share** actions; metadata table; in-app + "Report a broken link" (same Firestore `feedback` queue as the website); + related papers (one filtered request). +* **Upload Paper** — the website's public upload workflow, fully in-app: + same metadata + validation rules, Android file picker for one PDF (≤10 MB) + or photos (converted to a PDF on-device), the same gofile.io storage, the + same `pendingUploads` review queue, the same 10-point reward promise, the + same client throttle (5 / 6 h), and real progress / success / error states. +* **Study Tools** — CGPA calculator, attendance tracker (75 % warning line) + and study planner as native cards + sheets. 100 % on-device (localStorage, + same keys as the website): zero API requests. "Request a tool" → the + maintainers' Telegram bot (genuinely external). +* **Contributors** — ONE cached `GET /api/contributors` request (24 h + persisted, SWR) renders the whole list; the "Join them" card routes to the + in-app Upload screen. Never a request per contributor. +* **Links** — the curated university/scholarship portal list rendered + statically in-app (zero network); only the tapped portal itself opens + externally. +* **About** — in-app app identity, data sources, and the audited list of + genuinely-external destinations. * **Saved** — on-device bookmarks with local filter, works fully offline. -* **Profile** — session state, email-verification flow, device data controls - (cache refresh/clear), sign-out, about. +* **Profile** — session state, native Google sign-in entry, email-verification + flow, device data controls (cache refresh/clear), sign-out, about. ## API-request discipline (Cloudflare free tier) @@ -85,26 +121,81 @@ at a tab root, back goes to Home; at Home, back exits — standard behavior). ## PDFs PDF URLs come from the paper documents themselves (`file`/`server1`, -`file2`/`server2`) — the same links the website shows. "Open" hands the URL to -the system (Chrome/Drive/PDF viewer); "Download" (direct `.pdf` hosts only) -uses Android's DownloadManager into the public *Downloads* folder. Nothing is -mirrored, cached in app storage, or re-hosted. +`file2`/`server2`) — the same links the website shows. **"Open PDF" first +opens the app's own native viewer screen** (`PdfViewerActivity`): the direct +host URL is streamed by Android itself (never through the Cloudflare Worker, +so no Worker bandwidth is consumed), rendered with the platform `PdfRenderer` +(lazy per-page rendering into a heap-bounded LruCache), with pinch-zoom + +pan, vertical page scrolling, a page indicator, real download progress, and +error states with Retry / "Open in another app" (the same direct URL to a +system PDF app — never the DSMNRU website). The viewer keeps the file only +in the app's **temporary cache directory** — deleted on close and stale +files purged on open, so nothing is permanently downloaded into app storage +and nothing is mirrored or re-hosted. Landing-page links (Drive/mediafire +"Server 2") genuinely cannot render in-app and open through an external +intent. "Download" (direct `.pdf` hosts only) still uses Android's +DownloadManager into the public *Downloads* folder on an explicit tap. ## Authentication The existing Firebase project (`dsmnru-data`) with its email/password sign-in, sign-up, reset and email-verification flows — driven through the public Identity Toolkit REST endpoints (the same calls the website's SDK makes), so -no popup windows are needed and no new auth system exists. Sessions persist -`idToken/refreshToken` locally and refresh lazily (app resume / expiry), never -on a timer. The website's gate policy is mirrored in-app: verified sign-in is -required for search, filters, page 2+ and PDF actions; metadata browsing stays -public. **Google sign-in cannot run inside embedded WebViews (Google's own -policy — identical to the notice on the website):** the app explains this -explicitly and offers email/password or a one-tap hand-off to the site. +no popup windows are needed and no new auth system exists. **Google sign-in +is native**: the device's own Google account chooser (Android Credential +Manager, `DsmnruAppPlugin.googleSignIn`) returns a Google ID token which +`auth.signInWithGoogleCredential()` exchanges with the same project via +`accounts:signInWithIdp` — the identical call the Firebase JS SDK makes — so +the user identity, privileges and `users/{uid}` sync match the website +exactly. No browser, no Chrome, no website hand-off. Builds without the +Google client-ID configuration report `GOOGLE_SIGNIN_NOT_CONFIGURED` and +explain it in-app while offering email/password (see +`docs/GOOGLE_SIGNIN_SETUP.md` for the one-time console setup). Sessions +persist `idToken/refreshToken` locally and refresh lazily (app resume / +expiry), never on a timer. The website's gate policy is mirrored in-app: +verified sign-in is required for search, filters, page 2+ and PDF actions; +metadata browsing stays public. + +## Notifications (FCM — implemented) + +Push notifications run on the **same Firebase project (`dsmnru-data`)** with +a deliberately tiny footprint: + +* **Audience = one FCM topic.** Every opted-in install subscribes to the + global topic `all_users` — the FCM SDK owns registration and topic state, + so there is **no token database, no Firestore writes, no per-launch sync** + (the subscribe is version-gated: one call per install/update, plus once on + token rotation). +* **Real Android 13+ permission.** `POST_NOTIFICATIONS` is requested through + the actual system dialog once per install, ~9 s into the first session, + and never re-asked — grant or deny — and the app works identically + either way (denied pushes are silently suppressed before every post). + There is no in-app fake toggle. +* **Channels + branding.** The `dsmnru_general` channel ("Paper alerts") is + created at app start; foreground messages are rendered by `FcmService` + (`onMessageReceived`), background notification payloads are branded by the + manifest meta-data (icon/color/channel) and auto-displayed by the tray. +* **Taps open the app, not the website.** The tap intent is an ACTION_VIEW + data URL on `MainActivity`, riding the same deep-link pipeline as shared + links — `data.path` like `/pyq/` lands on the in-app paper screen, + cold or warm. +* **Sender side (admin → Worker → FCM):** the exact remaining backend steps + (service account, `POST /api/notify` guarded by the existing admin-token + check, the web admin panel form) are specified in + `docs/PUSH_NOTIFICATIONS.md` §5 — documented, not invented, since the + Worker has no push endpoint yet. Build-time config: drop the project's + `google-services.json` into `android/app/` (the Gradle file already + applies the Google Services plugin automatically when present; without it + the app runs normally with push disabled). ## Deep links +Shared `https://dsmnru-pyq.netlify.app/pyq/` and `paper.html?id=…` +links (and FCM notification taps) open the app's paper screen natively: +cold start via `DsmnruAppPlugin.getLaunchUrl()`, warm start via the +`siteDeepLink` event. Unresolvable slugs stay in-app with a pre-filled +search — never a browser hand-off. The website keeps working unchanged. + `https://dsmnru-pyq.netlify.app/pyq/` (and `paper.html?id=…`) shared links can open the app via the existing unverified intent filters (cold start reads the launch intent; warm start receives a `siteDeepLink` @@ -144,13 +235,13 @@ is present at build time). ## Deferred by design -* **FCM notifications** — not implemented yet. Prepared: single-activity - `MainActivity` already routes link-shaped intents (notification taps can - reuse `siteDeepLink`), and the Google Services plugin auto-applies when a - build-time `google-services.json` exists. Admin sends keep using the - existing web admin panel (no second panel). * **Release signing** — debug builds only, see above. -* **In-app Google sign-in** — needs a native Google credential flow; until - then the app shows the documented fallback (see Authentication). +* **Google sign-in console registration** — the code is complete, but each + build environment must register its keystore SHA-1 + set the Web client ID + once (see `docs/GOOGLE_SIGNIN_SETUP.md`). Unconfigured builds degrade to + the in-app email/password path — never a website redirect. +* **In-app paper comments** — discussion lives on the website by design; the + paper screen links there explicitly (the one deliberate external + destination for a product feature) and broken-link reports are in-app. * **View-count increments** — the app intentionally does not write `pyqs.views` increments (the website keeps counting); saves stay on-device. diff --git a/android-app/android/.gitignore b/android-app/android/.gitignore index 48354a3..f67d9db 100644 --- a/android-app/android/.gitignore +++ b/android-app/android/.gitignore @@ -99,3 +99,8 @@ app/src/main/assets/public app/src/main/assets/capacitor.config.json app/src/main/assets/capacitor.plugins.json app/src/main/res/xml/config.xml + +# Firebase / signing secrets must NEVER be committed (injected at build time) +google-services.json +*.jks +*.keystore diff --git a/android-app/android/app/build.gradle b/android-app/android/app/build.gradle index cf2f6a7..2efdf2b 100644 --- a/android-app/android/app/build.gradle +++ b/android-app/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.dsmnru.pyq" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 2 - versionName "1.1.0" + versionCode 4 + versionName "1.3.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -35,6 +35,12 @@ dependencies { implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + // Firebase Cloud Messaging: registration, topic subscribe (`all_users`) + // and notification delivery. Firebase itself initializes ONLY when a + // google-services.json exists (see the conditional google-services apply + // at the bottom of this file) — every FCM code path degrades silently + // when it doesn't, so debug builds without the file run normally. + implementation 'com.google.firebase:firebase-messaging:24.1.1' implementation project(':capacitor-android') testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" diff --git a/android-app/android/app/src/main/AndroidManifest.xml b/android-app/android/app/src/main/AndroidManifest.xml index 1409df1..7b8acb1 100644 --- a/android-app/android/app/src/main/AndroidManifest.xml +++ b/android-app/android/app/src/main/AndroidManifest.xml @@ -49,17 +49,58 @@ + + + + + + + + + + + + + + + + existing Cloudflare Worker API (HTTPS), so INTERNET is the only data + permission. DownloadManager needs no permission, and no storage + permission is requested or needed. + POST_NOTIFICATIONS (Android 13+): the REAL system runtime dialog — + requested once per install, a few seconds into the first session, + only when a Firebase-enabled build is running. Denying changes + nothing except that push stays silent; the decision is never + re-asked on later launches. --> + diff --git a/android-app/android/app/src/main/java/com/dsmnru/pyq/DsmnruAppPlugin.java b/android-app/android/app/src/main/java/com/dsmnru/pyq/DsmnruAppPlugin.java index bae825d..c384682 100644 --- a/android-app/android/app/src/main/java/com/dsmnru/pyq/DsmnruAppPlugin.java +++ b/android-app/android/app/src/main/java/com/dsmnru/pyq/DsmnruAppPlugin.java @@ -1,5 +1,6 @@ package com.dsmnru.pyq; +import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; @@ -9,20 +10,46 @@ import android.webkit.MimeTypeMap; import android.webkit.URLUtil; +import androidx.credentials.CredentialManager; +import androidx.credentials.CredentialManagerCallback; +import androidx.credentials.CustomCredential; +import androidx.credentials.GetCredentialRequest; +import androidx.credentials.GetCredentialResponse; +import androidx.credentials.CredentialOption; +import androidx.credentials.exceptions.GetCredentialException; + import com.getcapacitor.JSObject; import com.getcapacitor.Plugin; import com.getcapacitor.PluginCall; import com.getcapacitor.PluginMethod; import com.getcapacitor.annotation.CapacitorPlugin; +import com.google.android.libraries.identity.googleid.GetGoogleIdOption; +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.security.MessageDigest; +import java.util.concurrent.Executors; + /** - * DSMNRU PYQ — the app's own tiny native layer (no npm plugin, no third-party - * dependency). These are the operations a mobile web page simply cannot do - * inside a WebView; everything else stays in the app's own JS front-end. + * DSMNRU PYQ — the app's own tiny native layer (no npm plugin beyond the + * Credential Manager libraries). These are the operations a mobile web page + * simply cannot do inside a WebView; everything else stays in the app's own + * JS front-end. * - * • openExternal → hand a file/host link to the best Android handler - * (browser, Drive, PDF viewer…). Reuses the existing PDF - * hosts; the app never mirrors PDFs into its own storage. + * • pdfView → open a PDF INSIDE the app (PdfViewerActivity: native + * PdfRenderer, zoom/scroll, progress/error states). The + * original host URL is fetched directly — no Worker + * bandwidth, no permanent copy (temporary cache only). + * • googleSignIn → Android-native Google sign-in via the Credential + * Manager: the device's own Google account chooser returns + * a Google ID token which auth.js exchanges with the SAME + * Firebase project (accounts:signInWithIdp). No browser, + * no Chrome, no website hand-off, no second auth system. + * • openExternal → hand a genuinely-external link (university portals, + * Drive landing pages, the explicitly-chosen website) to + * the best Android handler. * • download → explicit user taps on a direct .pdf save to the public * Downloads folder through the system DownloadManager * (resume + notification handled by Android, permission-free). @@ -34,6 +61,169 @@ @CapacitorPlugin(name = "DsmnruApp") public class DsmnruAppPlugin extends Plugin { + // ── in-app PDF viewer ────────────────────────────────────────────── + + @PluginMethod + public void pdfView(PluginCall call) { + String url = call.getString("url", ""); + String title = call.getString("title", "Paper"); + if (!isHttpUrl(url)) { + call.reject("Only http(s) PDF links can be opened in the viewer"); + return; + } + try { + Intent intent = new Intent(getContext(), PdfViewerActivity.class); + intent.putExtra(PdfViewerActivity.EXTRA_URL, url); + intent.putExtra(PdfViewerActivity.EXTRA_TITLE, title == null ? "Paper" : title); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + getContext().startActivity(intent); + call.resolve(); + } catch (Exception e) { + call.reject("Could not open the PDF viewer: " + e.getMessage()); + } + } + + // ── Android-native Google sign-in (Credential Manager) ───────────── + + @PluginMethod + public void googleSignIn(PluginCall call) { + Activity activity = getActivity(); + if (activity == null) { + call.reject("GOOGLE_SIGNIN_UNAVAILABLE: app activity is not running"); + return; + } + String clientId = ""; + try { + clientId = getContext().getString(R.string.google_web_client_id); + } catch (Exception e) { + clientId = ""; + } + if (clientId == null || clientId.trim().isEmpty() || clientId.contains("REPLACE_WITH")) { + call.reject("GOOGLE_SIGNIN_NOT_CONFIGURED: this build has no Google Web client ID — " + + "set res/value google_web_client_id and register the signing SHA-1 " + + "(see android-app/docs/GOOGLE_SIGNIN_SETUP.md)"); + return; + } + + // Bind the returned token to this exact attempt (anti-replay): the + // hashed nonce goes to Google, the raw nonce stays in JS and is + // replayed to Identity Toolkit by auth.signInWithGoogleCredential. + String nonceHash = sha256Hex(call.getString("nonce", "")); + + try { + CredentialManager credentialManager = CredentialManager.getClient(activity); + GetGoogleIdOption googleOption = new GetGoogleIdOption.Builder() + .setServerClientId(clientId) + // false → show ALL device Google accounts (fresh chooser), + // not only previously-authorized ones. + .setFilterByAuthorizedAccounts(false) + .setAutoSelectEnabled(false) + .setNonce(nonceHash.isEmpty() ? null : nonceHash) + .build(); + GetCredentialRequest request = buildCredentialRequest(googleOption); + + Method async = findGetCredentialAsync(); + if (async == null) { + call.reject("GOOGLE_SIGNIN_UNAVAILABLE: Credential Manager async API missing"); + return; + } + async.invoke(credentialManager, activity, request, null, Executors.newSingleThreadExecutor(), + new CredentialManagerCallback() { + @Override + public void onResult(GetCredentialResponse result) { + try { + androidx.credentials.Credential credential = result.getCredential(); + if (credential instanceof CustomCredential + && GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL + .equals(credential.getType())) { + GoogleIdTokenCredential googleCredential = + GoogleIdTokenCredential.createFrom(((CustomCredential) credential).getData()); + JSObject ret = new JSObject(); + ret.put("idToken", googleCredential.getIdToken()); + ret.put("nonce", call.getString("nonce", "")); + call.resolve(ret); + } else { + call.reject("GOOGLE_SIGNIN_UNAVAILABLE: unexpected credential type"); + } + } catch (Exception e) { + call.reject("GOOGLE_SIGNIN_UNAVAILABLE: " + e.getMessage()); + } + } + + @Override + public void onError(GetCredentialException e) { + String type = e.getType() == null ? "" : e.getType(); + if (type.contains("CANCELED") || type.contains("CANCELLED") || type.contains("USER_CANCELED")) { + call.reject("GOOGLE_SIGNIN_CANCELLED: user closed the account chooser"); + } else if (type.contains("NO_CREDENTIAL")) { + call.reject("GOOGLE_SIGNIN_NO_ACCOUNT: no Google account is set up on this device"); + } else { + call.reject("GOOGLE_SIGNIN_UNAVAILABLE: " + type + (e.getMessage() != null ? " — " + e.getMessage() : "")); + } + } + }); + } catch (Exception e) { + call.reject("GOOGLE_SIGNIN_UNAVAILABLE: " + e.getMessage()); + } catch (Throwable t) { + call.reject("GOOGLE_SIGNIN_UNAVAILABLE: " + t.getClass().getSimpleName()); + } + } + + /** + * GetCredentialRequest.Builder construction via reflection: the Java + * builder surface of androidx.credentials changed between releases + * (Builder(CredentialOption) vs Builder()+addCredentialOption); both + * shapes are supported here so either library version compiles/runs. + */ + private GetCredentialRequest buildCredentialRequest(CredentialOption option) throws Exception { + Class builderClass = GetCredentialRequest.Builder.class; + for (Constructor ctor : builderClass.getConstructors()) { + if (ctor.getParameterCount() == 1 + && ctor.getParameterTypes()[0].isAssignableFrom(option.getClass())) { + Object builder = ctor.newInstance(option); + return (GetCredentialRequest) builderClass.getMethod("build").invoke(builder); + } + } + Object builder = builderClass.getConstructor().newInstance(); + builderClass.getMethod("addCredentialOption", CredentialOption.class).invoke(builder, option); + return (GetCredentialRequest) builderClass.getMethod("build").invoke(builder); + } + + /** + * Locate CredentialManager#getCredentialAsync(Context, request, + * cancellationToken, executor, callback) — the Java interop entry point — + * without hard-coding the CancellationToken parameter type (nullable and + * version-dependent; we always pass null). + */ + private Method findGetCredentialAsync() { + for (Method m : CredentialManager.class.getMethods()) { + if (!"getCredentialAsync".equals(m.getName())) continue; + Class[] params = m.getParameterTypes(); + if (params.length == 5 + && android.content.Context.class.isAssignableFrom(params[0]) + && params[1].isAssignableFrom(GetCredentialRequest.class) + && params[3] == java.util.concurrent.Executor.class + && params[4].isInterface()) { + return m; + } + } + return null; + } + + private static String sha256Hex(String value) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest((value == null ? "" : value).getBytes("UTF-8")); + StringBuilder sb = new StringBuilder(); + for (byte b : digest) sb.append(String.format("%02x", b)); + return sb.toString(); + } catch (Exception e) { + return ""; + } + } + + // ── genuinely-external hand-off (unchanged behaviour) ────────────── + @PluginMethod public void openExternal(PluginCall call) { String url = call.getString("url", ""); diff --git a/android-app/android/app/src/main/java/com/dsmnru/pyq/FcmService.java b/android-app/android/app/src/main/java/com/dsmnru/pyq/FcmService.java new file mode 100644 index 0000000..5a795c7 --- /dev/null +++ b/android-app/android/app/src/main/java/com/dsmnru/pyq/FcmService.java @@ -0,0 +1,225 @@ +package com.dsmnru.pyq; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; + +import androidx.annotation.NonNull; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; + +/** + * DSMNRU PYQ — Firebase Cloud Messaging receiver (the ONLY push component). + * + * Architecture (no second backend, no token database): + * + * • AUDIENCE — every opted-in install is a member of ONE global FCM + * topic, {@link #TOPIC_ALL_USERS}. The existing web admin + * panel (via the Worker) can send to that topic and every + * subscribed install receives it. Topic subscriptions are + * managed BY the FCM SDK — no Firestore tokens, no + * per-user documents, no per-launch synchronization. + * • REGISTRATION — the Firebase SDK registers the token with Google Play + * services by itself. We only react to + * {@link #onNewToken} (rotation): stash the token + * device-locally for diagnostics and (re-)assert the + * topic membership. That is the only network call we + * make, and only when the token actually changed. + * • FOREGROUND — notification messages do NOT auto-display while the + * app is open, so {@link #onMessageReceived} renders them + * on the app's notification channel (with the deep-link + * tap action). + * • BACKGROUND — the system tray auto-displays notification payloads + * (onMessageReceived is not called); the manifest + * meta-data (channel id / icon / color) makes those + * match the brand, and the tray tap opens the + * launch intent → the same deep-link routing. + * • TAP HANDLING — the content intent carries the paper link as an + * ACTION_VIEW data Uri on MainActivity, so BOTH cold and + * warm notification taps ride the exact deep-link + * pipeline share links use (cold → getLaunchUrl, warm → + * onNewIntent → 'siteDeepLink' → in-app paper screen — + * never the website). + * • QUOTA — zero polling, zero listeners, zero Worker calls, zero + * Firestore writes. FCM itself does the delivery work. + */ +public class FcmService extends FirebaseMessagingService { + + /** The single global topic every opted-in install subscribes to. */ + public static final String TOPIC_ALL_USERS = "all_users"; + + /** The app's notification channel (created at app start + lazily here). */ + public static final String CHANNEL_ID = "dsmnru_general"; + + /** Fallback link target when a message carries no path. */ + public static final String SITE_ORIGIN = "https://dsmnru-pyq.netlify.app"; + + private static final String PREFS = "dsmnru_fcm"; + private static final String KEY_TOPIC_VERSION = "topic_version"; + /** + * Bump to re-assert topic membership for every existing install once + * (e.g. when a NEW topic name ships). Never re-subscribes on every launch. + */ + private static final int TOPIC_VERSION = 1; + + // ── token lifecycle ──────────────────────────────────────────────── + + /** + * Token created/rotated (first launch, app update, security event). + * Fires a handful of times per device lifetime — the ONLY moment we do + * any token-related work. Nothing is written to Firestore. + */ + @Override + public void onNewToken(@NonNull String token) { + getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString("token", token).apply(); + subscribeAllUsers(this, true); + } + + // ── message handling ─────────────────────────────────────────────── + + @Override + public void onMessageReceived(@NonNull RemoteMessage message) { + String title = null; + String body = null; + if (message.getNotification() != null) { + title = message.getNotification().getTitle(); + body = message.getNotification().getBody(); + } + java.util.Map data = message.getData(); + if (title == null || title.isEmpty()) title = data.get("title"); + if (title == null || title.isEmpty()) title = "DSMNRU PYQ"; + if (body == null || body.isEmpty()) body = data.get("body"); + if (body == null || body.isEmpty()) body = "New update from the PYQ archive."; + showFcmNotification(this, title, body, data.get("path")); + } + + /** + * Render a foreground message on the app channel. Silently does nothing + * when the user has not granted POST_NOTIFICATIONS (Android 13+) — the + * system decision is always respected. + */ + private static void showFcmNotification(Context context, String title, String body, String path) { + if (Build.VERSION.SDK_INT >= 33 + && ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + return; + } + ensureChannel(context); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_dsmnru) + .setColor(ContextCompat.getColor(context, R.color.dsmnru_teal)) + .setContentTitle(title) + .setContentText(body) + .setStyle(new NotificationCompat.BigTextStyle().bigText(body)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .setAutoCancel(true) + .setContentIntent(tapIntent(context, path)); + try { + NotificationManagerCompat.from(context) + .notify((int) (System.currentTimeMillis() & 0x7fffffffL), builder.build()); + } catch (Exception securityIfNoListener) { + // API 33- race between the check above and a revoked permission. + } + } + + /** + * The tap action: an ACTION_VIEW data intent on MainActivity so a cold + * start lands in getLaunchUrl() and a warm start in onNewIntent() — the + * SAME routing as a shared /pyq/<slug> link, resolving to the in-app + * paper screen. + */ + private static PendingIntent tapIntent(Context context, String path) { + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= 23) flags |= PendingIntent.FLAG_IMMUTABLE; + Intent open = new Intent(Intent.ACTION_VIEW, deepLinkUri(path), context, MainActivity.class); + open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + return PendingIntent.getActivity(context, (int) (deepLinkUri(path).hashCode() & 0x7fffffffL), open, flags); + } + + /** + * Build the absolute site URL for a payload path ("/pyq/<slug>", + * "/paper.html?id=…") — the exact URL format MainActivity's deep-link + * pipeline (and slug.js#parseSiteUrl) already understands. + */ + private static Uri deepLinkUri(String path) { + String p = path == null ? "" : path.trim(); + if (p.isEmpty()) return Uri.parse(SITE_ORIGIN + "/"); + if (p.startsWith("http://") || p.startsWith("https://")) return Uri.parse(p); + return Uri.parse(SITE_ORIGIN + (p.startsWith("/") ? p : "/" + p)); + } + + // ── channel ──────────────────────────────────────────────────────── + + /** Idempotent channel creation (API 26+; no-op below). */ + public static void ensureChannel(Context context) { + if (Build.VERSION.SDK_INT < 26) return; + NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null || nm.getNotificationChannel(CHANNEL_ID) != null) return; + NotificationChannel channel = new NotificationChannel(CHANNEL_ID, + "Paper alerts", NotificationManager.IMPORTANCE_DEFAULT); + channel.setDescription("New papers, results dates and archive announcements"); + channel.enableLights(true); + channel.setLightColor(0xFF14B8A6); + channel.enableVibration(true); + nm.createNotificationChannel(channel); + } + + // ── topic subscription (the global audience) ──────────────────────── + + /** + * Version-gated subscribe — runs the FCM topic call ONCE per install (and + * once per {@link #TOPIC_VERSION} bump / token rotation when forced), so + * ordinary launches cost nothing. {@code force} is used from + * onNewToken: a rotated token must re-assert its topic membership. + */ + public static void subscribeAllUsers(Context context, boolean force) { + SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + if (!force && prefs.getInt(KEY_TOPIC_VERSION, 0) >= TOPIC_VERSION) return; + prefs.edit().putInt(KEY_TOPIC_VERSION, TOPIC_VERSION).apply(); + try { + FirebaseMessaging.getInstance() + .subscribeToTopic(TOPIC_ALL_USERS) + .addOnCompleteListener(task -> { + if (!task.isSuccessful()) { + // Network hiccup — clear the gate so the next cold + // start retries. Lazy retry, never a loop. + prefs.edit().putInt(KEY_TOPIC_VERSION, 0).apply(); + } + }); + } catch (Exception notConfiguredOrNoPlayServices) { + prefs.edit().putInt(KEY_TOPIC_VERSION, 0).apply(); + } + } + + // ── capability checks (used by MainActivity) ──────────────────────── + + /** True when google-services.json was baked in and Firebase initialized. */ + public static boolean isFirebaseAvailable(Context context) { + try { + return !FirebaseApp.getApps(context).isEmpty(); + } catch (Exception e) { + return false; + } + } + + /** True when notifications may be posted (API < 33: implicitly true). */ + public static boolean notificationsGranted(Context context) { + if (Build.VERSION.SDK_INT < 33) return true; + return ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED; + } +} diff --git a/android-app/android/app/src/main/java/com/dsmnru/pyq/MainActivity.java b/android-app/android/app/src/main/java/com/dsmnru/pyq/MainActivity.java index 8aab36a..9c53956 100644 --- a/android-app/android/app/src/main/java/com/dsmnru/pyq/MainActivity.java +++ b/android-app/android/app/src/main/java/com/dsmnru/pyq/MainActivity.java @@ -1,7 +1,14 @@ package com.dsmnru.pyq; +import android.Manifest; import android.content.Intent; +import android.content.SharedPreferences; import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; + +import androidx.core.app.ActivityCompat; import com.getcapacitor.BridgeActivity; @@ -12,28 +19,89 @@ * The app UI is bundled in the APK (android/app assets → capacitor www/) and * talks directly to the shared production backends (Cloudflare Worker API + * Firebase Auth) — it does not render the website. This activity therefore - * only owns the three things a Capacitor shell must do natively: + * only owns the few things a Capacitor shell must do natively: * * 1. Register the app's own tiny native plugin ({@link DsmnruAppPlugin}) - * for the share sheet, system downloads, and external link hand-off. + * for the share sheet, in-app PDF viewer, Google sign-in, system + * downloads and external link hand-off. * 2. Android back navigation is driven by the JS router through the built-in * @capacitor/app 'backButton' event (pop the in-app stack, then exit) — * no WebView history walking is needed because the app is not a browser. - * 3. https deep links for the site hosts (a shared /pyq/<slug> link) are - * forwarded to the app router instead of loading the website: + * 3. https deep links for the site hosts (a shared /pyq/<slug> link — + * or the tap action of an FCM notification) are forwarded to the app + * router instead of loading the website: * • cold start → DsmnruAppPlugin.getLaunchUrl() * • warm start → onNewIntent() triggers the 'siteDeepLink' event * Unverified intent filters: tapping such a link shows the standard * Android chooser; the website itself is completely unaffected. + * 4. FCM bootstrap ({@link FcmService}): create the notification channel, + * version-gated single 'all_users' topic subscribe, and — Android 13+, + * once per install, only when Firebase is actually configured — the REAL + * system POST_NOTIFICATIONS permission dialog, scheduled a few seconds + * into the first session so the user sees the app before being asked. + * The user's grant/deny decision is never re-litigated on later launches. */ public class MainActivity extends BridgeActivity { + private static final int REQ_POST_NOTIFICATIONS = 4101; + private static final String PREFS_FCM = "dsmnru_fcm"; + private static final String KEY_NOTIF_ASKED = "notif_permission_asked"; + /** First meaningful session: let the user land in the app before asking. */ + private static final long PERMISSION_ASK_DELAY_MS = 9000L; + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + private final Runnable permissionAsk = () -> { + if (Build.VERSION.SDK_INT < 33) return; + if (FcmService.notificationsGranted(this)) return; // already granted — nothing to ask + SharedPreferences prefs = getSharedPreferences(PREFS_FCM, MODE_PRIVATE); + if (prefs.getBoolean(KEY_NOTIF_ASKED, false)) return; // asked once — respect the decision + prefs.edit().putBoolean(KEY_NOTIF_ASKED, true).apply(); + ActivityCompat.requestPermissions(this, + new String[]{ Manifest.permission.POST_NOTIFICATIONS }, REQ_POST_NOTIFICATIONS); + }; + @Override public void onCreate(android.os.Bundle savedInstanceState) { // Custom in-app plugin must be known before the bridge initializes so // the bundled JS can call it through window.Capacitor.Plugins.DsmnruApp. registerPlugin(DsmnruAppPlugin.class); super.onCreate(savedInstanceState); + + // FCM bootstrap: channel is idempotent; the topic subscribe is + // version-gated (one FCM call per install/update, NOT per launch). + // Both are safe no-ops until google-services.json is baked into a build. + FcmService.ensureChannel(this); + FcmService.subscribeAllUsers(this, false); + } + + @Override + protected void onResume() { + super.onResume(); + scheduleNotificationPermissionAsk(); + } + + @Override + protected void onPause() { + mainHandler.removeCallbacks(permissionAsk); + super.onPause(); + } + + /** + * Ask for POST_NOTIFICATIONS exactly once per install (Android 13+), + * a few seconds into a session so the dialog is not the first thing a + * new user sees. Never shown again afterwards — granted or denied — and + * the app works identically either way (push simply stays silent when + * denied). On Android < 13 no runtime dialog exists or is needed. + */ + private void scheduleNotificationPermissionAsk() { + if (Build.VERSION.SDK_INT < 33) return; + if (FcmService.notificationsGranted(this)) return; + SharedPreferences prefs = getSharedPreferences(PREFS_FCM, MODE_PRIVATE); + if (prefs.getBoolean(KEY_NOTIF_ASKED, false)) return; + // Pointless to ask when the build carries no Firebase configuration. + if (!FcmService.isFirebaseAvailable(this)) return; + mainHandler.postDelayed(permissionAsk, PERMISSION_ASK_DELAY_MS); } @Override diff --git a/android-app/android/app/src/main/java/com/dsmnru/pyq/PdfViewerActivity.java b/android-app/android/app/src/main/java/com/dsmnru/pyq/PdfViewerActivity.java new file mode 100644 index 0000000..4b361e1 --- /dev/null +++ b/android-app/android/app/src/main/java/com/dsmnru/pyq/PdfViewerActivity.java @@ -0,0 +1,757 @@ +package com.dsmnru.pyq; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Typeface; +import android.graphics.pdf.PdfRenderer; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelFileDescriptor; +import android.content.Intent; +import android.content.ActivityNotFoundException; +import android.util.LruCache; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.View; +import android.view.WindowInsets; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.MessageDigest; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * DSMNRU PYQ — in-app PDF viewer screen (pure Android, zero dependencies). + * + * "Open PDF" in the app opens THIS screen first. It: + * • streams the paper's ORIGINAL host URL directly (no Cloudflare Worker + * traffic, no CORS surface) into the app's *temporary cache* directory — + * the file is deleted when the viewer closes and stale files are purged, + * so nothing is permanently downloaded into app storage; + * • renders pages with the platform PdfRenderer (API 21+) lazily — visible + * pages render on demand into an LruCache, so a 100-page paper never + * allocates 100 bitmaps; + * • supports pinch-zoom + pan on a page and vertical page scrolling; + * • shows real progress / error states, with Retry and "Open externally" + * fallbacks (the SAME direct URL handed to a system PDF app — never the + * DSMNRU website); + * • sits on top of MainActivity, so system Back returns straight to the + * paper detail screen. + */ +public class PdfViewerActivity extends Activity { + + public static final String EXTRA_URL = "url"; + public static final String EXTRA_TITLE = "title"; + + private static final long MAX_BYTES = 40L * 1024 * 1024; // generous cap + private static final long STALE_MS = 24L * 60 * 60 * 1000; // cache purge age + private static final int CONNECT_TIMEOUT_MS = 15000; + private static final int READ_TIMEOUT_MS = 30000; + private static final int MAX_PAGES = 400; + + private final Handler main = new Handler(Looper.getMainLooper()); + private final ExecutorService io = Executors.newSingleThreadExecutor(); + private final AtomicBoolean cancelled = new AtomicBoolean(false); + + private String url = ""; + private String title = "Paper"; + private File pdfFile; + + private LinearLayout chrome; + private TextView titleView; + private TextView pageIndicator; + private FrameLayout content; + private LinearLayout loadingView; + private ProgressBar progressBar; + private TextView progressText; + private LinearLayout errorView; + private TextView errorText; + private Button retryButton; + private ScrollView reader; + private LinearLayout pagesHost; + + private PdfRenderer renderer; + private ParcelFileDescriptor pfd; + private final Object rendererLock = new Object(); + private int pageCount = 0; + private float pageAspect = 1.414f; // height/width, A4 default + private BitmapLru pageCache; + + /** Bitmap cache bounded by heap share, not page count. */ + private static class BitmapLru extends LruCache { + BitmapLru(int maxSizeBytes) { super(maxSizeBytes); } + @Override protected int sizeOf(String key, Bitmap value) { + return value.getByteCount(); + } + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + url = getIntent() != null ? getIntent().getStringExtra(EXTRA_URL) : null; + title = getIntent() != null ? getIntent().getStringExtra(EXTRA_TITLE) : null; + if (title == null || title.trim().isEmpty()) title = "Paper"; + if (url == null || !(url.startsWith("https://") || url.startsWith("http://"))) { + finish(); + return; + } + + int heapShare = (int) Math.min(48L * 1024 * 1024, + Runtime.getRuntime().maxMemory() / 6); + pageCache = new BitmapLru(Math.max(heapShare, 16 * 1024 * 1024)); + + buildUi(); + purgeStaleCacheFiles(); + loadPdf(); + } + + // ── UI construction (programmatic — brand slate + teal) ──────────── + + private int dp(float v) { + return Math.round(getResources().getDisplayMetrics().density * v); + } + + private void buildUi() { + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(Color.parseColor("#0A101F")); + + chrome = new LinearLayout(this); + chrome.setOrientation(LinearLayout.HORIZONTAL); + chrome.setGravity(Gravity.CENTER_VERTICAL); + chrome.setBackgroundColor(Color.parseColor("#0F172A")); + chrome.setPadding(dp(6), dp(6), dp(6), dp(6)); + int chromePadH = dp(10); + + TextView back = new TextView(this); + back.setText("‹"); + back.setTextSize(26); + back.setTextColor(Color.WHITE); + back.setPadding(chromePadH, 0, chromePadH, dp(2)); + back.setOnClickListener(v -> finish()); + chrome.addView(back, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, dp(44), 0)); + + titleView = new TextView(this); + titleView.setText(title); + titleView.setTextColor(Color.WHITE); + titleView.setTextSize(15); + titleView.setTypeface(Typeface.DEFAULT_BOLD); + titleView.setSingleLine(true); + titleView.setEllipsize(android.text.TextUtils.TruncateAt.END); + titleView.setPadding(dp(6), 0, dp(6), 0); + chrome.addView(titleView, new LinearLayout.LayoutParams(0, dp(44), 1f)); + + pageIndicator = new TextView(this); + pageIndicator.setTextColor(Color.parseColor("#6EE7D8")); + pageIndicator.setTextSize(13); + pageIndicator.setTypeface(Typeface.DEFAULT_BOLD); + pageIndicator.setPadding(dp(4), 0, dp(8), 0); + pageIndicator.setVisibility(View.GONE); + chrome.addView(pageIndicator, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, 0)); + + TextView external = new TextView(this); + external.setText("↗"); + external.setTextSize(20); + external.setTextColor(Color.parseColor("#6EE7D8")); + external.setPadding(chromePadH, 0, chromePadH, 0); + external.setOnClickListener(v -> openExternal()); + chrome.addView(external, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, dp(44), 0)); + + root.addView(chrome, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + content = new FrameLayout(this); + + // loading + loadingView = new LinearLayout(this); + loadingView.setOrientation(LinearLayout.VERTICAL); + loadingView.setGravity(Gravity.CENTER); + progressBar = new ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal); + progressBar.setIndeterminate(true); + LinearLayout.LayoutParams pbLp = new LinearLayout.LayoutParams(dp(220), ViewGroup.LayoutParams.WRAP_CONTENT); + progressText = new TextView(this); + progressText.setTextColor(Color.parseColor("#CBD5E1")); + progressText.setTextSize(14); + progressText.setGravity(Gravity.CENTER); + progressText.setPadding(0, dp(14), 0, 0); + loadingView.addView(progressBar, pbLp); + loadingView.addView(progressText, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + content.addView(loadingView, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)); + + // error + errorView = new LinearLayout(this); + errorView.setOrientation(LinearLayout.VERTICAL); + errorView.setGravity(Gravity.CENTER); + errorText = new TextView(this); + errorText.setTextColor(Color.parseColor("#FFB4B6")); + errorText.setTextSize(14); + errorText.setGravity(Gravity.CENTER); + errorText.setPadding(dp(28), 0, dp(28), 0); + retryButton = button("Try again"); + retryButton.setOnClickListener(v -> loadPdf()); + Button openBtn = button("Open in another app"); + openBtn.setOnClickListener(v -> openExternal()); + LinearLayout errBtns = new LinearLayout(this); + errBtns.setOrientation(LinearLayout.HORIZONTAL); + errBtns.setGravity(Gravity.CENTER); + errBtns.addView(retryButton); + errBtns.addView(openBtn); + errorView.addView(errorText, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + errorView.addView(errBtns, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + errorView.setVisibility(View.GONE); + content.addView(errorView, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)); + + // reader + reader = new ScrollView(this); + reader.setFillViewport(true); + reader.setBackgroundColor(Color.parseColor("#1E293B")); + reader.getViewTreeObserver().addOnScrollChangedListener(this::renderVisiblePages); + pagesHost = new LinearLayout(this); + pagesHost.setOrientation(LinearLayout.VERTICAL); + int pageGap = dp(8); + pagesHost.setPadding(0, pageGap, 0, pageGap); + reader.addView(pagesHost, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + reader.setVisibility(View.GONE); + content.addView(reader, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + + root.addView(content, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + setContentView(root); + applyInsets(root); + } + + private Button button(String label) { + Button b = new Button(this, null, 0); + b.setText(label); + b.setTextColor(Color.parseColor("#04211D")); + b.setTypeface(Typeface.DEFAULT_BOLD); + b.setAllCaps(false); + b.setBackground(androidx.core.content.ContextCompat.getDrawable(this, R.drawable.pdf_viewer_btn)); + b.setPadding(dp(16), 0, dp(16), 0); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, dp(44)); + lp.setMargins(dp(6), dp(14), dp(6), 0); + b.setLayoutParams(lp); + return b; + } + + /** Content must clear the status bar on Android 15+ edge-to-edge too. */ + private void applyInsets(View root) { + root.setOnApplyWindowInsetsListener((v, insets) -> { + int top; + if (Build.VERSION.SDK_INT >= 30) { + top = insets.getInsets(WindowInsets.Type.systemBars()).top; + } else { + @SuppressWarnings("deprecation") + int legacy = insets.getSystemWindowInsetTop(); + top = legacy; + } + chrome.setPadding(chrome.getPaddingLeft(), top, chrome.getPaddingRight(), chrome.getPaddingBottom()); + if (Build.VERSION.SDK_INT >= 30) return insets; + @SuppressWarnings("deprecation") + WindowInsets consumed = insets.consumeSystemWindowInsets(); + return consumed; + }); + } + + // ── state switching ──────────────────────────────────────────────── + + private void showLoading(String text) { + loadingView.setVisibility(View.VISIBLE); + errorView.setVisibility(View.GONE); + reader.setVisibility(View.GONE); + progressText.setText(text); + } + + private void showError(String message) { + loadingView.setVisibility(View.GONE); + errorView.setVisibility(View.VISIBLE); + reader.setVisibility(View.GONE); + errorText.setText(message); + } + + private void showReader() { + loadingView.setVisibility(View.GONE); + errorView.setVisibility(View.GONE); + reader.setVisibility(View.VISIBLE); + pageIndicator.setVisibility(View.VISIBLE); + } + + // ── download ─────────────────────────────────────────────────────── + + private File cacheDir() { + File dir = new File(getCacheDir(), "pdfview"); + if (!dir.exists()) dir.mkdirs(); + return dir; + } + + private void purgeStaleCacheFiles() { + File dir = cacheDir(); + File[] files = dir.listFiles(); + if (files == null) return; + long now = System.currentTimeMillis(); + for (File f : files) { + if (now - f.lastModified() > STALE_MS) f.delete(); + } + } + + private static String cacheKeyFor(String url) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] dig = md.digest(url.getBytes("UTF-8")); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 12; i++) sb.append(String.format("%02x", dig[i])); + return sb.toString(); + } catch (Exception e) { + return String.valueOf(url.hashCode()); + } + } + + /** Executor submit that survives a destroy-while-loading race. */ + private void safeExecute(Runnable task) { + try { + io.execute(task); + } catch (Exception rejected) { + // activity already destroyed — ignore + } + } + + private void loadPdf() { + cancelled.set(false); + showLoading("Preparing the paper…"); + progressBar.setIndeterminate(true); + safeExecute(() -> { + try { + File target = new File(cacheDir(), cacheKeyFor(url) + ".pdf"); + pdfFile = target; + downloadToFile(url, target); + if (cancelled.get()) return; + main.post(this::openRenderer); + } catch (Exception e) { + if (cancelled.get()) return; + String msg = e.getMessage() == null ? "Download failed" : e.getMessage(); + main.post(() -> showError(msg)); + } + }); + } + + private void downloadToFile(String sourceUrl, File target) throws Exception { + // Reuse a complete, verified copy of THIS url when it is still cached + // (e.g. quick close/re-open) — otherwise stream a fresh one. + if (target.exists() && target.length() > 4 && target.lastModified() > System.currentTimeMillis() - STALE_MS) { + return; + } + File partial = new File(target.getAbsolutePath() + ".part"); + HttpURLConnection conn = null; + try { + URL u = new URL(sourceUrl); + conn = (HttpURLConnection) u.openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setInstanceFollowRedirects(true); + conn.setRequestProperty("User-Agent", "DSMNRU-PYQ-Android/2 (in-app pdf viewer)"); + int code = conn.getResponseCode(); + if (code < 200 || code >= 300) { + throw new Exception("The paper host answered with HTTP " + code); + } + long total = conn.getContentLengthLong(); + boolean magicChecked = false; + long written = 0; + byte[] pending = new byte[0]; // leading bytes awaiting the %PDF check + try (InputStream in = conn.getInputStream(); + OutputStream out = new FileOutputStream(partial)) { + byte[] buf = new byte[16384]; + long lastUi = 0; + int n; + while ((n = in.read(buf)) > 0) { + if (cancelled.get()) return; + if (!magicChecked) { + byte[] combined = new byte[pending.length + n]; + System.arraycopy(pending, 0, combined, 0, pending.length); + System.arraycopy(buf, 0, combined, pending.length, n); + // The %PDF magic must appear within the first 1 KB; if we + // still don't have enough bytes, keep buffering. + if (combined.length >= 1024 || n < buf.length) { + if (!looksLikePdf(combined)) { + throw new Exception("This link is not a direct PDF file — try the other server or open it externally."); + } + magicChecked = true; + out.write(combined, 0, combined.length); + written += combined.length; + pending = new byte[0]; + } else { + pending = combined; + } + continue; + } + out.write(buf, 0, n); + written += n; + long now = System.currentTimeMillis(); + if (now - lastUi > 200) { + lastUi = now; + updateProgress(written, total); + } + if (written > MAX_BYTES) { + throw new Exception("This file is too large for the in-app viewer (over 40 MB) — download it instead."); + } + } + if (!magicChecked) { + if (!looksLikePdf(pending)) { + throw new Exception("This link is not a direct PDF file — try the other server or open it externally."); + } + out.write(pending); + written += pending.length; + } + } + if (!partial.renameTo(target)) { + throw new Exception("Could not store the temporary copy."); + } + updateProgress(1, 1); + } finally { + if (conn != null) conn.disconnect(); + partial.delete(); + } + } + + private static boolean looksLikePdf(byte[] head) { + for (int i = 0; i <= Math.max(0, head.length - 4) && i < 1024; i++) { + if (head[i] == '%' && i + 4 <= head.length + && head[i + 1] == 'P' && head[i + 2] == 'D' && head[i + 3] == 'F') { + return true; + } + } + return false; + } + + private void updateProgress(long written, long total) { + main.post(() -> { + if (cancelled.get()) return; + if (total > 0) { + progressBar.setIndeterminate(false); + progressBar.setMax(1000); + progressBar.setProgress((int) Math.min(1000, written * 1000 / total)); + long totalKb = total / 1024; + progressText.setText("Downloading… " + (written / 1024) + " / " + totalKb + " KB"); + } else { + progressBar.setIndeterminate(true); + progressText.setText("Downloading… " + (written / 1024) + " KB"); + } + }); + } + + // ── rendering ────────────────────────────────────────────────────── + + private void openRenderer() { + try { + pfd = ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY); + renderer = new PdfRenderer(pfd); + pageCount = Math.min(renderer.getPageCount(), MAX_PAGES); + synchronized (rendererLock) { + PdfRenderer.Page first = renderer.openPage(0); + pageAspect = first.getHeight() / (float) first.getWidth(); + first.close(); + } + } catch (Exception e) { + showError("This PDF could not be opened in the app (" + e.getMessage() + "). Try downloading it instead."); + return; + } + pagesHost.removeAllViews(); + int gap = dp(8); + for (int i = 0; i < pageCount; i++) { + PageImageView slot = new PageImageView(this, i + 1, pageAspect); + pagesHost.addView(slot); + if (i < pageCount - 1) { + LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) slot.getLayoutParams(); + lp.bottomMargin = gap; + } + } + showReader(); + pagesHost.post(this::renderVisiblePages); + } + + /** Render every page slot currently (or nearly) on screen, lazily. */ + private void renderVisiblePages() { + if (renderer == null || pagesHost.getChildCount() == 0) return; + int scrollY = reader.getScrollY(); + int viewport = reader.getHeight(); + int width = pagesHost.getWidth(); + if (width <= 0) return; + int margin = viewport / 2; + int top = scrollY - margin; + int bottom = scrollY + viewport + margin; + int acc = 0; + int firstVisible = -1; + for (int i = 0; i < pagesHost.getChildCount(); i++) { + View child = pagesHost.getChildAt(i); + int childTop = acc; + int childBottom = acc + child.getHeight() + ((LinearLayout.LayoutParams) child.getLayoutParams()).bottomMargin; + acc = childBottom; + if (childBottom < top || childTop > bottom) continue; + if (firstVisible == -1 && childBottom > scrollY) firstVisible = i; + if (child instanceof PageImageView) { + renderPageInto((PageImageView) child, i, width); + } + } + if (firstVisible == -1) firstVisible = 0; + final int pageShown = firstVisible + 1; + pageIndicator.setText(pageShown + " / " + pageCount); + } + + private void renderPageInto(final PageImageView slot, final int index, final int viewWidth) { + final String key = "p" + index; + Bitmap cached = pageCache.get(key); + if (cached != null) { + slot.setPageBitmap(cached); + return; + } + if (slot.isRendering()) return; + slot.setRendering(true); + safeExecute(() -> { + Bitmap bmp = null; + try { + synchronized (rendererLock) { + if (renderer == null || cancelled.get()) return; + bmp = pageCache.get(key); + if (bmp == null) { + PdfRenderer.Page page = renderer.openPage(index); + try { + int ptW = page.getWidth(); + int ptH = page.getHeight(); + float density = getResources().getDisplayMetrics().density; + int targetW = Math.max(Math.round(viewWidth * 1.75f), + Math.round(ptW * density * 1.6f)); + targetW = Math.min(targetW, 1800); + int targetH = Math.round(targetW * (ptH / (float) ptW)); + bmp = Bitmap.createBitmap(targetW, targetH, Bitmap.Config.ARGB_8888); + bmp.eraseColor(Color.WHITE); + page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY); + pageCache.put(key, bmp); + } finally { + page.close(); + } + } + } + } catch (Exception e) { + bmp = null; + } finally { + final Bitmap result = bmp; + main.post(() -> { + slot.setRendering(false); + if (result != null) slot.setPageBitmap(result); + }); + } + }); + } + + // ── external fallback ────────────────────────────────────────────── + + private void openExternal() { + try { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + intent.addCategory(Intent.CATEGORY_BROWSABLE); + startActivity(intent); + } catch (ActivityNotFoundException e) { + Toast.makeText(this, "No app on this device can open that link", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Toast.makeText(this, "Could not open the link", Toast.LENGTH_SHORT).show(); + } + } + + // ── lifecycle ────────────────────────────────────────────────────── + + @Override + protected void onDestroy() { + cancelled.set(true); + io.shutdown(); + synchronized (rendererLock) { + try { if (renderer != null) renderer.close(); } catch (Exception ignored) { } + renderer = null; + } + try { if (pfd != null) pfd.close(); } catch (Exception ignored) { } + pfd = null; + // Temporary copy only: nothing permanent is kept in app storage. + if (pdfFile != null) pdfFile.delete(); + super.onDestroy(); + } + + // ── zoomable page view ───────────────────────────────────────────── + + /** One rendered page: pinch-zoom + pan with a matrix, vertical scroll at 1x. */ + private final class PageImageView extends androidx.appcompat.widget.AppCompatImageView { + + private final int pageNumber; + private final Matrix draw = new Matrix(); + private final ScaleGestureDetector scaleDetector; + private final android.view.GestureDetector gestureDetector; + private boolean rendering = false; + private float scale = 1f; + private float lastTouchX = 0f; + private float lastTouchY = 0f; + + PageImageView(android.content.Context context, int pageNumber, float aspect) { + super(context); + this.pageNumber = pageNumber; + setBackgroundColor(Color.parseColor("#0F172A")); + setScaleType(ScaleType.MATRIX); + setPagePlaceholder(aspect); + + scaleDetector = new ScaleGestureDetector(context, new ScaleListener()); + gestureDetector = new android.view.GestureDetector(context, new GestureListener()); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + // The fit matrix needs the real view size — reapply once measured. + if (w > 0 && h > 0 && scale == 1f) resetMatrix(); + } + + void setPagePlaceholder(float aspect) { + int width = getResources().getDisplayMetrics().widthPixels; + int height = Math.round(width * aspect); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, height); + setLayoutParams(lp); + Bitmap ph = Bitmap.createBitmap(8, Math.max(1, Math.round(8 * aspect)), Bitmap.Config.ARGB_8888); + ph.eraseColor(Color.parseColor("#16213B")); + setImageBitmap(ph); + resetMatrix(); + } + + boolean isRendering() { return rendering; } + void setRendering(boolean value) { rendering = value; } + + void setPageBitmap(Bitmap bitmap) { + if (getPageBitmap() == bitmap) return; + setImageBitmap(bitmap); + resetMatrix(); + } + + private void resetMatrix() { + scale = 1f; + draw.reset(); + // Fit the (aspect-matched) bitmap exactly to the view bounds. + android.graphics.drawable.Drawable d = getDrawable(); + if (d != null && getWidth() > 0 && d.getIntrinsicWidth() > 0) { + float fit = getWidth() / (float) d.getIntrinsicWidth(); + draw.setScale(fit, fit); + } + setImageMatrix(draw); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + scaleDetector.onTouchEvent(event); + gestureDetector.onTouchEvent(event); // double-tap zoom + + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + lastTouchX = event.getX(); + lastTouchY = event.getY(); + if (scale > 1f) parent().requestDisallowInterceptTouchEvent(true); + break; + case MotionEvent.ACTION_MOVE: + if (scale > 1f && !scaleDetector.isInProgress()) { + // Raw finger delta: positive X = finger moved right → + // the page must follow the finger (no scroll-sign maths). + panBy(event.getX() - lastTouchX, event.getY() - lastTouchY); + } + lastTouchX = event.getX(); + lastTouchY = event.getY(); + if (scale > 1f) parent().requestDisallowInterceptTouchEvent(true); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + if (scale <= 1f) parent().requestDisallowInterceptTouchEvent(false); + break; + default: + break; + } + return true; + } + + private ViewGroup parent() { + return (ViewGroup) getParent(); + } + + private void applyScale(float factor, float focusX, float focusY) { + float next = Math.max(1f, Math.min(4f, scale * factor)); + factor = next / scale; + scale = next; + draw.postScale(factor, factor, focusX, focusY); + clampTranslation(); + setImageMatrix(draw); + } + + private void panBy(float dx, float dy) { + draw.postTranslate(dx, dy); + clampTranslation(); + setImageMatrix(draw); + } + + /** Keep the zoomed image covering the view — no empty gaps. */ + private void clampTranslation() { + if (getDrawable() == null) return; + android.graphics.RectF bounds = new android.graphics.RectF(0, 0, + getDrawable().getIntrinsicWidth(), getDrawable().getIntrinsicHeight()); + draw.mapRect(bounds); + android.graphics.RectF view = new android.graphics.RectF(0, 0, getWidth(), getHeight()); + float dx = 0, dy = 0; + if (bounds.left > view.left) dx = view.left - bounds.left; + else if (bounds.right < view.right) dx = view.right - bounds.right; + if (bounds.top > view.top) dy = view.top - bounds.top; + else if (bounds.bottom < view.bottom) dy = view.bottom - bounds.bottom; + if (dx != 0 || dy != 0) draw.postTranslate(dx, dy); + } + + private final class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener { + @Override public boolean onScale(ScaleGestureDetector detector) { + applyScale(detector.getScaleFactor(), detector.getFocusX(), detector.getFocusY()); + return true; + } + } + + private final class GestureListener extends android.view.GestureDetector.SimpleOnGestureListener { + @Override public boolean onDown(MotionEvent e) { return true; } + + @Override public boolean onDoubleTap(MotionEvent e) { + if (scale > 1f) { + resetMatrix(); + } else { + applyScale(2.5f, e.getX(), e.getY()); + } + return true; + } + } + } +} diff --git a/android-app/android/app/src/main/res/drawable/ic_stat_dsmnru.xml b/android-app/android/app/src/main/res/drawable/ic_stat_dsmnru.xml new file mode 100644 index 0000000..02c85f3 --- /dev/null +++ b/android-app/android/app/src/main/res/drawable/ic_stat_dsmnru.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android-app/android/app/src/main/res/drawable/pdf_viewer_btn.xml b/android-app/android/app/src/main/res/drawable/pdf_viewer_btn.xml new file mode 100644 index 0000000..3ee5b98 --- /dev/null +++ b/android-app/android/app/src/main/res/drawable/pdf_viewer_btn.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android-app/android/app/src/main/res/values/strings.xml b/android-app/android/app/src/main/res/values/strings.xml index 7604a4e..9402710 100644 --- a/android-app/android/app/src/main/res/values/strings.xml +++ b/android-app/android/app/src/main/res/values/strings.xml @@ -4,4 +4,19 @@ DSMNRU PYQ com.dsmnru.pyq com.dsmnru.pyq + + + REPLACE_WITH_WEB_CLIENT_ID.apps.googleusercontent.com diff --git a/android-app/docs/GOOGLE_SIGNIN_SETUP.md b/android-app/docs/GOOGLE_SIGNIN_SETUP.md new file mode 100644 index 0000000..4d3cfe5 --- /dev/null +++ b/android-app/docs/GOOGLE_SIGNIN_SETUP.md @@ -0,0 +1,112 @@ +# Android Google Sign-In — exact configuration guide + +The app implements **native Android Google sign-in** end-to-end in code: + +``` +Google button (in-app sheet) + → DsmnruAppPlugin.googleSignIn() (Java, Credential Manager) + → device Google account chooser (no browser, no Chrome, no popup) + → Google ID token (audience = the project's Web client ID) + → auth.signInWithGoogleCredential() (www/js/auth.js) + → POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp + (the same call the Firebase JS SDK makes — SAME dsmnru-data project, + SAME user identity as the website) + → in-app session restored exactly like email/password sign-in + → existing users/{uid} profile sync (one owner-scoped check) +``` + +There is **no second Firebase project, no second user database and no website +hand-off** anywhere in this flow. What the code cannot do by itself is the +Google-side *registration* of this app — those steps live in the Firebase / +Google Cloud consoles and are listed below. Until they are done, the plugin +reports `GOOGLE_SIGNIN_NOT_CONFIGURED` and the app explains it in-app while +offering email/password sign-in (never a redirect). + +--- + +## 1. What is already in the repository + +| Piece | Where | +|---|---| +| Credential Manager flow (Java) | `android/app/src/main/java/com/dsmnru/pyq/DsmnruAppPlugin.java` → `googleSignIn()` | +| Token exchange + session handling (JS) | `android-app/www/js/auth.js` → `signInWithGoogleCredential()` | +| UI (button, explainer sheets, fallbacks) | `android-app/www/js/authui.js` | +| Library dependencies | `android-app/android/app/build.gradle` → `androidx.credentials:credentials:1.3.0`, `credentials-play-services-auth:1.3.0`, `com.google.android.libraries.identity.googleid:googleid:1.1.1` | +| Client-ID placeholder (public value, **not** a secret) | `android/app/src/main/res/values/strings.xml` → `google_web_client_id` | + +## 2. What must be configured outside the repository (one time) + +### a. Set the OAuth **Web client ID** + +1. Open the **Firebase console → Project `dsmnru-data` → Authentication → + Sign-in method → Google**. If Google is not enabled yet, enable it (this + is also what the website's Google button uses). +2. Copy the **Web client ID** shown there (it ends in + `.apps.googleusercontent.com`). It is the `client_type: 3` entry of the + project's `google-services.json` / the client ID on the Google provider. + Alternatively: Google Cloud console → APIs & Services → Credentials → + the *OAuth 2.0 Client ID* of type **Web application** belonging to + `dsmnru-data`. +3. Paste it into + `android-app/android/app/src/main/res/values/strings.xml`: + + ```xml + 1234567890-abcdefg.apps.googleusercontent.com + ``` + + (Or override it at build time with a `resValue` in `build.gradle` — any + mechanism that sets the `google_web_client_id` string resource works. + This ID is a public identifier; committing it is safe and intended.) + +### b. Register the app's Android OAuth client (package + SHA-1) + +Google verifies that the calling app (package name + signing-certificate +SHA-1) belongs to the same project: + +1. Get the SHA-1 of the keystore that will sign the APK: + + keytool -list -v -keystore .jks -alias # release + keytool -list -v -keystore ~/.android/debug.keystore \ + -alias androiddebugkey -storepass android # debug builds + +2. Register **both** fingerprints you build with: + - *Easiest path:* Firebase console → Project settings → **Your apps → + Android app `com.dsmnru.pyq` → Add fingerprint** (add debug **and** + release SHA-1). Firebase creates the Android OAuth client automatically. + - *Or manually:* Google Cloud console → APIs & Services → Credentials → + **Create credentials → OAuth client ID → Android** — package name + `com.dsmnru.pyq`, paste each SHA-1. +3. Google sign-in on a device checks the running APK's signature against + these fingerprints — a debug APK needs the **debug keystore fingerprint** + registered, a release APK the release one. + +### c. Nothing else + +- No `google-services.json` is required for sign-in (the app talks to + Identity Toolkit REST with the project's public web API key, exactly like + the website). If you add `google-services.json` later for FCM, the build + already auto-applies the Google Services plugin. +- No OAuth client secret, no keystore, no service-account JSON may ever be + committed — none is needed by this flow. + +## 3. Verify + +1. `npm ci && npx cap sync android && cd android && ./gradlew assembleDebug` +2. Install the APK on a device **with a Google account and Play services**. +3. Profile tab (signed out) → **Sign in with Google** → the device account + chooser appears → pick an account → the app is signed in and Profile + shows "Google account". If the same email is used on the website, it is + the *same* Firebase user. +4. If the chooser reports an error instead, the usual causes are (a) the + Web client ID in `strings.xml` doesn't belong to `dsmnru-data`, or (b) + the SHA-1 of the installed APK isn't registered (step b). + +## 4. Behaviour matrix (implemented) + +| Situation | App behaviour | +|---|---| +| Configured + Google account on device | Native account chooser → Firebase session | +| User closes the chooser | Silent return, nothing changes | +| Build missing the client ID (`REPLACE_WITH…` placeholder) | In-app explainer + email/password sign-in (never a website redirect) | +| Device without Play services / no Google account | In-app explainer + email/password sign-in | +| Any unexpected Credential Manager error | Typed error surfaced as a toast, email/password offered | diff --git a/android-app/docs/PUSH_NOTIFICATIONS.md b/android-app/docs/PUSH_NOTIFICATIONS.md new file mode 100644 index 0000000..97121cd --- /dev/null +++ b/android-app/docs/PUSH_NOTIFICATIONS.md @@ -0,0 +1,158 @@ +# Push notifications (FCM) — Android implementation & sender guide + +The Android app has **full, final FCM support**: registration, token-rotation +handling, the `all_users` topic subscription, notification channels, the +Android 13+ runtime permission dialog, foreground rendering, background tray +display and tap handling with in-app paper deep links. This document is also +the **contract for the sender side** (admin panel → Worker → FCM), which is +the one piece that does **not** exist in this repository yet — see §5. + +--- + +## 1. How the Android side works + +``` +Firebase (project dsmnru-data — the SAME project the app & website use) + │ registers the install token with Google Play services + ▼ +FcmService.onNewToken() ← fires a handful of times per device + • stores the token in device-local SharedPreferences (diagnostics ONLY; + nothing is written to Firestore — ever) + • re-asserts membership of the ONE global topic `all_users` + ▼ +Sender: POST to FCM v1 { topic: "all_users", ... } (see §4/§5) + │ + ├─ app in BACKGROUND → system tray shows the notification automatically + │ (branded via the manifest meta-data: channel dsmnru_general, + │ icon ic_stat_dsmnru, color #14B8A6); tray tap → launch intent + ├─ app in FOREGROUND → FcmService.onMessageReceived() renders the + │ notification itself on the same channel + ▼ +TAP (either path) → ACTION_VIEW intent with data + https://dsmnru-pyq.netlify.app + data.path + → MainActivity (cold: getLaunchUrl / warm: onNewIntent → 'siteDeepLink') + → the app's JS router (slug.js#parseSiteUrl) opens the IN-APP paper + screen. The website is never opened for app notifications. +``` + +### Quota / discipline guarantees + +- **No polling, no timers, no listeners** — delivery is entirely FCM's job. +- **No token storage in Firestore** — the token stays on the device. +- **No per-launch synchronization** — the topic subscribe is *version-gated* + (`TOPIC_VERSION`): one FCM call per install/update, and once more only when + the FCM token itself rotates (`onNewToken`). Ordinary launches cost zero + network. +- **No per-notification Firestore writes** — notifications are fire-and-forget. +- **No second Firebase project** — `dsmnru-data` only. + +## 2. Android 13+ notification permission + +`POST_NOTIFICATIONS` is declared in the manifest and requested through the +**real system dialog** (`ActivityCompat.requestPermissions`), once per +install, ~9 s into the first session (so the user has seen the app first) — +and only when the build actually has Firebase configured. Behavior: + +| Situation | Behavior | +|---|---| +| Android < 13 | No runtime dialog exists; notifications work (toggle in system settings) | +| Already granted | Nothing is ever asked again | +| Denied | App continues 100 % normally; foreground/background pushes are silently suppressed (`notificationsGranted` is checked before every post) | +| Asked before | Never re-asked on later launches — the system decision is respected | + +There is **no in-app fake toggle** and no website hand-off anywhere. + +## 3. What a build needs (out-of-repo, one time) + +1. `google-services.json` for **`com.dsmnru.pyq`** from Firebase console → + Project settings → Your apps (project **dsmnru-data**) → place it at + `android-app/android/app/google-services.json` (or inject it in CI). The + Gradle file **already applies the Google Services plugin automatically** + when the file exists — without it, every FCM code path degrades silently + and the app runs normally (push simply never activates). +2. The Cloud Messaging API needs to be available for the project (Firebase + console → Project settings → Cloud Messaging; Firebase auto-enables the + v1 API). No server key is needed on the device side. + +## 4. Message contract (what the sender must send) + +FCM **HTTP v1** payload (the app honors exactly this): + +```json +{ + "message": { + "topic": "all_users", + "notification": { "title": "New paper: DBMS {2023}", "body": "Just approved — open it in the app." }, + "data": { + "path": "/pyq/dbms-2023" + }, + "android": { + "priority": "HIGH", + "notification": { "channel_id": "dsmnru_general", "notification_count": 1 } + } + } +} +``` + +- `notification.title` / `body` — shown in tray and foreground. +- `data.path` — optional deep link: `/pyq/`, `/paper.html?id=` or + any in-app route path (`/` opens the app home). Omit it for a plain + "open the app" tap. +- For **data-only** messages (silent), the app still renders a notification + from `data.title` / `data.body` when foregrounded. + +## 5. Sender-side work still required (documented, deliberately NOT invented) + +The existing Worker (`worker/src/index.js`) has **no push endpoint** today — +its only admin-token-protected route is `POST /api/invalidate`. The existing +web admin panel stays the notification management interface; to complete the +chain, these are the exact steps (all server-side / website-side — **no** +second database, tokens or admin UI): + +1. **Service account** (Google Cloud console → IAM → Service accounts → + create in `dsmnru-data`, role *Firebase Cloud Messaging API Admin*) → + store the JSON key as a Worker secret: + `wrangler secret put FCM_SERVICE_ACCOUNT` (never commit it). +2. **Worker route** `POST /api/notify` (in `worker/src/index.js`): + - guard it with the **existing** `verifyFirebaseAdminToken()` helper + (same `admin:true` rule as `/api/invalidate`) so only the existing web + admin panel can send; + - body `{ title, body, path }` → validate (title ≤ 120, body ≤ 300, + `path` must start with `/`); + - sign a service-account JWT (RS256 via WebCrypto, scope + `https://www.googleapis.com/auth/firebase.messaging`) → exchange at + `https://oauth2.googleapis.com/token` → + `POST https://fcm.googleapis.com/v1/projects/dsmnru-data/messages:send` + with the §4 payload; + - keep the route cache-free and rate-limited (e.g. one send per minute + per admin token) to protect quotas. +3. **Admin panel** (website): a small "Send notification" form that calls + the Worker route above. That is a website change and is intentionally + left to the website maintainers. + +## 6. Device test matrix (requires a real Android device / emulator with Play services) + +Automated coverage lives in `android-app/test/fcm.test.mjs` (manifest, +Gradle, service wiring, permission policy, payload→deep-link contract — +everything verifiable without a device). The following must be verified +manually on a device, because FCM delivery and permission dialogs cannot run +in Node: + +| # | Scenario | Expected | +|---|---|---| +| 1 | Fresh install, Android 13+, Firebase-enabled build | App runs; ~9 s in, the system notification dialog appears once | +| 2 | Tap **Allow** | Dialog closes; app works; topic subscribed (logcat: `FcmService`/`subscribeToTopic` success) | +| 3 | Tap **Deny** | Dialog closes; app fully usable; a test push does not surface but the app is normal | +| 4 | Relaunch after 2 or 3 | No dialog ever again | +| 5 | Android 12 device | No permission dialog; pushes work | +| 6 | Send §4 payload (background app) | Tray notification with teal accent + book icon | +| 7 | Send §4 payload (app open, foreground) | Same notification rendered by the app | +| 8 | Tap notification (app closed / cold) | App opens **directly on the paper screen** for `data.path` | +| 9 | Tap notification (app open, warm) | Router pushes the paper screen on the existing stack | +| 10 | Payload without `data.path` | App opens on home | +| 11 | Kill app, clear task, tap tray notification | Cold start still lands on the paper screen | +| 12 | `google-services.json` missing build | App identical minus push; no crashes; permission dialog never appears | + +**Build note:** `google-services.json` cannot be committed (it is generated +per Firebase account; keep it in CI secrets). All non-device checks are +enforced by `npm test`. diff --git a/android-app/test/app-frontend-smoke.test.mjs b/android-app/test/app-frontend-smoke.test.mjs index c468acf..08b0b80 100644 --- a/android-app/test/app-frontend-smoke.test.mjs +++ b/android-app/test/app-frontend-smoke.test.mjs @@ -51,6 +51,10 @@ const SEARCH = { ], total: 2, page: 1, limit: 20, totalPages: 1, }; +const CONTRIBUTORS = [ + { id: 'c1', name: 'Aarav Sharma', avatar: '', role: '12 papers' }, + { id: 'c2', name: 'Meera N.', avatar: '', role: '5 papers' }, +]; function jwt(exp) { const b64u = (s) => Buffer.from(s).toString('base64url'); @@ -88,9 +92,16 @@ function setupDom() { if (u.includes('/api/pyqs/search')) return ok(SEARCH); if (u.includes('/api/pyqs/p1')) return ok(PAPER); if (u.includes('/api/pyqs?')) return ok(SEARCH); + if (u.includes('/api/contributors')) return ok(CONTRIBUTORS); + if (u.includes('api.gofile.io/servers')) return ok({ status: 'ok', data: { servers: [{ name: 'store1' }] } }); + if (u.includes('/pendingUploads')) return ok({}); if (u.includes('signInWithPassword')) return ok({ idToken: jwt(nowSec + 3600), refreshToken: 'RT', expiresIn: '3600', email: 'stud@dsmnru.in', }); + if (u.includes('accounts:signInWithIdp')) return ok({ + idToken: jwt(nowSec + 3600), refreshToken: 'RT-G', expiresIn: '3600', + federatedId: '1089', providerId: 'google.com', + }); if (u.includes('accounts:update')) return ok({ displayName: 'Test Student' }); if (u.includes('/documents')) return { ok: true, status: 200, json: async () => ({ fields: {} }) }; return { ok: false, status: 404, json: async () => ({ error: 'mock: not mocked ' + u }) }; @@ -196,5 +207,157 @@ if (JSDOM) { const after = calls.filter((c) => c.url.includes('/api/homepage')).length; assert.equal(after, homeCalls, 'homepage cache prevented a refetch on revisit'); assert.ok(calls.length - before <= 1, 'no request storm on navigation'); + + // ════════════════════════════════════════════════════════════════════ + // v1.2 — self-contained-app features (drawer + new screens) + // ════════════════════════════════════════════════════════════════════ + + // Drawer opens from the app-bar menu (visible at every tab root). + const openDrawer = async () => { + assert.ok(await waitFor(() => !document.getElementById('appbar-menu').hidden), 'menu button visible at tab root'); + document.getElementById('appbar-menu').click(); + assert.ok(await waitFor(() => !document.getElementById('drawer-root').hidden), 'drawer opens'); + }; + + // ── Drawer: opens from the app bar, contains the in-app destinations ─ + const menuBtn = document.getElementById('appbar-menu'); + assert.ok(menuBtn, 'app bar has a menu (drawer) button'); + await openDrawer(); + const drawerText = text(document.getElementById('drawer-root')); + for (const item of ['Upload paper', 'Study tools', 'Contributors', 'Links', 'About this app']) { + assert.ok(drawerText.includes(item), `drawer contains “${item}”`); + } + const openedBeforeDrawer = opened.length; + + // ── Drawer → Upload Paper: an IN-APP screen, not the website ───────── + document.querySelector('#drawer-root [data-view="upload"]').click(); + assert.ok(await waitFor(() => view().querySelector('#up-form')), 'upload screen rendered from drawer'); + assert.equal(document.getElementById('drawer-root').classList.contains('is-open'), false, 'drawer closed after navigation'); + assert.equal(opened.length, openedBeforeDrawer, 'opening Upload never opens a browser'); + assert.match(text(view()), /10\s*points/, 'reward explanation rendered'); + + // Validation errors render inside the app (no navigation, no fetches). + const uploadCallsBefore = calls.length; + view().querySelector('#up-submit').click(); + assert.ok(await waitFor(() => !view().querySelector('[data-err]').hidden), 'validation error shown'); + assert.match(text(view().querySelector('[data-err]')), /enter your name/i); + assert.equal(calls.length, uploadCallsBefore, 'validation costs zero network'); + + // Happy path: one PDF → gofile (mocked fetch + XHR) → one metadata insert. + globalThis.XMLHttpRequest = class { + constructor() { + this.upload = { addEventListener() {} }; + this.listeners = {}; + this.status = 200; + this.response = { status: 'ok', data: { downloadPage: 'https://store1.gofile.io/download/web/x/paper.pdf' } }; + } + open() {} + addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); } + send() { setTimeout(() => { (this.listeners.load || []).forEach((fn) => fn()); }, 0); } + }; + view().querySelector('#up-title').value = 'B.Tech DSA {2023}'; + view().querySelector('#up-name').value = 'Aarav Sharma'; + view().querySelector('#up-email').value = 'Aarav@Test.dev'; + const fileInput = view().querySelector('#up-file'); + Object.defineProperty(fileInput, 'files', { + value: [new window.File(['%PDF-1.4 fake'], 'paper.pdf', { type: 'application/pdf' })], + configurable: true, + }); + fileInput.dispatchEvent(new window.Event('change', { bubbles: true })); + assert.match(text(view().querySelector('#up-drop-text')), /paper\.pdf/, 'selected file shown in the picker card'); + view().querySelector('#up-submit').click(); + assert.ok(await waitFor(() => text(view()).includes('Submission received')), 'in-app success state'); + assert.match(text(view()), /pending review/i, 'moderation queue explained in-app'); + assert.ok(calls.some((c) => c.url.includes('/pendingUploads')), 'metadata written to the SAME Firestore queue'); + const uploads = calls.filter((c) => c.url.includes('/pendingUploads')); + assert.equal(uploads.length, 1, 'exactly one metadata insert'); + assert.ok(window.localStorage.getItem('dsmnruUploadThrottle'), 'website-parity throttle recorded'); + + // ── Drawer → Study Tools: fully on-device (no API traffic at all) ──── + document.querySelector('.tab[data-tab="home"]').click(); + await waitFor(() => view().querySelector('.hero')); + await openDrawer(); + document.querySelector('#drawer-root [data-view="tools"]').click(); + assert.ok(await waitFor(() => view().querySelector('.tool-card')), 'tools screen rendered'); + const toolsCallsBefore = calls.length; + const toolText = text(view()); + for (const t of ['CGPA calculator', 'Attendance tracker', 'Study planner']) { + assert.ok(toolText.includes(t), `tools screen contains “${t}”`); + } + view().querySelectorAll('.tool-card button')[0].click(); // Open calculator + assert.ok(await waitFor(() => document.querySelector('.sheet-root #cg-calc')), 'CGPA sheet opens in-app'); + document.querySelector('.sheet-root #cg-calc').click(); + assert.ok(await waitFor(() => document.querySelector('.sheet-root .tool-result-gpa')), 'SGPA computed on-device'); + assert.match(text(document.querySelector('.sheet-root .tool-result-gpa')), /10\.00/, 'O grade × 4 credits = 10.00'); + document.querySelector('.sheet-root [data-dismiss]').click(); + assert.equal(calls.length, toolsCallsBefore, 'study tools make ZERO network requests'); + + // ── Drawer → Contributors: ONE cached Worker request ──────────────── + document.querySelector('.tab[data-tab="home"]').click(); + await waitFor(() => view().querySelector('.hero')); + await openDrawer(); + document.querySelector('#drawer-root [data-view="contributors"]').click(); + assert.ok(await waitFor(() => view().querySelector('.contrib-card')), 'contributors rendered'); + assert.match(text(view()), /Aarav Sharma/, 'contributor name from /api/contributors'); + assert.match(text(view()), /Join them!/, 'join card routes to in-app upload'); + const contribCalls = calls.filter((c) => c.url.includes('/api/contributors')).length; + assert.equal(contribCalls, 1, 'exactly one /api/contributors request'); + view().querySelector('.contrib-join').click(); + assert.ok(await waitFor(() => view().querySelector('#up-form')), 'join card opens the IN-APP upload screen'); + + // Re-open contributors → still exactly one request (cache/SWR). + document.querySelector('.tab[data-tab="home"]').click(); + await waitFor(() => view().querySelector('.hero')); + await openDrawer(); + document.querySelector('#drawer-root [data-view="contributors"]').click(); + await waitFor(() => view().querySelector('.contrib-card')); + assert.equal(calls.filter((c) => c.url.includes('/api/contributors')).length, 1, 'revisit costs zero traffic'); + + // ── Drawer → Links: static in-app list; only the tapped portal is external ── + document.querySelector('.tab[data-tab="home"]').click(); + await waitFor(() => view().querySelector('.hero')); + await openDrawer(); + document.querySelector('#drawer-root [data-view="links"]').click(); + assert.ok(await waitFor(() => view().querySelector('.link-item')), 'links rendered in-app'); + assert.equal(view().querySelectorAll('.link-cat-head').length, 4, 'same four categories as the website'); + const openedBeforeLinks = opened.length; + view().querySelector('.link-item[data-link-url]').click(); + assert.equal(opened.length, openedBeforeLinks + 1, 'tapping a portal opens exactly one external intent'); + assert.match(opened.at(-1), /^https:\/\/(dsmru|dsmnru|scholarship)/, 'external destination is the university portal itself'); + assert.ok(!opened.some((u) => u.includes('dsmnru-pyq.netlify.app')), 'links screen never opens the PYQ website'); + + // ── Drawer → About: in-app screen with the external-destination audit ── + document.querySelector('.tab[data-tab="home"]').click(); + await waitFor(() => view().querySelector('.hero')); + await openDrawer(); + document.querySelector('#drawer-root [data-view="about"]').click(); + assert.ok(await waitFor(() => view().querySelector('.about-hero')), 'about screen rendered in-app'); + assert.match(text(view()), /Fully inside this app/, 'about lists what stays in-app'); + + // ── Home shortcuts are in-app navigations now (no browser hand-off) ── + document.querySelector('.tab[data-tab="home"]').click(); + await waitFor(() => view().querySelector('.hero')); + const openedBeforeShortcuts = opened.length; + assert.ok(await waitFor(() => view().querySelector('.shortcut[data-i="0"]')), 'in-app shortcuts rendered'); + view().querySelector('.shortcut[data-i="0"]').click(); // Upload a paper + assert.ok(await waitFor(() => view().querySelector('#up-form')), 'home shortcut opens the in-app upload screen'); + assert.equal(opened.length, openedBeforeShortcuts, 'shortcuts never open the browser'); + + // ── Google button without a native layer → in-app explainer, no website ── + document.querySelector('.tab[data-tab="profile"]').click(); + await waitFor(() => view().querySelector('.profile-card')); + const signOut = view().querySelector('[data-act="signout"]'); + if (signOut) { + signOut.click(); + await waitFor(() => document.querySelector('.sheet-root [data-confirm]')); + document.querySelector('.sheet-root [data-confirm]').click(); + await waitFor(() => view().querySelector('[data-act="google"]')); + } + view().querySelector('[data-act="google"]').click(); + assert.ok(await waitFor(() => { + const sheet = document.querySelector('.sheet-root'); + return sheet && /Google sign-in/.test(text(sheet)) && /email/.test(text(sheet)); + }), 'google fallback sheet opens in-app'); + assert.ok(!/Open website to use Google/.test(text(document.querySelector('.sheet-root'))), 'NO website hand-off for Google'); }); } diff --git a/android-app/test/app-native-bridge.test.mjs b/android-app/test/app-native-bridge.test.mjs new file mode 100644 index 0000000..d2e8929 --- /dev/null +++ b/android-app/test/app-native-bridge.test.mjs @@ -0,0 +1,207 @@ +/** + * DSMNRU PYQ Android — jsdom integration test with a FAKE Capacitor bridge. + * + * Runs in its own node:test process, so the native.js module registry is + * fresh and `window.Capacitor.Plugins.DsmnruApp` can be installed BEFORE the + * app boots — exercising the real native code paths: + * + * - "Open PDF" → DsmnruApp.pdfView (in-app viewer screen) with the direct + * host URL — no external intent, no Worker traffic; + * - PDF viewer failure → falls back to the SAME direct URL via the system + * (openExternal), never the DSMNRU website; + * - Google sign-in → DsmnruApp.googleSignIn (device account chooser) → + * Firebase accounts:signInWithIdp with the same nonce the + * plugin saw → signed-in session flagged as a Google account; + * - GOOGLE_SIGNIN_NOT_CONFIGURED builds → in-app explainer with the + * email/password path — NO website hand-off; + * - non-PDF "Server 2" (Drive landing page) → genuinely external intent. + * + * Skips gracefully when jsdom is unavailable (provided by worker/node_modules + * in CI). Run: npm test (from android-app/) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const WWW = join(here, '../www'); + +let JSDOM; +try { + ({ JSDOM } = await import(pathToFileURL(join(here, '../../worker/node_modules/jsdom/lib/api.js')).href)); +} catch { + try { + ({ JSDOM } = await import('jsdom')); + } catch { + test('native bridge smoke (skipped: jsdom not installed)', { skip: true }, () => {}); + } +} + +const HOME = { + recent: [], trending: [], courseCounts: [], + stats: { totalPyqs: 1, totalCourses: 1 }, +}; +const PAPER = { + id: 'p1', title: 'Data Structures {2023}', course: 'B.Tech', semester: '4th', + session: '2023-24', branch: 'CSE', subject: 'DS', views: 13, year: 2023, + file: 'https://files.catbox.moe/abcd12.pdf', file2: 'https://drive.google.com/x', + seoSlug: 'data-structures-2023', createdAt: '2023-06-01T10:00:00Z', +}; + +function jwt(exp, provider) { + const b64u = (s) => Buffer.from(s).toString('base64url'); + return b64u('{"alg":"none"}') + '.' + b64u(JSON.stringify({ + exp, user_id: 'g-uid-1', sub: 'g-uid-1', email: 'student@gmail.com', name: 'Google Student', + email_verified: true, firebase: { sign_in_provider: provider }, + })) + '.s'; +} + +if (JSDOM) { + test('native paths: in-app PDF viewer, Google credential sign-in, typed fallbacks', async (t) => { + const html = readFileSync(join(WWW, 'index.html'), 'utf8'); + const dom = new JSDOM(html, { url: 'https://localhost/' }); + const { window } = dom; + t.after(() => { try { window.close(); } catch { /* already gone */ } }); + window.Element.prototype.scrollTo = () => {}; + window.requestAnimationFrame = (fn) => setTimeout(() => fn(performance.now()), 0); + window.cancelAnimationFrame = (id) => clearTimeout(id); + const opened = []; + window.open = (u) => { opened.push(String(u)); return null; }; + + for (const key of ['window', 'document', 'navigator', 'location', 'localStorage', 'HTMLElement', 'Element', 'Node', 'Event', 'CustomEvent', 'MouseEvent', 'requestAnimationFrame', 'cancelAnimationFrame']) { + try { + Object.defineProperty(globalThis, key, { value: window[key], configurable: true, writable: true }); + } catch { /* node-owned globals resist — code guards with typeof */ } + } + window.localStorage.clear(); + + // ── the fake native plugin (installed BEFORE app.js boots) ────────── + const bridgeCalls = []; + let googleResult = { idToken: 'GOOGLE_ID_TOKEN' }; + let pdfResult = { ok: true }; + const DsmnruApp = { + async pdfView(opts) { + bridgeCalls.push({ kind: 'pdfView', url: opts.url, title: opts.title }); + if (pdfResult && pdfResult.reject) throw new Error(pdfResult.reject); + return pdfResult || {}; + }, + async googleSignIn(opts) { + bridgeCalls.push({ kind: 'googleSignIn', nonce: opts && opts.nonce }); + if (googleResult && googleResult.err) throw new Error(googleResult.err); + return googleResult || {}; + }, + async openExternal(opts) { + bridgeCalls.push({ kind: 'openExternal', url: opts.url }); + return {}; + }, + async share() { return {}; }, + async download() { return {}; }, + async getLaunchUrl() { return { url: '' }; }, + }; + globalThis.Capacitor = { Plugins: { DsmnruApp } }; + + const calls = []; + const nowSec = Math.floor(Date.now() / 1000); + const ok = (data) => ({ ok: true, status: 200, json: async () => data }); + const idpBodies = []; + globalThis.fetch = async (url, opts = {}) => { + calls.push({ url: String(url), opts }); + if (opts.signal?.aborted) { const e = new Error('AbortError'); e.name = 'AbortError'; throw e; } + const u = String(url); + if (u.includes('/api/homepage')) return ok(HOME); + if (u.includes('/api/courses')) return ok(['B.Tech']); + if (u.includes('/api/pyqs/p1')) return ok(PAPER); + if (u.includes('/api/pyqs/search')) return ok({ items: [{ id: 'p1', title: PAPER.title, course: 'B.Tech', views: 12, slug: PAPER.seoSlug }], total: 1, page: 1, totalPages: 1 }); + if (u.includes('accounts:signInWithIdp')) { + idpBodies.push(JSON.parse(opts.body)); + return ok({ idToken: jwt(nowSec + 3600, 'google.com'), refreshToken: 'RT-G', expiresIn: '3600', providerId: 'google.com' }); + } + if (u.includes('/documents')) return { ok: true, status: 200, json: async () => ({ fields: {} }) }; + return { ok: false, status: 404, json: async () => ({ error: 'mock: not mocked ' + u }) }; + }; + + await import(pathToFileURL(join(WWW, 'js/app.js')).href); + const view = () => document.getElementById('view'); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + async function waitFor(fn, ms = 2500) { + const t0 = Date.now(); + for (;;) { + if (fn()) return true; + if (Date.now() - t0 > ms) return false; + await sleep(15); + } + } + const text = (el) => (el ? el.textContent : ''); + + assert.ok(await waitFor(() => view().querySelector('.hero')), 'app booted with the native bridge'); + + // ── PDF gate mirrors the website policy for signed-out users ───────── + document.querySelector('.tab[data-tab="search"]').click(); + await waitFor(() => view().querySelector('#sq')); + const input = view().querySelector('#sq'); + input.value = 'data structures'; + input.dispatchEvent(new window.Event('input', { bubbles: true })); + assert.ok(await waitFor(() => view().querySelector('[data-paper-id="p1"]')), 'search result rendered'); + view().querySelector('[data-paper-id="p1"]').click(); + assert.ok(await waitFor(() => view().querySelector('.paper-hero')), 'paper screen rendered'); + + const externalBefore = bridgeCalls.filter((c) => c.kind === 'openExternal').length; + view().querySelector('[data-act="view"]').click(); + assert.ok(await waitFor(() => document.querySelector('.sheet-root #auth-email')), 'verified-sign-in gate opens the in-app auth sheet first'); + + // ── Google: not configured → in-app explainer, no website hand-off ──── + googleResult = { err: 'GOOGLE_SIGNIN_NOT_CONFIGURED: no client id in this build' }; + document.querySelector('.sheet-root [data-act="google"]').click(); + assert.ok(await waitFor(() => { + const sheet = document.querySelector('.sheet-root'); + return sheet && /Google sign-in/.test(text(sheet)) && /configured/.test(text(sheet)); + }), 'not-configured explainer shown'); + assert.ok(!/Open website/.test(text(document.querySelector('.sheet-root'))), 'never sends the user to the website'); + document.querySelector('.sheet-root [data-act="email"]').click(); + assert.ok(await waitFor(() => document.querySelector('.sheet-root #auth-email')), 'email/password path offered in-app'); + + // ── Google sign-in: device chooser → Firebase → Google session ───────── + googleResult = { idToken: 'GOOGLE_ID_TOKEN' }; + document.querySelector('.sheet-root [data-act="google"]').click(); + assert.ok(await waitFor(() => idpBodies.length === 1), 'Identity Toolkit signInWithIdp called'); + const seenNonce = bridgeCalls.filter((c) => c.kind === 'googleSignIn').at(-1).nonce; + assert.ok(seenNonce && seenNonce.length >= 16, 'JS generated a nonce for the chooser'); + assert.match(idpBodies[0].postBody, new RegExp(`nonce=${seenNonce}`), 'the same nonce is replayed to Firebase'); + assert.match(idpBodies[0].postBody, /id_token=GOOGLE_ID_TOKEN/); + assert.match(idpBodies[0].postBody, /providerId=google\.com/); + + // ── Signed in → Open PDF goes to the IN-APP viewer (pdfView bridge call) ── + assert.ok(await waitFor(() => view().querySelector('.paper-hero [data-act="view"]')), 'paper re-rendered for the verified session'); + view().querySelector('[data-act="view"]').click(); + assert.ok(await waitFor(() => bridgeCalls.some((c) => c.kind === 'pdfView')), 'native viewer took over'); + const pdfCall = bridgeCalls.find((c) => c.kind === 'pdfView'); + assert.equal(pdfCall.url, PAPER.file, 'viewer got the DIRECT host URL (file field)'); + assert.match(pdfCall.title, /Data Structures/, 'viewer got the paper title'); + assert.equal(bridgeCalls.filter((c) => c.kind === 'openExternal').length, externalBefore, 'no external intent when the in-app viewer opens'); + assert.ok(!opened.length, 'no browser window either'); + + // ── Viewer failure → same direct URL to the system, NEVER the website ── + pdfResult = { ok: false, reject: 'PDF render failed' }; + view().querySelector('[data-act="server1"]').click(); + assert.ok(await waitFor(() => bridgeCalls.filter((c) => c.kind === 'openExternal').length === externalBefore + 1), 'fallback used the system viewer'); + const fallback = bridgeCalls.filter((c) => c.kind === 'openExternal').at(-1); + assert.equal(fallback.url, PAPER.file, 'fallback keeps the same direct URL'); + + // ── Non-PDF Server 2 (Drive landing page) → genuinely external intent ── + view().querySelector('[data-act="server2"]').click(); + assert.ok(await waitFor(() => bridgeCalls.some((c) => c.kind === 'openExternal' && c.url === PAPER.file2)), 'Drive landing page opens externally (unavoidable destination)'); + + // ── Profile reflects the SAME Firebase identity, flagged as Google ────── + document.querySelector('.tab[data-tab="profile"]').click(); + assert.ok(await waitFor(() => { + const card = view().querySelector('.profile-card'); + return card && /Google Student/.test(text(card)) && /Google account/.test(text(card)); + }), 'profile shows the same Firebase identity flagged as a Google account'); + assert.ok(calls.some((c) => c.url.includes('/documents/users/g-uid-1')), 'one-time users/{uid} sync ran'); + const firestoreCalls = calls.filter((c) => c.url.includes('firestore.googleapis.com')).length; + assert.ok(firestoreCalls <= 2, 'no Firestore chatter beyond the profile sync'); + }); +} diff --git a/android-app/test/fcm.test.mjs b/android-app/test/fcm.test.mjs new file mode 100644 index 0000000..9fbf576 --- /dev/null +++ b/android-app/test/fcm.test.mjs @@ -0,0 +1,186 @@ +/** + * DSMNRU PYQ Android — FCM / push notification audit (no device required). + * + * Everything that can be verified statically IS verified here: + * • manifest: POST_NOTIFICATIONS permission, FcmService registration with + * the MESSAGING_EVENT intent filter, default channel/icon/color meta-data; + * • Gradle: firebase-messaging dependency + the conditional google-services + * apply (debug builds without google-services.json keep working); + * • FcmService.java: ONE global topic (`all_users`), version-gated + * subscription (never per-launch), token stored device-locally (NO + * Firestore import), foreground rendering behind a permission check, + * tap intent = ACTION_VIEW data URL on MainActivity; + * • MainActivity.java: real system permission dialog (requestPermissions — + * not a fake toggle), once per install (pref flag set BEFORE asking), + * gated to Android 13+ and Firebase-configured builds, channel creation; + * • app JS: no polling timers / no notification impersonation; + * • payload contract: `data.path` → absolute site URL → slug.js#parseSiteUrl + * → the IN-APP paper route (mirroring FcmService.deepLinkUri byte-for-byte). + * Device-only checks (delivery, dialog UI, tray rendering) are listed in + * docs/PUSH_NOTIFICATIONS.md §6. Run: npm test (from android-app/) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const APP = join(here, '../android/app'); +const main = (p) => readFileSync(join(APP, 'src/main', p), 'utf8'); +const docs = () => readFileSync(join(here, '../docs/PUSH_NOTIFICATIONS.md'), 'utf8'); + +test('manifest declares the notification permission, FCM service and tray branding', () => { + const manifest = main('AndroidManifest.xml'); + + assert.match(manifest, //, + 'Android 13+ runtime notification permission declared'); + assert.match(manifest, //, + 'INTERNET still declared'); + + const service = manifest.match(//); + assert.ok(service, 'FcmService registered in the manifest'); + assert.match(service[0], /com\.google\.firebase\.MESSAGING_EVENT/, 'listens for FCM events'); + assert.match(service[0], /android:exported="false"/, 'FCM service is not exported'); + + assert.match(manifest, /com\.google\.firebase\.messaging\.default_notification_channel_id[^>]*android:value="dsmnru_general"/, + 'background tray notifications use the app channel'); + assert.match(manifest, /com\.google\.firebase\.messaging\.default_notification_icon[^>]*@drawable\/ic_stat_dsmnru/, + 'background tray notifications use the app icon'); + assert.match(manifest, /com\.google\.firebase\.messaging\.default_notification_color[^>]*@color\/dsmnru_teal/, + 'background tray notifications use the brand color'); + + // The in-app paper deep-link hosts stay registered (tap targets ride them). + assert.match(manifest, /android:host="dsmnru-pyq\.netlify\.app"/, 'site deep-link host still registered'); +}); + +test('Gradle wires firebase-messaging and keeps google-services conditional', () => { + const gradle = readFileSync(join(APP, 'build.gradle'), 'utf8'); + assert.match(gradle, /com\.google\.firebase:firebase-messaging:\d+\.\d+\.\d+/, + 'Firebase Messaging dependency present'); + assert.match(gradle, /apply plugin: 'com\.google\.gms\.google-services'/, + 'google-services plugin applied when google-services.json exists'); + assert.match(gradle, /google-services\.json/, 'apply is guarded by the presence of google-services.json'); + assert.match(gradle, /versionCode 4/, 'versionCode bumped for the FCM release'); + assert.match(gradle, /versionName "1\.3\.0"/, 'versionName bumped for the FCM release'); + + const rootGradle = readFileSync(join(here, '../android/build.gradle'), 'utf8'); + assert.match(rootGradle, /com\.google\.gms:google-services:[\d.]+/, 'plugin classpath on the root buildscript'); +}); + +test('FcmService: one global topic, version-gated subscribe, no token database', () => { + const src = main('java/com/dsmnru/pyq/FcmService.java'); + + assert.match(src, /TOPIC_ALL_USERS = "all_users"/, 'the single global topic is `all_users`'); + assert.match(src, /extends FirebaseMessagingService/, 'proper FCM service subclass'); + + // Registration + refresh handling + assert.match(src, /onNewToken/, 'token rotation handled'); + assert.match(src, /getSharedPreferences\([^)]*\)\.edit\(\)\.putString\("token"/, + 'token stored device-locally (diagnostics only)'); + + // Subscription discipline: version-gated, forced only on rotation, lazy retry. + assert.match(src, /KEY_TOPIC_VERSION/, 'subscription gate persisted'); + assert.match(src, /subscribeToTopic\(TOPIC_ALL_USERS\)/, 'subscribes the global topic via the FCM SDK'); + assert.match(src, /if \(!force && prefs\.getInt\(KEY_TOPIC_VERSION, 0\) >= TOPIC_VERSION\) return;/, + 'ordinary launches never re-subscribe'); + assert.match(src, /putInt\(KEY_TOPIC_VERSION, 0\)/, 'failed subscribe resets the gate for a lazy retry'); + + // Quota protection: NO Firestore, NO HTTP client, NO timers in the push path. + assert.ok(!/com\.google\.firebase\.firestore|FirebaseFirestore|firebase\s*\(\)|\.collection\(/.test(src), + 'no Firestore API usage in FcmService (no token DB)'); + assert.ok(!/HttpURLConnection|OkHttp|okhttp|URL\(/.test(src), 'no custom network calls — FCM SDK only'); + assert.ok(!/Timer|setInterval|ScheduledExecutor/.test(src), 'no polling timers'); + + // Foreground handling renders a notification, permission-checked. + assert.match(src, /onMessageReceived/, 'foreground messages handled'); + assert.match(src, /checkSelfPermission\(context, android\.Manifest\.permission\.POST_NOTIFICATIONS\)/, + 'permission checked before posting'); + assert.match(src, /new NotificationCompat\.Builder\(context, CHANNEL_ID\)/, 'renders on the app channel'); + + // Tap handling: ACTION_VIEW data URL on MainActivity (deep-link pipeline). + assert.match(src, /new Intent\(Intent\.ACTION_VIEW, deepLinkUri\(path\), context, MainActivity\.class\)/, + 'tap intent opens MainActivity with the link as data'); + assert.match(src, /FLAG_IMMUTABLE/, 'PendingIntent is immutable on modern Android'); + assert.match(src, /SITE_ORIGIN = "https:\/\/dsmnru-pyq\.netlify\.app"/, + 'paths resolve to the site origin the app router parses'); +}); + +test('MainActivity: real system permission dialog, asked once, correctly gated', () => { + const src = main('java/com/dsmnru/pyq/MainActivity.java'); + + assert.match(src, /Manifest\.permission\.POST_NOTIFICATIONS/, 'asks for POST_NOTIFICATIONS'); + assert.match(src, /ActivityCompat\.requestPermissions/, 'the REAL system dialog (no fake in-app toggle)'); + assert.match(src, /KEY_NOTIF_ASKED[\s\S]{0,120}putBoolean\(KEY_NOTIF_ASKED, true\)[\s\S]{0,120}requestPermissions/, + 'the asked-flag is persisted BEFORE the dialog (never re-asked)'); + assert.match(src, /if \(Build\.VERSION\.SDK_INT < 33\) return;/, 'gated to Android 13+'); + assert.match(src, /isFirebaseAvailable/, 'skipped when the build has no Firebase config'); + assert.match(src, /notificationsGranted\(this\)\) return/, 'already granted → nothing to do'); + assert.match(src, /PERMISSION_ASK_DELAY_MS = 9000L/, 'asked after the user has seen the app'); + assert.match(src, /FcmService\.ensureChannel\(this\)/, 'notification channel created at app start'); + assert.match(src, /FcmService\.subscribeAllUsers\(this, false\)/, + 'version-gated topic subscribe bootstrapped (not forced) at app start'); +}); + +test('app JS never polls for notifications or fakes the permission', () => { + const appjs = readFileSync(join(here, '../www/js/app.js'), 'utf8'); + const nativejs = readFileSync(join(here, '../www/js/native.js'), 'utf8'); + for (const [name, src] of [['app.js', appjs], ['native.js', nativejs]]) { + assert.ok(!/setInterval/.test(src), `${name} must not poll`); + assert.ok(!/new Notification\(|Notification\.requestPermission/.test(src), + `${name} must not impersonate notifications in JS — native owns push`); + } +}); + +test('payload contract: data.path resolves to the IN-APP paper route', async () => { + // Mirrors FcmService.deepLinkUri exactly. + const SITE_ORIGIN = 'https://dsmnru-pyq.netlify.app'; + const deepLinkUri = (path) => { + const p = path == null ? '' : String(path).trim(); + if (p === '') return SITE_ORIGIN + '/'; + if (p.startsWith('http://') || p.startsWith('https://')) return p; + return SITE_ORIGIN + (p.startsWith('/') ? p : '/' + p); + }; + + const { parseSiteUrl } = await import(pathToFileURL(join(here, '../www/js/slug.js')).href); + + const cases = [ + ['/pyq/data-structures-2023', { view: 'paper', slug: 'data-structures-2023' }], + ['/paper.html?id=p1', { view: 'paper', id: 'p1' }], + ]; + for (const [path, expect] of cases) { + const route = parseSiteUrl(deepLinkUri(path)); + assert.ok(route, `${path} parses as a site route`); + assert.equal(route.view, expect.view, `${path} opens the ${expect.view} screen IN-APP`); + if (expect.slug) assert.equal(route.slug, expect.slug); + if (expect.id) assert.equal(route.id, expect.id); + } + // Empty path → app home (route root), never an external website hand-off. + const home = parseSiteUrl(deepLinkUri('')); + assert.ok(home === null || home.view !== undefined, 'root path handled without crashing'); +}); + +test('docs/PUSH_NOTIFICATIONS.md documents the sender-side (Worker/admin) gap', () => { + const doc = docs(); + for (const required of [ + 'all_users', + 'dsmnru_general', + 'POST_NOTIFICATIONS', + 'google-services.json', + '/api/notify', + 'verifyFirebaseAdminToken', + 'fcm.googleapis.com/v1/projects/dsmnru-data/messages:send', + 'data.path', + 'dsmnru-data', + ]) { + assert.ok(doc.includes(required), `docs mention ${required}`); + } + // The message contract block must be valid JSON with the documented shape. + const block = doc.match(/```json\n([\s\S]*?)```/); + assert.ok(block, 'a JSON message contract is documented'); + const payload = JSON.parse(block[1]); + assert.equal(payload.message.topic, 'all_users', 'contract sends to the global topic'); + assert.ok(payload.message.notification.title, 'contract has a title'); + assert.match(payload.message.data.path, /^\//, 'contract path is app-relative'); +}); diff --git a/android-app/test/features.test.mjs b/android-app/test/features.test.mjs new file mode 100644 index 0000000..aba4aa3 --- /dev/null +++ b/android-app/test/features.test.mjs @@ -0,0 +1,501 @@ +/** + * DSMNRU PYQ Android — unit tests for the v1.2 self-contained-app features: + * + * - api.contributors(): ONE cached Worker request, SWR + persistence + * - auth.signInWithGoogleCredential(): Identity Toolkit accounts:signInWithIdp + * mapping (same Firebase project, google.com provider, nonce replay) + * - uploadcore.js: website-parity validation, reward-email normalization, + * client throttle, gofile URL, pendingUploads Firestore doc shape, JPEG + * dimension parsing, minimal-PDF assembly (xref correctness) + * - toolscore.js: CGPA/attendance/planner logic (website parity) + * - linkdata.js: every link is a genuinely-external https portal — none + * points at the DSMNRU PYQ website + * - WEBSITE-REDIRECT AUDIT: no navigation-to-website for normal app + * features (static analysis over every www/js module with a strict + * allowlist), per the self-containment requirement + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { createApi } from '../www/js/api.js'; +import { createAuth, decodeJwtPayload } from '../www/js/auth.js'; +import { + normalizeRewardEmail, isValidRewardEmail, classifyFiles, validateUploadAttempt, + readThrottleLog, getUploadThrottleState, recordUploadThrottle, + UPLOAD_THROTTLE_MAX_PER_WINDOW, UPLOAD_THROTTLE_MIN_GAP_MS, + fetchGofileUploadUrl, buildPendingUploadDoc, pendingUploadsUrl, + jpegDimensions, assemblePdfFromJpegs, IMAGE_ENCODE_ATTEMPTS, MAX_FINAL_PDF_SIZE, +} from '../www/js/uploadcore.js'; +import { + GRADE_POINTS, computeGpa, gradeLabel, + attendancePercent, attendanceMonthStats, attendanceSummary, + plannerStats, sortPlannerTasks, + loadPlannerTasks, savePlannerTasks, loadAttendance, saveAttendance, +} from '../www/js/toolscore.js'; +import { LINK_CATEGORIES } from '../www/js/linkdata.js'; + +const here = dirname(fileURLToPath(import.meta.url)); + +function makeStorage(initial = {}) { + const map = new Map(Object.entries(initial)); + return { + get length() { return map.size; }, + key(i) { return [...map.keys()][i] ?? null; }, + getItem(k) { return map.has(k) ? map.get(k) : null; }, + setItem(k, v) { map.set(k, String(v)); }, + removeItem(k) { map.delete(k); }, + _map: map, + }; +} + +function makeFetch(handlers) { + const calls = []; + const fetchImpl = async (url, opts = {}) => { + calls.push({ url: String(url), opts }); + if (opts.signal?.aborted) { + const e = new Error('AbortError'); + e.name = 'AbortError'; + throw e; + } + for (const [pattern, fn] of handlers) { + if (url.includes(pattern)) return fn(url, opts); + } + return { ok: false, status: 404, json: async () => ({ error: 'Not found' }) }; + }; + fetchImpl.calls = calls; + return fetchImpl; +} + +const jsonRes = (data, ok = true, status = 200) => async () => ({ ok, status, json: async () => data }); + +function makeJwt(payload) { + const b64u = (s) => Buffer.from(s).toString('base64url'); + return b64u('{"alg":"none"}') + '.' + b64u(JSON.stringify(payload)) + '.sig'; +} + +// ── contributors: one request, cached + SWR ───────────────────────────── + +test('contributors: one Worker request, 24h cache, offline-stale fallback', async () => { + let hits = 0; + let nowMs = 1_000_000; + const storage = makeStorage(); + const fetchImpl = makeFetch([['/api/contributors', async () => { + hits++; + return { ok: true, status: 200, json: async () => [{ id: 'c1', name: 'Aarav', role: '10 papers' }] }; + }]]); + const api = createApi({ fetchImpl, storage, now: () => nowMs }); + + const first = await api.contributors(); + assert.equal(hits, 1); + assert.deepEqual(first.data[0].name, 'Aarav'); + + // Instant re-open (drawer → screen → back → screen) costs ZERO traffic. + const second = await api.contributors(); + assert.equal(hits, 1); + assert.equal(second.fromCache, true); + + // Past the fresh window → serve cache + exactly one background revalidate. + nowMs += 25 * 60 * 60 * 1000; + const third = await api.contributors(); + assert.equal(third.stale, true); + await third.revalidating; + assert.equal(hits, 2); + + // A brand-new client instance (app restart) restores the persisted list + // instead of hitting the network when offline (still inside its TTL here). + const offlineApi = createApi({ + fetchImpl: async () => { throw new TypeError('offline'); }, + storage, now: () => nowMs + 60_000, + }); + const fourth = await offlineApi.contributors(); + assert.equal(fourth.fromCache, true, 'served from the persisted cache'); + assert.equal(fourth.stale, false, 'zero traffic while the payload is fresh — even offline'); + assert.equal(fourth.data.length, 1); + + // Same restart but past the TTL with the network down → stale, not an error. + const offlineApi2 = createApi({ + fetchImpl: async () => { throw new TypeError('offline'); }, + storage, now: () => nowMs + 30 * 60 * 60 * 1000, + }); + const fifth = await offlineApi2.contributors(); + assert.equal(fifth.fromCache, true); + assert.equal(fifth.stale, true); + assert.equal(fifth.data.length, 1); +}); + +// ── Google sign-in (native credential → same Firebase project) ────────── + +test('signInWithGoogleCredential exchanges the Google ID token with accounts:signInWithIdp', async () => { + const nowSec = Math.floor(Date.now() / 1000); + const fbToken = makeJwt({ + exp: nowSec + 3600, user_id: 'g-uid-1', sub: 'g-uid-1', + email: 'student@gmail.com', name: 'Google Student', email_verified: true, + firebase: { sign_in_provider: 'google.com', identities: {} }, + }); + const bodies = []; + const fetchImpl = makeFetch([ + ['accounts:signInWithIdp', async (url, opts) => { + bodies.push(JSON.parse(opts.body)); + return { ok: true, status: 200, json: async () => ({ + idToken: fbToken, refreshToken: 'RT-G', expiresIn: '3600', + federatedId: '1089', providerId: 'google.com', + }) }; + }], + ['firestore.googleapis.com', jsonRes({ fields: {} }, true, 200)], + ]); + const auth = createAuth({ storage: makeStorage(), fetchImpl, now: () => Date.now() }); + + const user = await auth.signInWithGoogleCredential({ idToken: 'GOOGLE_ID_TOKEN', nonce: 'raw-nonce' }); + + assert.equal(user.uid, 'g-uid-1'); + assert.equal(user.providerId, 'google.com'); + assert.equal(auth.isGoogle(), true, 'recognised as a Google account'); + assert.equal(auth.needsEmailVerification(), false, 'google.com skips the verification gate like the website'); + assert.equal(auth.canUnlockPrivileges(), true); + + assert.equal(bodies.length, 1, 'exactly one Identity Toolkit call'); + assert.match(bodies[0].postBody, /id_token=GOOGLE_ID_TOKEN/); + assert.match(bodies[0].postBody, /providerId=google\.com/); + assert.match(bodies[0].postBody, /nonce=raw-nonce/, 'raw nonce replayed for verification'); + assert.equal(bodies[0].requestUri, 'http://localhost'); + assert.equal(bodies[0].returnSecureToken, true); + // Same user-doc sync as every other sign-in (1 owner-scoped lookup here). + assert.ok(fetchImpl.calls.some((c) => c.url.includes('/documents/users/g-uid-1'))); +}); + +test('signInWithGoogleCredential rejects without a credential and maps errors', async () => { + const auth = createAuth({ + storage: makeStorage(), + fetchImpl: makeFetch([ + ['accounts:signInWithIdp', jsonRes({ error: { message: 'INVALID_IDP_ID_TOKEN : 400' } }, false, 400)], + ]), + now: () => Date.now(), + }); + await assert.rejects(auth.signInWithGoogleCredential({}), /credential/i); + await assert.rejects(auth.signInWithGoogleCredential({ idToken: 'x' }), /Invalid|credential|request/i); +}); + +// ── uploadcore: validation / throttle / gofile / metadata ─────────────── + +const pdfFile = (sizeMB = 1) => ({ name: 'paper.pdf', type: 'application/pdf', size: sizeMB * 1024 * 1024 }); +const imgFile = (name = 'photo.jpg') => ({ name, type: 'image/jpeg', size: 500_000 }); + +test('reward email normalization matches points.js (trim + lowercase)', () => { + assert.equal(normalizeRewardEmail(' Rahul@GMAIL.Com '), 'rahul@gmail.com'); + assert.equal(isValidRewardEmail('rahul@gmail.com'), true); + assert.equal(isValidRewardEmail('not-an-email'), false); + assert.equal(isValidRewardEmail('a@b'), false); + assert.equal(isValidRewardEmail('x'.repeat(161) + '@mail.com'), false, '160 char cap'); + assert.equal(isValidRewardEmail('x'.repeat(150) + '@mail.com'), true, '159 chars still valid'); +}); + +test('classifyFiles buckets pdfs, images and unsupported files', () => { + const { pdfs, images, unsupported } = classifyFiles([ + pdfFile(), imgFile(), imgFile('two.png'), { name: 'notes.txt', type: 'text/plain', size: 10 }, + ]); + assert.equal(pdfs.length, 1); + assert.equal(images.length, 2); + assert.equal(unsupported.length, 1); +}); + +test('validateUploadAttempt mirrors the website validation messages exactly', () => { + const base = { title: 'B.Tech DSA {2023}', studentName: 'Aarav', rawEmail: 'aarav@t.co' }; + ok(validateUploadAttempt({ ...base, files: [pdfFile()] })); + ok(validateUploadAttempt({ ...base, files: [imgFile(), imgFile('b.jpg')] })); + + fails(validateUploadAttempt({ ...base, studentName: '', files: [pdfFile()] }), /enter your name/i); + fails(validateUploadAttempt({ ...base, title: 'ab', files: [pdfFile()] }), /between 3 and 200/i); + fails(validateUploadAttempt({ ...base, rawEmail: '', files: [pdfFile()] }), /credit your contribution points/i); + fails(validateUploadAttempt({ ...base, rawEmail: 'bad@mail', files: [pdfFile()] }), /valid email/i); + fails(validateUploadAttempt({ ...base, files: [] }), /select a PDF or images/i); + fails(validateUploadAttempt({ ...base, files: [{ name: 'a.txt', type: 'text/plain', size: 5 }] }), /Only PDF or image files/i); + fails(validateUploadAttempt({ ...base, files: [pdfFile(), pdfFile()] }), /only one PDF/i); + fails(validateUploadAttempt({ ...base, files: [pdfFile(), imgFile()] }), /not both together/i); + fails(validateUploadAttempt({ ...base, files: [pdfFile(11)] }), /10MB/i); + + const throttled = { allowed: false, message: 'Upload limit reached (5 per 6 hours). Try again in about 320 minutes.' }; + fails(validateUploadAttempt({ ...base, files: [pdfFile()], throttleState: throttled }), /Upload limit reached/i); + + function ok(r) { assert.equal(r.ok, true, JSON.stringify(r)); } + function fails(r, re) { assert.equal(r.ok, false); assert.match(r.message, re); } +}); + +test('upload throttle: 45s gap and max 5 per 6h window (website parity)', () => { + const storage = makeStorage(); + let now = 10_000_000; + assert.equal(getUploadThrottleState(storage, now).allowed, true); + + for (let i = 0; i < UPLOAD_THROTTLE_MAX_PER_WINDOW; i++) { + recordUploadThrottle(storage, now); + now += UPLOAD_THROTTLE_MIN_GAP_MS + 1000; + } + const state = getUploadThrottleState(storage, now); + assert.equal(state.allowed, false); + assert.match(state.message, /limit reached/i); + + // Gap enforcement below the cap + const s2 = makeStorage(); + recordUploadThrottle(s2, now); + assert.equal(getUploadThrottleState(s2, now + UPLOAD_THROTTLE_MIN_GAP_MS - 1).allowed, false); + assert.match(getUploadThrottleState(s2, now + UPLOAD_THROTTLE_MIN_GAP_MS - 1).message, /wait/i); + assert.equal(getUploadThrottleState(s2, now + UPLOAD_THROTTLE_MIN_GAP_MS + 1).allowed, true); + + // Old entries age out of the window + now += 7 * 60 * 60 * 1000; + assert.equal(getUploadThrottleState(storage, now).allowed, true); + assert.equal(readThrottleLog(storage, now).length, 0); +}); + +test('fetchGofileUploadUrl picks the first server (same service as the website)', async () => { + const fetchImpl = makeFetch([['api.gofile.io/servers', jsonRes({ + status: 'ok', data: { servers: [{ name: 'store1' }, { name: 'store2' }] }, + })]]); + assert.equal(await fetchGofileUploadUrl(fetchImpl), 'https://store1.gofile.io/uploadFile'); + + await assert.rejects( + fetchGofileUploadUrl(makeFetch([['api.gofile.io/servers', jsonRes({ status: 'error' })]])), + /No upload servers available/, + ); + await assert.rejects( + fetchGofileUploadUrl(async () => ({ ok: false, status: 503, json: async () => ({}) })), + /Failed to get upload server/, + ); +}); + +test('buildPendingUploadDoc matches the Firestore rules shape exactly', () => { + const doc = buildPendingUploadDoc({ + title: 'B.Com Accounts {2022}', + course: 'B.Com', + semester: '3rd', + studentName: 'Aarav', + studentCourse: 'B.Com', + studentEmail: 'aarav@t.co', + userId: 'uid-1', + fileName: 'paper.pdf', + downloadUrl: 'https://store1.gofile.io/download/web/abc/paper.pdf', + fileSize: 1234567, + createdAtIso: '2026-09-03T00:00:00.000Z', + }); + const f = doc.fields; + assert.equal(f.title.stringValue, 'B.Com Accounts {2022}'); + assert.equal(f.studentEmail.stringValue, 'aarav@t.co'); + assert.equal(f.email.stringValue, 'aarav@t.co', 'email alias included like the website'); + assert.equal(f.userId.stringValue, 'uid-1'); + assert.equal(f.downloadUrl.stringValue, 'https://store1.gofile.io/download/web/abc/paper.pdf'); + assert.equal(f.fileSize.integerValue, '1234567'); + assert.equal(f.status.stringValue, 'pending'); + assert.equal(f.uploadedAt.timestampValue, '2026-09-03T00:00:00.000Z'); + // Review/points fields must NEVER be client-written (Firestore rules reject them). + for (const banned of ['pointsAwarded', 'pointsTransactionId', 'pointsAmount', 'reviewedAt', 'reviewedBy', 'rejectionReason']) { + assert.equal(banned in f, false, `${banned} must be absent`); + } + assert.match(pendingUploadsUrl(), /firestore\.googleapis\.com\/v1\/projects\/dsmnru-data\/databases\/\(default\)\/documents\/pendingUploads$/); + assert.equal(MAX_FINAL_PDF_SIZE, 10 * 1024 * 1024); + assert.equal(IMAGE_ENCODE_ATTEMPTS.length, 6, 'same quality ladder as the website'); +}); + +// ── uploadcore: JPEG parsing + minimal PDF assembly ───────────────────── + +function craftJpeg(width, height) { + // SOF0 segment carrying the dimensions, wrapped in SOI/EOI. + return new Uint8Array([ + 0xFF, 0xD8, + 0xFF, 0xC0, 0x00, 0x11, 0x08, + (height >> 8) & 0xFF, height & 0xFF, + (width >> 8) & 0xFF, width & 0xFF, + 0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, + 0xFF, 0xD9, + ]); +} + +test('jpegDimensions reads SOF0 dimensions', () => { + const dims = jpegDimensions(craftJpeg(640, 480)); + assert.deepEqual(dims, { width: 640, height: 480 }); + assert.equal(jpegDimensions(new Uint8Array([1, 2, 3])), null); + assert.equal(jpegDimensions(null), null); +}); + +test('assemblePdfFromJpegs writes a structurally valid PDF with correct xref offsets', () => { + const pages = [ + { jpeg: craftJpeg(640, 480), width: 640, height: 480 }, + { jpeg: craftJpeg(800, 600), width: 800, height: 600 }, + ]; + const pdf = assemblePdfFromJpegs(pages); + assert.equal(pdf[0], 0x25); // % + assert.equal(pdf[1], 0x50); // P + assert.equal(pdf[2], 0x44); // D + assert.equal(pdf[3], 0x46); // F + + const text = Buffer.from(pdf).toString('latin1'); + assert.ok(text.includes('/Filter /DCTDecode'), 'jpegs embedded without re-encoding'); + assert.ok(text.includes('/Count 2')); + assert.ok(text.includes('/MediaBox [0 0 595.28 841.89]'), 'A4 pages'); + assert.ok(text.trimEnd().endsWith('%%EOF')); + + // xref correctness: every offset must point at its object header. + const startxref = Number(text.match(/startxref\n(\d+)\n%%EOF$/)[1]); + assert.equal(text.slice(startxref, startxref + 4), 'xref', 'startxref points at the table'); + const offsets = [...text.matchAll(/^(\d{10}) 00000 n $/gm)].map((m) => Number(m[1])); + assert.equal(offsets.length, 2 + pages.length * 3, 'catalog + pages-tree + (page/content/image) per page'); + for (const off of offsets) { + const at = text.slice(off, off + 8); + assert.match(at, /^\d+ 0 obj/, `offset ${off} lands on an object header, got: ${at}`); + } + assert.throws(() => assemblePdfFromJpegs([]), /No pages/); +}); + +// ── toolscore: CGPA / attendance / planner (website parity) ──────────── + +test('CGPA calculator: same grade map and credit-weighted math as the website', () => { + assert.deepEqual(GRADE_POINTS, { O: 10, 'A+': 9, A: 8, 'B+': 7, B: 6, C: 5, D: 4, F: 0 }); + // Website example: grade points × credits / total credits + const { totalCredits, totalPoints, gpa } = computeGpa([ + { grade: 'O', credits: 4 }, { grade: 'A', credits: 3 }, { grade: 'B+', credits: 3 }, + ]); + assert.equal(totalCredits, 10); + assert.equal(totalPoints, 10 * 4 + 8 * 3 + 7 * 3); + assert.equal(gpa, 85 / 10); + assert.equal(computeGpa([]).gpa, 0); + assert.equal(computeGpa([{ grade: 'Z', credits: 3 }]).totalPoints, 0, 'unknown grade counts 0 like the site'); + assert.equal(computeGpa([{ grade: 'O', credits: 0 }]).totalCredits, 0, 'zero credits cannot divide'); + assert.equal(gradeLabel(9.4), 'Outstanding'); + assert.equal(gradeLabel(0), '—'); +}); + +test('attendance math: month stats, overall percent and 75% warnings', () => { + const records = { '2026-09-01': 'P', '2026-09-02': 'P', '2026-09-03': 'A', '2026-08-30': 'P' }; + assert.deepEqual(attendanceMonthStats(records, '2026-09'), { present: 2, total: 3, pct: 67 }); + assert.equal(attendancePercent(records), 75); + assert.equal(attendanceMonthStats({}, '2026-09').total, 0); + + // Deterministic September-only records: first `present` days are P, rest A. + const mk = (present, total) => { + const r = {}; + for (let i = 0; i < total; i++) { + r[`2026-09-${String(i + 1).padStart(2, '0')}`] = i < present ? 'P' : 'A'; + } + return r; + }; + const subjects = [ + { subject: 'DSA', records: mk(20, 20) }, // 100% + { subject: 'OS', records: mk(9, 10) }, // 90% + { subject: 'Maths', records: mk(7, 10) }, // 70% → low AND near (>= 70) + { subject: 'DBMS', records: mk(21, 30) }, // 70% → low AND near + ]; + const summary = attendanceSummary(subjects, { now: new Date('2026-09-15T10:00:00Z') }); + assert.equal(summary.subjects, 4); + assert.equal(summary.low, 2); + assert.equal(summary.near, 2); +}); + +test('planner stats and sorting: incomplete first, then by due date', () => { + const tasks = [ + { id: 3, title: 'done later', due: '2026-10-01T10:00', completed: true }, + { id: 1, title: 'due sooner', due: '2026-09-20T09:00', completed: false }, + { id: 2, title: 'due later', due: '2026-09-25T09:00', completed: false }, + { id: 4, title: 'no date', due: null, completed: false }, + ]; + assert.deepEqual(plannerStats(tasks), { total: 4, completed: 1, pct: 25 }); + const order = sortPlannerTasks(tasks).map((t) => t.title); + assert.deepEqual(order, ['due sooner', 'due later', 'no date', 'done later']); + assert.deepEqual(plannerStats([]), { total: 0, completed: 0, pct: 0 }); +}); + +test('tools persistence round-trips on the same storage keys as the website', () => { + const storage = makeStorage(); + savePlannerTasks(storage, [{ id: 1, title: 'Revise', due: null, completed: false }]); + saveAttendance(storage, [{ id: 2, subject: 'DSA', records: { '2026-09-01': 'P' } }]); + assert.equal(loadPlannerTasks(storage)[0].title, 'Revise'); + assert.equal(loadAttendance(storage)[0].subject, 'DSA'); + assert.deepEqual(loadPlannerTasks(null), []); +}); + +// ── linkdata: static portals are genuinely external, never the PYQ site ─ + +test('links dataset: https-only university/government portals, zero PYQ-website URLs', () => { + assert.equal(LINK_CATEGORIES.length, 4, 'same four categories as the website Links page'); + let total = 0; + for (const cat of LINK_CATEGORIES) { + assert.ok(cat.title && cat.icon); + for (const link of cat.links) { + total++; + assert.ok(link.url.startsWith('https://'), `${link.url} must be https`); + assert.ok(!/dsmnru-pyq\.(netlify\.app|email)/.test(link.url), `${link.url} must NOT be the PYQ website`); + assert.ok(link.title && link.description); + } + } + assert.equal(total, 14, 'same 14 destinations as links.html'); +}); + +// ── WEBSITE-REDIRECT AUDIT (self-containment guarantee) ──────────────── + +test('audit: no website navigation for normal app features (strict allowlist)', () => { + const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => { + const p = join(dir, e.name); + return e.isDirectory() ? walk(p) : (e.name.endsWith('.js') ? [p] : []); + }); + const jsDir = join(here, '../www/js'); + const files = walk(jsDir); + + // 1) In-window navigation is only permitted inside native.js's last-resort + // fallback (which only runs OUTSIDE the app, in a plain browser). + for (const file of files) { + const src = readFileSync(file, 'utf8'); + const navHits = [ + /window\.location/, /location\.href/, /location\.assign\(/, /location\.replace\(/, + /document\.location/, + ].map((re) => re.test(src)); + if (navHits.some(Boolean) && !file.endsWith('native.js')) { + assert.fail(`${file} navigates the window — app features must not use the browser`); + } + } + + // 2) The site origin may only be referenced by these files, for these + // documented purposes: deep-link hand-off, explicit "open website" + // choices, share links and the Firebase verification continue-url. + const SITE_ORIGIN_ALLOWED = new Set([ + 'api.js', 'app.js', 'slug.js', 'auth.js', + join('views', 'paper.js'), join('views', 'profile.js'), join('views', 'about.js'), + ]); + for (const file of files) { + const rel = file.slice(jsDir.length + 1); + const src = readFileSync(file, 'utf8'); + if (src.includes('dsmnru-pyq.netlify.app') || src.includes('SITE_ORIGIN')) { + assert.ok( + SITE_ORIGIN_ALLOWED.has(rel), + `${rel} references the website — normal features must stay in-app`, + ); + } + } + + // 3) The previously-existing website hand-offs are gone: + const home = readFileSync(join(jsDir, 'views', 'home.js'), 'utf8'); + assert.ok(!home.includes('openExternal'), 'home shortcuts must navigate in-app'); + const authui = readFileSync(join(jsDir, 'authui.js'), 'utf8'); + assert.ok(!authui.includes('Open website to use Google'), 'Google flow must not hand off to the website'); + assert.ok(!authui.includes('openExternal'), 'auth sheets must not open the browser at all'); + const profile = readFileSync(join(jsDir, 'views', 'profile.js'), 'utf8'); + assert.ok(!profile.includes("act: 'web'") && !profile.includes('Open DSMNRU website'), + 'profile must not offer a generic "open the website" item (admin panel excepted)'); + const appSrc = readFileSync(join(jsDir, 'app.js'), 'utf8'); + assert.ok(!/openExternal\(`\$\{SITE_ORIGIN\}\/pyq\//.test(appSrc), + 'unresolvable deep-link slugs must stay in-app (search fallback, not the browser)'); + + // 4) Feature screens exist and are wired into the router + drawer. + const appjs = readFileSync(join(jsDir, 'app.js'), 'utf8'); + for (const view of ['upload', 'tools', 'contributors', 'links', 'about']) { + assert.match(appjs, new RegExp(`${view}:\\s*render`), `router registers ${view}`); + } + const drawer = readFileSync(join(jsDir, 'drawer.js'), 'utf8'); + for (const view of ['upload', 'tools', 'contributors', 'links', 'about']) { + const exposed = drawer.includes(`view: '${view}'`) || drawer.includes(`data-view="${view}"`); + assert.ok(exposed, `drawer exposes ${view}`); + } + const paper = readFileSync(join(jsDir, 'views', 'paper.js'), 'utf8'); + assert.match(paper, /ctx\.openPdf\(/, 'Open PDF goes through the in-app viewer first'); + assert.match(paper, /documents\/feedback/, 'report broken link submits in-app'); +}); diff --git a/android-app/www/css/app.css b/android-app/www/css/app.css index ccec85e..9160706 100644 --- a/android-app/www/css/app.css +++ b/android-app/www/css/app.css @@ -479,6 +479,7 @@ input, textarea { user-select: text; } .state-block p { color: var(--muted); font-size: 0.84rem; line-height: 1.55; max-width: 320px; } .state-block .btn { margin-top: 10px; } .state-block--warn .state-icon { background: rgba(251, 191, 36, 0.1); border-color: rgba(251, 191, 36, 0.3); color: var(--gold); } +.state-block--ok .state-icon { background: rgba(20, 184, 166, 0.12); border-color: rgba(20, 184, 166, 0.35); color: var(--teal); } .state-block--error .state-icon { background: rgba(255, 84, 89, 0.1); border-color: rgba(255, 84, 89, 0.3); color: var(--danger); } /* ── sheets (auth, filters, options, share) ──────────────────────────── */ @@ -666,3 +667,207 @@ input, textarea { user-select: text; } .action-grid { grid-template-columns: repeat(4, 1fr); } .action-grid .btn--wide { grid-column: span 2; } } + +/* ============================================================================ + v1.2 additions — side drawer, Upload, Study Tools, Contributors, Links, + About, native Google button. Same brand system as above. + ========================================================================== */ + +/* ── side drawer ─────────────────────────────────────────────────────── */ +.drawer-root { position: fixed; inset: 0; z-index: 90; pointer-events: none; } +.drawer-root.is-open { pointer-events: auto; } +.drawer-scrim { + position: absolute; inset: 0; + background: rgba(2, 6, 23, 0.62); + opacity: 0; transition: opacity 0.2s var(--ease); +} +.drawer-root.is-open .drawer-scrim { opacity: 1; } +.drawer { + position: absolute; top: 0; bottom: 0; left: 0; + width: min(84vw, 320px); + display: flex; flex-direction: column; + background: var(--bg-elev); + border-right: 1px solid var(--line); + padding: calc(var(--safe-top) + 10px) 0 calc(var(--safe-bottom) + 10px); + transform: translateX(-102%); + transition: transform 0.22s var(--ease); + overflow-y: auto; +} +.drawer-root.is-open .drawer { transform: none; } +.drawer-head { display: flex; align-items: center; gap: 11px; padding: 8px 16px 14px; border-bottom: 1px solid var(--line); } +.drawer-logo { + width: 40px; height: 40px; border-radius: 12px; flex: 0 0 auto; + background: #0f1b34 url('../img/emblem.png') center / contain no-repeat; + border: 1px solid rgba(110, 231, 216, 0.25); +} +.drawer-title { font-weight: 800; letter-spacing: -0.01em; } +.drawer-sub { font-size: 0.7rem; color: var(--muted); font-weight: 600; } +.drawer-x { margin-left: auto; } +.drawer-user { display: flex; align-items: center; gap: 11px; padding: 14px 16px 6px; min-width: 0; } +.drawer-avatar { + width: 40px; height: 40px; border-radius: 13px; flex: 0 0 auto; + display: flex; align-items: center; justify-content: center; + background: rgba(20, 184, 166, 0.14); border: 1px solid rgba(20, 184, 166, 0.3); + color: var(--mint); font-weight: 800; font-size: 0.9rem; +} +.drawer-avatar--ghost { background: rgba(148, 163, 184, 0.1); border-color: var(--line-strong); color: var(--faint); } +.drawer-avatar .ic { width: 19px; height: 19px; } +.drawer-user-name { font-weight: 700; font-size: 0.88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.drawer-user-mail { font-size: 0.72rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.drawer-nav { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; } +.drawer-item { + appearance: none; border: 0; background: transparent; color: var(--text); + display: flex; align-items: center; gap: 13px; width: 100%; + min-height: 48px; padding: 9px 10px; border-radius: 13px; + font: inherit; font-size: 0.9rem; font-weight: 700; text-align: left; cursor: pointer; +} +.drawer-item:active { background: rgba(148, 163, 184, 0.1); } +.drawer-item .di-ic { color: var(--mint); display: inline-flex; flex: 0 0 auto; } +.drawer-item .di-ic .ic { width: 20px; height: 20px; } +.drawer-item small { display: block; color: var(--muted); font-weight: 500; font-size: 0.72rem; margin-top: 1px; } +.drawer-sep { height: 1px; background: var(--line); margin: 8px 10px; } +.drawer-foot { margin-top: auto; padding: 12px 18px 0; font-size: 0.7rem; color: var(--faint); border-top: 1px solid var(--line); } + +/* ── notices variant ─────────────────────────────────────────────────── */ +.notice--info .ic { color: var(--mint); } + +/* ── google button / auth divider ────────────────────────────────────── */ +.auth-or { display: flex; align-items: center; gap: 10px; margin: 12px 0; color: var(--faint); font-size: 0.72rem; font-weight: 700; } +.auth-or::before, .auth-or::after { content: ''; height: 1px; background: var(--line-strong); flex: 1; } +.btn--google { background: #FFFFFF; color: #1F2937; border-color: #FFFFFF; font-weight: 800; } +.btn--google:active { transform: scale(0.97); } +.g-mark { + display: inline-flex; align-items: center; justify-content: center; + width: 20px; height: 20px; margin-right: 8px; border-radius: 50%; + background: conic-gradient(from -45deg, #EA4335 110deg, #4285F4 110deg 200deg, #34A853 200deg 290deg, #FBBC05 290deg); + color: #fff; font-size: 0.72rem; font-weight: 900; +} + +/* ── upload form ─────────────────────────────────────────────────────── */ +.req { color: var(--gold); } +.opt { color: var(--faint); font-weight: 600; } +.field-hint { color: var(--faint); font-size: 0.7rem; margin-top: 2px; } +.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.file-drop { + display: flex; align-items: center; gap: 12px; + border: 1.5px dashed var(--line-strong); border-radius: var(--radius); + padding: 14px; cursor: pointer; background: rgba(148, 163, 184, 0.04); +} +.file-drop:active { border-color: rgba(110, 231, 216, 0.5); } +.file-drop.has-file { border-style: solid; border-color: rgba(20, 184, 166, 0.5); background: rgba(20, 184, 166, 0.06); } +.file-drop-ic .ic { width: 24px; height: 24px; color: var(--mint); } +.file-drop-main { flex: 1 1 auto; min-width: 0; font-weight: 700; font-size: 0.84rem; word-break: break-word; } +.file-drop-main small { display: block; color: var(--muted); font-weight: 500; font-size: 0.72rem; margin-top: 2px; } +.file-drop-btn { + flex: 0 0 auto; font-size: 0.74rem; font-weight: 800; color: var(--teal-ink); + background: var(--grad-brand); border-radius: 999px; padding: 8px 14px; +} +.up-progress { margin: 4px 0 12px; } +.up-progress-bar { height: 8px; border-radius: 99px; background: rgba(148, 163, 184, 0.14); overflow: hidden; } +.up-progress-bar > div { height: 100%; border-radius: 99px; background: var(--grad-brand); transition: width 0.25s var(--ease); } +.up-progress-text { color: var(--muted); font-size: 0.76rem; font-weight: 600; margin-top: 7px; } + +/* ── study tools ─────────────────────────────────────────────────────── */ +.tool-grid { display: flex; flex-direction: column; gap: 12px; } +.tool-card { display: flex; flex-direction: column; gap: 12px; } +.tool-head { display: flex; gap: 12px; align-items: flex-start; } +.tool-ic { + width: 42px; height: 42px; border-radius: 13px; flex: 0 0 auto; + display: flex; align-items: center; justify-content: center; + background: rgba(20, 184, 166, 0.12); border: 1px solid rgba(20, 184, 166, 0.25); color: var(--mint); +} +.tool-ic .ic { width: 21px; height: 21px; } +.tool-ic--sm { width: 34px; height: 34px; border-radius: 11px; } +.tool-ic--sm .ic { width: 17px; height: 17px; } +.tool-ic--gold { background: rgba(251, 191, 36, 0.1); border-color: rgba(251, 191, 36, 0.3); color: var(--gold); } +.tool-title { font-size: 0.96rem; font-weight: 800; letter-spacing: -0.01em; } +.tool-desc { color: var(--muted); font-size: 0.78rem; line-height: 1.5; margin-top: 3px; } +.tool-stat { display: flex; flex-direction: column; gap: 6px; } +.tool-stat-pill { + align-self: flex-start; + color: var(--muted); font-size: 0.76rem; font-weight: 600; + border: 1px solid var(--line); border-radius: 999px; padding: 6px 12px; +} +.tool-stat-pill b { color: var(--text); } +.tool-stat-pill.is-warn { border-color: rgba(251, 191, 36, 0.4); color: var(--gold); } +.tool-stat-pill--soft { color: var(--faint); } +.tool-progress { height: 7px; border-radius: 99px; background: rgba(148, 163, 184, 0.14); overflow: hidden; } +.tool-progress > div { height: 100%; background: var(--grad-brand); border-radius: 99px; transition: width 0.25s var(--ease); } +.tool-count-row { display: flex; align-items: center; gap: 10px; } +.field-label { font-size: 0.76rem; font-weight: 800; color: var(--muted); } +.step-btn { + appearance: none; border: 1px solid var(--line-strong); background: rgba(148, 163, 184, 0.08); + color: var(--text); width: 34px; height: 34px; border-radius: 11px; font-size: 1rem; font-weight: 800; cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; +} +.step-btn:active { transform: scale(0.93); } +.step-group { display: flex; align-items: center; gap: 6px; } +.step-group .input { width: 56px; text-align: center; padding: 8px 4px; } +.cg-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } +.cg-row-num { width: 18px; color: var(--faint); font-size: 0.76rem; font-weight: 700; flex: 0 0 auto; } +.cg-row .input { flex: 1 1 auto; min-width: 0; } +.tool-result { text-align: center; border: 1px solid rgba(20, 184, 166, 0.35); background: rgba(20, 184, 166, 0.07); border-radius: var(--radius); padding: 16px; } +.tool-result--warn { border-color: rgba(251, 191, 36, 0.4); background: rgba(251, 191, 36, 0.07); color: var(--gold); font-weight: 700; font-size: 0.84rem; } +.tool-result-gpa { font-size: 2rem; font-weight: 800; color: var(--mint); letter-spacing: -0.02em; } +.tool-result-meta { color: var(--muted); font-size: 0.76rem; font-weight: 600; margin-top: 4px; } +.tool-form-row { display: flex; gap: 8px; align-items: stretch; } +.tool-form-row .input { flex: 1 1 auto; min-width: 0; } +.tool-form-row .btn { flex: 0 0 auto; } +.tool-date-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 10px; } +.tool-date-row label { display: flex; flex-direction: column; gap: 5px; font-size: 0.72rem; font-weight: 700; color: var(--muted); } +.tool-empty { text-align: center; color: var(--faint); font-size: 0.82rem; padding: 18px 10px; } +.tool-stat-line { color: var(--muted); font-size: 0.78rem; font-weight: 600; margin-top: 6px; } +.att-card { margin-bottom: 10px; } +.att-head { display: flex; align-items: center; gap: 10px; } +.att-head b { flex: 1; min-width: 0; font-size: 0.9rem; } +.att-pct { font-weight: 800; color: var(--mint); } +.att-pct.is-warn { color: var(--gold); } +.att-meta { color: var(--muted); font-size: 0.74rem; margin: 5px 0 8px; } +.att-actions { display: flex; gap: 8px; margin-top: 10px; } +.btn--sm { min-height: 38px; padding: 7px 12px; font-size: 0.76rem; } +.pl-card { display: flex; align-items: flex-start; gap: 11px; margin-bottom: 10px; } +.pl-card.is-done .pl-title { text-decoration: line-through; color: var(--faint); } +.pl-check { display: flex; align-items: center; padding-top: 3px; } +.pl-check input { width: 19px; height: 19px; accent-color: var(--teal); } +.pl-title { font-weight: 700; font-size: 0.88rem; } +.pl-due { color: var(--muted); font-size: 0.72rem; font-weight: 600; margin-top: 3px; display: flex; align-items: center; gap: 5px; } +.pl-due .ic { width: 13px; height: 13px; } + +/* ── contributors ────────────────────────────────────────────────────── */ +.contrib-grid { display: grid; grid-template-columns: 1fr; gap: 10px; } +.contrib-card { display: flex; align-items: center; gap: 13px; text-align: left; font: inherit; color: inherit; cursor: default; } +.contrib-card + .contrib-card { margin-top: 0; } +.contrib-avatar { + width: 44px; height: 44px; border-radius: 14px; flex: 0 0 auto; + display: flex; align-items: center; justify-content: center; + font-weight: 800; font-size: 1.05rem; +} +.contrib-avatar--ghost { background: rgba(20, 184, 166, 0.1); border: 1px dashed rgba(20, 184, 166, 0.4); color: var(--mint); } +.contrib-avatar .ic { width: 19px; height: 19px; } +.contrib-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.contrib-main b { font-size: 0.92rem; font-weight: 800; } +.contrib-main span { color: var(--muted); font-size: 0.76rem; font-weight: 500; } +.contrib-join { cursor: pointer; border-style: dashed; border-color: rgba(20, 184, 166, 0.35); } +.contrib-join:active { transform: scale(0.985); } +@media (min-width: 520px) { .contrib-grid { grid-template-columns: 1fr 1fr; } } + +/* ── links ───────────────────────────────────────────────────────────── */ +.link-cat-head { display: flex; align-items: center; gap: 11px; margin-bottom: 10px; } +.link-cat-head h3 { font-size: 0.95rem; font-weight: 800; letter-spacing: -0.01em; } +.link-list { display: flex; flex-direction: column; gap: 4px; } +.link-item { padding: 10px 8px; border-radius: 12px; } +.link-host { color: var(--teal); opacity: 0.85; font-size: 0.68rem !important; } +.link-item .tail .ic { width: 16px; height: 16px; } + +/* ── about ───────────────────────────────────────────────────────────── */ +.about-hero { text-align: center; padding: 26px 16px; } +.about-hero .hero-emblem { margin: 0 auto 12px; } +.about-hero h1 { font-size: 1.3rem; font-weight: 800; letter-spacing: -0.02em; } +.about-hero .h-sub { margin-top: 6px; } +.about-h3 { display: flex; align-items: center; gap: 8px; font-size: 0.9rem; font-weight: 800; margin-bottom: 10px; } +.about-h3 .ic { width: 17px; height: 17px; color: var(--mint); } +.about-list { list-style: none; display: flex; flex-direction: column; gap: 8px; } +.about-list li { position: relative; padding-left: 16px; color: var(--muted); font-size: 0.8rem; line-height: 1.5; } +.about-list li::before { content: ''; position: absolute; left: 2px; top: 8px; width: 5px; height: 5px; border-radius: 99px; background: var(--teal); } +.about-list .link-ext { color: var(--mint); font-weight: 700; text-decoration: none; } +.mono { font-family: ui-monospace, monospace; font-size: 0.74rem; } diff --git a/android-app/www/index.html b/android-app/www/index.html index 1f28a01..0976afd 100644 --- a/android-app/www/index.html +++ b/android-app/www/index.html @@ -18,6 +18,7 @@ -->
+
@@ -36,6 +37,9 @@
+ + +
diff --git a/android-app/www/js/api.js b/android-app/www/js/api.js index 7a8604e..f121901 100644 --- a/android-app/www/js/api.js +++ b/android-app/www/js/api.js @@ -10,6 +10,7 @@ * GET /api/pyqs/search → server-side search (never client-side archive scans) * GET /api/pyqs/:id → full paper document (PDF links included) * GET /api/pyqs/slug/:s → deep-link slug → index item (additive Worker route) + * GET /api/contributors → contributor list (KV-cached server-side) * * Traffic discipline (Cloudflare free-tier friendly): * - Every GET is deduplicated by request key: concurrent identical calls @@ -42,6 +43,7 @@ const TTL_MS = { search: 3 * 60 * 1000, detail: 30 * 60 * 1000, slug: 10 * 60 * 1000, + contributors: 24 * 60 * 60 * 1000, }; const PERSIST_PREFIX = 'dsm.cache.v1.'; @@ -226,6 +228,15 @@ export function createApi(options = {}) { return withCache('/courses', null, { ttl: TTL_MS.courses, persist: true, force: !!opts.force }); }, + /** + * Contributor list — ONE Worker request for the whole screen (the Worker + * serves it from KV; it never fans out per contributor). Persisted with a + * 24h fresh window so re-opening the screen is free, SWR afterwards. + */ + contributors(opts = {}) { + return withCache('/contributors', null, { ttl: TTL_MS.contributors, persist: true, force: !!opts.force }); + }, + /** Paginated browse list. Cached per page/filters; never prefetched ahead. */ list(params, opts = {}) { return withCache('/pyqs', buildListParams(params), { diff --git a/android-app/www/js/app.js b/android-app/www/js/app.js index d0a9691..239be85 100644 --- a/android-app/www/js/app.js +++ b/android-app/www/js/app.js @@ -13,6 +13,7 @@ import { native } from './native.js'; import { parseSiteUrl } from './slug.js'; import * as ui from './ui.js'; import { openAuthSheet, verificationPromptSheet, googleInfoSheet, initAuthUI } from './authui.js'; +import { createDrawer } from './drawer.js'; import renderHome from './views/home.js'; import renderSearch from './views/search.js'; @@ -21,6 +22,11 @@ import renderCourse from './views/course.js'; import renderSaved from './views/saved.js'; import renderProfile from './views/profile.js'; import renderPaper from './views/paper.js'; +import renderUpload from './views/upload.js'; +import renderTools from './views/tools.js'; +import renderContributors from './views/contributors.js'; +import renderLinks from './views/links.js'; +import renderAbout from './views/about.js'; const VIEWS = { home: renderHome, @@ -30,6 +36,11 @@ const VIEWS = { saved: renderSaved, profile: renderProfile, paper: renderPaper, + upload: renderUpload, + tools: renderTools, + contributors: renderContributors, + links: renderLinks, + about: renderAbout, }; const TAB_VIEWS = new Set(['home', 'search', 'browse', 'saved', 'profile']); @@ -41,12 +52,14 @@ initAuthUI(auth); const els = { appbar: document.getElementById('appbar'), + menu: document.getElementById('appbar-menu'), back: document.getElementById('appbar-back'), title: document.getElementById('appbar-title'), actions: document.getElementById('appbar-actions'), view: document.getElementById('view'), tabbar: document.getElementById('tabbar'), net: document.getElementById('net-banner'), + drawerRoot: document.getElementById('drawer-root'), }; const state = { @@ -94,6 +107,10 @@ function updateHeader(entry) { const isRoot = TAB_VIEWS.has(entry.view) && stack.length === 1; els.back.hidden = isRoot && !meta.back; els.back.innerHTML = ui.icon('back'); + // Hamburger lives at top-level screens (standard Android idiom); pushed + // screens show the back arrow instead. The drawer remains one tap away at + // every tab root, including Home. + if (els.menu) els.menu.hidden = !isRoot || !!meta.back; els.title.innerHTML = (isRoot || meta.brand) ? `DSMNRU PYQPYQ archive · Android` : `${ui.esc(meta.title || '')}${meta.sub ? `${ui.esc(meta.sub)}` : ''}`; @@ -123,6 +140,30 @@ function updateTabbar() { els.back.innerHTML = ui.icon('back'); els.back.addEventListener('click', () => routerBack()); + +// ── side drawer (in-app feature hub) ─────────────────────────────────── +const drawer = createDrawer({ + onNavigate({ kind, view, params }) { + if (kind === 'tab') router.tab(view, params); + else router.go(view, params); + }, + onAbout() { router.go('about'); }, +}); +function syncDrawerAuth() { + const u = auth.current(); + drawer.setAuthState({ + signedIn: !!u, + userName: (u && u.name) || '', + email: (u && u.email) || '', + }); +} +auth.onChange(syncDrawerAuth); +if (els.menu) { + els.menu.innerHTML = ui.icon('menu'); + els.menu.addEventListener('click', () => { syncDrawerAuth(); drawer.open(); }); +} +if (els.drawerRoot) els.drawerRoot.replaceWith(drawer.el); + els.tabbar.querySelectorAll('.tab').forEach((t) => { t.querySelector('.tab-ic').innerHTML = ui.icon({ home: 'home', search: 'search', browse: 'courses', saved: 'bookmark', profile: 'user', @@ -198,6 +239,21 @@ function buildContext(entry) { }, openGoogleInfo() { googleInfoSheet(); }, openPaper(params) { openPaperTarget(params); }, + /** + * Open a PDF INSIDE the app (native viewer screen). When the native + * layer is missing (browser preview / harness) hand the SAME direct URL + * to the system viewer — never the DSMNRU website. The viewer activity + * owns progress/error/retry UX; here we only route. + */ + async openPdf(url, title) { + const res = await native.pdfViewer(url, title); + if (res && res.ok) return true; + return native.openExternal(url); + }, + drawer: { + open() { syncDrawerAuth(); drawer.open(); }, + closeIfOpen: () => drawer.closeIfOpen(), + }, setHeader(header) { entry.header = header; updateHeader(entry); @@ -208,9 +264,10 @@ function buildContext(entry) { /** * Open a paper from id and/or slug. A slug without an id (shared deep link) - * is resolved with one exact Worker lookup — with a local fallback — so the - * app never has to render the website. Unresolvable slugs hand off to the - * browser instead of dead-ending. + * is resolved with one exact Worker lookup — the app never renders or opens + * the website. Unresolvable slugs stay IN-APP: the search screen opens + * pre-filled with the slug's words so the user can find the paper (or see a + * proper empty state) — no browser hand-off, no dead end. */ async function openPaperTarget(params) { const target = { ...(params || {}) }; @@ -222,10 +279,10 @@ async function openPaperTarget(params) { const item = await api.resolveSlug(target.slug).catch(() => null); if (item && item.id) { router.go('paper', { id: item.id, slug: target.slug }); - } else { - ui.toast('Opening on the website instead'); - native.openExternal(`${SITE_ORIGIN}/pyq/${encodeURIComponent(target.slug)}`); + return; } + ui.toast('That exact paper isn\'t in the archive — showing search'); + router.go('search', { q: String(target.slug).replace(/[-_]+/g, ' ').trim() }); } // ── deep links ───────────────────────────────────────────────────────── @@ -275,6 +332,9 @@ function wireAndroid() { if (!App) return; try { App.addListener('backButton', () => { + // Standard Android precedence: drawer → sheet → in-app stack → exit. + if (drawer.closeIfOpen()) return; + if (ui.closeSheet()) return; if (!routerBack()) App.exitApp(); }); App.addListener('resume', () => { diff --git a/android-app/www/js/auth.js b/android-app/www/js/auth.js index d951083..781e1ad 100644 --- a/android-app/www/js/auth.js +++ b/android-app/www/js/auth.js @@ -5,23 +5,24 @@ * second auth system. * * Why REST instead of the firebase-auth JS SDK? - * - Google sign-in via popup is blocked inside embedded WebViews (Google - * rejects disallowed_useragent), so the website already shows a "use email - * & password in the app" hint for the Capacitor user agent. - * - The Identity Toolkit REST endpoints are exactly what the JS SDK calls - * under the hood for password auth: sign-in/sign-up/refresh/verification - * work flawlessly from the app without pulling ~270 KB of SDK or opening - * any popup, and without any Firestore reads at startup. + * - Email/password, verification and token refresh map 1:1 to Identity + * Toolkit endpoints — the same calls the JS SDK makes under the hood — + * without pulling ~270 KB of SDK into the APK, and without any Firestore + * reads at startup. + * - Google sign-in runs NATIVELY in the app: the DsmnruApp plugin collects a + * Google ID token through Android's Credential Manager (device account + * chooser — no popup, no browser), and this module exchanges it with + * `accounts:signInWithIdp` against the same `dsmnru-data` project, so the + * user identity is identical to the website's. * - The web API key below is the same public client config already embedded * in the production website (script.js); it is not a secret, and no * service-account or private credential is ever shipped here. * - * Google accounts: users who originally signed up with Google keep working on - * the website; inside the app, opening Google sign-in would require a native - * OAuth flow + console changes, so the app clearly explains the limitation - * and offers (a) email/password sign-in (same account if a password was ever - * set) or (b) one-tap hand-off to the site in the system browser. No silent - * breakage, no fallback backend. + * Google accounts get the same privileges as on the website (google.com + * provider claims skip the email-verification gate). If a particular APK + * build lacks the Google client-ID configuration (see + * docs/GOOGLE_SIGNIN_SETUP.md), the UI explains it and offers in-app + * email/password — it never sends the user to the website to sign in. * * Session storage: idToken + refresh token + parsed expiry, refreshed lazily * (on app resume / when within 5 minutes of expiry). No polling. @@ -29,7 +30,6 @@ export const FIREBASE_PROJECT_ID = 'dsmnru-data'; export const FIREBASE_WEB_API_KEY = 'AIzaSyBRlsk-knQs-AMlaTFxlneBMTwlSfwyFaQ'; -export const GOOGLE_SIGNIN_UNSUPPORTED = 'GOOGLE_SIGNIN_UNSUPPORTED_IN_APP'; const IDT = 'https://identitytoolkit.googleapis.com/v1'; const SECURE_TOKEN = 'https://securetoken.googleapis.com/v1/token'; @@ -282,6 +282,41 @@ export function createAuth(options = {}) { } }, + /** + * Android-native Google sign-in, second (final) step. + * + * The DsmnruApp plugin first obtains a Google ID token from the device's + * Google account chooser (Credential Manager — see DsmnruAppPlugin.java). + * That token is exchanged here against the SAME Firebase project the + * website uses, via the Identity Toolkit `accounts:signInWithIdp` + * endpoint — the exact call the Firebase JS SDK makes for + * signInWithPopup(GoogleAuthProvider). The result is the same user + * identity, session shape and users/{uid} sync as every other sign-in — + * no second auth system, no browser, no website redirect. + * + * `nonce` (raw, generated in JS) is bound into the Google token by the + * plugin (SHA-256 form) and replayed here so Identity Toolkit can verify + * it — standard anti-replay pairing. + */ + async signInWithGoogleCredential({ idToken, nonce = '' } = {}) { + const token = String(idToken || '').trim(); + if (!token) throw new Error('Google did not return a credential.'); + const postBody = 'id_token=' + encodeURIComponent(token) + + '&providerId=google.com' + + (nonce ? '&nonce=' + encodeURIComponent(String(nonce)) : ''); + try { + const data = await identity('signInWithIdp', { + postBody, + requestUri: 'http://localhost', + returnIdpCredential: true, + returnSecureToken: true, + }); + return await adoptTokenSession(data); + } catch (err) { + throw new Error(friendly(err)); + } + }, + async signUp({ name, email, password }) { let data; try { diff --git a/android-app/www/js/authui.js b/android-app/www/js/authui.js index 8e790b9..890934f 100644 --- a/android-app/www/js/authui.js +++ b/android-app/www/js/authui.js @@ -3,6 +3,11 @@ * email verification), built on the same Firebase project the website uses * (see ./auth.js). These sheets are also what the archive "gates" open, * mirroring the website's login-modal policy for search, pagination and PDFs. + * + * Google sign-in is NATIVE: the device's Google account chooser (Android + * Credential Manager via DsmnruAppPlugin) returns a Google ID token which is + * exchanged with the same Firebase project — no browser, no Chrome, no + * website hand-off, ever. */ import * as ui from './ui.js'; @@ -16,6 +21,43 @@ export function initAuthUI(authInstance) { auth = authInstance; } +/** Fresh nonce binding the Google credential to this sign-in attempt. */ +function generateNonce() { + try { + const bytes = new Uint8Array(16); + (globalThis.crypto || window.crypto).getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + } catch { + return 'n-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 12); + } +} + +/** + * Run the Android-native Google sign-in end-to-end: + * account chooser → Google ID token → Firebase (same project) → session. + * Resolves true when the user ended up signed in. + */ +export async function startGoogleSignIn({ onAuthenticated } = {}) { + const nonce = generateNonce(); + const res = await native.googleSignIn(nonce); + if (res && res.ok && res.idToken) { + try { + await auth.signInWithGoogleCredential({ idToken: res.idToken, nonce: res.nonce || nonce }); + ui.closeSheet(); + ui.toast('Signed in with Google'); + if (onAuthenticated) onAuthenticated(); + return true; + } catch (err) { + ui.toast(String(err.message || err), 'err'); + return false; + } + } + const code = (res && res.code) || ''; + if (code === 'GOOGLE_SIGNIN_CANCELLED') return false; // user backed out — silent + googleInfoSheet({ code, onAuthenticated }); + return false; +} + function field(id, label, type, placeholder, autocomplete) { return `
@@ -62,9 +104,12 @@ export function openAuthSheet({ reason = '', onAuthenticated, mode = 'login' } = ${field('auth-email', 'Email', 'email', 'you@student.edu', 'email')} ${field('auth-pass', 'Password', 'password', '••••••••', 'current-password')} -
+
or
+ +
-
@@ -81,8 +126,8 @@ export function openAuthSheet({ reason = '', onAuthenticated, mode = 'login' } =

Firebase emails a reset link (opens on the website — you can finish there or come back here).

-

Same account system as dsmnru-pyq.netlify.app — your saved papers - and comments on the website are already here. Nothing new is created.

`; +

Same account system as the DSMNRU PYQ website — your saved papers + and comments there are already here. Nothing new is created.

`; const forms = { login: node.querySelector('[data-form="login"]'), @@ -101,8 +146,7 @@ export function openAuthSheet({ reason = '', onAuthenticated, mode = 'login' } = }); node.querySelector('[data-act="forgot"]').addEventListener('click', () => showForm('reset')); node.querySelector('[data-act="google"]').addEventListener('click', () => { - ui.closeSheet(); - openGoogleInfo(); + startGoogleSignIn({ onAuthenticated }); }); wireSubmit(forms.login, async (f) => { @@ -137,31 +181,42 @@ export function openAuthSheet({ reason = '', onAuthenticated, mode = 'login' } = return sheetRef; } -/** Same limitation messaging the website shows for embedded WebViews — never silent. */ -export function googleInfoSheet() { +/** + * Google sign-in explainer — shown only when the native flow could not run + * (cancelled attempts never surface this). There is NO "open the website" + * hand-off: the user either signs in natively when the build supports it, or + * uses email & password against the same Firebase account. + */ +export function googleInfoSheet({ code = '', onAuthenticated } = {}) { + const configured = code !== 'GOOGLE_SIGNIN_NOT_CONFIGURED'; const node = document.createElement('div'); node.innerHTML = ` -

Google sign-in uses a browser popup that Google blocks inside embedded - app WebViews, so it can't run natively in this app yet. Your account is still the same - Firebase account everywhere:

+ ${configured + ? `

Google sign-in runs with the device's own account chooser — no browser needed. + It looks like Google sign-in isn't available on this device right now (no Google account on the + phone, or Play services is out of date).

` + : `

This app build doesn't have Google sign-in configured yet (the Google client-ID + setup from docs/GOOGLE_SIGNIN_SETUP.md hasn't been applied). Your account is + still exactly the same Firebase account everywhere:

`}
- + ${configured ? `` : ''}
`; const s = ui.sheet({ title: 'Google sign-in', content: node }); node.querySelector('[data-act="email"]').addEventListener('click', () => { s.close(); openAuthSheet({ mode: 'login' }); }); - node.querySelector('[data-act="web"]').addEventListener('click', () => { - s.close(); - native.openExternal('https://dsmnru-pyq.netlify.app/'); - }); + const retry = node.querySelector('[data-act="retry"]'); + if (retry) { + retry.addEventListener('click', () => { + s.close(); + startGoogleSignIn({ onAuthenticated }); + }); + } return s; } -function openGoogleInfo() { googleInfoSheet(); } - export function verificationPromptSheet({ afterVerified } = {}) { const node = document.createElement('div'); const user = auth.current(); diff --git a/android-app/www/js/drawer.js b/android-app/www/js/drawer.js new file mode 100644 index 0000000..3beb485 --- /dev/null +++ b/android-app/www/js/drawer.js @@ -0,0 +1,146 @@ +/** + * DSMNRU PYQ Android — app side drawer (navigation menu). + * + * A standard Android navigation drawer: slides in from the start edge over a + * scrim, closes on scrim tap / item pick / Escape / hardware back, and shows + * the sign-in state in its footer. It is the app's hub for the in-app + * feature screens (Upload Paper, Study Tools, Contributors, Links) plus the + * existing bottom tabs and About — it NEVER opens the website. + * + * Pure UI: navigation intents are handed to the `onNavigate({ kind, view, params })` + * callback provided by app.js, so routing/back-stack ownership stays in one place. + */ + +import * as ui from './ui.js'; + +const TABS = [ + { view: 'home', icon: 'home', label: 'Home' }, + { view: 'search', icon: 'search', label: 'Search' }, + { view: 'browse', icon: 'courses', label: 'Courses' }, + { view: 'saved', icon: 'bookmark', label: 'Saved' }, + { view: 'profile', icon: 'user', label: 'Profile & settings' }, +]; + +const FEATURES = [ + { view: 'upload', icon: 'upload', label: 'Upload paper', sub: 'Contribute a PYQ — works fully in-app' }, + { view: 'tools', icon: 'tools', label: 'Study tools', sub: 'CGPA · attendance · planner — run on-device' }, + { view: 'contributors', icon: 'users', label: 'Contributors', sub: 'The students behind the archive' }, + { view: 'links', icon: 'link', label: 'Links', sub: 'University & scholarship portals' }, +]; + +export function createDrawer({ onNavigate, onAbout }) { + const root = document.createElement('div'); + root.id = 'drawer-root'; + root.className = 'drawer-root'; + root.hidden = true; + root.innerHTML = ` +
+ `; + + let open = false; + + function setOpen(next) { + if (next === open) return; + open = next; + if (open) { + root.hidden = false; + paintUser(); + requestAnimationFrame(() => root.classList.add('is-open')); + } else { + root.classList.remove('is-open'); + setTimeout(() => { if (!open) root.hidden = true; }, 200); + } + } + + function paintUser() { + // Painted on open so the drawer always reflects current auth state. + const userEl = root.querySelector('[data-drawer-user]'); + const footEl = root.querySelector('[data-drawer-foot]'); + if (!userEl || !paintUser.state) return; + const { userName, email, signedIn } = paintUser.state; + if (signedIn) { + const initials = String(userName || '?').split(/\s+/).filter(Boolean).slice(0, 2) + .map((w) => w[0]).join('').toUpperCase(); + userEl.innerHTML = ` +
${ui.esc(initials)}
+
+
${ui.esc(userName || 'Student')}
+
${ui.esc(email || '')}
+
`; + footEl.innerHTML = 'Signed in — same Firebase account as the website'; + } else { + userEl.innerHTML = ` +
${ui.icon('user')}
+
+
Browsing as guest
+
Sign in from the Profile tab
+
`; + footEl.innerHTML = 'Sign-in, saves and uploads stay inside the app'; + } + } + + /** app.js refreshes this snapshot whenever auth changes or drawer opens. */ + paintUser.state = { signedIn: false, userName: '', email: '' }; + + root.addEventListener('click', (e) => { + if (e.target.closest('[data-drawer-dismiss]') || e.target.closest('.drawer-x')) { + setOpen(false); + return; + } + const item = e.target.closest('.drawer-item'); + if (!item) return; + setOpen(false); + if (item.dataset.kind === 'about') { + if (onAbout) onAbout(); + return; + } + if (onNavigate) { + onNavigate({ kind: item.dataset.kind, view: item.dataset.view, params: {} }); + } + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && open) setOpen(false); + }); + + return { + el: root, + open: () => setOpen(true), + close: () => setOpen(false), + /** Consume a hardware-back press: true when the drawer was open and got closed. */ + closeIfOpen() { + if (!open) return false; + setOpen(false); + return true; + }, + isOpen() { return open; }, + setAuthState(state) { paintUser.state = state || paintUser.state; if (open) paintUser(); }, + }; +} diff --git a/android-app/www/js/linkdata.js b/android-app/www/js/linkdata.js new file mode 100644 index 0000000..193ae71 --- /dev/null +++ b/android-app/www/js/linkdata.js @@ -0,0 +1,56 @@ +/** + * DSMNRU PYQ Android — curated link dataset for the in-app Links screen. + * + * Verbatim categories from the website's Links page (links.html) so both + * surfaces show the same destinations — the data is static, so it ships with + * the app: ZERO network requests, no new Worker endpoint, no duplication of + * any backend. Every URL is a genuinely external university/government + * portal — none points at the DSMNRU PYQ website (enforced by a unit test). + */ + +export const LINK_CATEGORIES = [ + { + id: 'student', + title: 'Student & academic portals', + icon: 'user', + links: [ + { title: 'Samarth Student Portal', url: 'https://dsmru.samarth.edu.in/index.php/site/login', icon: 'user', description: 'Access your student profile, courses, and academic records.' }, + { title: 'Result Portal', url: 'https://dsmru.up.nic.in/main/User/results.aspx', icon: 'eye', description: 'Check your semester and examination results.' }, + { title: 'Backup Result Portal', url: 'https://dsmnru.ac.in/Results', icon: 'eye', description: 'Check your semester and examination results. University + affiliated colleges.' }, + { title: 'Notice Portal', url: 'https://dsmru.up.nic.in/main/User/Notices.aspx', icon: 'flag', description: 'Official notices, circulars, and announcements.' }, + { title: 'DSMNRU ERP Portal', url: 'https://dsmnruerp.in/', icon: 'bank', description: "University's Enterprise Resource Planning system." }, + ], + }, + { + id: 'admissions', + title: 'Admissions & university life', + icon: 'bank', + links: [ + { title: 'Admission Portal', url: 'https://dsmnru.ac.in/', icon: 'bank', description: 'Main portal for new student admissions and information.' }, + { title: 'Merit/Counselling Notices', url: 'https://dsmnru.ac.in/AdmissionResult', icon: 'star', description: 'Merit lists and counselling schedules for admissions.' }, + { title: 'Convocation Portal', url: 'https://dsmru.samarth.edu.in/convocation', icon: 'courses', description: 'Information and registration for convocation ceremonies.' }, + { title: 'Alumni Portal', url: 'https://dsmru.samarth.edu.in/alumni', icon: 'users', description: 'Connect with fellow alumni and stay updated with the university.' }, + ], + }, + { + id: 'scholarships', + title: 'Financial aid & scholarships', + icon: 'rupee', + links: [ + { title: 'UP Scholarship Portal', url: 'https://scholarship.up.gov.in/', icon: 'rupee', description: 'Apply for state-level scholarships from the UP Government.' }, + { title: 'National Scholarship Portal', url: 'https://scholarships.gov.in/', icon: 'star', description: 'Centralized portal for various national-level scholarships.' }, + ], + }, + { + id: 'admin', + title: 'Administrative & other portals', + icon: 'shield', + links: [ + { title: 'Samarth Administration Portal', url: 'https://dsmru.samarth.ac.in/index.php/site/login', icon: 'bank', description: 'Portal for university administrative staff and services.' }, + { title: 'Grievance Portal', url: 'https://dsmru.samarth.ac.in/index.php/pgportal/grievance-public/public', icon: 'flag', description: 'Submit and track grievances and complaints.' }, + { title: 'Company Registration', url: 'https://dsmru.samarth.ac.in/index.php/training/company-profile-requests/register', icon: 'briefcase', description: 'For companies to register for campus placements and training.' }, + ], + }, +]; + +export const SITE_LINK_COUNT = LINK_CATEGORIES.reduce((n, c) => n + c.links.length, 0); diff --git a/android-app/www/js/native.js b/android-app/www/js/native.js index e365c5d..1143e04 100644 --- a/android-app/www/js/native.js +++ b/android-app/www/js/native.js @@ -3,9 +3,14 @@ * (`DsmnruApp`, implemented in Java under android/app/src/main/java). * * It keeps the app genuinely "Android": - * - openExternal(): hand PDF/host links to the system (browser, Drive, PDF - * readers) — same destination as the website's window.open, no duplicate - * PDF storage, no app-private download of everything. + * - pdfViewer(): the in-app PDF screen (native PdfRenderer + zoom/scroll, + * progress and error states) — the FIRST thing "Open PDF" tries. + * - googleSignIn(): the device's Google account chooser (Credential + * Manager) → Google ID token for Firebase sign-in — no browser, no popup, + * no website hand-off. + * - openExternal(): hand genuinely-external links (university portals, + * Drive landing pages, the explicitly-chosen website) to the system — + * same destination as the website's window.open, no duplicate storage. * - download(): direct .pdf links via Android's system DownloadManager into * the public Downloads folder (system notification, resumable, permission * free) — only when the user explicitly taps Download. @@ -64,9 +69,58 @@ export const native = { } }, - /** Open a direct PDF URL — Android resolves a PDF-capable app or browser. */ - async openPdf(url) { - return this.openExternal(url); + /** + * Open a PDF INSIDE the app, in the native viewer screen + * (PdfViewerActivity: progress, zoom/scroll, back navigation, retry and + * open-external fallbacks). The direct `file`/`server1`/`file2`/`server2` + * URL is fetched by Android itself — no Cloudflare Worker traffic, no + * permanent download (the file lives in the system cache dir and is + * deleted when the viewer closes). + * + * Returns { ok: true } when the native viewer took over, or + * { ok: false, reason } when the environment has no native layer (plain + * browser preview / unit harness) so the caller can fall back to + * openExternal — never to the DSMNRU website. + */ + async pdfViewer(url, title) { + const safe = httpUrl(url); + if (!safe) return { ok: false, reason: 'invalid-url' }; + if (isNative() && typeof bridge.pdfView === 'function') { + try { + await bridge.pdfView({ url: safe, title: String(title || 'Paper') }); + return { ok: true }; + } catch (err) { + return { ok: false, reason: String((err && err.message) || err || 'viewer-error') }; + } + } + return { ok: false, reason: 'unavailable' }; + }, + + /** + * Android-native Google sign-in, first step: the device's own Google + * account chooser (Credential Manager) returns a Google ID token which the + * caller exchanges with Firebase (auth.signInWithGoogleCredential). + * Resolves { ok: true, idToken, nonce } on success, or + * { ok: false, code, message } with one of the machine codes below — + * notably GOOGLE_SIGNIN_NOT_CONFIGURED when this APK build lacks the + * Google client-ID configuration (never a website redirect). + */ + async googleSignIn(nonce) { + if (isNative() && typeof bridge.googleSignIn === 'function') { + try { + const res = await bridge.googleSignIn({ nonce: String(nonce || '') }); + if (res && res.idToken) return { ok: true, idToken: res.idToken, nonce: res.nonce || nonce || '' }; + return { ok: false, code: 'GOOGLE_SIGNIN_UNAVAILABLE', message: 'Empty Google credential' }; + } catch (err) { + const msg = String((err && err.message) || err || 'Google sign-in failed'); + let code = 'GOOGLE_SIGNIN_UNAVAILABLE'; + if (/GOOGLE_SIGNIN_NOT_CONFIGURED/.test(msg)) code = 'GOOGLE_SIGNIN_NOT_CONFIGURED'; + else if (/GOOGLE_SIGNIN_CANCELLED/.test(msg)) code = 'GOOGLE_SIGNIN_CANCELLED'; + else if (/GOOGLE_SIGNIN_NO_ACCOUNT/.test(msg)) code = 'GOOGLE_SIGNIN_NO_ACCOUNT'; + return { ok: false, code, message: msg }; + } + } + return { ok: false, code: 'GOOGLE_SIGNIN_UNAVAILABLE', message: 'Native Google sign-in not available here' }; }, /** Save a direct .pdf to the device Downloads via DownloadManager. */ diff --git a/android-app/www/js/toolscore.js b/android-app/www/js/toolscore.js new file mode 100644 index 0000000..ca15e83 --- /dev/null +++ b/android-app/www/js/toolscore.js @@ -0,0 +1,148 @@ +/** + * DSMNRU PYQ Android — study-tools core (pure, DOM-free, unit-tested). + * + * Ports of the website's client-side tools (script.js IIFE modules) so the + * in-app Study Tools screen computes exactly the same numbers with the same + * storage keys — 100% on-device, zero network: + * + * • CGPA/SGPA calculator — same 10-point grade map (O..F), credit-weighted + * • Attendance tracker — same record shape { subject, records: { 'YYYY-MM-DD': 'P'|'A' } } + * and the same 75% warning threshold + * • Study planner — same task shape { id, title, due, completed } + */ + +// ── CGPA / SGPA (website gradeMap parity) ────────────────────────────── + +export const GRADE_POINTS = { O: 10, 'A+': 9, A: 8, 'B+': 7, B: 6, C: 5, D: 4, F: 0 }; + +/** + * Credit-weighted GPA over rows of { grade, credits }. + * Rows with unknown grades count 0 points (website behaviour); credits of 0 + * are skipped so they cannot divide the total. + * @returns { totalCredits, totalPoints, gpa } + */ +export function computeGpa(rows) { + const list = Array.isArray(rows) ? rows : []; + let totalPoints = 0; + let totalCredits = 0; + for (const row of list) { + const credits = Number(row && row.credits) || 0; + const grade = String(row && row.grade || '').trim().toUpperCase(); + const points = Object.prototype.hasOwnProperty.call(GRADE_POINTS, grade) ? GRADE_POINTS[grade] : 0; + if (credits <= 0) continue; + totalPoints += points * credits; + totalCredits += credits; + } + const gpa = totalCredits ? totalPoints / totalCredits : 0; + return { totalCredits, totalPoints, gpa }; +} + +export function gradeLabel(gpa) { + const g = Number(gpa) || 0; + if (g >= 9) return 'Outstanding'; + if (g >= 8) return 'Excellent'; + if (g >= 7) return 'Very good'; + if (g >= 6) return 'Good'; + if (g >= 5) return 'Pass'; + if (g > 0) return 'Needs work'; + return '—'; +} + +// ── Attendance (website record shape + threshold parity) ─────────────── + +export const ATTENDANCE_WARNING_THRESHOLD = 75; + +export function todayISO(now = new Date()) { + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; +} +export function monthOf(iso) { + return String(iso || '').slice(0, 7); +} + +/** Percent present for one subject record map ({} → 0). */ +export function attendancePercent(records) { + const map = records && typeof records === 'object' ? records : {}; + const total = Object.keys(map).length; + if (!total) return 0; + const present = Object.keys(map).filter((k) => map[k] === 'P').length; + return Math.round((present / total) * 100); +} + +/** { present, total, pct } for one subject restricted to a 'YYYY-MM' month. */ +export function attendanceMonthStats(records, month) { + const map = records && typeof records === 'object' ? records : {}; + const keys = Object.keys(map).filter((k) => String(k).startsWith(month)); + const present = keys.filter((k) => map[k] === 'P').length; + return { present, total: keys.length, pct: keys.length ? Math.round((present / keys.length) * 100) : 0 }; +} + +/** Overall + per-subject warnings at the website's 75% threshold. */ +export function attendanceSummary(subjects, { now = new Date() } = {}) { + const list = Array.isArray(subjects) ? subjects : []; + const month = monthOf(todayISO(now)); + let low = 0; + let near = 0; + for (const s of list) { + const pct = attendanceMonthStats(s && s.records, month).pct || attendancePercent(s && s.records); + if (pct && pct < ATTENDANCE_WARNING_THRESHOLD) { + low++; + if (pct >= ATTENDANCE_WARNING_THRESHOLD - 5) near++; + } + } + return { subjects: list.length, low, near }; +} + +// ── Study planner ────────────────────────────────────────────────────── + +/** { total, completed, pct } over planner tasks. */ +export function plannerStats(tasks) { + const list = Array.isArray(tasks) ? tasks : []; + const total = list.length; + const completed = list.filter((t) => t && t.completed).length; + return { total, completed, pct: total ? Math.round((completed / total) * 100) : 0 }; +} + +/** Sort tasks: incomplete first, then by due date (undated last), then newest. */ +export function sortPlannerTasks(tasks) { + const list = Array.from(Array.isArray(tasks) ? tasks : []); + return list.sort((a, b) => { + const ac = !!(a && a.completed); + const bc = !!(b && b.completed); + if (ac !== bc) return ac ? 1 : -1; + const ad = a && a.due ? Date.parse(a.due) : NaN; + const bd = b && b.due ? Date.parse(b.due) : NaN; + if (!Number.isNaN(ad) && !Number.isNaN(bd)) return ad - bd; + if (!Number.isNaN(ad)) return -1; + if (!Number.isNaN(bd)) return 1; + return (b && b.id || 0) - (a && a.id || 0); + }); +} + +// ── local persistence (same keys as the website modules) ────────────── + +function readJson(storage, key, fallback) { + if (!storage) return fallback; + try { + const raw = storage.getItem(key); + const parsed = raw ? JSON.parse(raw) : null; + return parsed === null ? fallback : parsed; + } catch { return fallback; } +} + +function writeJson(storage, key, value) { + if (!storage) return; + try { storage.setItem(key, JSON.stringify(value)); } catch { /* quota */ } +} + +export const KEYS = { + planner: 'dsmnruStudyPlanner', + attendance: 'dsmnruAttendance', + cgpaLast: 'dsmnruCgpaLast', +}; + +export function loadPlannerTasks(storage) { return readJson(storage, KEYS.planner, []); } +export function savePlannerTasks(storage, tasks) { writeJson(storage, KEYS.planner, Array.isArray(tasks) ? tasks : []); } +export function loadAttendance(storage) { return readJson(storage, KEYS.attendance, []); } +export function saveAttendance(storage, subjects) { writeJson(storage, KEYS.attendance, Array.isArray(subjects) ? subjects : []); } +export function loadLastCgpa(storage) { return readJson(storage, KEYS.cgpaLast, null); } +export function saveLastCgpa(storage, payload) { writeJson(storage, KEYS.cgpaLast, payload); } diff --git a/android-app/www/js/ui.js b/android-app/www/js/ui.js index d22eebe..ae69ed8 100644 --- a/android-app/www/js/ui.js +++ b/android-app/www/js/ui.js @@ -43,6 +43,16 @@ const ICONS = { users: '', flag: '', logout: '', + menu: '', + link: '', + calc: '', + calcheck: '', + tasks: '', + send: '', + rupee: '', + bank: '', + shield: '', + briefcase: '', mail: '', google: '', filter: '', @@ -93,13 +103,25 @@ export function toast(message, kind = 'ok') { // ── bottom sheet ─────────────────────────────────────────────────────── let openSheetEl = null; +/** + * Close the open bottom sheet, if any. + * @returns true when a sheet was actually open (so callers can treat the + * event — e.g. the Android back button — as consumed). + */ export function closeSheet() { if (openSheetEl) { const el = openSheetEl; openSheetEl = null; el.classList.remove('is-open'); setTimeout(() => el.remove(), 180); + return true; } + return false; +} + +/** true while a bottom sheet is showing (back-button precedence checks). */ +export function sheetIsOpen() { + return !!openSheetEl; } /** diff --git a/android-app/www/js/uploadcore.js b/android-app/www/js/uploadcore.js new file mode 100644 index 0000000..7185084 --- /dev/null +++ b/android-app/www/js/uploadcore.js @@ -0,0 +1,340 @@ +/** + * DSMNRU PYQ Android — upload feature core (pure, DOM-free, unit-tested). + * + * A faithful port of the website's public upload workflow rules + * (script.js → userUploadForm) so the in-app Upload Paper screen behaves + * EXACTLY like the website against the SAME backends: + * + * 1. Same field validation (title/name/email + file-type rules). + * 2. Same reward identity: email = trim + lowercase (points.js parity). + * 3. Same client-side throttle (45 s gap, 5 uploads per 6 h window, + * `dsmnruUploadThrottle` localStorage key — the site's own guard). + * 4. Same metadata document written to the SAME `pendingUploads` + * collection (Firestore rules validate the shape server-side). + * + * The image→PDF conversion avoids the website's jsPDF CDN dependency with a + * minimal PDF 1.4 writer that embeds already-encoded JPEGs (/DCTDecode) on + * A4 pages — zero third-party code, byte-accurate xref offsets. + */ + +export const MAX_FINAL_PDF_SIZE = 10 * 1024 * 1024; // 10 MB, like the website +export const GOFILE_SERVERS_URL = 'https://api.gofile.io/servers'; + +// ── reward identity (points.js parity) ───────────────────────────────── + +export function normalizeRewardEmail(raw) { + if (raw === null || raw === undefined) return ''; + return String(raw).trim().toLowerCase(); +} + +export function isValidRewardEmail(raw) { + const email = normalizeRewardEmail(raw); + if (!email || email.length > 160) return false; + return /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/.test(email); +} + +// ── file classification ──────────────────────────────────────────────── + +export function isPdfFile(file) { + const type = String(file && file.type || '').toLowerCase(); + const name = String(file && file.name || '').toLowerCase(); + return type === 'application/pdf' || /\.pdf$/.test(name); +} + +export function isImageFile(file) { + const type = String(file && file.type || '').toLowerCase(); + const name = String(file && file.name || '').toLowerCase(); + return type.startsWith('image/') || /\.(jpe?g|png|webp|gif|bmp)$/.test(name); +} + +/** + * Split a FileList into { pdfs, images, unsupported } — the same buckets the + * website validates before anything is uploaded. + */ +export function classifyFiles(files) { + const list = Array.from(files || []); + return { + pdfs: list.filter(isPdfFile), + images: list.filter((f) => isImageFile(f) && !isPdfFile(f)), + unsupported: list.filter((f) => !isPdfFile(f) && !isImageFile(f)), + }; +} + +// ── validation (mirrors the website's alerts) ────────────────────────── + +/** + * Validate one upload attempt BEFORE any network traffic. + * @returns { ok: true, email } or { ok: false, message } + */ +export function validateUploadAttempt({ title, studentName, rawEmail, files, throttleState }) { + const name = String(studentName || '').trim(); + const rewardEmail = normalizeRewardEmail(rawEmail); + + if (!name) return { ok: false, message: 'Please enter your name.' }; + if (name.length < 2 || name.length > 80) { + return { ok: false, message: 'Name must be between 2 and 80 characters.' }; + } + const cleanTitle = String(title || '').trim(); + if (cleanTitle.length < 3 || cleanTitle.length > 200) { + return { ok: false, message: 'Title must be between 3 and 200 characters.' }; + } + if (!rewardEmail) { + return { ok: false, message: 'Please enter your email — it is used to credit your contribution points.' }; + } + if (!isValidRewardEmail(rewardEmail)) { + return { ok: false, message: 'Please enter a valid email address.' }; + } + + const { pdfs, images, unsupported } = classifyFiles(files); + if (!files.length) { + return { ok: false, message: 'Please select a PDF or images.' }; + } + if (unsupported.length) { + return { ok: false, message: 'Only PDF or image files are allowed.' }; + } + if (!pdfs.length && !images.length) { + return { ok: false, message: 'Please select one PDF or one or more images.' }; + } + if (pdfs.length > 1) { + return { ok: false, message: 'Please select only one PDF file.' }; + } + if (pdfs.length === 1 && images.length > 0) { + return { ok: false, message: 'Please upload either one PDF or multiple images, not both together.' }; + } + if (pdfs.length === 1 && pdfs[0].size > MAX_FINAL_PDF_SIZE) { + return { ok: false, message: 'PDF size exceeds 10MB. Please upload a smaller PDF.' }; + } + + if (throttleState && !throttleState.allowed) { + return { ok: false, message: throttleState.message }; + } + return { ok: true, email: rewardEmail }; +} + +// ── client-side throttle (same constants + key as the website) ───────── + +export const UPLOAD_THROTTLE_KEY = 'dsmnruUploadThrottle'; +export const UPLOAD_THROTTLE_MIN_GAP_MS = 45 * 1000; +export const UPLOAD_THROTTLE_WINDOW_MS = 6 * 60 * 60 * 1000; +export const UPLOAD_THROTTLE_MAX_PER_WINDOW = 5; + +/** Read+prune the throttle log. `storage` is localStorage-compatible or null. */ +export function readThrottleLog(storage, nowMs) { + if (!storage) return []; + let raw = null; + try { raw = storage.getItem(UPLOAD_THROTTLE_KEY); } catch { return []; } + let parsed = []; + try { parsed = raw ? JSON.parse(raw) : []; } catch { parsed = []; } + if (!Array.isArray(parsed)) parsed = []; + return parsed + .map((n) => Number(n)) + .filter((n) => Number.isFinite(n) && nowMs - n < UPLOAD_THROTTLE_WINDOW_MS) + .sort((a, b) => a - b); +} + +export function getUploadThrottleState(storage, nowMs) { + const log = readThrottleLog(storage, nowMs); + if (log.length >= UPLOAD_THROTTLE_MAX_PER_WINDOW) { + const waitMinutes = Math.max(1, Math.ceil((UPLOAD_THROTTLE_WINDOW_MS - (nowMs - log[0])) / 60000)); + return { + allowed: false, + log, + message: `Upload limit reached (${UPLOAD_THROTTLE_MAX_PER_WINDOW} per 6 hours). Try again in about ${waitMinutes} minute${waitMinutes === 1 ? '' : 's'}.`, + }; + } + if (log.length && nowMs - log[log.length - 1] < UPLOAD_THROTTLE_MIN_GAP_MS) { + const waitSec = Math.ceil((UPLOAD_THROTTLE_MIN_GAP_MS - (nowMs - log[log.length - 1])) / 1000); + return { + allowed: false, + log, + message: `Please wait ${waitSec}s between uploads.`, + }; + } + return { allowed: true, log }; +} + +/** Persist one successful upload into the throttle log (website parity). */ +export function recordUploadThrottle(storage, nowMs) { + if (!storage) return; + const log = readThrottleLog(storage, nowMs); + log.push(nowMs); + try { + storage.setItem(UPLOAD_THROTTLE_KEY, JSON.stringify(log.slice(-UPLOAD_THROTTLE_MAX_PER_WINDOW))); + } catch { /* quota — throttle degrades quietly, server rules stay authoritative */ } +} + +// ── gofile upload (same service the website uses) ────────────────────── + +/** Pick the first gofile upload server. Resolves to an https upload URL. */ +export async function fetchGofileUploadUrl(fetchImpl = (...a) => fetch(...a)) { + const res = await fetchImpl(GOFILE_SERVERS_URL); + if (!res.ok) throw new Error('Failed to get upload server'); + const data = await res.json().catch(() => null); + const servers = data && data.status === 'ok' && data.data && Array.isArray(data.data.servers) + ? data.data.servers + : (data && data.data && data.data.servers); + if (!Array.isArray(servers) || !servers.length || !servers[0] || !servers[0].name) { + throw new Error('No upload servers available'); + } + return `https://${servers[0].name}.gofile.io/uploadFile`; +} + +// ── pendingUploads metadata document (Firestore REST shape) ──────────── + +/** + * Build the exact `pendingUploads` document the website writes, in Firestore + * REST `fields` form. Review state / points fields are intentionally absent — + * the Firestore rules reject any submission that carries them. + */ +export function buildPendingUploadDoc({ title, course = '', semester = '', studentName, studentCourse = '', studentEmail, userId = '', fileName, downloadUrl, fileSize = 0, createdAtIso }) { + const s = (v) => ({ stringValue: String(v ?? '') }); + return { + fields: { + title: s(title), + course: s(course), + semester: s(semester), + studentName: s(studentName), + studentCourse: s(studentCourse), + // Normalized reward identity — admin approval credits +10 points here. + studentEmail: s(studentEmail), + email: s(studentEmail), + userId: s(userId), + fileName: s(fileName), + downloadUrl: s(downloadUrl), + fileSize: { integerValue: String(Math.max(0, Math.round(Number(fileSize) || 0))) }, + uploadedAt: { timestampValue: createdAtIso || new Date().toISOString() }, + status: { stringValue: 'pending' }, + }, + }; +} + +/** Firestore REST insert URL (auto document id, like collection.add). */ +export function pendingUploadsUrl(projectId = 'dsmnru-data') { + return `https://firestore.googleapis.com/v1/projects/${projectId}/databases/(default)/documents/pendingUploads`; +} + +// ── minimal PDF writer for image submissions (DCTDecode embedding) ───── + +const A4 = { width: 595.28, height: 841.89 }; +const PAGE_MARGIN = 20; + +/** + * Read pixel dimensions from a JPEG byte stream (SOF0/SOF1/SOF2/SOF9-15). + * @returns { width, height } or null when the stream is not a JPEG. + */ +export function jpegDimensions(bytes) { + const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []); + if (u8.length < 4 || u8[0] !== 0xFF || u8[1] !== 0xD8) return null; + let i = 2; + while (i + 9 < u8.length) { + if (u8[i] !== 0xFF) { i++; continue; } + const marker = u8[i + 1]; + if (marker === 0xD8 || (marker >= 0xD0 && marker <= 0xD9)) { i += 2; continue; } + const len = (u8[i + 2] << 8) | u8[i + 3]; + const isSof = (marker >= 0xC0 && marker <= 0xC3) || (marker >= 0xC5 && marker <= 0xC7) + || (marker >= 0xC9 && marker <= 0xCB) || (marker >= 0xCD && marker <= 0xCF); + if (isSof) { + return { + height: (u8[i + 5] << 8) | u8[i + 6], + width: (u8[i + 7] << 8) | u8[i + 8], + }; + } + i += 2 + len; + } + return null; +} + +class ByteWriter { + constructor() { this.chunks = []; this.length = 0; } + push(chunk) { + const u8 = typeof chunk === 'string' + ? new TextEncoder().encode(chunk) + : (chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)); + this.chunks.push(u8); + this.length += u8.length; + } + /** 10-digit zero-padded xref offset */ + offset10() { return String(this.length).padStart(10, '0'); } + concat() { + const out = new Uint8Array(this.length); + let at = 0; + for (const c of this.chunks) { out.set(c, at); at += c.length; } + return out; + } +} + +/** + * Assemble a single-page-per-image PDF from JPEG streams. + * @param {Array<{jpeg: Uint8Array, width: number, height: number}>} pages + * @returns {Uint8Array} the complete PDF file bytes + */ +export function assemblePdfFromJpegs(pages) { + if (!Array.isArray(pages) || !pages.length) throw new Error('No pages to assemble'); + const w = new ByteWriter(); + const objectOffsets = []; // object number → byte offset + const n = pages.length; + + // Header + binary comment line (high bytes so tools sniff the file as binary). + w.push(new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34, 0x0A, 0x25, 0xE2, 0xE3, 0xCF, 0xD3, 0x0A])); + + const writeObj = (num, body) => { + objectOffsets[num] = w.offset10(); + w.push(`${num} 0 obj\n${body}\nendobj\n`); + }; + + writeObj(1, '<< /Type /Catalog /Pages 2 0 R >>'); + + const kids = pages.map((_, i) => `${3 + i * 3} 0 R`).join(' '); + writeObj(2, `<< /Type /Pages /Kids [${kids}] /Count ${n} >>`); + + pages.forEach((p, i) => { + const pageObj = 3 + i * 3; + const contentObj = pageObj + 1; + const imageObj = pageObj + 2; + + // Fit the image inside the content box, centered (website parity). + const boxW = A4.width - PAGE_MARGIN * 2; + const boxH = A4.height - PAGE_MARGIN * 2; + const fit = Math.min(boxW / p.width, boxH / p.height); + const drawW = p.width * fit; + const drawH = p.height * fit; + const x = (A4.width - drawW) / 2; + const y = (A4.height - drawH) / 2; + const content = `q\n${drawW.toFixed(2)} 0 0 ${drawH.toFixed(2)} ${x.toFixed(2)} ${y.toFixed(2)} cm\n/Im${i} Do\nQ\n`; + + writeObj(pageObj, `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${A4.width.toFixed(2)} ${A4.height.toFixed(2)}] ` + + `/Resources << /XObject << /Im${i} ${imageObj} 0 R >> >> /Contents ${contentObj} 0 R >>`); + + objectOffsets[contentObj] = w.offset10(); + w.push(`${contentObj} 0 obj\n<< /Length ${content.length} >>\nstream\n${content}endstream\nendobj\n`); + + objectOffsets[imageObj] = w.offset10(); + w.push(`${imageObj} 0 obj\n<< /Type /XObject /Subtype /Image /Width ${p.width} /Height ${p.height} ` + + `/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${p.jpeg.length} >>\nstream\n`); + w.push(p.jpeg); + w.push('\nendstream\nendobj\n'); + }); + + const xrefStart = w.offset10(); + const size = 3 + n * 3; + let xref = `xref\n0 ${size}\n0000000000 65535 f \n`; + for (let num = 1; num < size; num++) { + xref += `${objectOffsets[num] || '0000000000'} 00000 n \n`; + } + xref += `trailer\n<< /Size ${size} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF`; + w.push(xref); + return w.concat(); +} + +/** + * Quality ladder used when re-encoding photos to fit the 10 MB cap — + * same idea as the website's attempts list. + */ +export const IMAGE_ENCODE_ATTEMPTS = [ + { maxDimension: 2000, quality: 0.9 }, + { maxDimension: 1700, quality: 0.82 }, + { maxDimension: 1500, quality: 0.76 }, + { maxDimension: 1300, quality: 0.7 }, + { maxDimension: 1100, quality: 0.64 }, + { maxDimension: 900, quality: 0.58 }, +]; diff --git a/android-app/www/js/views/about.js b/android-app/www/js/views/about.js new file mode 100644 index 0000000..62d184e --- /dev/null +++ b/android-app/www/js/views/about.js @@ -0,0 +1,65 @@ +/** + * DSMNRU PYQ Android — About screen. + * + * In-app app identity/data-source summary (pushed from the drawer). It keeps + * the audit honest: what runs in-app, and the short, deliberate list of + * genuinely external destinations. + */ + +import { SITE_ORIGIN, WORKER_ORIGIN } from '../api.js'; +import * as ui from '../ui.js'; + +const APP_VERSION = '1.3.0'; + +export default async function renderAbout(root, ctx) { + ctx.setHeader({ title: 'About', brand: false }); + + root.innerHTML = ` +
+
+ +

DSMNRU PYQ

+

Dedicated Android app · v${APP_VERSION}
+ Dr. Shakuntala Misra National Rehabilitation University
previous-year question-paper archive

+
+ +
+

${ui.icon('check')} Fully inside this app

+
    +
  • Browse, search, filters and course pages — served by the shared Cloudflare Worker API
  • +
  • In-app PDF viewer (zoom/scroll) — reads the papers' original hosts directly
  • +
  • Upload paper — same storage + review queue as the website
  • +
  • Study tools — CGPA, attendance, planner run 100% on this device
  • +
  • Contributors and Links screens
  • +
  • Sign-in — email/password and native Google, same Firebase project as the website
  • +
  • Saved papers & history — stored only on this device
  • +
+
+ +
+

${ui.icon('open')} Genuinely external destinations

+
    +
  • University/government portals on the Links screen
  • +
  • Paper hosts that cannot render in-app (e.g. Drive landing pages)
  • +
  • The full website & moderation tools: ${ui.esc(SITE_ORIGIN.replace('https://', ''))}
  • +
  • Email verification / password-reset links sent by Firebase
  • +
+
+ +
+

${ui.icon('info')} Data sources

+
    +
  • Archive API: ${ui.esc(WORKER_ORIGIN.replace('https://', ''))} (KV-cached, one shared backend with the website)
  • +
  • Accounts & submissions: Firebase project dsmnru-data
  • +
  • No second database, no mirrored PDFs, no duplicate backend
  • +
+
+ +
+ +

© DSMNRU Academic Archive · explicit choice, not a fallback

+
+
`; + + root.querySelector('#about-web').addEventListener('click', () => ctx.native.openExternal(SITE_ORIGIN + '/')); +} diff --git a/android-app/www/js/views/contributors.js b/android-app/www/js/views/contributors.js new file mode 100644 index 0000000..ec7b82d --- /dev/null +++ b/android-app/www/js/views/contributors.js @@ -0,0 +1,99 @@ +/** + * DSMNRU PYQ Android — in-app Contributors screen. + * + * ONE cached Worker request (GET /api/contributors — the exact endpoint the + * website uses, KV-backed server-side) feeds the whole screen; the payload is + * persisted with a 24h fresh window and served stale-while-revalidate, so + * revisits and offline starts cost zero network and there is never a request + * per contributor. + */ + +import * as ui from '../ui.js'; + +export default async function renderContributors(root, ctx) { + const { api } = ctx; + ctx.setHeader({ title: 'Contributors', sub: 'the students behind the archive', brand: false }); + + root.innerHTML = ` +
+
+ ${ctx.ui.icon('users')} +
Every paper here was shared by a student. Approvals earn 10 contribution points per paper.
+
+
${ui.skeletonRows(5)}
+
`; + + const listHost = root.querySelector('#contrib-list'); + + try { + const res = await api.contributors(); + const items = Array.isArray(res.data) ? res.data : []; + + listHost.innerHTML = ''; + const grid = document.createElement('div'); + grid.className = 'contrib-grid'; + + items.forEach((c, i) => { + const card = document.createElement('div'); + card.className = 'card card-pad contrib-card'; + // Deterministic pastel from the name so the avatar is stable per person. + const hues = [168, 200, 45, 320, 265, 20, 120, 240]; + const hue = hues[i % hues.length]; + const initial = String(c.name || '?').trim().charAt(0).toUpperCase() || '?'; + card.innerHTML = ` +
${ui.esc(c.avatar || initial)}
+
+ ${ui.esc(c.name || 'Contributor')} + ${ui.esc(c.role || 'Paper contributor')} +
`; + grid.appendChild(card); + }); + + // "Join" card routes to the IN-APP upload screen — never the website. + const join = document.createElement('button'); + join.type = 'button'; + join.className = 'card card-pad contrib-card contrib-join'; + join.innerHTML = ` +
${ui.icon('upload')}
+
+ Join them! + Upload a paper to earn points +
+ ${ui.icon('chevron')}`; + join.addEventListener('click', () => ctx.router.go('upload')); + grid.appendChild(join); + + listHost.appendChild(grid); + if (res.stale) { + const note = document.createElement('p'); + note.className = 'server-note'; + note.innerHTML = ui.stalePill('Offline copy of the contributor list'); + listHost.appendChild(note); + } + if (!items.length) { + const empty = ui.stateBlock({ + iconName: 'users', + title: 'No contributors listed yet', + text: 'Be the first — upload a paper and your name will appear here after approval.', + actionLabel: 'Upload a paper', + onAction: () => ctx.router.go('upload'), + }); + listHost.innerHTML = ''; + listHost.appendChild(empty); + } + + ctx.setRefresh(() => { + api.contributors({ force: true }).then(() => renderContributors(root, ctx)).catch(() => ui.toast('Still offline', 'err')); + }); + } catch (err) { + listHost.innerHTML = ''; + listHost.appendChild(ui.stateBlock({ + iconName: ctx.state.online ? 'alert' : 'wifiOff', + tone: 'error', + title: "Couldn't load contributors", + text: ctx.state.online ? String(err.message || err) : 'You appear to be offline and no cached copy exists yet.', + actionLabel: 'Retry', + onAction: () => renderContributors(root, ctx), + })); + } +} diff --git a/android-app/www/js/views/home.js b/android-app/www/js/views/home.js index 8b3a773..4fdca74 100644 --- a/android-app/www/js/views/home.js +++ b/android-app/www/js/views/home.js @@ -8,8 +8,6 @@ * history), recent/trending rails and shortcuts are app-native sections. */ -import { SITE_ORIGIN } from '../api.js'; - export default async function renderHome(root, ctx) { const { ui, api, store } = ctx; @@ -184,11 +182,13 @@ export default async function renderHome(root, ctx) { const row = document.createElement('div'); row.className = 'shortcut-row'; row.style.marginTop = '10px'; + // All shortcuts open IN-APP screens (same features the drawer offers) — + // the website is never involved. const items = [ - { icon: 'upload', label: 'Upload a paper', url: `${SITE_ORIGIN}/#upload-section` }, - { icon: 'tools', label: 'Study tools', url: `${SITE_ORIGIN}/tools.html` }, - { icon: 'users', label: 'Contributors', url: `${SITE_ORIGIN}/contributors.html` }, - { icon: 'globe', label: 'Full website', url: SITE_ORIGIN + '/' }, + { icon: 'upload', label: 'Upload a paper', view: 'upload' }, + { icon: 'tools', label: 'Study tools', view: 'tools' }, + { icon: 'users', label: 'Contributors', view: 'contributors' }, + { icon: 'link', label: 'Links', view: 'links' }, ]; row.innerHTML = items.map((s, i) => ` - + +
`; more.addEventListener('click', (e) => { const b = e.target.closest('[data-act]'); if (!b) return; + if (b.dataset.act === 'report') openReportSheet(); if (b.dataset.act === 'web') native.openExternal(siteUrl); - if (b.dataset.act === 'report') native.openExternal(siteUrl); }); stack.appendChild(more); + /** + * In-app "Report a broken link" — writes to the SAME `feedback` collection + * with the SAME fields the website's report modal uses + * (type='broken_link', status='new'), via one Firestore REST insert with + * the user's own ID token. Gate = verified sign-in (the Firestore rule). + */ + function openReportSheet() { + const start = () => { + const node = document.createElement('div'); + node.innerHTML = ` +

Reporting: ${ui.esc(title)}${course ? ` · ${ui.esc(course)}` : ''}

+
+ + +
+ +
+ + +
`; + const s = ui.sheet({ title: 'Report a broken link', content: node }); + node.querySelector('#rep-send').addEventListener('click', async (e2) => { + const btn = e2.currentTarget; + const details = node.querySelector('#rep-details').value.trim(); + const errEl = node.querySelector('[data-err]'); + if (details.length < 3) { + errEl.textContent = 'Please describe the problem (a few words is enough).'; + errEl.hidden = false; + return; + } + btn.disabled = true; + btn.textContent = 'Sending…'; + try { + const user = auth.current(); + const fields = { + type: { stringValue: 'broken_link' }, + title: { stringValue: title }, + course: { stringValue: course || '' }, + details: { stringValue: details }, + email: { stringValue: user && user.email || '' }, + userId: { stringValue: user ? user.uid : '' }, + userEmail: { stringValue: user && user.email || '' }, + createdAt: { timestampValue: new Date().toISOString() }, + status: { stringValue: 'new' }, + }; + const headers = { 'Content-Type': 'application/json' }; + if (user && user.idToken) headers.Authorization = 'Bearer ' + user.idToken; + const res = await fetch( + `https://firestore.googleapis.com/v1/projects/dsmnru-data/databases/(default)/documents/feedback`, + { method: 'POST', headers, body: JSON.stringify({ fields }) }, + ); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error((body && body.error && body.error.message) || 'Could not send the report.'); + } + ui.closeSheet(); + ui.toast('Report sent — thank you!'); + } catch (err) { + errEl.textContent = String(err.message || err); + errEl.hidden = false; + btn.disabled = false; + btn.textContent = 'Send report'; + } + }); + }; + // The Firestore rule only allows verified users to create feedback docs. + ctx.requireAuth(start, 'Reporting needs a verified account (same rule as the website).'); + } + // Related papers — one filtered request like the website's related rail. const relHost = document.createElement('section'); relHost.id = 'paper-related'; diff --git a/android-app/www/js/views/profile.js b/android-app/www/js/views/profile.js index 5751fdd..cd8dbcc 100644 --- a/android-app/www/js/views/profile.js +++ b/android-app/www/js/views/profile.js @@ -8,7 +8,7 @@ * after a manual sign-in (see auth.js). */ -const APP_VERSION = '1.1.0'; +const APP_VERSION = '1.3.0'; export default async function renderProfile(root, ctx) { const { ui, auth, store, api, native } = ctx; @@ -125,12 +125,11 @@ export default async function renderProfile(root, ctx) { if (user) { items.push({ act: 'signout', icon: 'logout', label: 'Sign out', sub: 'Keeps your saved papers on this device' }); } else { - items.push({ act: 'google', icon: 'google', label: 'Google sign-in info', sub: 'Why Google uses a browser-only flow, and how to still use your account' }); + items.push({ act: 'google', icon: 'google', label: 'Sign in with Google', sub: 'Native account chooser — same Firebase account as the website' }); } if (user && user.admin) { items.push({ act: 'admin', icon: 'tools', label: 'Open admin panel', sub: 'Administration stays on the website — there is no second panel' }); } - items.push({ act: 'web', icon: 'globe', label: 'Open DSMNRU website', sub: 'Full site: uploads, comments, tools, contributor profile' }); items.push({ act: 'about', icon: 'info', label: `About this app · v${APP_VERSION}`, sub: 'Same backend as the website — dedicated Android interface' }); more.innerHTML = `
${items.map((it) => ` `).join('')}
`; @@ -147,14 +146,11 @@ export default async function renderProfile(root, ctx) { }); break; case 'google': { - import('../authui.js').then(({ googleInfoSheet }) => googleInfoSheet()); + import('../authui.js').then(({ startGoogleSignIn }) => startGoogleSignIn({})); break; } case 'admin': - case 'web': - native.openExternal(b.dataset.act === 'admin' - ? 'https://dsmnru-pyq.netlify.app/admin.html' - : 'https://dsmnru-pyq.netlify.app/'); + native.openExternal('https://dsmnru-pyq.netlify.app/admin.html'); break; case 'about': ui.sheet({ diff --git a/android-app/www/js/views/tools.js b/android-app/www/js/views/tools.js new file mode 100644 index 0000000..6993bd7 --- /dev/null +++ b/android-app/www/js/views/tools.js @@ -0,0 +1,398 @@ +/** + * DSMNRU PYQ Android — in-app Study Tools screen. + * + * The website's student tools as native-feeling app cards + bottom sheets. + * Every tool runs ENTIRELY on-device (they are client-side calculators on + * the website too) — ZERO Worker/API traffic, no WebView, no website page: + * + * • CGPA/SGPA calculator — same 10-point scale, saved history locally + * • Attendance tracker — per-subject day marks, month %, 75% warnings + * • Study planner — tasks with due dates + progress + * • Request a tool → the maintainers' Telegram bot (genuinely + * external destination — not a PYQ feature) + */ + +import * as ui from '../ui.js'; +import * as tools from '../toolscore.js'; + +const storage = (typeof localStorage !== 'undefined') ? localStorage : null; + +export default async function renderTools(root, ctx) { + ctx.setHeader({ title: 'Study tools', sub: 'run on this device', brand: false }); + + root.innerHTML = ` +
+
+ ${ctx.ui.icon('shield')} +
Everything here runs offline, on your device. No account needed and nothing about your + marks, attendance or plans ever leaves the phone.
+
+
+
`; + + const grid = root.querySelector('#tools-grid'); + + function toolCard({ icon, title, desc, statHtml, buttonLabel, onTap }) { + const card = document.createElement('section'); + card.className = 'card card-pad tool-card'; + card.innerHTML = ` +
+ ${ui.icon(icon)} +
+
${ui.esc(title)}
+

${ui.esc(desc)}

+
+
+ ${statHtml ? `
` : ''} + `; + card.querySelector('button').addEventListener('click', onTap); + card.dataset.statSlot = statHtml ? '1' : ''; + grid.appendChild(card); + return card.querySelector('[data-stat]'); + } + + // ── CGPA calculator ────────────────────────────────────────────────── + const cgpaStat = toolCard({ + icon: 'calc', + title: 'CGPA calculator', + desc: 'Semester SGPA / CGPA on the university 10-point scale (O → F).', + statHtml: true, + buttonLabel: 'Open calculator', + onTap: () => openCgpaSheet(), + }); + const lastCgpa = tools.loadLastCgpa(storage); + cgpaStat.innerHTML = lastCgpa && Number.isFinite(Number(lastCgpa.gpa)) + ? `Last result ${Number(lastCgpa.gpa).toFixed(2)} · ${tools.gradeLabel(lastCgpa.gpa)}` + : `No calculation yet`; + + // ── Attendance tracker ─────────────────────────────────────────────── + const attStat = toolCard({ + icon: 'calcheck', + title: 'Attendance tracker', + desc: 'Mark present/absent per subject and watch the 75% limit.', + statHtml: true, + buttonLabel: 'Open tracker', + onTap: () => openAttendanceSheet(), + }); + function paintAttendanceStat() { + const summary = tools.attendanceSummary(tools.loadAttendance(storage)); + attStat.innerHTML = summary.subjects + ? `${summary.subjects} subject${summary.subjects === 1 ? '' : 's'}${summary.near ? ` · ${summary.near} near the limit` : ' tracked'}` + : `No subjects yet`; + } + paintAttendanceStat(); + + // ── Study planner ──────────────────────────────────────────────────── + const plannerStat = toolCard({ + icon: 'tasks', + title: 'Study planner', + desc: 'Plan tasks with due dates and track your progress.', + statHtml: true, + buttonLabel: 'Open planner', + onTap: () => openPlannerSheet(), + }); + function paintPlannerStat() { + const s = tools.plannerStats(tools.loadPlannerTasks(storage)); + plannerStat.innerHTML = s.total + ? `${s.total} task${s.total === 1 ? '' : 's'} · ${s.completed} done +
` + : `No tasks yet`; + } + paintPlannerStat(); + + // ── Request a tool (genuinely external destination) ────────────────── + const reqCard = document.createElement('section'); + reqCard.className = 'card card-pad tool-card'; + reqCard.innerHTML = ` +
+ ${ui.icon('send')} +
+
Request a tool
+

Have an idea that would help DSMNRU students? Suggest it to the maintainers on Telegram.

+
+
+ `; + reqCard.querySelector('button').addEventListener('click', () => { + ctx.native.openExternal('https://t.me/dsmnru_bot'); + }); + grid.appendChild(reqCard); + + // ══ CGPA sheet ═══════════════════════════════════════════════════════ + function openCgpaSheet() { + let count = 1; + const node = document.createElement('div'); + node.innerHTML = ` +
+ Subjects + + 1 + + + +
+
+
+ `; + + const rowsEl = node.querySelector('#cg-rows'); + const countEl = node.querySelector('#cg-count'); + const resultEl = node.querySelector('#cg-result'); + + function renderRows() { + countEl.textContent = String(count); + rowsEl.innerHTML = ''; + for (let i = 1; i <= count; i++) { + const row = document.createElement('div'); + row.className = 'cg-row'; + const options = Object.keys(tools.GRADE_POINTS).map((g) => + ``).join(''); + row.innerHTML = ` + ${i} + +
+ + + +
`; + rowsEl.appendChild(row); + } + rowsEl.querySelectorAll('[data-cred]').forEach((b) => { + b.addEventListener('click', () => { + const input = b.closest('.step-group').querySelector('.cg-credit'); + input.value = Math.max(0, Math.min(30, (Number(input.value) || 0) + Number(b.dataset.cred))); + }); + }); + } + + node.querySelector('.tool-count-row').addEventListener('click', (e) => { + const b = e.target.closest('[data-step]'); + if (!b) return; + count = Math.max(1, Math.min(20, count + Number(b.dataset.step))); + renderRows(); + }); + node.querySelector('#cg-reset').addEventListener('click', () => { + count = 1; + resultEl.innerHTML = ''; + renderRows(); + }); + node.querySelector('#cg-calc').addEventListener('click', () => { + const rows = [...rowsEl.querySelectorAll('.cg-row')].map((row) => ({ + grade: row.querySelector('.cg-grade').value, + credits: Number(row.querySelector('.cg-credit').value) || 0, + })); + const { totalCredits, totalPoints, gpa } = tools.computeGpa(rows); + if (!totalCredits) { + resultEl.innerHTML = `
Enter credits for at least one subject.
`; + return; + } + resultEl.innerHTML = ` +
+
${gpa.toFixed(2)}
+
${tools.gradeLabel(gpa)} · ${totalCredits} credits · ${totalPoints.toFixed(1)} points
+
`; + tools.saveLastCgpa(storage, { totalCredits, totalPoints, gpa, timestamp: new Date().toISOString() }); + cgpaStat.innerHTML = `Last result ${gpa.toFixed(2)} · ${tools.gradeLabel(gpa)}`; + }); + + renderRows(); + ui.sheet({ title: 'CGPA calculator', subtitle: 'Pick a letter grade + credits per subject', content: node }); + } + + // ══ Attendance sheet ═════════════════════════════════════════════════ + function openAttendanceSheet() { + const subjects = tools.loadAttendance(storage); + let viewMonth = tools.monthOf(tools.todayISO()); + let markDate = tools.todayISO(); + + const node = document.createElement('div'); + node.innerHTML = ` +
+ + +
+
+ + +
+
+ `; + + const listEl = node.querySelector('#att-list'); + const dateInput = node.querySelector('#att-date'); + const monthInput = node.querySelector('#att-month'); + dateInput.value = markDate; + monthInput.value = viewMonth; + + function persist() { + tools.saveAttendance(storage, subjects); + paintAttendanceStat(); + } + + function render() { + viewMonth = tools.monthOf(monthInput.value || viewMonth); + markDate = dateInput.value || markDate; + if (!subjects.length) { + listEl.innerHTML = `
No subjects yet — add one above to start tracking.
`; + return; + } + listEl.innerHTML = subjects.map((s) => { + const stats = tools.attendanceMonthStats(s.records, viewMonth); + const status = s.records && s.records[markDate]; + const warn = stats.total >= 3 && stats.pct < tools.ATTENDANCE_WARNING_THRESHOLD; + return ` +
+
+ ${ui.esc(s.subject)} + ${stats.total ? stats.pct + '%' : '—'} +
+
${stats.total + ? `${stats.present}/${stats.total} present in ${viewMonth}` + : `No marks in ${viewMonth} yet`}${status ? ` · ${markDate}: ${status === 'P' ? 'Present' : 'Absent'}` : ''}
+
+
+ + + +
+
`; + }).join(''); + } + + node.querySelector('#att-add').addEventListener('submit', (e) => { + e.preventDefault(); + const input = node.querySelector('#att-subject'); + const name = String(input.value || '').trim(); + if (!name) return; + subjects.push({ id: Date.now(), subject: name, records: {} }); + input.value = ''; + persist(); + render(); + }); + node.querySelector('#att-list').addEventListener('click', (e) => { + const cardEl = e.target.closest('[data-id]'); + if (!cardEl) return; + const subject = subjects.find((x) => String(x.id) === cardEl.dataset.id); + if (!subject) return; + const mark = e.target.closest('[data-mark]'); + if (mark) { + if (!subject.records) subject.records = {}; + subject.records[dateInput.value || markDate] = mark.dataset.mark; + persist(); + render(); + return; + } + if (e.target.closest('[data-del]')) { + const idx = subjects.indexOf(subject); + subjects.splice(idx, 1); + persist(); + render(); + } + }); + dateInput.addEventListener('input', render); + monthInput.addEventListener('input', render); + node.querySelector('#att-clear').addEventListener('click', () => { + ui.confirmSheet({ + title: 'Clear attendance?', + text: 'This removes every subject and mark from this device. The archive is untouched.', + confirmLabel: 'Clear', + danger: true, + onConfirm: () => { subjects.length = 0; persist(); render(); }, + }); + }); + + render(); + ui.sheet({ title: 'Attendance tracker', subtitle: '75% is the usual warning line', content: node }); + } + + // ══ Planner sheet ════════════════════════════════════════════════════ + function openPlannerSheet() { + let tasks = tools.loadPlannerTasks(storage); + + const node = document.createElement('div'); + node.innerHTML = ` +
+ + + +
+
+
No tasks yet
+
`; + + const listEl = node.querySelector('#pl-list'); + const statsEl = node.querySelector('#pl-stats'); + const fillEl = node.querySelector('#pl-fill'); + + function persist() { + tools.savePlannerTasks(storage, tasks); + paintPlannerStat(); + } + function paintStats() { + const s = tools.plannerStats(tasks); + fillEl.style.width = `${s.pct}%`; + statsEl.innerHTML = s.total + ? `${s.completed}/${s.total} completed` + : 'No tasks yet'; + } + function render() { + const sorted = tools.sortPlannerTasks(tasks); + if (!sorted.length) { + listEl.innerHTML = `
Nothing planned — add a task above.
`; + paintStats(); + return; + } + listEl.innerHTML = sorted.map((t) => ` +
+ +
+
${ui.esc(t.title)}
+ ${t.due ? `
${ui.icon('clock')} ${ui.esc(fmtDue(t.due))}
` : ''} +
+ +
`).join(''); + paintStats(); + } + + node.querySelector('#pl-add').addEventListener('submit', (e) => { + e.preventDefault(); + const titleEl = node.querySelector('#pl-title'); + const dueEl = node.querySelector('#pl-due'); + const title = String(titleEl.value || '').trim(); + if (!title) return; + tasks.push({ id: Date.now(), title, due: dueEl.value || null, completed: false }); + titleEl.value = ''; + persist(); + render(); + }); + listEl.addEventListener('click', (e) => { + const cardEl = e.target.closest('[data-id]'); + if (!cardEl) return; + const task = tasks.find((x) => String(x.id) === cardEl.dataset.id); + if (!task) return; + if (e.target.matches('[data-toggle]')) { + task.completed = e.target.checked; + persist(); + render(); + return; + } + if (e.target.closest('[data-del]')) { + tasks = tasks.filter((x) => x !== task); + persist(); + render(); + } + }); + + render(); + ui.sheet({ title: 'Study planner', subtitle: 'Stored on this device only', content: node }); + } + + function fmtDue(iso) { + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' }); + } catch { return iso; } + } + + ctx.setRefresh(() => renderTools(root, ctx, {})); +} diff --git a/android-app/www/js/views/upload.js b/android-app/www/js/views/upload.js new file mode 100644 index 0000000..27ba2c1 --- /dev/null +++ b/android-app/www/js/views/upload.js @@ -0,0 +1,321 @@ +/** + * DSMNRU PYQ Android — in-app "Upload paper" screen. + * + * The website's public upload workflow, rebuilt as native-feeling app UI — + * NO website page, NO browser redirect: + * + * • same required metadata (title, name, reward email + optional + * course/semester), same validation rules (uploadcore.js); + * • Android file picker for one PDF (≤10 MB) or several images — standard + * which the Capacitor WebView hands to the system + * picker (Photos / Documents); + * • images are converted to a single PDF on-device (canvas → JPEG → + * minimal PDF writer — no jsPDF download, no third-party code); + * • the file goes to the SAME gofile.io storage the website uses; + * • metadata lands in the SAME Firestore `pendingUploads` collection via + * one REST insert (Firestore rules validate it server-side); + * • the same local abuse throttle as the website (5 / 6 h, 45 s gap). + * + * Uploads are public on the website (the typed email is the reward + * identity), so no sign-in gate is forced — but when the user IS signed in + * the form is prefilled and userId is attached, exactly like the site. + * + * Network budget: 1 gofile servers call + 1 gofile upload + 1 Firestore + * insert per successful submission — identical to the website, nothing extra. + */ + +import * as ui from '../ui.js'; +import * as core from '../uploadcore.js'; + +const storage = (typeof localStorage !== 'undefined') ? localStorage : null; + +/** Canvas-dependent step: one File → { jpeg, width, height } under limits. */ +async function encodeImageFile(file, { maxDimension, quality }) { + const bitmapUrl = URL.createObjectURL(file); + try { + const img = await new Promise((resolve, reject) => { + const el = new Image(); + el.onload = () => resolve(el); + el.onerror = () => reject(new Error(`Could not read image “${file.name}”.`)); + el.src = bitmapUrl; + }); + const scale = Math.min(1, maxDimension / Math.max(img.naturalWidth || img.width, img.naturalHeight || img.height, 1)); + const cw = Math.max(1, Math.round((img.naturalWidth || img.width) * scale)); + const ch = Math.max(1, Math.round((img.naturalHeight || img.height) * scale)); + const canvas = document.createElement('canvas'); + canvas.width = cw; + canvas.height = ch; + const ctx = canvas.getContext('2d', { alpha: false }); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, cw, ch); + ctx.drawImage(img, 0, 0, cw, ch); + const dataUrl = canvas.toDataURL('image/jpeg', quality); + const jpeg = base64ToBytes(String(dataUrl).slice(dataUrl.indexOf(',') + 1)); + return { jpeg, width: cw, height: ch }; + } finally { + try { URL.revokeObjectURL(bitmapUrl); } catch { /* ignore */ } + } +} + +function base64ToBytes(b64) { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function fileToBytes(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(new Uint8Array(reader.result)); + reader.onerror = () => reject(new Error(`Could not read “${file.name}”.`)); + reader.readAsArrayBuffer(file); + }); +} + +/** XHR (progress events) upload of one file to gofile — CORS-enabled. */ +function uploadToGofile(uploadUrl, file, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', uploadUrl, true); + xhr.responseType = 'json'; + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total); + }); + xhr.addEventListener('load', () => { + let data = xhr.response; + if (data == null) { try { data = JSON.parse(xhr.responseText); } catch { data = null; } } + if (xhr.status < 200 || xhr.status >= 300) { + reject(new Error(`Upload failed with status ${xhr.status}`)); + return; + } + if (!data || data.status !== 'ok' || !data.data || !data.data.downloadPage) { + reject(new Error('Upload failed: Invalid response from server')); + return; + } + resolve(data.data.downloadPage); + }); + xhr.addEventListener('error', () => reject(new Error('Network error while uploading the file.'))); + xhr.addEventListener('abort', () => reject(new Error('Upload cancelled.'))); + const form = new FormData(); + form.append('file', file, file.name || 'paper.pdf'); + xhr.send(form); + }); +} + +export default async function renderUpload(root, ctx) { + const { ui: _u, api, auth } = ctx; + ctx.setHeader({ title: 'Upload paper', sub: 'help grow the archive', brand: false }); + + const user = auth.current(); + const prefillName = user && user.name && user.providerId ? user.name : ''; + const prefillEmail = user && user.email ? user.email : ''; + + root.innerHTML = ` +
+
+ ${ctx.ui.icon('upload')} +
Share a question paper or syllabus. Approved uploads earn + 10 points for the email you enter — the same reward system as the website.
+
+ +
+
+
+ + +
+
+ + +
+
+ + +

Points are credited to this email (trim + lowercase, like the website).

+
+
+
+ + +
+
+ + +
+
+ +
+ + + +
+ + + + + + +

Same flow as the website: the file goes to the shared storage service and a moderator + reviews it before it appears in the archive. Nothing is auto-published.

+
+
+
`; + + const form = root.querySelector('#up-form'); + const fileInput = root.querySelector('#up-file'); + const dropText = root.querySelector('#up-drop-text'); + const drop = root.querySelector('#up-drop'); + const errEl = root.querySelector('[data-err]'); + const progressWrap = root.querySelector('#up-progress'); + const progressFill = root.querySelector('#up-progress-fill'); + const progressText = root.querySelector('#up-progress-text'); + const submitBtn = root.querySelector('#up-submit'); + let selected = []; + + function paintSelection() { + if (!selected.length) { + drop.classList.remove('has-file'); + dropText.innerHTML = `Choose one PDF (≤10 MB) or photos of the paperPDF, JPG, PNG, WebP — picked with the Android file picker`; + return; + } + const { pdfs, images } = core.classifyFiles(selected); + drop.classList.add('has-file'); + if (pdfs.length === 1) { + dropText.innerHTML = `${ui.esc(pdfs[0].name)}${(pdfs[0].size / (1024 * 1024)).toFixed(2)} MB · ready`; + } else if (images.length) { + dropText.innerHTML = `${images.length} photo${images.length === 1 ? '' : 's'} selectedThey will be combined into one PDF on this device`; + } else { + dropText.innerHTML = `${selected.length} file(s) selected${ui.esc(selected.map((f) => f.name).join(', ').slice(0, 80))}`; + } + } + + fileInput.addEventListener('change', () => { + selected = Array.from(fileInput.files || []); + errEl.hidden = true; + paintSelection(); + }); + + function setProgress(pct, text) { + progressWrap.hidden = false; + progressFill.style.width = `${Math.max(0, Math.min(100, pct))}%`; + if (text) progressText.textContent = text; + } + function setError(msg) { + errEl.textContent = msg; + errEl.hidden = false; + progressWrap.hidden = true; + submitBtn.disabled = false; + submitBtn.innerHTML = `${ui.icon('upload')} Upload paper`; + } + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + if (submitBtn.disabled) return; + + const title = root.querySelector('#up-title').value; + const studentName = root.querySelector('#up-name').value; + const rawEmail = root.querySelector('#up-email').value; + const course = root.querySelector('#up-course').value; + const semester = root.querySelector('#up-sem').value; + const throttle = core.getUploadThrottleState(storage, Date.now()); + + const check = core.validateUploadAttempt({ + title, studentName, rawEmail, files: selected, throttleState: throttle, + }); + if (!check.ok) { setError(check.message); return; } + + errEl.hidden = true; + submitBtn.disabled = true; + submitBtn.innerHTML = 'Working…'; + + try { + // 1) Build the final PDF file (single PDF passes through untouched). + let file = null; + const { pdfs, images } = core.classifyFiles(selected); + if (pdfs.length === 1) { + file = pdfs[0]; + setProgress(12, 'Reading PDF…'); + } else { + let pages = []; + const attempts = core.IMAGE_ENCODE_ATTEMPTS; + for (let a = 0; a < attempts.length; a++) { + setProgress(10 + a * 5, `Converting ${images.length} photo${images.length === 1 ? '' : 's'} to PDF (pass ${a + 1}/${attempts.length})…`); + pages = []; + for (const img of images) { + pages.push(await encodeImageFile(img, attempts[a])); + } + const pdfBytes = core.assemblePdfFromJpegs(pages); + if (pdfBytes.length <= core.MAX_FINAL_PDF_SIZE) { + file = new File([pdfBytes], `images-${Date.now()}.pdf`, { type: 'application/pdf' }); + break; + } + } + if (!file) throw new Error('Could not generate a PDF under 10MB. Please upload fewer or clearer photos.'); + } + + // 2) Upload to the shared gofile storage (same service as the website). + setProgress(30, 'Getting an upload server…'); + const uploadUrl = await core.fetchGofileUploadUrl(); + setProgress(40, 'Uploading file…'); + const downloadUrl = await uploadToGofile(uploadUrl, file, (frac) => { + setProgress(40 + Math.round(frac * 45), `Uploading file… ${Math.round(frac * 100)}%`); + }); + + // 3) Save metadata to the same pendingUploads queue (one REST insert). + setProgress(92, 'Saving submission for review…'); + const doc = core.buildPendingUploadDoc({ + title: String(title).trim(), + course: String(course).trim(), + semester: String(semester).trim(), + studentName: String(studentName).trim(), + studentCourse: String(course).trim() || 'General', + studentEmail: check.email, + userId: user ? user.uid : '', + fileName: file.name || 'paper.pdf', + downloadUrl, + fileSize: file.size, + createdAtIso: new Date().toISOString(), + }); + const headers = { 'Content-Type': 'application/json' }; + if (user && user.idToken) headers.Authorization = 'Bearer ' + user.idToken; + const res = await fetch(core.pendingUploadsUrl(), { method: 'POST', headers, body: JSON.stringify(doc) }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error((body && body.error && body.error.message) || 'Could not register the submission. Try again.'); + } + + core.recordUploadThrottle(storage, Date.now()); + setProgress(100, 'Done'); + + // 4) In-app success state (no page jump, no website). + root.innerHTML = ` +
+
+
${ui.icon('check')}
+

Submission received

+

“${ui.esc(String(title).trim())}” is now pending review by the moderators.

+

10 points will be credited to ${ui.esc(check.email)} once it is approved.

+
+ + +
+
+
`; + root.querySelector('#up-again').addEventListener('click', () => renderUpload(root, ctx)); + root.querySelector('#up-done').addEventListener('click', () => ctx.router.back()); + ui.toast('Submission received — pending review'); + } catch (err) { + setError(String((err && err.message) || err)); + } + }); + + ctx.setRefresh(() => renderUpload(root, ctx, {})); +} From 25c73d21a39b0dd5a6a61071610633558300801e Mon Sep 17 00:00:00 2001 From: Lav-developer <170819619+Lav-developer@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:08:48 +0000 Subject: [PATCH 02/13] docs: align CI notes with the pull_request APK workflow The android-apk workflow now also runs on pull_request events (checks job: Worker suite + app npm test with jsdom reused from worker/node_modules via 'npm ci --prefix worker', then the debug-apk artifact build). Correct the stale 'npm i jsdom' note in the smoke-test header and state the PR trigger in the README build section. No code changes. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- android-app/README.md | 9 ++++++--- android-app/test/app-frontend-smoke.test.mjs | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/android-app/README.md b/android-app/README.md index b907001..5a6852b 100644 --- a/android-app/README.md +++ b/android-app/README.md @@ -225,9 +225,12 @@ cd android && ./gradlew assembleDebug # APK: android/app/build/outputs/apk/debug/app-debug.apk ``` -The GitHub Actions workflow (`.github/workflows/android-apk.yml`) runs the -test job + builds the debug APK in the cloud and uploads it as an artifact — -nothing binary is committed, and no releases are created automatically. A +The GitHub Actions workflow (`.github/workflows/android-apk.yml`) runs on +pushes to `android-app` **and on pull requests targeting it**: the `checks` +job runs the Worker suite and the app's `npm test` (jsdom reused from the +Worker devDependencies) first, then the `debug-apk` job builds the debug APK +in the cloud and uploads it as a workflow artifact — nothing binary is +committed, and no releases are created automatically. A signed release APK/AAB can be added later via repository secrets (keystore + `google-services.json` are **never** committed; `build.gradle` already auto-applies the Google Services plugin when `android/app/google-services.json` diff --git a/android-app/test/app-frontend-smoke.test.mjs b/android-app/test/app-frontend-smoke.test.mjs index 08b0b80..b0eabc7 100644 --- a/android-app/test/app-frontend-smoke.test.mjs +++ b/android-app/test/app-frontend-smoke.test.mjs @@ -7,8 +7,11 @@ * search executes → paper detail opens → actions (external open, save) → * Saved tab → offline-safe re-render. * - * Skips gracefully when jsdom is unavailable (it is provided by - * worker/node_modules or CI `npm i jsdom`; see the android-apk workflow). + * Skips gracefully when jsdom is unavailable (jsdom is not an android-app + * dependency — CI installs the Worker devDependencies via + * `npm ci --prefix worker` in the android-apk workflow's checks job, and this + * suite reuses jsdom from worker/node_modules; the workflow now runs for + * pull_request events too, so these UI flows are enforced on every PR). * Run: npm test (from android-app/) */ From b182d56854c3975342968b0c5358c26137570cbe Mon Sep 17 00:00:00 2001 From: Lav-developer <170819619+Lav-developer@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:21:47 +0000 Subject: [PATCH 03/13] Fix PR CI: add Credential Manager deps, lifecycle overrides, bitmap dedupe, home async guards, test realm/fixtures, action bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes and fixes per CI failure: A) DsmnruAppPlugin compile errors (missing androidx.credentials.*/googleid.*): the Credential Manager + Google ID token dependencies were missing from android/app/build.gradle. Added androidx.credentials:credentials:1.3.0, credentials-play-services-auth:1.3.0 and com.google.android.libraries.identity.googleid:googleid:1.1.1 — compatible with AGP 8.13.0 / Gradle 8.14.3 / Java 21 / Capacitor 8.5.1. Native architecture unchanged: Credential Manager → Google ID token → Firebase accounts:signInWithIdp (no browser/website auth). B) MainActivity: onResume/onPause overrode BridgeActivity's PUBLIC lifecycle methods with weaker protected visibility (verified against Capacitor 8.5.1 BridgeActivity source). Overrides are now public; behavior kept. C) PdfViewerActivity PageImageView used a nonexistent ImageView.getPageBitmap(). Replaced with an explicit `displayed` field tracking the attached bitmap — the intended dedupe (LruCache hit ⇒ skip redundant setImageBitmap/matrix reset) is preserved, as are lazy rendering, zoom/pan and the temp-cache contract. D) app-frontend-smoke: the upload validation step ran as a signed-in user, for whom the form legitimately prefills "your name" — so the title error surfaced instead of the name validation state. The fixture now clears the (by-design) prefilled fields before the empty-form submit, reaching the name-validation state the assertion expects; assertion unchanged. Also fixed the real Home crash behind "renderStats → Cannot set properties of null": the section renderers invoked from late async callbacks (SWR revalidate / pull-refresh) crashed after navigating away — all Home renderers now no-op when their host nodes are gone. E) app-native-bridge searched with a typed query while signed out, but the app gates server search behind a verified session (website-parity rule) — the gate correctly fired instead of fetching. The test now follows the real policy: assert the gate, sign in via the mocked password path, then search (debounce/abort/stale-protection paths unchanged), and moved the Google not-configured/success scenarios after an explicit sign-out. All original assertions preserved; none weakened. F) jsdom suites now execute in-repo (worker/node_modules): the harness bridged window FormData/File/Blob/FileReader into the host realm so `new FormData()` in upload code shares the realm of the jsdom File objects (Node's undici FormData rejected jsdom Blobs — realm mismatch, not an app bug). Local: 46/46 app tests + all 7 Worker suites pass. G) actions/checkout@v4→v7, setup-node@v4→v7, setup-java@v4→v6 (currently supported majors, clearing the node20 deprecation warnings). Triggers and job behavior unchanged. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/android-apk.yml | 10 +- android-app/android/app/build.gradle | 7 ++ .../java/com/dsmnru/pyq/MainActivity.java | 4 +- .../com/dsmnru/pyq/PdfViewerActivity.java | 8 +- android-app/test/app-frontend-smoke.test.mjs | 10 +- android-app/test/app-native-bridge.test.mjs | 110 ++++++++++++------ android-app/www/js/views/home.js | 5 + 7 files changed, 108 insertions(+), 46 deletions(-) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index d808ef0..c9c0dbd 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -29,10 +29,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' cache: npm @@ -65,17 +65,17 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' cache: npm cache-dependency-path: android-app/package-lock.json - name: Set up Java 21 (Gradle/AGP requirement) - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: 'temurin' java-version: '21' diff --git a/android-app/android/app/build.gradle b/android-app/android/app/build.gradle index 2efdf2b..3057998 100644 --- a/android-app/android/app/build.gradle +++ b/android-app/android/app/build.gradle @@ -41,6 +41,13 @@ dependencies { // at the bottom of this file) — every FCM code path degrades silently // when it doesn't, so debug builds without the file run normally. implementation 'com.google.firebase:firebase-messaging:24.1.1' + // Android Credential Manager + Google ID token library for the NATIVE + // Google sign-in (account chooser → Google ID token → Firebase + // accounts:signInWithIdp in www/js/auth.js — same dsmnru-data project). + // Compatible with AGP 8.13 / Gradle 8.14.3 / Java 21 / Capacitor 8.5. + implementation 'androidx.credentials:credentials:1.3.0' + implementation 'androidx.credentials:credentials-play-services-auth:1.3.0' + implementation 'com.google.android.libraries.identity.googleid:googleid:1.1.1' implementation project(':capacitor-android') testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" diff --git a/android-app/android/app/src/main/java/com/dsmnru/pyq/MainActivity.java b/android-app/android/app/src/main/java/com/dsmnru/pyq/MainActivity.java index 9c53956..052cbf0 100644 --- a/android-app/android/app/src/main/java/com/dsmnru/pyq/MainActivity.java +++ b/android-app/android/app/src/main/java/com/dsmnru/pyq/MainActivity.java @@ -76,13 +76,13 @@ public void onCreate(android.os.Bundle savedInstanceState) { } @Override - protected void onResume() { + public void onResume() { super.onResume(); scheduleNotificationPermissionAsk(); } @Override - protected void onPause() { + public void onPause() { mainHandler.removeCallbacks(permissionAsk); super.onPause(); } diff --git a/android-app/android/app/src/main/java/com/dsmnru/pyq/PdfViewerActivity.java b/android-app/android/app/src/main/java/com/dsmnru/pyq/PdfViewerActivity.java index 4b361e1..8f77ef3 100644 --- a/android-app/android/app/src/main/java/com/dsmnru/pyq/PdfViewerActivity.java +++ b/android-app/android/app/src/main/java/com/dsmnru/pyq/PdfViewerActivity.java @@ -617,6 +617,8 @@ private final class PageImageView extends androidx.appcompat.widget.AppCompatIma private float scale = 1f; private float lastTouchX = 0f; private float lastTouchY = 0f; + /** The bitmap currently attached to this view (ImageView has no getter). */ + private Bitmap displayed; PageImageView(android.content.Context context, int pageNumber, float aspect) { super(context); @@ -644,6 +646,7 @@ void setPagePlaceholder(float aspect) { setLayoutParams(lp); Bitmap ph = Bitmap.createBitmap(8, Math.max(1, Math.round(8 * aspect)), Bitmap.Config.ARGB_8888); ph.eraseColor(Color.parseColor("#16213B")); + displayed = ph; setImageBitmap(ph); resetMatrix(); } @@ -652,7 +655,10 @@ void setPagePlaceholder(float aspect) { void setRendering(boolean value) { rendering = value; } void setPageBitmap(Bitmap bitmap) { - if (getPageBitmap() == bitmap) return; + // Same instance already attached (LruCache hit on a re-scroll) — + // keep the current matrix instead of resetting an ongoing zoom. + if (displayed == bitmap) return; + displayed = bitmap; setImageBitmap(bitmap); resetMatrix(); } diff --git a/android-app/test/app-frontend-smoke.test.mjs b/android-app/test/app-frontend-smoke.test.mjs index b0eabc7..623c45f 100644 --- a/android-app/test/app-frontend-smoke.test.mjs +++ b/android-app/test/app-frontend-smoke.test.mjs @@ -77,7 +77,10 @@ function setupDom() { const opened = []; window.open = (u) => { opened.push(String(u)); return null; }; - for (const key of ['window', 'document', 'navigator', 'location', 'localStorage', 'HTMLElement', 'Element', 'Node', 'Event', 'CustomEvent', 'MouseEvent', 'requestAnimationFrame', 'cancelAnimationFrame']) { + // 'FormData'/'File'/'Blob'/'FileReader' are bridged too so upload code that +// does `new FormData()` resolves to the SAME realm as the jsdom File +// objects (Node's undici FormData would reject jsdom Blobs). +for (const key of ['window', 'document', 'navigator', 'location', 'localStorage', 'HTMLElement', 'Element', 'Node', 'Event', 'CustomEvent', 'MouseEvent', 'requestAnimationFrame', 'cancelAnimationFrame', 'FormData', 'File', 'Blob', 'FileReader']) { try { Object.defineProperty(globalThis, key, { value: window[key], configurable: true, writable: true }); } catch { /* node-owned globals (navigator) may resist — code paths guard with typeof */ } @@ -240,6 +243,11 @@ if (JSDOM) { assert.match(text(view()), /10\s*points/, 'reward explanation rendered'); // Validation errors render inside the app (no navigation, no fetches). + // The signed-in session prefills "your name"/email (intended app behavior), + // so clear the form first to exercise the empty-form validation state. + view().querySelector('#up-title').value = ''; + view().querySelector('#up-name').value = ''; + view().querySelector('#up-email').value = ''; const uploadCallsBefore = calls.length; view().querySelector('#up-submit').click(); assert.ok(await waitFor(() => !view().querySelector('[data-err]').hidden), 'validation error shown'); diff --git a/android-app/test/app-native-bridge.test.mjs b/android-app/test/app-native-bridge.test.mjs index d2e8929..5626d75 100644 --- a/android-app/test/app-native-bridge.test.mjs +++ b/android-app/test/app-native-bridge.test.mjs @@ -51,10 +51,17 @@ const PAPER = { seoSlug: 'data-structures-2023', createdAt: '2023-06-01T10:00:00Z', }; -function jwt(exp, provider) { +function jwt(exp, provider, who = 'google') { + const people = { + google: { uid: 'g-uid-1', email: 'student@gmail.com', name: 'Google Student' }, + // Verified password account (email_verified: true — the website-parity + // privilege gate requires it before search / PDF actions unlock). + password: { uid: 'pw-uid-1', email: 'stud@dsmnru.in', name: 'Test Student' }, + }; + const who_ = people[who] || people.google; const b64u = (s) => Buffer.from(s).toString('base64url'); return b64u('{"alg":"none"}') + '.' + b64u(JSON.stringify({ - exp, user_id: 'g-uid-1', sub: 'g-uid-1', email: 'student@gmail.com', name: 'Google Student', + exp, user_id: who_.uid, sub: who_.uid, email: who_.email, name: who_.name, email_verified: true, firebase: { sign_in_provider: provider }, })) + '.s'; } @@ -71,7 +78,10 @@ if (JSDOM) { const opened = []; window.open = (u) => { opened.push(String(u)); return null; }; - for (const key of ['window', 'document', 'navigator', 'location', 'localStorage', 'HTMLElement', 'Element', 'Node', 'Event', 'CustomEvent', 'MouseEvent', 'requestAnimationFrame', 'cancelAnimationFrame']) { + // 'FormData'/'File'/'Blob'/'FileReader' are bridged too so upload code that +// does `new FormData()` resolves to the SAME realm as the jsdom File +// objects (Node's undici FormData would reject jsdom Blobs). +for (const key of ['window', 'document', 'navigator', 'location', 'localStorage', 'HTMLElement', 'Element', 'Node', 'Event', 'CustomEvent', 'MouseEvent', 'requestAnimationFrame', 'cancelAnimationFrame', 'FormData', 'File', 'Blob', 'FileReader']) { try { Object.defineProperty(globalThis, key, { value: window[key], configurable: true, writable: true }); } catch { /* node-owned globals resist — code guards with typeof */ } @@ -115,9 +125,12 @@ if (JSDOM) { if (u.includes('/api/courses')) return ok(['B.Tech']); if (u.includes('/api/pyqs/p1')) return ok(PAPER); if (u.includes('/api/pyqs/search')) return ok({ items: [{ id: 'p1', title: PAPER.title, course: 'B.Tech', views: 12, slug: PAPER.seoSlug }], total: 1, page: 1, totalPages: 1 }); + if (u.includes('signInWithPassword')) { + return ok({ idToken: jwt(nowSec + 3600, 'password', 'password'), refreshToken: 'RT-P', expiresIn: '3600' }); + } if (u.includes('accounts:signInWithIdp')) { idpBodies.push(JSON.parse(opts.body)); - return ok({ idToken: jwt(nowSec + 3600, 'google.com'), refreshToken: 'RT-G', expiresIn: '3600', providerId: 'google.com' }); + return ok({ idToken: jwt(nowSec + 3600, 'google.com', 'google'), refreshToken: 'RT-G', expiresIn: '3600', providerId: 'google.com' }); } if (u.includes('/documents')) return { ok: true, status: 200, json: async () => ({ fields: {} }) }; return { ok: false, status: 404, json: async () => ({ error: 'mock: not mocked ' + u }) }; @@ -138,43 +151,35 @@ if (JSDOM) { assert.ok(await waitFor(() => view().querySelector('.hero')), 'app booted with the native bridge'); - // ── PDF gate mirrors the website policy for signed-out users ───────── + // ── Website-parity gate: a typed query requires a VERIFIED session ──── document.querySelector('.tab[data-tab="search"]').click(); await waitFor(() => view().querySelector('#sq')); - const input = view().querySelector('#sq'); - input.value = 'data structures'; - input.dispatchEvent(new window.Event('input', { bubbles: true })); - assert.ok(await waitFor(() => view().querySelector('[data-paper-id="p1"]')), 'search result rendered'); + const typeQuery = async () => { + const input = view().querySelector('#sq'); + input.value = 'data structures'; + input.dispatchEvent(new window.Event('input', { bubbles: true })); + }; + await typeQuery(); + assert.ok(await waitFor(() => document.querySelector('.sheet-root #auth-email')), + 'anonymous typed search opens the in-app sign-in gate (website rule) — no results leak'); + + // ── Sign in through the gate sheet (email/password → same Firebase) ──── + document.querySelector('.sheet-root #auth-email').value = 'stud@dsmnru.in'; + document.querySelector('.sheet-root #auth-pass').value = 'hunter22'; + document.querySelector('.sheet-root form[data-form="login"] button[type="submit"]').click(); + assert.ok(await waitFor(() => !document.querySelector('.sheet-root')), 'gate sheet closes on sign-in'); + assert.ok(calls.some((c) => c.url.includes('signInWithPassword')), 'Identity Toolkit password sign-in called'); + + // ── Verified → debounced search executes against the Worker endpoint ─── + await waitFor(() => view().querySelector('#sq')); + await typeQuery(); + assert.ok(await waitFor(() => view().querySelector('[data-paper-id="p1"]')), 'search result rendered for the verified session'); view().querySelector('[data-paper-id="p1"]').click(); assert.ok(await waitFor(() => view().querySelector('.paper-hero')), 'paper screen rendered'); const externalBefore = bridgeCalls.filter((c) => c.kind === 'openExternal').length; - view().querySelector('[data-act="view"]').click(); - assert.ok(await waitFor(() => document.querySelector('.sheet-root #auth-email')), 'verified-sign-in gate opens the in-app auth sheet first'); - - // ── Google: not configured → in-app explainer, no website hand-off ──── - googleResult = { err: 'GOOGLE_SIGNIN_NOT_CONFIGURED: no client id in this build' }; - document.querySelector('.sheet-root [data-act="google"]').click(); - assert.ok(await waitFor(() => { - const sheet = document.querySelector('.sheet-root'); - return sheet && /Google sign-in/.test(text(sheet)) && /configured/.test(text(sheet)); - }), 'not-configured explainer shown'); - assert.ok(!/Open website/.test(text(document.querySelector('.sheet-root'))), 'never sends the user to the website'); - document.querySelector('.sheet-root [data-act="email"]').click(); - assert.ok(await waitFor(() => document.querySelector('.sheet-root #auth-email')), 'email/password path offered in-app'); - - // ── Google sign-in: device chooser → Firebase → Google session ───────── - googleResult = { idToken: 'GOOGLE_ID_TOKEN' }; - document.querySelector('.sheet-root [data-act="google"]').click(); - assert.ok(await waitFor(() => idpBodies.length === 1), 'Identity Toolkit signInWithIdp called'); - const seenNonce = bridgeCalls.filter((c) => c.kind === 'googleSignIn').at(-1).nonce; - assert.ok(seenNonce && seenNonce.length >= 16, 'JS generated a nonce for the chooser'); - assert.match(idpBodies[0].postBody, new RegExp(`nonce=${seenNonce}`), 'the same nonce is replayed to Firebase'); - assert.match(idpBodies[0].postBody, /id_token=GOOGLE_ID_TOKEN/); - assert.match(idpBodies[0].postBody, /providerId=google\.com/); - // ── Signed in → Open PDF goes to the IN-APP viewer (pdfView bridge call) ── - assert.ok(await waitFor(() => view().querySelector('.paper-hero [data-act="view"]')), 'paper re-rendered for the verified session'); + // ── Open PDF → the IN-APP viewer (pdfView bridge call, direct URL) ───── view().querySelector('[data-act="view"]').click(); assert.ok(await waitFor(() => bridgeCalls.some((c) => c.kind === 'pdfView')), 'native viewer took over'); const pdfCall = bridgeCalls.find((c) => c.kind === 'pdfView'); @@ -194,14 +199,45 @@ if (JSDOM) { view().querySelector('[data-act="server2"]').click(); assert.ok(await waitFor(() => bridgeCalls.some((c) => c.kind === 'openExternal' && c.url === PAPER.file2)), 'Drive landing page opens externally (unavoidable destination)'); - // ── Profile reflects the SAME Firebase identity, flagged as Google ────── + // ── Sign out → the Google scenarios run from the signed-out profile ──── document.querySelector('.tab[data-tab="profile"]').click(); + assert.ok(await waitFor(() => view().querySelector('[data-act="signout"]')), 'profile shows the signed-in card'); + view().querySelector('[data-act="signout"]').click(); + await waitFor(() => document.querySelector('.sheet-root [data-confirm]')); + document.querySelector('.sheet-root [data-confirm]').click(); + assert.ok(await waitFor(() => view().querySelector('[data-act="google"]')), 'signed out — native Google row back'); + + // ── Google: not configured → in-app explainer, no website hand-off ────── + googleResult = { err: 'GOOGLE_SIGNIN_NOT_CONFIGURED: no client id in this build' }; + view().querySelector('[data-act="google"]').click(); + assert.ok(await waitFor(() => { + const sheet = document.querySelector('.sheet-root'); + return sheet && /Google sign-in/.test(text(sheet)) && /configured/.test(text(sheet)); + }), 'not-configured explainer shown'); + assert.ok(!/Open website/.test(text(document.querySelector('.sheet-root'))), 'never sends the user to the website'); + document.querySelector('.sheet-root [data-act="email"]').click(); + assert.ok(await waitFor(() => document.querySelector('.sheet-root #auth-email')), 'email/password path offered in-app'); + document.querySelector('.sheet-root [data-dismiss]').click(); + await waitFor(() => !document.querySelector('.sheet-root')); + + // ── Google sign-in: device chooser → Firebase → Google session ────────── + googleResult = { idToken: 'GOOGLE_ID_TOKEN' }; + view().querySelector('[data-act="google"]').click(); + assert.ok(await waitFor(() => idpBodies.length === 1), 'Identity Toolkit signInWithIdp called'); + const seenNonce = bridgeCalls.filter((c) => c.kind === 'googleSignIn').at(-1).nonce; + assert.ok(seenNonce && seenNonce.length >= 16, 'JS generated a nonce for the chooser'); + assert.match(idpBodies[0].postBody, new RegExp(`nonce=${seenNonce}`), 'the same nonce is replayed to Firebase'); + assert.match(idpBodies[0].postBody, /id_token=GOOGLE_ID_TOKEN/); + assert.match(idpBodies[0].postBody, /providerId=google\.com/); + + // ── Profile reflects the SAME Firebase identity, flagged as Google ────── assert.ok(await waitFor(() => { const card = view().querySelector('.profile-card'); return card && /Google Student/.test(text(card)) && /Google account/.test(text(card)); }), 'profile shows the same Firebase identity flagged as a Google account'); - assert.ok(calls.some((c) => c.url.includes('/documents/users/g-uid-1')), 'one-time users/{uid} sync ran'); + assert.ok(calls.some((c) => c.url.includes('/documents/users/g-uid-1')), 'Google sign-in synced users/g-uid-1'); + assert.ok(calls.some((c) => c.url.includes('/documents/users/pw-uid-1')), 'password sign-in synced users/pw-uid-1'); const firestoreCalls = calls.filter((c) => c.url.includes('firestore.googleapis.com')).length; - assert.ok(firestoreCalls <= 2, 'no Firestore chatter beyond the profile sync'); + assert.ok(firestoreCalls <= 2, 'exactly one owner-scoped profile sync per manual sign-in, nothing else'); }); } diff --git a/android-app/www/js/views/home.js b/android-app/www/js/views/home.js index 4fdca74..ff99a45 100644 --- a/android-app/www/js/views/home.js +++ b/android-app/www/js/views/home.js @@ -100,6 +100,7 @@ export default async function renderHome(root, ctx) { function renderStats(d) { const stats = d.stats || {}; const el = root.querySelector('#home-stats'); + if (!el) return; // view changed before a late async render (revalidate/refresh) — nothing to update el.innerHTML = ` ${Number(stats.totalPyqs) || 0} papers ${Number(stats.totalCourses) || 0} courses @@ -108,6 +109,7 @@ export default async function renderHome(root, ctx) { function renderCourses(d) { const host = root.querySelector('#home-courses'); + if (!host) return; // view changed before a late async render const counts = Array.isArray(d.courseCounts) ? d.courseCounts.slice(0, 6) : []; host.innerHTML = ''; if (!counts.length) return; @@ -137,6 +139,7 @@ export default async function renderHome(root, ctx) { function renderContinue() { const host = root.querySelector('#home-continue'); + if (!host) return; // view changed before a late async render const items = store.recentViews().slice(0, 4); if (!items.length) { host.classList.add('hidden'); return; } host.classList.remove('hidden'); @@ -150,6 +153,7 @@ export default async function renderHome(root, ctx) { function rail(d, key, title, iconName) { const host = root.querySelector(`#home-${key === 'recent' ? 'recent' : 'trending'}`); + if (!host) return; // view changed before a late async render const items = Array.isArray(d[key]) ? d[key].slice(0, 6) : []; host.innerHTML = ''; const head = ui.sectionHead(title, { @@ -177,6 +181,7 @@ export default async function renderHome(root, ctx) { function renderShortcuts() { const host = root.querySelector('#home-shortcuts'); + if (!host) return; // view changed before a late async render host.innerHTML = ''; host.appendChild(ui.sectionHead('Shortcuts', { iconName: 'tools' })); const row = document.createElement('div'); From 65cb13ed58f4d73d596c00dd9d86fb2201d5bd18 Mon Sep 17 00:00:00 2001 From: Lav-developer <170819619+Lav-developer@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:25:49 +0000 Subject: [PATCH 04/13] ci: capture the Gradle build tail into the step summary for API-readable compile diagnostics (failure behavior unchanged via pipefail) Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/android-apk.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index c9c0dbd..fa9d066 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -94,8 +94,13 @@ jobs: - name: Build debug APK working-directory: android-app/android run: | + set -o pipefail chmod +x ./gradlew - ./gradlew --no-daemon assembleDebug + # tee keeps the step's exit code (pipefail) while capturing the log — + # the tail lands in the step summary so compile errors are readable + # via the API even where the raw log download is not available. + ./gradlew --no-daemon assembleDebug 2>&1 | tee build-log.txt + { echo "## Gradle build output (tail)"; echo '```'; tail -n 150 build-log.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY" - name: Show APK details run: ls -lh android-app/android/app/build/outputs/apk/debug/ From dcd65d7c5aab24e99781798010a7bb6f24844704 Mon Sep 17 00:00:00 2001 From: Lav-developer <170819619+Lav-developer@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:31:08 +0000 Subject: [PATCH 05/13] Fix Android compile: androidx.credentials 1.3.0 renamed the CredentialManager factory getClient() -> create() Per the androidx.credentials 1.3.0 source (CredentialManager.kt), the companion factory is now @JvmStatic fun create(context: Context); getClient(context) only existed up to 1.2.x. The plugin called the old name, so :app:compileDebugJavaWithJavac failed with 'cannot find symbol: method getClient(Activity)'. The remainder of the credential surface is unchanged and version-correct: GetGoogleIdOption.Builder setters, GoogleIdTokenCredential.createFrom (@JvmStatic), CustomCredential.getType/getData and the getCredentialAsync entry point (still located reflectively, arity-shape tolerant). Native Google architecture untouched: Credential Manager -> Google ID token -> Firebase accounts:signInWithIdp. Also reverts the temporary step-summary log capture (its purpose, surfacing the compile error, is served; failure behavior identical). Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/android-apk.yml | 7 +------ .../app/src/main/java/com/dsmnru/pyq/DsmnruAppPlugin.java | 4 +++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index fa9d066..c9c0dbd 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -94,13 +94,8 @@ jobs: - name: Build debug APK working-directory: android-app/android run: | - set -o pipefail chmod +x ./gradlew - # tee keeps the step's exit code (pipefail) while capturing the log — - # the tail lands in the step summary so compile errors are readable - # via the API even where the raw log download is not available. - ./gradlew --no-daemon assembleDebug 2>&1 | tee build-log.txt - { echo "## Gradle build output (tail)"; echo '```'; tail -n 150 build-log.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY" + ./gradlew --no-daemon assembleDebug - name: Show APK details run: ls -lh android-app/android/app/build/outputs/apk/debug/ diff --git a/android-app/android/app/src/main/java/com/dsmnru/pyq/DsmnruAppPlugin.java b/android-app/android/app/src/main/java/com/dsmnru/pyq/DsmnruAppPlugin.java index c384682..7408494 100644 --- a/android-app/android/app/src/main/java/com/dsmnru/pyq/DsmnruAppPlugin.java +++ b/android-app/android/app/src/main/java/com/dsmnru/pyq/DsmnruAppPlugin.java @@ -111,7 +111,9 @@ public void googleSignIn(PluginCall call) { String nonceHash = sha256Hex(call.getString("nonce", "")); try { - CredentialManager credentialManager = CredentialManager.getClient(activity); + // androidx.credentials 1.3.0 renamed the companion factory + // getClient(context) → create(context) (@JvmStatic — direct call). + CredentialManager credentialManager = CredentialManager.create(activity); GetGoogleIdOption googleOption = new GetGoogleIdOption.Builder() .setServerClientId(clientId) // false → show ALL device Google accounts (fresh chooser), From e4060ad948927bba39a8a4160e5b89567fe54016 Mon Sep 17 00:00:00 2001 From: Lav-developer <170819619+Lav-developer@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:34:06 +0000 Subject: [PATCH 06/13] ci: bump actions/upload-artifact v4 -> v7 (clears the Node 20 deprecation warning; behavior unchanged) Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/android-apk.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index c9c0dbd..a02a8d6 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -101,7 +101,7 @@ jobs: run: ls -lh android-app/android/app/build/outputs/apk/debug/ - name: Upload debug APK artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: dsmnru-pyq-debug-apk path: android-app/android/app/build/outputs/apk/debug/app-debug.apk From cad7d4fd7d93153287f69d53734da313d14f2ac8 Mon Sep 17 00:00:00 2001 From: Lav-developer <170819619+Lav-developer@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:39:37 +0000 Subject: [PATCH 07/13] v1.3.1: stable debug builds, UX polish, auth/profile/rewards fixes, URL-free UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debug APK update strategy (#1): all debug APKs (local + CI) now sign with a shared COMMITTED debug.keystore holding the standard PUBLIC Android debug credentials (store 'android', alias 'androiddebugkey') — one stable debug signature, so newly downloaded debug APKs UPDATE the installed debug build instead of failing with "package conflicts with an existing package". applicationId stays com.dsmnru.pyq everywhere (verified against the Capacitor config, namespace and manifest — one app identity); versionCode 5 / versionName 1.3.1, version now shown on Profile/About ("Version 1.3.1"). Release signing stays out-of-repo; no production credentials invented. UI decongestion (#2): global [hidden]{display:none!important} correctness rule, calmer section headers (quiet uppercase labels), softer card borders, borderless stat pills, accent-line notices instead of boxed gold panels, more vertical rhythm (stack 22px, card-pad 18px, form fields 16px), empty Home rails now hide instead of showing filler text, trimmed notice copy, drawer/tab/sheet spacing polish. All functionality and test-asserted content preserved. No technical endpoints in the UI (#3): About no longer renders the Worker hostname or the Firebase project id; the paper report POST moved to feedback.js (views stay endpoint-free); search/browse/paper/contributors/ home/upload errors are human text with details in console logs; friendly() scrubs URLs; Google fallback copy de-jargoned. Enforced by a new audit test. Back arrow (#4): root cause was CSS — .icon-btn display outranked the UA [hidden] rule, so the back arrow (and every hidden-toggled element) stayed visible. Fixed by the global [hidden] rule plus an explicit state machine: back is enabled ONLY on pushed screens; Home/tab roots show only the hamburger; tab switches clear back state; drawer never mutates the stack. Covered by a new jsdom navigation-state test. Create account (#5): the silent failure was wireSubmit looking for [data-err] INSIDE the signup/reset forms while the single error div sat outside the login form — errors were written to null. Every form now owns its error target; busy labels ('Creating account…'); duplicate-submit guard; chosen name now lands in the session AND users/{uid} profile (nameOverride through adoptTokenSession); friendly mappings added (INVALID_LOGIN_CREDENTIALS, MISSING_PASSWORD) and URL-scrubbed fallbacks. Covered by jsdom tests for EMAIL_EXISTS feedback + successful signup. Google sign-in (#6): no website hand-off existed or remains (audited); native Credential Manager → ID token → accounts:signInWithIdp preserved; not-configured/explicit fallbacks are in-app only; assertions updated to the human copy. Profile & rewards (#7/#8): photo avatar (Firebase/Google picture) with initials fallback, editable display name (accounts:update + users/{uid} name patch — the SAME website profile), lazy reward summary reading the SAME email-keyed reward_accounts/{email-key}.points + point_transactions the website's points card uses (two reads, 5-min session cache, sign-out invalidation), zero-state with upload CTA, human error + retry. No fake delete button (no existing secure flow). Sign-out clears session + caches. Auth lifecycle (#9/#10): untouched architecture; rewards load only after authentication; every async op has loading → success/readable error. Tests (#12): 48/48 pass locally (jsdom runs in-repo): new coverage for back-arrow state, signup success + Firebase error, profile rendering (name/email/version/rewards), profile update (updateMask=name), rewards rendering, no-endpoint audit, [hidden] rule, per-form signup errors. Worker suites: 398/398 pass. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- android-app/README.md | 16 +- android-app/android/.gitignore | 9 + android-app/android/app/build.gradle | 25 ++- android-app/android/app/debug.keystore | Bin 0 -> 2626 bytes android-app/test/app-frontend-smoke.test.mjs | 122 ++++++++++- android-app/test/app-native-bridge.test.mjs | 8 +- android-app/test/fcm.test.mjs | 8 +- android-app/test/features.test.mjs | 62 +++++- android-app/www/css/app.css | 97 +++++++++ android-app/www/js/app.js | 20 +- android-app/www/js/auth.js | 122 ++++++++++- android-app/www/js/authui.js | 41 ++-- android-app/www/js/feedback.js | 41 ++++ android-app/www/js/views/about.js | 14 +- android-app/www/js/views/browse.js | 2 +- android-app/www/js/views/contributors.js | 2 +- android-app/www/js/views/home.js | 24 +-- android-app/www/js/views/links.js | 3 +- android-app/www/js/views/paper.js | 28 +-- android-app/www/js/views/profile.js | 211 ++++++++++++++----- android-app/www/js/views/search.js | 6 +- android-app/www/js/views/tools.js | 3 +- android-app/www/js/views/upload.js | 8 +- 23 files changed, 736 insertions(+), 136 deletions(-) create mode 100644 android-app/android/app/debug.keystore create mode 100644 android-app/www/js/feedback.js diff --git a/android-app/README.md b/android-app/README.md index 5a6852b..7b4349a 100644 --- a/android-app/README.md +++ b/android-app/README.md @@ -236,9 +236,23 @@ signed release APK/AAB can be added later via repository secrets already auto-applies the Google Services plugin when `android/app/google-services.json` is present at build time). +## Debug builds & updates in place + +Every debug APK — local and CI — is signed with the **shared, committed +`android/app/debug.keystore`** (the standard PUBLIC Android debug +credentials: store `android`, alias `androiddebugkey` — the same ones in +every developer's `~/.android/debug.keystore`). One stable debug signature +means a newly downloaded debug APK can **update** an installed debug build +in place instead of failing with "App not installed as package conflicts +with an existing package". The applicationId stays `com.dsmnru.pyq` across +debug and release (one app identity, no second package). Release signing +remains out-of-repo and unconfigured — no production keystore credentials +exist in this repository. `versionCode` is bumped with every released +iteration (currently **5 / 1.3.1**). + ## Deferred by design -* **Release signing** — debug builds only, see above. +* **Release signing** — out-of-repo by design, see above. * **Google sign-in console registration** — the code is complete, but each build environment must register its keystore SHA-1 + set the Web client ID once (see `docs/GOOGLE_SIGNIN_SETUP.md`). Unconfigured builds degrade to diff --git a/android-app/android/.gitignore b/android-app/android/.gitignore index f67d9db..4b65bdd 100644 --- a/android-app/android/.gitignore +++ b/android-app/android/.gitignore @@ -104,3 +104,12 @@ app/src/main/res/xml/config.xml google-services.json *.jks *.keystore +# EXCEPTION: the shared DEBUG keystore (android/app/debug.keystore) is +# deliberately committed — its credentials are the standard PUBLIC Android +# debug credentials (store 'android', alias 'androiddebugkey'), the same ones +# in every developer's ~/.android/debug.keystore. Committing it gives every +# CI/debug APK the same signature, so a newly downloaded debug APK can +# UPDATE the previously installed debug build instead of failing with +# "package conflicts with an existing package". Release signing stays +# out-of-repo and unrelated. +!app/debug.keystore diff --git a/android-app/android/app/build.gradle b/android-app/android/app/build.gradle index 3057998..fe98e1a 100644 --- a/android-app/android/app/build.gradle +++ b/android-app/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.dsmnru.pyq" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 4 - versionName "1.3.0" + versionCode 5 + versionName "1.3.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -16,10 +16,31 @@ android { ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' // public Android debug convention + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } buildTypes { + debug { + // All debug APKs — local AND CI — are signed with the shared, + // COMMITTED debug.keystore (standard PUBLIC Android debug + // credentials). One stable debug signature means a newly + // downloaded debug APK can UPDATE an installed debug build in + // place instead of failing with "App not installed as package + // conflicts with an existing package". The applicationId + // (com.dsmnru.pyq) stays identical across debug/release — one + // app identity. + signingConfig signingConfigs.debug + } release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + // Release signing is deliberately NOT configured in-repo (no + // production keystore credentials are committed or invented). } } } diff --git a/android-app/android/app/debug.keystore b/android-app/android/app/debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..77553b51079f5d1e21de1f620b3fb960ae326ac2 GIT binary patch literal 2626 zcmai$XEYlO7sn%#RH#ukg9wdLMS`j(6y>4zu9~fpidDo`kCmcCjn=Bh-m}AsRccde zwWVfiRgqdX>QVK2&-^=0x-)dS zMCdYz2%SHbXVF9u?2iS>C4!hvr87V}z-a>fap=%@em7udvtK=FgC-oTiai*M6Y`g z1AZ4yq)#%q_(3%9+sRWhT~=Hw{G>l=i75$204 z-K!566b4`KXkV7CK5|mMGsCa+P#c?`hVa^qlV_F@KAwbZwVoU?m^Dmcd;709+sM$A zdAd+l%k4c2uRIzB{HKCcCsohg@H=>b(Xgy3DZ6U5=AMcRCS3otGQ<3w?kzF#j%7w0 z&LUQ%LH|qX8*gpI)Z~bM7T?X)DhQ%*tU#?cM~~<0@w#LoUjg*UoxA>R!HatEUk2D# zj_Tpb`O)Nm@?MK0#X3^*J!uf*(LMSH_C_j&XKt7)9B=o&E)l24K-AizFSW|5Id4^X zoV%9s!Qk4Mf|QhoAG4B;sg*TR^_mw+=v;iJMb*`!@e>pBJ=h|f*yFSWnbfTEf&rP( zJJq35eu5mjSc+4W>x0KlRh)guCb{?G+;vxB5P6m3@y`tpF9Rt)71XQsv?W^sdo#QFyW^W*I@Y-+Scn4dm5yKZTvmgxqp zT(JzEs4F`sEX)^eDKK}pc{lEk)sM%RyM@fFUW4j{w=LZhUu9TMGHc zooZr92Gfp?=2FwELZnXT(zTn8D@+4A{uE6%&d)ENiKGNk`&i*fY44hXD$HNfoGd5p z#S43OQGTwtF2uroH>J$=TdPn;v-792n{D^i&Nsi&|IVo{#xhsJ; zR7<~%4F;tMNY>@2`=;aXu@F+)Cvh6>)OP$|oJrWMKy0}kxcYDZp zUInFo((TWhQ0{dPkLQ)maF5zx#7HLJ(RVLs7R`xycvq-kbirR%qvq$>f!Q%UsAQBX zOjAgxJ!^X9MP``THg0@Py%T`Sa+@LcX2uR^&OUbNgoDf#mTd z-&EeYuzGN0|`a?B(9BBY{E72`OZhUp)M)a1WhB+L~!%(JO3{@z?C2(xb##m zJdIok)Bl-rmXQv4YGO`B`F{gNpAXhU-NTUiSJ!rD_l3*B{|X9@Cf@|oYXH<)gnFJK z7S9PHf=0jzW5~eI6Jl~0E*7rg+l9MUoT;?q@C=b>Gld!W;OkLOHr{<(dm)`oH~;dN zxhLW2N5WF?0ryD2zipe~2=ZF5|o&=Fx0if+(souR#i23;sd2_)jTAWP5 zNv;ph*@1141yJb@Gs_?Be(EwGuox~E<%$bE5}sleogQC(@Ds~ZFL|`Afj&jync!rvTcP{G$A<0P{%=TX^b6;1KlLac zkj36mtLILywVF`ce!K{YQ_|~7s|fbOhlHrI>WlJJ=NV3N7Fni+G!f)b$&2Xq%l_q$ z2Fp0``mNS*om51X>3iuwat3NBO3OYVQ8N9?rcJsxxH4(#*#z(WTO?fUYvKsL50n%h z`&@&!GbWOTnO3}&NMCH8WXF27(&LM@aiTFJJ%FFl-iJE&Bp^>Iq9#08k9{-?J9q+Q65L`dwu_P7eiBJ8Ht`Cm zmhjU$Df^(_6_NVtHXDnVz=IU=v0Z<;ro9UJE(;OTKcy6*l|S;dcE<-&H4>NBbK%MlI|Y^RW@l6 zvOG?g9HjN?Lt|deqsw--+-E}2oo2AeM-A;h#>|31;lbR?qi9NbVZyhq?ywyusjR6B zOnv>ryV4V)Qj-fFisP|M-r@|I-)`DUZr*fTu520mTFUc)Oe~jaaPTZGa+xXSD zS#C%~Vq?9g#drO~h7IVemT;5JZ@iutsWfC6^T`tKWlvVB4aZUF%It_k$~wv48s@dt zI1}u#1GpAYtBKwB3&Sm(W;5jUS@#1-ZUONgnB5w_9(Y4UAgPc4!ugQF!?cAvs{>6$1AEMReLHL z6R2|pOY�+Ixk;bt|LRR6jTl8YwjEouj!f((*Ic2y& z92fb9-_`dGYf)|9dKqxTpn_vu1hmwjAr2V?91gGtcmQw!Z-6Jj!GZl!|(?`?Qi~+Cg1oh Buffer.from(s).toString('base64url'); return b64u('{"alg":"none"}') + '.' + b64u(JSON.stringify({ - exp, user_id: 'uid-9', sub: 'uid-9', email: 'stud@dsmnru.in', name: 'Test Student', + exp, user_id: w.uid, sub: w.uid, email: w.email, name: w.name, email_verified: true, firebase: { sign_in_provider: 'password' }, })) + '.s'; } @@ -101,8 +106,30 @@ for (const key of ['window', 'document', 'navigator', 'location', 'localStorage' if (u.includes('/api/contributors')) return ok(CONTRIBUTORS); if (u.includes('api.gofile.io/servers')) return ok({ status: 'ok', data: { servers: [{ name: 'store1' }] } }); if (u.includes('/pendingUploads')) return ok({}); + if (u.includes('accounts:signUp')) { + const body = JSON.parse(opts.body || '{}'); + if (body.email === 'taken@dsmnru.in') { + return { ok: false, status: 400, json: async () => ({ error: { message: 'EMAIL_EXISTS' } }) }; + } + return ok({ idToken: jwt(nowSec + 3600, 'fresh'), refreshToken: 'RT-N', expiresIn: '3600' }); + } + if (u.includes('accounts:lookup')) { + return ok({ users: [{ email: 'stud@dsmnru.in', displayName: 'Test Student', emailVerified: true }] }); + } + if (u.includes('sendOobCode')) return ok({}); + if (u.includes('/reward_accounts/')) { + return ok({ fields: { points: { integerValue: '40' }, email: { stringValue: 'stud@dsmnru.in' } } }); + } + if (u.includes(':runQuery')) { + const row = (n) => ({ document: { fields: { + amount: { integerValue: '10' }, type: { stringValue: 'PYQ_UPLOAD' }, + email: { stringValue: 'stud@dsmnru.in' }, + createdAt: { timestampValue: '2026-08-1' + n + 'T10:00:00Z' }, + } } }); + return ok([row(1), row(2), row(3)]); + } if (u.includes('signInWithPassword')) return ok({ - idToken: jwt(nowSec + 3600), refreshToken: 'RT', expiresIn: '3600', email: 'stud@dsmnru.in', + idToken: jwt(nowSec + 3600, 'existing'), refreshToken: 'RT', expiresIn: '3600', email: 'stud@dsmnru.in', }); if (u.includes('accounts:signInWithIdp')) return ok({ idToken: jwt(nowSec + 3600), refreshToken: 'RT-G', expiresIn: '3600', @@ -354,6 +381,95 @@ if (JSDOM) { assert.ok(await waitFor(() => view().querySelector('#up-form')), 'home shortcut opens the in-app upload screen'); assert.equal(opened.length, openedBeforeShortcuts, 'shortcuts never open the browser'); + // ══════════════════════════════════════════════════════════════════ + // v1.3.1 — profile management, rewards, back-arrow state, signup + // ══════════════════════════════════════════════════════════════════ + + // ── Profile: identity, version, lazy rewards, editable name ───────── + document.querySelector('.tab[data-tab="profile"]').click(); + await waitFor(() => view().querySelector('.profile-card')); + assert.match(text(view().querySelector('.profile-card')), /Test Student/, 'profile shows display name'); + assert.match(text(view().querySelector('.profile-card')), /stud@dsmnru\.in/, 'profile shows email'); + assert.match(text(view()), /Version 1\.3\.1/, 'app version visible on Profile'); + assert.ok(await waitFor(() => view().querySelector('#pf-rewards .hero-title')), 'rewards section loads lazily'); + assert.equal(view().querySelectorAll('#pf-rewards .hero-title')[0].textContent, '40', + 'points balance from the SAME reward account the website reads'); + assert.match(text(view()), /PYQ contribution/, 'rewarded contributions listed'); + assert.ok(calls.some((c) => c.url.includes('/reward_accounts/')), 'reward account read lazily (auth only)'); + assert.ok(calls.some((c) => c.url.includes(':runQuery')), 'reward history read once'); + const rewardReads = calls.filter((c) => c.url.includes('/reward_accounts/') || c.url.includes(':runQuery')).length; + view().querySelector('[data-act="editname"]').click(); + await waitFor(() => document.querySelector('.sheet-root #pf-name')); + document.querySelector('.sheet-root #pf-name').value = 'Aarav Test'; + document.querySelector('.sheet-root #pf-save').click(); + assert.ok(await waitFor(() => text(view()).includes('Aarav Test')), 'name updated in UI after save'); + assert.ok(await waitFor(() => text(document.body).includes('Profile updated')), 'success feedback shown'); + assert.ok(calls.some((c) => c.url.includes('updateMask=name')), 'users/{uid}.name patched — SAME website profile row'); + assert.ok(calls.some((c) => c.url.includes('accounts:update')), 'Auth display name updated too'); + assert.ok(calls.filter((c) => c.url.includes('/reward_accounts/') || c.url.includes(':runQuery')).length === rewardReads, + 'rewards not re-fetched during unrelated actions'); + + // ── Back-arrow state: Home NEVER shows one; pushed screens do ─────── + document.querySelector('.tab[data-tab="home"]').click(); + await waitFor(() => view().querySelector('.hero')); + const backArrow = document.getElementById('appbar-back'); + const menuBtn2 = document.getElementById('appbar-menu'); + assert.equal(backArrow.hidden, true, 'Home shows NO back arrow'); + assert.equal(backArrow.disabled, true, 'back arrow is not activatable on Home'); + assert.equal(menuBtn2.hidden, false, 'Home shows the drawer menu'); + assert.ok(await waitFor(() => view().querySelector('[data-paper-id]')), 'recent rail rendered'); + view().querySelector('[data-paper-id]').click(); + assert.ok(await waitFor(() => view().querySelector('.paper-hero')), 'paper pushed'); + assert.equal(backArrow.hidden, false, 'pushed paper screen shows the back arrow'); + assert.equal(menuBtn2.hidden, true, 'menu hidden while pushed'); + backArrow.click(); + assert.ok(await waitFor(() => view().querySelector('.hero')), 'back arrow pops to Home'); + assert.equal(backArrow.hidden, true, 'back arrow removed after returning Home'); + document.querySelector('.tab[data-tab="search"]').click(); + await waitFor(() => view().querySelector('#sq')); + assert.equal(backArrow.hidden, true, 'tab switch never leaves stale back state'); + document.querySelector('.tab[data-tab="home"]').click(); + await waitFor(() => view().querySelector('.hero')); + document.getElementById('appbar-menu').click(); + await waitFor(() => !document.getElementById('drawer-root').hidden); + document.querySelector('#drawer-root [data-view="upload"]').click(); + await waitFor(() => view().querySelector('#up-form')); + assert.equal(backArrow.hidden, false, 'drawer-navigated screen shows the back arrow'); + document.getElementById('appbar-menu').hidden = true; // guard: must be hidden while pushed + document.querySelector('.tab[data-tab="home"]').click(); + await waitFor(() => view().querySelector('.hero')); + assert.equal(backArrow.hidden, true, 'Home via bottom nav clears the back arrow again'); + + // ── Create account: real Firebase error feedback, then success ────── + document.querySelector('.tab[data-tab="profile"]').click(); + await waitFor(() => view().querySelector('[data-act="signout"]')); + view().querySelector('[data-act="signout"]').click(); + await waitFor(() => document.querySelector('.sheet-root [data-confirm]')); + document.querySelector('.sheet-root [data-confirm]').click(); + await waitFor(() => view().querySelector('[data-act="signup"]')); + view().querySelector('[data-act="signup"]').click(); + assert.ok(await waitFor(() => document.querySelector('.sheet-root form[data-form="signup"]')), 'signup form opens in-app'); + // error path: existing email → Firebase EMAIL_EXISTS → readable message + document.querySelector('.sheet-root #auth-name').value = 'Aarav Sharma'; + document.querySelector('.sheet-root #auth-s-email').value = 'taken@dsmnru.in'; + document.querySelector('.sheet-root #auth-s-pass').value = 'secret123'; + document.querySelector('.sheet-root form[data-form="signup"] button[type="submit"]').click(); + assert.ok(await waitFor(() => { + const err = document.querySelector('.sheet-root form[data-form="signup"] [data-err]'); + return err && !err.hidden && /already exists/i.test(err.textContent); + }), 'EMAIL_EXISTS surfaces as a readable in-form error'); + assert.equal(document.querySelector('.sheet-root form[data-form="signup"] button[type="submit"]').disabled, false, + 'submit re-enabled after failure'); + // success path + document.querySelector('.sheet-root #auth-s-email').value = 'new@dsmnru.in'; + document.querySelector('.sheet-root form[data-form="signup"] button[type="submit"]').click(); + assert.ok(await waitFor(() => !document.querySelector('.sheet-root')), 'sheet closes on successful signup'); + assert.ok(calls.some((c) => c.url.includes('accounts:signUp')), 'Identity Toolkit signUp called (same Firebase project)'); + assert.ok(await waitFor(() => { + const card = view().querySelector('.profile-card'); + return card && /Aarav Sharma/.test(text(card)) && /new@dsmnru\.in/.test(text(card)); + }), 'authenticated profile shows the new account with its chosen name'); + // ── Google button without a native layer → in-app explainer, no website ── document.querySelector('.tab[data-tab="profile"]').click(); await waitFor(() => view().querySelector('.profile-card')); diff --git a/android-app/test/app-native-bridge.test.mjs b/android-app/test/app-native-bridge.test.mjs index 5626d75..740fe8b 100644 --- a/android-app/test/app-native-bridge.test.mjs +++ b/android-app/test/app-native-bridge.test.mjs @@ -212,7 +212,7 @@ for (const key of ['window', 'document', 'navigator', 'location', 'localStorage' view().querySelector('[data-act="google"]').click(); assert.ok(await waitFor(() => { const sheet = document.querySelector('.sheet-root'); - return sheet && /Google sign-in/.test(text(sheet)) && /configured/.test(text(sheet)); + return sheet && /Google sign-in/.test(text(sheet)) && /isn't set up/.test(text(sheet)); }), 'not-configured explainer shown'); assert.ok(!/Open website/.test(text(document.querySelector('.sheet-root'))), 'never sends the user to the website'); document.querySelector('.sheet-root [data-act="email"]').click(); @@ -237,7 +237,9 @@ for (const key of ['window', 'document', 'navigator', 'location', 'localStorage' }), 'profile shows the same Firebase identity flagged as a Google account'); assert.ok(calls.some((c) => c.url.includes('/documents/users/g-uid-1')), 'Google sign-in synced users/g-uid-1'); assert.ok(calls.some((c) => c.url.includes('/documents/users/pw-uid-1')), 'password sign-in synced users/pw-uid-1'); - const firestoreCalls = calls.filter((c) => c.url.includes('firestore.googleapis.com')).length; - assert.ok(firestoreCalls <= 2, 'exactly one owner-scoped profile sync per manual sign-in, nothing else'); + // One owner-scoped profile sync per manual sign-in (the lazy reward reads + // on the Profile screen are separate, user-initiated reads). + const profileSyncs = calls.filter((c) => c.url.includes('/documents/users/')).length; + assert.ok(profileSyncs <= 2, 'exactly one owner-scoped profile sync per manual sign-in, nothing else'); }); } diff --git a/android-app/test/fcm.test.mjs b/android-app/test/fcm.test.mjs index 9fbf576..73aeecb 100644 --- a/android-app/test/fcm.test.mjs +++ b/android-app/test/fcm.test.mjs @@ -62,8 +62,12 @@ test('Gradle wires firebase-messaging and keeps google-services conditional', () assert.match(gradle, /apply plugin: 'com\.google\.gms\.google-services'/, 'google-services plugin applied when google-services.json exists'); assert.match(gradle, /google-services\.json/, 'apply is guarded by the presence of google-services.json'); - assert.match(gradle, /versionCode 4/, 'versionCode bumped for the FCM release'); - assert.match(gradle, /versionName "1\.3\.0"/, 'versionName bumped for the FCM release'); + assert.match(gradle, /versionCode 5/, 'versionCode increased for the 1.3.1 release'); + assert.match(gradle, /versionName "1\.3\.1"/, 'versionName 1.3.1'); + // Consistent package identity + stable debug signature (update-in-place). + assert.match(gradle, /applicationId "com\.dsmnru\.pyq"/, 'single applicationId preserved'); + assert.match(gradle, /signingConfig signingConfigs\.debug/, 'debug buildType uses the shared debug signing config'); + assert.match(gradle, /storeFile file\('debug\.keystore'\)/, 'shared committed debug keystore (public debug credentials)'); const rootGradle = readFileSync(join(here, '../android/build.gradle'), 'utf8'); assert.match(rootGradle, /com\.google\.gms:google-services:[\d.]+/, 'plugin classpath on the root buildscript'); diff --git a/android-app/test/features.test.mjs b/android-app/test/features.test.mjs index aba4aa3..4d457c5 100644 --- a/android-app/test/features.test.mjs +++ b/android-app/test/features.test.mjs @@ -431,6 +431,64 @@ test('links dataset: https-only university/government portals, zero PYQ-website assert.equal(total, 14, 'same 14 destinations as links.html'); }); +// ── v1.3.1: no technical endpoints in the UI; hidden semantics; signup ── + +test('audit: no API/worker/Firebase endpoints are rendered anywhere in the UI', () => { + const jsDir = join(here, '../www/js'); + // Views + shared UI are the ONLY layers that render user-facing text. + // Endpoint strings live exclusively in logic modules (api.js, auth.js, + // uploadcore.js) and are never placed into the DOM. + const uiFiles = [ + join(jsDir, 'ui.js'), + join(jsDir, 'drawer.js'), + ...readdirSync(join(jsDir, 'views')).map((f) => join(jsDir, 'views', f)), + ]; + const banned = [ + 'dsmnru-pyq-api', '.workers.dev', 'firestore.googleapis', + 'identitytoolkit', 'securetoken', 'gofile.io', '/api/', + ]; + const stripComments = (s) => s + .replace(/\/\*[\s\S]*?\*\//g, '') // block comments + .split('\n') + .filter((line) => !/^\s*(\*|\/\/)/.test(line)) // comment-only lines + .join('\n'); + for (const file of uiFiles) { + const src = stripComments(readFileSync(file, 'utf8')); + for (const markerString of banned) { + assert.ok(!src.includes(markerString), + `${file} renders or embeds the technical endpoint "${markerString}" — the UI must stay human-only`); + } + } + // Firebase error text must never leak raw backend messages (URL scrubbing). + const auth = readFileSync(join(jsDir, 'auth.js'), 'utf8'); + assert.match(auth, /replace\(\/https\?:\\\/\\\/\\S\+\/g/, 'friendly() scrubs URLs from error text'); + assert.match(auth, /fetchRewardSummary/, 'lazy reward summary exists (same email-keyed reward data)'); +}); + +test('v1.3.1 polish: [hidden] wins over component CSS; signup errors are per-form; version is 1.3.1', () => { + const css = readFileSync(join(here, '../www/css/app.css'), 'utf8'); + assert.match(css, /\[hidden\]\s*{\s*display:\s*none\s*!important;/, + 'global [hidden] rule — the app-bar back arrow and every toggled element obey hidden'); + + const authui = readFileSync(join(here, '../www/js/authui.js'), 'utf8'); + const signupForm = authui.match(/
/); + assert.ok(signupForm, 'signup form present'); + assert.match(signupForm[0], /data-err/, 'signup form owns its error target (no silent failures)'); + assert.match(authui, /Creating account…/, 'signup busy state present'); + assert.ok(!authui.includes('Create account on website'), 'no website account-creation hand-off'); + assert.ok(!authui.includes('GOOGLE_SIGNIN_SETUP.md'), 'no technical paths in user-facing Google fallback'); + + const profile = readFileSync(join(here, '../www/js/views/profile.js'), 'utf8'); + assert.match(profile, /1\.3\.1/, 'app version visible on Profile'); + assert.match(profile, /avatar-img/, 'profile photo rendered where Firebase/Google provides one'); + assert.match(profile, /updateDisplayName/, 'name editing wired to the SAME user profile'); + assert.match(profile, /reward points/, 'upload/reward points visible in Profile'); + assert.ok(!profile.includes('Delete account'), 'no fake delete button without an existing secure flow'); + + const home = readFileSync(join(here, '../www/js/views/home.js'), 'utf8'); + assert.match(home, /host\.classList\.add\('hidden'\)/, 'empty home rails hide instead of showing filler text'); +}); + // ── WEBSITE-REDIRECT AUDIT (self-containment guarantee) ──────────────── test('audit: no website navigation for normal app features (strict allowlist)', () => { @@ -497,5 +555,7 @@ test('audit: no website navigation for normal app features (strict allowlist)', } const paper = readFileSync(join(jsDir, 'views', 'paper.js'), 'utf8'); assert.match(paper, /ctx\.openPdf\(/, 'Open PDF goes through the in-app viewer first'); - assert.match(paper, /documents\/feedback/, 'report broken link submits in-app'); + assert.match(paper, /submitBrokenLinkReport/, 'report broken link submits in-app (endpoint lives in the feedback module)'); + const feedback = readFileSync(join(jsDir, 'feedback.js'), 'utf8'); + assert.match(feedback, /documents\/feedback/, 'reports land in the SAME Firestore queue the website uses'); }); diff --git a/android-app/www/css/app.css b/android-app/www/css/app.css index 9160706..4eaeeba 100644 --- a/android-app/www/css/app.css +++ b/android-app/www/css/app.css @@ -871,3 +871,100 @@ input, textarea { user-select: text; } .about-list li::before { content: ''; position: absolute; left: 2px; top: 8px; width: 5px; height: 5px; border-radius: 99px; background: var(--teal); } .about-list .link-ext { color: var(--mint); font-weight: 700; text-decoration: none; } .mono { font-family: ui-monospace, monospace; font-size: 0.74rem; } + +/* ══════════════════════════════════════════════════════════════════════ + v1.3.1 — correctness + polish layer + ══════════════════════════════════════════════════════════════════════ */ + +/* The `hidden` attribute must ALWAYS win over component display rules. + Component selectors like `.icon-btn { display:inline-flex }` outrank the + UA's [hidden] rule, which made the app-bar back arrow (and any other + element toggled via hidden) permanently visible. One global rule fixes + the semantics for every toggled element in the app. */ +[hidden] { display: none !important; } + +/* ── breathing room: fewer, calmer, better-separated sections ───────── */ +.stack { gap: 22px; } +#view { padding-bottom: 28px; } + +.card { border-color: rgba(148, 163, 184, 0.14); } +.card-pad { padding: 18px; } +.card-pad > .section-head:first-child { margin-bottom: 4px; } + +/* Section headers become quiet labels — the content is the hierarchy. */ +.section-head h2 { + font-size: 0.8rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} +.section-head .ic { width: 16px; height: 16px; opacity: 0.85; } +.section-head + * { margin-top: 12px; } + +/* Notices: calm accent line instead of a boxed gold panel. */ +.notice { + border: 0; + border-left: 2px solid rgba(20, 184, 166, 0.55); + border-radius: 10px; + background: rgba(20, 184, 166, 0.06); + padding: 10px 14px; + font-size: 0.8rem; +} +.notice--info { border-left-color: rgba(20, 184, 166, 0.55); background: rgba(20, 184, 166, 0.06); } +.notice .ic { color: var(--teal); width: 16px; height: 16px; } + +/* Stat pills: quiet text chips, no pill chrome. */ +.stat-pill { border: 0; background: rgba(148, 163, 184, 0.08); padding: 6px 11px; } + +/* Cards stack more air between siblings; consistent form rhythm. */ +.stack > section + section { margin-top: 2px; } +form .field { margin-bottom: 16px; } + +/* Home: compact secondary info, prominent primary actions. */ +.hero { padding: 4px 2px 2px; } +.hero-title { font-size: 1.5rem; } +.hero-kicker { font-size: 0.72rem; } +.stat-pills { margin-top: 12px; } +.search-launch { margin-top: 14px; } +.quick-grid { gap: 12px; } + +/* Paper list rhythm: slightly larger separation, calmer cards. */ +.paper-card { padding: 13px 15px; } + +/* Drawer: calmer, scannable, no boxy separators. */ +.drawer { padding-bottom: 10px; } +.drawer-item { padding: 13px 18px; border-radius: 14px; } +.drawer-item span small { font-size: 0.7rem; } +.drawer-sep { margin: 10px 18px; opacity: 0.5; } +.drawer-user { padding: 14px 18px; } + +/* Sheets keep content away from edges. */ +.sheet { padding-bottom: 14px; } +.sheet-body { padding: 4px 18px 6px; } + +/* Bottom navigation: same items, less ink. */ +.tabbar { border-top-color: rgba(148, 163, 184, 0.1); } +.tab span { font-size: 0.62rem; letter-spacing: 0.02em; } + +/* Empty-rail placeholders are hidden by the views; nothing needed here. */ + +/* Profile: photo avatar (Firebase/Google picture) + rewards rows. */ +.avatar-img { + width: 100%; + height: 100%; + border-radius: inherit; + object-fit: cover; + display: block; +} +.pf-txn { + display: flex; + align-items: baseline; + gap: 10px; + padding: 7px 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.08); + font-size: 0.8rem; +} +.pf-txn:last-child { border-bottom: 0; } +.pf-txn-amt { color: var(--teal); font-weight: 800; font-size: 0.86rem; } +.pf-txn-date { color: var(--faint); font-size: 0.7rem; } diff --git a/android-app/www/js/app.js b/android-app/www/js/app.js index 239be85..3d672fc 100644 --- a/android-app/www/js/app.js +++ b/android-app/www/js/app.js @@ -104,13 +104,29 @@ async function renderView() { function updateHeader(entry) { const meta = entry.header || { title: 'DSMNRU PYQ' }; + // A pushed screen (stack depth > 1) is the ONLY state where "back" is + // meaningful; tab roots — including Home — show the drawer hamburger. + // tab() collapses the stack to depth 1 and back() pops it, so this flag + // is a pure function of navigation state (never stale across bottom-nav + // switches; the drawer only overlays and never mutates the stack). const isRoot = TAB_VIEWS.has(entry.view) && stack.length === 1; - els.back.hidden = isRoot && !meta.back; + const showBack = !isRoot || !!meta.back; + els.back.hidden = !showBack; + els.back.disabled = !showBack; // never focusable/activatable while hidden + if (showBack) { + els.back.removeAttribute('aria-hidden'); + } else { + els.back.setAttribute('aria-hidden', 'true'); + } els.back.innerHTML = ui.icon('back'); // Hamburger lives at top-level screens (standard Android idiom); pushed // screens show the back arrow instead. The drawer remains one tap away at // every tab root, including Home. - if (els.menu) els.menu.hidden = !isRoot || !!meta.back; + if (els.menu) { + const showMenu = isRoot && !meta.back; + els.menu.hidden = !showMenu; + els.menu.disabled = !showMenu; + } els.title.innerHTML = (isRoot || meta.brand) ? `DSMNRU PYQPYQ archive · Android` : `${ui.esc(meta.title || '')}${meta.sub ? `${ui.esc(meta.sub)}` : ''}`; diff --git a/android-app/www/js/auth.js b/android-app/www/js/auth.js index 781e1ad..06e611e 100644 --- a/android-app/www/js/auth.js +++ b/android-app/www/js/auth.js @@ -41,8 +41,10 @@ const REFRESH_SKEW_MS = 5 * 60 * 1000; const FRIENDLY_ERRORS = { 'EMAIL_NOT_FOUND': 'No account exists for this email yet.', 'INVALID_EMAIL': 'That email address does not look valid.', + 'INVALID_LOGIN_CREDENTIALS': 'Incorrect email or password.', 'INVALID_PASSWORD': 'Incorrect password. Try again or reset it.', 'WRONG_PASSWORD': 'Incorrect password. Try again or reset it.', + 'MISSING_PASSWORD': 'Please enter your password.', 'USER_DISABLED': 'This account has been disabled.', 'EMAIL_EXISTS': 'An account with this email already exists — sign in instead.', 'WEAK_PASSWORD': 'Please choose a password with at least 6 characters.', @@ -59,7 +61,13 @@ function friendly(err) { for (const key of Object.keys(FRIENDLY_ERRORS)) { if (code.includes(key)) return FRIENDLY_ERRORS[key]; } - return (err && err.message) || 'Something went wrong. Please try again.'; + // Never surface raw backend text (it can embed URLs/identifiers) in the UI. + const raw = String((err && err.message) || ''); + if (!raw || /failed to fetch|networkerror|load failed|timed?\s?out/i.test(raw)) { + return 'Please check your internet connection and try again.'; + } + const scrubbed = raw.replace(/https?:\/\/\S+/g, '').replace(/\s{2,}/g, ' ').trim(); + return scrubbed || 'Something went wrong. Please try again.'; } /** Decode a JWT payload without verification — display/session metadata only. */ @@ -84,6 +92,9 @@ export function createAuth(options = {}) { let user = null; // normalized session view let persist = true; // false while offline-recovering const listeners = new Set(); + // Session-scoped reward summary cache (see fetchRewardSummary): one pair + // of reads per short window instead of one pair per Profile visit. + let rewardCache = null; // { uid, email, at, summary } function emit() { for (const fn of listeners) { @@ -108,14 +119,14 @@ export function createAuth(options = {}) { } catch { /* ignore quota */ } } - function viewFromTokens(idToken, refreshToken, obtainedAt) { + function viewFromTokens(idToken, refreshToken, obtainedAt, nameOverride) { const claims = decodeJwtPayload(idToken) || {}; const provider = (claims.firebase && claims.firebase.sign_in_provider) || 'password'; const exp = Number(claims.exp) || 0; return { uid: claims.user_id || claims.sub || '', email: claims.email || '', - name: claims.name || claims.email || 'Student', + name: nameOverride || claims.name || claims.email || 'Student', picture: claims.picture || '', emailVerified: claims.email_verified === true, providerId: provider, @@ -167,8 +178,8 @@ export function createAuth(options = {}) { } /** Sign-in (or sign-up) responses both carry idToken/refreshToken. */ - async function adoptTokenSession(payload) { - const session = viewFromTokens(payload.idToken, payload.refreshToken, now()); + async function adoptTokenSession(payload, nameOverride) { + const session = viewFromTokens(payload.idToken, payload.refreshToken, now(), nameOverride); if (!session.uid) throw new Error('Unexpected auth response'); setUser(session); await syncUserDocument().catch(() => { /* non-fatal, best effort */ }); @@ -328,14 +339,14 @@ export function createAuth(options = {}) { } catch (err) { throw new Error(friendly(err)); } + const displayName = String(name || '').trim(); try { - const displayName = String(name || '').trim(); if (displayName) { await identity('update', { idToken: data.idToken, displayName }); data.displayName = displayName; } } catch { /* name is cosmetic; never fail signup over it */ } - const session = await adoptTokenSession(data); + const session = await adoptTokenSession(data, displayName || undefined); try { await identity('sendOobCode', { requestType: 'VERIFY_EMAIL', idToken: session.idToken, continueUrl: VERIFY_CONTINUE_URL }); } catch { /* the website will re-prompt verification */ } @@ -343,9 +354,106 @@ export function createAuth(options = {}) { }, signOut() { + rewardCache = null; setUser(null); }, + /** + * Profile management (same Firestore profile the website uses): + * updates the Firebase Auth display name AND the users/{uid}.name field + * (owner-writable per the existing security rules), then refreshes the + * in-app session. Throws human-readable errors. + */ + async updateDisplayName(nextName) { + if (!user) throw new Error('Sign in first to update your profile.'); + const displayName = String(nextName || '').trim(); + if (displayName.length < 2 || displayName.length > 80) { + throw new Error('Name must be between 2 and 80 characters.'); + } + try { + await identity('update', { idToken: user.idToken, displayName }); + } catch (err) { + throw new Error(friendly(err)); + } + try { + await fetchImpl(`${FS}/users/${encodeURIComponent(user.uid)}?updateMask=name&key=${FIREBASE_WEB_API_KEY}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + user.idToken, + }, + body: JSON.stringify({ fields: { name: { stringValue: displayName } } }), + }); // best effort — the auth-side name is already updated + } catch { /* non-fatal */ } + const refreshed = await auth.reloadProfile(); + setUser({ ...(refreshed || user), name: displayName }); + return user; + }, + + /** + * Reward/contribution summary for the signed-in user — SAME data the + * website's points card reads: reward_accounts/{email-key}.points plus + * the user's point_transactions (rewarded uploads). Exactly TWO lazy + * reads, only on request (Profile screen), never at startup. + * Missing account ⇒ zero-state, not an error. + */ + async fetchRewardSummary() { + if (!user || !user.email) return null; + const email = String(user.email).trim().toLowerCase(); + const nowMs = now(); + if (rewardCache && rewardCache.uid === user.uid && rewardCache.email === email + && nowMs - rewardCache.at < 5 * 60 * 1000) { + return rewardCache.summary; // short-window cache — Profile revisits cost zero reads + } + const accountKey = email.replace(/[^a-z0-9]/g, '_'); // points.js derivation + const headers = { Authorization: 'Bearer ' + user.idToken }; + let points = 0; + try { + const res = await fetchImpl(`${FS}/reward_accounts/${encodeURIComponent(accountKey)}?key=${FIREBASE_WEB_API_KEY}`, { headers }); + if (res.status === 200) { + const body = await res.json().catch(() => null); + const f = (body && body.fields) || {}; + points = Number(f.points && (f.points.integerValue ?? f.points.doubleValue)) || 0; + } else if (res.status !== 404 && res.status !== 403) { + throw new Error('reward lookup failed'); + } + } catch (err) { + if (String(err && err.message) === 'reward lookup failed') throw err; + throw new Error('points unavailable'); // network failure — caller humanizes + } + let transactions = []; + try { + const res = await fetchImpl(`${FS}:runQuery?key=${FIREBASE_WEB_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify({ + structuredQuery: { + from: [{ collectionId: 'point_transactions' }], + where: { fieldFilter: { field: { fieldPath: 'email' }, op: 'EQUAL', value: { stringValue: email } } }, + limit: 20, + }, + }), + }); + if (res.ok) { + const rows = await res.json().catch(() => []); + transactions = (Array.isArray(rows) ? rows : []) + .filter((r) => r && r.document && r.document.fields) + .map((r) => { + const f = r.document.fields; + const ts = (f.createdAt && (f.createdAt.timestampValue || '')) || (f.date && f.date.timestampValue) || ''; + return { + amount: Number(f.amount && (f.amount.integerValue ?? f.amount.doubleValue)) || 0, + type: (f.type && f.type.stringValue) || 'Reward', + date: ts, + }; + }); + } + } catch { /* history is optional decoration — balance already shown */ } + const summary = { points, transactions }; + rewardCache = { uid: user.uid, email, at: nowMs, summary }; + return summary; + }, + async resendVerification() { if (!user) return; try { diff --git a/android-app/www/js/authui.js b/android-app/www/js/authui.js index 890934f..6539f90 100644 --- a/android-app/www/js/authui.js +++ b/android-app/www/js/authui.js @@ -66,20 +66,31 @@ function field(id, label, type, placeholder, autocomplete) {
`; } -function wireSubmit(formEl, onSubmit) { +function wireSubmit(formEl, onSubmit, { busyLabel = 'Please wait…' } = {}) { formEl.addEventListener('submit', async (e) => { e.preventDefault(); - if (busy) return; + if (busy) return; // no duplicate submissions busy = true; const btn = formEl.querySelector('button[type=submit]'); - const errEl = formEl.querySelector('[data-err]'); - if (errEl) { errEl.hidden = true; } + // Every form owns its error target — a failure must ALWAYS be visible + // on the page (this div used to live outside the signup/reset forms, + // which made those failures silent). + let errEl = formEl.querySelector('[data-err]'); + if (!errEl) { + errEl = document.createElement('div'); + errEl.className = 'form-error'; + errEl.setAttribute('data-err', ''); + formEl.prepend(errEl); + } + errEl.hidden = true; const original = btn ? btn.textContent : ''; - if (btn) { btn.disabled = true; btn.textContent = 'Please wait…'; } + if (btn) { btn.disabled = true; btn.textContent = busyLabel; } try { await onSubmit(formEl); } catch (err) { - if (errEl) { errEl.textContent = String(err && err.message || err); errEl.hidden = false; } + errEl.textContent = String(err && err.message || err); + errEl.hidden = false; + try { errEl.scrollIntoView({ block: 'nearest' }); } catch { /* jsdom */ } } finally { busy = false; if (btn) { btn.disabled = false; btn.textContent = original; } @@ -98,9 +109,8 @@ export function openAuthSheet({ reason = '', onAuthenticated, mode = 'login' } = - - + ${field('auth-email', 'Email', 'email', 'you@student.edu', 'email')} ${field('auth-pass', 'Password', 'password', '••••••••', 'current-password')} @@ -114,6 +124,7 @@ export function openAuthSheet({ reason = '', onAuthenticated, mode = 'login' } =
+ ${field('auth-name', 'Full name', 'text', 'e.g. Ananya Sharma', 'name')} ${field('auth-s-email', 'Email', 'email', 'you@student.edu', 'email')} ${field('auth-s-pass', 'Password (6+ characters)', 'password', 'Choose a strong password', 'new-password')} @@ -121,6 +132,7 @@ export function openAuthSheet({ reason = '', onAuthenticated, mode = 'login' } =