diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index d808ef0..929346e 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' @@ -91,6 +91,20 @@ jobs: working-directory: android-app run: npx cap sync android + - name: Restore Firebase config from GitHub Secrets (optional) + id: firebase + env: + GOOGLE_SERVICES_JSON_B64: ${{ secrets.GOOGLE_SERVICES_JSON_B64 }} + run: | + if [ -n "${GOOGLE_SERVICES_JSON_B64:-}" ]; then + echo "$GOOGLE_SERVICES_JSON_B64" | base64 -d > android-app/android/app/google-services.json + echo "present=true" >> "$GITHUB_OUTPUT" + echo "Firebase config applied from the GOOGLE_SERVICES_JSON_B64 secret (FCM + generated OAuth resources active)." + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice::GOOGLE_SERVICES_JSON_B64 is not configured — the build proceeds without it (Google sign-in uses the committed web-client fallback; FCM stays inactive)." + fi + - name: Build debug APK working-directory: android-app/android run: | @@ -101,9 +115,111 @@ 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 if-no-files-found: error retention-days: 14 + + # ──────────────────────────────────────────────────────────────────────── + # Production release build. Runs in one of two modes: + # · Signing secrets configured (ANDROID_KEYSTORE_B64 + password/alias + # secrets) → signed dsmnru-pyq.apk, verified with apksigner, uploaded. + # · Not configured → the job stops with a clear notice and produces + # NOTHING — a production APK is never falsely labelled. + # Credentials exist only as GitHub Secrets; they are decoded into + # $RUNNER_TEMP at build time and never printed or committed. + # ──────────────────────────────────────────────────────────────────────── + release-apk: + name: Build release APK (dsmnru-pyq.apk) + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Check release signing configuration + id: signing + env: + ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} + run: | + if [ -n "${ANDROID_KEYSTORE_B64:-}" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice::Production signing is not configured yet. Add the ANDROID_KEYSTORE_B64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD repository secrets (keystore base64) to build dsmnru-pyq.apk. The keystore must stay the same for every future update." + fi + + - name: Set up Java 21 (Gradle/AGP requirement) + if: steps.signing.outputs.present == 'true' + uses: actions/setup-java@v6 + with: + distribution: 'temurin' + java-version: '21' + cache: gradle + + - name: Install JS dependencies + if: steps.signing.outputs.present == 'true' + working-directory: android-app + run: npm ci + + - name: Sync Capacitor Android project + if: steps.signing.outputs.present == 'true' + working-directory: android-app + run: npx cap sync android + + - name: Restore Firebase config from GitHub Secrets (optional) + if: steps.signing.outputs.present == 'true' + env: + GOOGLE_SERVICES_JSON_B64: ${{ secrets.GOOGLE_SERVICES_JSON_B64 }} + run: | + if [ -n "${GOOGLE_SERVICES_JSON_B64:-}" ]; then + echo "$GOOGLE_SERVICES_JSON_B64" | base64 -d > android-app/android/app/google-services.json + echo "Firebase config applied from secrets." + else + echo "::notice::GOOGLE_SERVICES_JSON_B64 not configured — the release APK will not include Firebase push/OAuth generated resources (Google sign-in uses the committed web-client fallback)." + fi + + - name: Decode production signing key + if: steps.signing.outputs.present == 'true' + env: + ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} + run: echo "$ANDROID_KEYSTORE_B64" | base64 -d > "${RUNNER_TEMP}/dsmnru-release.keystore" + + - name: Build signed release APK + if: steps.signing.outputs.present == 'true' + working-directory: android-app/android + env: + ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/dsmnru-release.keystore + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + chmod +x ./gradlew + ./gradlew --no-daemon assembleRelease + + - name: Verify signature, package and version (certificate fingerprints are public — safe to print) + if: steps.signing.outputs.present == 'true' + run: | + APK=android-app/android/app/build/outputs/apk/release/app-release.apk + test -f "$APK" + BT=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1) + "$BT/apksigner" verify --print-certs "$APK" | sed -n '1,4p' + "$BT/aapt" dump badging "$APK" | grep -E '^package:' | head -1 + + - name: Rename to the production artifact name + if: steps.signing.outputs.present == 'true' + run: | + cp android-app/android/app/build/outputs/apk/release/app-release.apk android-app/android/app/build/outputs/apk/release/dsmnru-pyq.apk + ls -lh android-app/android/app/build/outputs/apk/release/ + + - name: Upload dsmnru-pyq.apk artifact + if: steps.signing.outputs.present == 'true' + uses: actions/upload-artifact@v7 + with: + name: dsmnru-pyq.apk + path: android-app/android/app/build/outputs/apk/release/dsmnru-pyq.apk + if-no-files-found: error + retention-days: 30 diff --git a/android-app/README.md b/android-app/README.md index f692eab..7b4349a 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` @@ -134,23 +225,40 @@ 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` 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 -* **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). +* **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 + 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..4b65bdd 100644 --- a/android-app/android/.gitignore +++ b/android-app/android/.gitignore @@ -99,3 +99,17 @@ 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 +# 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 cf2f6a7..6919dff 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 11 + versionName "1.4.0" 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,49 @@ 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' + } + release { + // Values arrive from the CI environment (GitHub Secrets). Empty + // config when unset — unused and harmless for debug builds. + def ksFile = System.getenv('ANDROID_KEYSTORE_FILE') + if (ksFile != null) { + storeFile file(ksFile) + storePassword System.getenv('ANDROID_KEYSTORE_PASSWORD') + keyAlias System.getenv('ANDROID_KEY_ALIAS') + keyPassword System.getenv('ANDROID_KEY_PASSWORD') + } + } + } 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' + // PRODUCTION signing is injected at CI time through environment + // variables fed from GitHub Secrets (ANDROID_KEYSTORE_* — see + // signingConfigs.release below). Nothing is committed: no + // keystore, no passwords, no keys. When the variables are absent + // (local builds, unconfigured CI) the release APK is left + // UNSIGNED — a production APK is never falsely labelled. + if (System.getenv('ANDROID_KEYSTORE_FILE') != null) { + signingConfig signingConfigs.release + } } } } @@ -35,6 +74,19 @@ 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' + // 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/debug.keystore b/android-app/android/app/debug.keystore new file mode 100644 index 0000000..77553b5 Binary files /dev/null and b/android-app/android/app/debug.keystore differ 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..784a135 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,190 @@ @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; + } + // serverClientId MUST be the WEB OAuth client (audience of the Google + // ID token that Firebase's accounts:signInWithIdp accepts). With + // google-services.json present, the Google Services plugin generates + // `default_web_client_id` from the file's client_type: 3 entry — the + // authoritative web client. The manual `google_web_client_id` string + // stays as a fallback for builds generated without the file. The + // ANDROID OAuth client (client_type: 1/2) is never used here — it is + // identified by package + SHA-1 at the OS level, not by client id. + String clientId = ""; + try { + int resId = getContext().getResources().getIdentifier( + "default_web_client_id", "string", getContext().getPackageName()); + if (resId != 0) { + clientId = getContext().getString(resId); + } + } catch (Exception generatedResourceMissing) { + clientId = ""; + } + if (clientId == null || clientId.trim().isEmpty() || clientId.contains("REPLACE_WITH")) { + 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 — " + + "add google-services.json (or set 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 { + // 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), + // 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..fe3f45d --- /dev/null +++ b/android-app/android/app/src/main/java/com/dsmnru/pyq/FcmService.java @@ -0,0 +1,239 @@ +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; + } + } + + /** + * Static signal that the Google Services plugin processed a + * google-services.json at build time (it always generates the + * google_app_id resource). Independent of runtime init order. + */ + public static boolean hasFirebaseConfigResources(Context context) { + try { + return context.getResources().getIdentifier( + "google_app_id", "string", context.getPackageName()) != 0; + } 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..04d8fb2 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,108 @@ * 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 (isFinishing() || isDestroyed()) return; // never from a dying activity + 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 + try { + // We are inside a postDelayed() on the main handler started from + // onResume(), i.e. in a valid RESUMED state — the system dialog + // can attach. The asked-flag is written only AFTER the request + // was handed to the OS, so a failed call never silences the + // dialog forever. + ActivityCompat.requestPermissions(this, + new String[]{ Manifest.permission.POST_NOTIFICATIONS }, REQ_POST_NOTIFICATIONS); + prefs.edit().putBoolean(KEY_NOTIF_ASKED, true).apply(); + } catch (Exception dialogCouldNotShow) { + // Rare OEM failure — leave the flag unset so the next session + // retries once instead of never asking at all. + } + }; + @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 + public void onResume() { + super.onResume(); + scheduleNotificationPermissionAsk(); + } + + @Override + public 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. + * + * Note: builds WITHOUT Firebase configuration (no google-services.json) + * intentionally never ask — there is nothing to deliver. With the config + * committed on the branch, every CI/installed build initializes Firebase + * via FirebaseInitProvider and this gate is open. + */ + 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. + // FirebaseApp.getApps() is the live signal; the generated google_app_id + // resource is the static fallback (the plugin writes it whenever + // google-services.json is present), covering any init-order gap. + if (!FcmService.isFirebaseAvailable(this) && !FcmService.hasFirebaseConfigResources(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..8f77ef3 --- /dev/null +++ b/android-app/android/app/src/main/java/com/dsmnru/pyq/PdfViewerActivity.java @@ -0,0 +1,763 @@ +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; + /** 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); + 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")); + displayed = ph; + setImageBitmap(ph); + resetMatrix(); + } + + boolean isRendering() { return rendering; } + void setRendering(boolean value) { rendering = value; } + + void setPageBitmap(Bitmap bitmap) { + // 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(); + } + + 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/drawable/splash_icon.png b/android-app/android/app/src/main/res/drawable/splash_icon.png index 5f70bbd..2543169 100644 Binary files a/android-app/android/app/src/main/res/drawable/splash_icon.png and b/android-app/android/app/src/main/res/drawable/splash_icon.png differ diff --git a/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 433dea4..e012ef5 100644 Binary files a/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 129d875..61b487f 100644 Binary files a/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index 240b364..e012ef5 100644 Binary files a/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/android-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index f61607c..0b6cca3 100644 Binary files a/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 77fe5da..19756c5 100644 Binary files a/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index e033b17..0b6cca3 100644 Binary files a/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/android-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index dbc0c02..fb0cd67 100644 Binary files a/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index 2926f48..0ac8903 100644 Binary files a/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index dca3356..fb0cd67 100644 Binary files a/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/android-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index e029544..867b286 100644 Binary files a/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index e2daf62..e6e9fdb 100644 Binary files a/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 2fee8b8..867b286 100644 Binary files a/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/android-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index e5f2b25..1a63e1d 100644 Binary files a/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 64ec993..0911e10 100644 Binary files a/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 0eda8c8..1a63e1d 100644 Binary files a/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android-app/android/app/src/main/res/values/colors.xml b/android-app/android/app/src/main/res/values/colors.xml index 3dc1265..7c9edda 100644 --- a/android-app/android/app/src/main/res/values/colors.xml +++ b/android-app/android/app/src/main/res/values/colors.xml @@ -10,6 +10,6 @@ - #0F172A - #0F172A + #0B245B + #0B245B 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..9f11aad 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..fc470f4 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/) */ @@ -51,11 +54,20 @@ 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) { +function jwt(exp, who) { + const people = { + existing: { uid: 'uid-9', email: 'stud@dsmnru.in', name: 'Test Student' }, + fresh: { uid: 'uid-10', email: 'new@dsmnru.in', name: 'Aarav Sharma' }, + }; + const w = people[who] || people.existing; const b64u = (s) => 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'; } @@ -70,13 +82,23 @@ 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 */ } } const calls = []; + // Stateful comments backend: the ORDERED (index-requiring) query always + // fails with FAILED_PRECONDITION — exactly the production failure mode; + // the unordered fallback serves this store plus one malformed legacy + // document; POSTs append with unique ids. failComments fails every + // comments read (query outage simulation). + const mockComments = []; + let failComments = false; const nowSec = Math.floor(Date.now() / 1000); const fetchImpl = async (url, opts = {}) => { calls.push({ url: String(url), opts }); @@ -88,15 +110,73 @@ 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('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')) { + const body = JSON.parse(opts.body || '{}'); + if (body.email === 'unknown@nowhere.in') { + return { ok: false, status: 400, json: async () => ({ error: { message: 'EMAIL_NOT_FOUND' } }) }; + } + return ok({}); + } + if (u.includes('/reward_accounts/')) { + return ok({ fields: { points: { integerValue: '40' }, email: { stringValue: 'stud@dsmnru.in' } } }); + } + if (u.includes('/documents/comments')) { + const body = JSON.parse(opts.body || '{}'); + mockComments.push(body.fields); + return ok({ name: `projects/dsmnru-data/databases/(default)/documents/comments/c${mockComments.length}`, fields: body.fields }); + } + if (u.includes(':runQuery')) { + const parsed = JSON.parse(opts.body || '{}'); + if (((parsed.structuredQuery || {}).from || [{}])[0].collectionId === 'comments') { + if (failComments) { + return { ok: false, status: 400, json: async () => ({ error: { message: 'The query requires an INDEX.', status: 'FAILED_PRECONDITION' } }) }; + } + if (u.includes('/pyqs/')) return ok([]); // legacy subcollection: parent-scoped, empty + // The ordered (paperId + createdAt) query is rejected — missing + // composite index, same as the production report. + if ((parsed.structuredQuery.orderBy || []).length > 0) { + return { ok: false, status: 400, json: async () => ({ error: { message: 'The query requires an INDEX.', status: 'FAILED_PRECONDITION' } }) }; + } + const rows = mockComments.map((fields, i) => ({ + document: { name: `projects/dsmnru-data/databases/(default)/documents/comments/c${i + 1}`, fields }, + })); + // One malformed legacy document (no text field) — must be skipped. + rows.push({ document: { name: 'projects/dsmnru-data/databases/(default)/documents/comments/broken1', fields: { paperId: { stringValue: 'p1' } } } }); + return ok(rows); + } + 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', + 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 }) }; }; globalThis.fetch = fetchImpl; - return { dom, window, calls, opened }; + return { dom, window, calls, opened, setFailComments: (v) => { failComments = v; } }; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -112,7 +192,7 @@ const text = (el) => (el ? el.textContent : ''); if (JSDOM) { test('dedicated app UI boots, gates, authenticates, searches and opens papers', async (t) => { - const { window, calls, opened } = setupDom(); + const { window, calls, opened, setFailComments } = setupDom(); t.after(() => { try { window.close(); } catch { /* already gone */ } }); // Fresh device state @@ -124,12 +204,29 @@ if (JSDOM) { const view = () => document.getElementById('view'); // ── Home ──────────────────────────────────────────────────────────── - assert.ok(view().querySelector('.hero'), 'brand hero present'); - assert.match(text(view().querySelector('#home-stats')), /5\s*papers/, 'stats from ONE homepage call'); + assert.ok(!view().querySelector('.hero'), 'NO hero section on Home'); + assert.ok(!view().querySelector('#home-search') && !view().querySelector('.search-entry'), + 'Home carries NO search field — the bottom-nav Search tab is the single full search'); + assert.ok(view().querySelector('#home-courses'), 'Home starts with the quick-access section'); + assert.ok(view().querySelector('#home-courses .course-card'), 'course cards rendered from ONE homepage call'); + assert.match(text(view().querySelector('#home-courses')), /B\.Tech/, 'course card shows the course'); + assert.match(text(view().querySelector('#home-courses')), /papers/, 'course card shows the paper count'); assert.match(text(view().querySelector('.paper-card-title')), /Data Structures/, 'recent paper card rendered'); const homeCalls = calls.filter((c) => c.url.includes('/api/homepage')).length; assert.equal(homeCalls, 1, 'exactly one /api/homepage request for home'); + // v1.3.3 — ONE brand area (the app bar): Home adds no duplicate logo/text. + assert.ok(!view().querySelector('.hero-emblem'), 'no duplicate brand block on Home'); + + // v1.3.5 — hero (greeting/search/stats) fully removed; the dedicated + // Search TAB remains the one full search experience. + document.querySelector('.tab[data-tab="search"]').click(); + assert.ok(await waitFor(() => view().querySelector('#sq') !== null), 'Search tab opens the full search screen'); + assert.equal(view().querySelector('#sq').value, '', 'Search screen opens idle (no fake query)'); + assert.ok(view().querySelector('#btn-filter'), 'full Search experience intact (filters present)'); + document.querySelector('.tab[data-tab="home"]').click(); + await waitFor(() => view().querySelector('#home-courses')); + // ── Bottom nav → Courses (anonymous course pick is gated like the site) ─ document.querySelector('.tab[data-tab="browse"]').click(); assert.ok(await waitFor(() => view().querySelector('[data-course]')), 'course grid rendered'); @@ -137,6 +234,25 @@ if (JSDOM) { assert.ok(await waitFor(() => document.querySelector('.sheet-root')), 'auth gate sheet opens for anonymous course browse'); assert.match(text(document.querySelector('.sheet-root')), /Sign in|Course browsing/, 'gate explains itself'); + // ── v1.3.3: password reset runs in-app; success only after Firebase resolves ── + document.querySelector('.sheet-root [data-act="forgot"]').click(); + assert.ok(await waitFor(() => !document.querySelector('.sheet-root form[data-form="reset"]').classList.contains('hidden')), 'reset form opens'); + document.querySelector('#auth-r-email').value = 'unknown@nowhere.in'; + document.querySelector('.sheet-root form[data-form="reset"] button[type="submit"]').click(); + assert.ok(await waitFor(() => text(document.querySelector('.sheet-root')).includes('No account exists for this email yet.')), + 'unknown email gets a readable message'); + document.querySelector('#auth-r-email').value = 'stud@dsmnru.in'; + document.querySelector('.sheet-root form[data-form="reset"] button[type="submit"]').click(); + assert.ok(await waitFor(() => { + const el = document.querySelector('[data-reset-ok]'); + return el && !el.hidden && el.textContent.includes('inbox and spam folder'); + }), 'success note appears only after the Firebase call resolved'); + assert.ok(calls.some((c) => c.url.includes('sendOobCode')), 'Firebase password-reset endpoint called'); + const resetBtn = document.querySelector('form[data-form="reset"] button[type="submit"]'); + assert.ok(resetBtn && !resetBtn.disabled, 'form stays usable for a resend'); + document.querySelector('#auth-mode [data-chip="login"]').click(); + assert.ok(await waitFor(() => !document.querySelector('.sheet-root form[data-form="login"]').classList.contains('hidden')), 'back to login'); + // ── Sign in through the sheet (email/password → same Firebase project) ── const sheet = document.querySelector('.sheet-root'); sheet.querySelector('#auth-email').value = 'stud@dsmnru.in'; @@ -168,6 +284,54 @@ if (JSDOM) { const detailCalls = calls.filter((c) => /\/api\/pyqs\/p1(\?|$)/.test(c.url)).length; assert.equal(detailCalls, 1, 'one detail fetch'); + // ── v1.3.6: discussion read path — index failure → fallbacks, classified errors, dedupe ── + const discTrafficBefore = calls.filter((c) => c.url.includes(':runQuery')).length; + const discOpen = view().querySelector('[data-act="disc-open"]'); + assert.ok(discOpen, 'paper offers an in-app discussion'); + assert.ok(!view().querySelector('#disc-text'), 'comments are NOT loaded before the section is opened'); + + // Production failure mode: the ordered query is rejected (missing index) + // AND the fallback paths are unreachable → a READ problem must be shown + // as such, never as "check your connection". + setFailComments(true); + discOpen.click(); + assert.match(text(view().querySelector('#disc-list')), /Loading discussion/, 'loading state shown first'); + assert.ok(await waitFor(() => text(view()).includes('Unable to load this discussion. Please try again.')), + 'query/index failure classified as a read problem'); + assert.ok(!text(view()).includes('Check your connection'), 'no misleading network message for query failures'); + assert.ok(view().querySelector('#disc-list [data-act="disc-open"]'), 'Retry offered'); + + // Retry re-runs the ACTUAL fetch operation once the backend recovers. + setFailComments(false); + view().querySelector('#disc-list [data-act="disc-open"]').click(); + assert.ok(await waitFor(() => text(view()).includes('No comments yet. Start the discussion.')), + 'retry reaches the fallback read; empty state exact'); + assert.ok(calls.filter((c) => c.url.includes(':runQuery')).length > discTrafficBefore, + 'comments queries fired only after opening (lazy)'); + + view().querySelector('#disc-text').value = 'This paper helped a lot, thanks!'; + view().querySelector('[data-act="disc-post"]').click(); + assert.ok(await waitFor(() => text(view()).includes('This paper helped a lot')), + 'posted comment appears immediately (no reload)'); + const postCalls = calls.filter((c) => c.url.includes('/documents/comments') && (c.opts || {}).method === 'POST'); + assert.equal(postCalls.length, 1, 'comment written ONCE to the SAME Firestore comments collection'); + assert.match(String(postCalls[0].opts.body), /"paperId"\s*:\s*\{\s*"stringValue"\s*:\s*"p1"/, + 'write schema: paperId (website field)'); + assert.match(String(postCalls[0].opts.body), /uid-9/, 'write schema: userId = signed-in user'); + assert.ok(!opened.some((u) => u.includes('dsmnru') || u.includes('netlify')), + 'discussion never opens the website'); + + // Leave the paper, come back: refetched list shows the comment exactly + // ONCE (doc-id dedupe) and the malformed legacy document never renders. + document.getElementById('appbar-back').click(); + assert.ok(await waitFor(() => view().querySelector('[data-paper-id="p1"]')), 'back to results'); + view().querySelector('[data-paper-id="p1"]').click(); + assert.ok(await waitFor(() => view().querySelector('.paper-hero')), 'paper re-opened'); + view().querySelector('[data-act="disc-open"]').click(); + assert.ok(await waitFor(() => text(view()).includes('This paper helped a lot')), 'comment persists across reopen'); + assert.equal(view().querySelectorAll('[data-comment-id]').length, 1, + 'exactly ONE copy after post + refetch (doc-id dedupe; malformed row skipped)'); + // ── PDF open → handed to the system (no in-app viewer, no storage copy) ─ const viewBtn = view().querySelector('[data-act="view"]'); assert.ok(viewBtn, 'Open PDF button present'); @@ -192,9 +356,288 @@ if (JSDOM) { // ── Cache discipline: re-visiting Home immediately issues NO new homepage fetch ─ const before = calls.length; document.querySelector('.tab[data-tab="home"]').click(); - await waitFor(() => view().querySelector('.hero')); + await waitFor(() => view().querySelector('#home-courses')); 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). + // 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'); + 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('#home-courses')); + 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('#home-courses')); + 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('#home-courses')); + 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('#home-courses')); + 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('#home-courses')); + 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('#home-courses')); + 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'); + + // ══════════════════════════════════════════════════════════════════ + // 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\.4\.0/, '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.match(text(view()), /approved uploads/, 'approved-upload count from the SAME reward ledger'); + 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; + + // Personal information — same users/{uid} fields the website edits; + // email is displayed READ-ONLY. + assert.ok(await waitFor(() => view().querySelector('#pf-info .pf-info-row')), 'personal info loaded lazily'); + assert.match(text(view().querySelector('#pf-info')), /cannot be edited here/, 'email is read-only in Profile'); + assert.ok(!view().querySelector('#pf-info input'), 'personal info renders as display rows, not an inline form'); + + // Edit Profile sheet — website-editable fields only (name/course/phone). + view().querySelector('[data-act="editprofile"]').click(); + assert.ok(await waitFor(() => document.querySelector('.sheet-root #pf-name')), 'edit sheet loads current Firestore values'); + assert.ok(document.querySelector('.sheet-root #pf-course'), 'course field (existing schema field)'); + assert.ok(document.querySelector('.sheet-root #pf-phone'), 'phone field (existing schema field)'); + assert.ok(document.querySelector('.sheet-root input[disabled][readonly]'), 'email shown but NOT editable'); + document.querySelector('.sheet-root #pf-name').value = 'Aarav Test'; + document.querySelector('.sheet-root #pf-save').click(); + assert.ok(await waitFor(() => text(document.body).includes('Saving changes')), 'busy state shown while saving'); + assert.ok(await waitFor(() => text(view()).includes('Aarav Test')), 'name updated in UI after save — no restart'); + assert.ok(await waitFor(() => text(document.body).includes('Profile updated successfully')), 'exact success feedback'); + assert.ok(calls.some((c) => c.url.includes('updateMask.fieldPaths=name')), 'users/{uid} patched — SAME website profile row'); + assert.ok(calls.some((c) => c.url.includes('updateMask.fieldPaths=course')), 'course saved to the same document'); + 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'); + + // Change Password — mismatch blocked locally; success calls Firebase. + view().querySelector('[data-act="changepw"]').click(); + assert.ok(await waitFor(() => document.querySelector('.sheet-root #pf-pw-cur')), 'change-password sheet opens'); + document.querySelector('.sheet-root #pf-pw-cur').value = 'hunter22'; + document.querySelector('.sheet-root #pf-pw-new').value = 'hunter23'; + document.querySelector('.sheet-root #pf-pw-conf').value = 'hunter24'; + document.querySelector('.sheet-root #pf-pw-save').click(); + assert.ok(await waitFor(() => !document.querySelector('.sheet-root [data-err]').hidden + && /do not match/.test(text(document.querySelector('.sheet-root [data-err]')))), 'mismatch blocked with a readable error'); + document.querySelector('.sheet-root #pf-pw-conf').value = 'hunter23'; + document.querySelector('.sheet-root #pf-pw-save').click(); + assert.ok(await waitFor(() => text(document.body).includes('Password changed successfully')), 'exact success feedback'); + assert.ok(calls.filter((c) => c.url.includes('signInWithPassword')).length >= 2, + 'current password re-verified (fresh auth) before the update'); + assert.ok(calls.some((c) => c.url.includes('accounts:update')), 'Firebase password update called'); + const pwLogs = calls.filter((c) => c.url.includes('accounts:update')).length; + assert.ok(pwLogs >= 1, 'password update performed exactly through Firebase Auth'); + + // ── Back-arrow state: Home NEVER shows one; pushed screens do ─────── + document.querySelector('.tab[data-tab="home"]').click(); + await waitFor(() => view().querySelector('#home-courses')); + 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('#home-courses')), '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('#home-courses')); + 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('#home-courses')); + 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')); + 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..9e169cc --- /dev/null +++ b/android-app/test/app-native-bridge.test.mjs @@ -0,0 +1,266 @@ +/** + * 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, 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: who_.uid, sub: who_.uid, email: who_.email, name: who_.name, + 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; }; + + // '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 */ } + } + 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('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', '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 }) }; + }; + + 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('#home-courses')), 'app booted with the native bridge'); + + // ── Website-parity gate: a typed query requires a VERIFIED session ──── + document.querySelector('.tab[data-tab="search"]').click(); + await waitFor(() => view().querySelector('#sq')); + 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; + + // ── 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'); + 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)'); + + // ── 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)) && /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(); + 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-UP: Create Account offers the SAME native chooser ─────── + const signupEntry = view().querySelector('[data-act="signup"]'); + assert.ok(signupEntry, 'signed-out profile offers Create account'); + signupEntry.click(); + assert.ok(await waitFor(() => { + const f = document.querySelector('.sheet-root form[data-form="signup"]'); + return f && !f.classList.contains('hidden'); + }), 'Create account form shown'); + const signupGoogle = document.querySelector('.sheet-root form[data-form="signup"] [data-act="google"]'); + assert.ok(signupGoogle, 'Create Account page carries Continue with Google'); + signupGoogle.click(); + assert.ok(await waitFor(() => { + const sheet = document.querySelector('.sheet-root'); + return sheet && /Google sign-in/.test(text(sheet)); + }), 'signup Google uses the SAME native flow (explainer when unavailable)'); + assert.ok(!/Open website/.test(text(document.querySelector('.sheet-root'))), 'Google sign-up never leaves the 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 (one shared flow)'); + 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')), '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'); + // One owner-scoped profile sync per manual sign-in, plus the Profile + // screen's own user-initiated reads (lazy, session-cached: one + // users/{uid} profile GET per signed-in session — never at startup, + // never for signed-out users). + const profileSyncs = calls.filter((c) => c.url.includes('/documents/users/')).length; + assert.ok(profileSyncs <= 4, 'bounded owner-scoped calls: one sync per sign-in + one cached profile read per session'); + }); +} diff --git a/android-app/test/fcm.test.mjs b/android-app/test/fcm.test.mjs new file mode 100644 index 0000000..fef0d22 --- /dev/null +++ b/android-app/test/fcm.test.mjs @@ -0,0 +1,276 @@ +/** + * 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 { readdirSync, 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 11/, 'versionCode increased for the 1.4.0 production release'); + assert.match(gradle, /versionName "1\.4\.0"/, 'versionName 1.4.0 (production release)'); + // 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'); +}); + +test('release path: env-driven production signing (no committed secrets) and the dsmnru-pyq.apk CI artifact', () => { + const gradle = readFileSync(join(APP, 'build.gradle'), 'utf8'); + // Production signing is injected through the CI environment only. + assert.match(gradle, /release \{[\s\S]*?System\.getenv\('ANDROID_KEYSTORE_FILE'\)/, + 'release signing config reads the CI environment (GitHub Secrets), never repo files'); + assert.match(gradle, /if \(System\.getenv\('ANDROID_KEYSTORE_FILE'\) != null\) \{\s*signingConfig signingConfigs\.release/, + 'release buildType signs ONLY when the signing environment is present'); + // No production signing material is ever committed. + const files = []; + const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => { + const p = join(dir, e.name); + return e.isDirectory() ? walk(p) : files.push(p); + }); + walk(APP); + assert.equal(files.some((f) => f.endsWith('.jks') || f.includes('release.keystore')), false, + 'no release keystore committed anywhere in the app module'); + assert.ok(!gradle.includes('ANDROID_KEYSTORE_PASSWORD='), 'no signing credential literal in gradle'); + + // The CI workflow builds the signed release only from secrets, verifies it, + // renames it to dsmnru-pyq.apk and uploads it; secrets are never echoed. + const wf = readFileSync(join(here, '../../.github/workflows/android-apk.yml'), 'utf8'); + assert.match(wf, /secrets\.ANDROID_KEYSTORE_B64/, 'keystore arrives from GitHub Secrets'); + assert.match(wf, /ANDROID_KEYSTORE_PASSWORD: \$\{\{ secrets\.ANDROID_KEYSTORE_PASSWORD \}\}/, + 'passwords flow through step env, never arguments or echo'); + assert.equal(/echo [^\n]*ANDROID_KEYSTORE_B64\$\{/.test(wf), false, 'the keystore secret is never echoed'); + assert.equal(wf.includes('base64 -w0'), false, 'nothing re-encodes secrets in the workflow'); + assert.match(wf, /assembleRelease/, 'release build step present'); + assert.match(wf, /apksigner" verify --print-certs/, 'signature verified in CI (public fingerprints printed)'); + assert.match(wf, /app-release\.apk[\s\S]*dsmnru-pyq\.apk/, 'renamed to the exact production filename'); + assert.match(wf, /name: dsmnru-pyq\.apk/, 'artifact uploaded as dsmnru-pyq.apk'); + assert.match(wf, /secrets\.GOOGLE_SERVICES_JSON_B64/, 'Firebase config injectable from secrets (gitignored by policy)'); +}); + +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, /requestPermissions\([\s\S]{0,120}\)[\s\S]{0,80}putBoolean\(KEY_NOTIF_ASKED, true\)/, + 'the asked-flag is persisted only after the dialog request was accepted by the OS (never re-asked afterwards)'); + 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('FCM: token + all_users subscription are independent of the notification permission', () => { + const src = main('java/com/dsmnru/pyq/FcmService.java'); + const activity = main('java/com/dsmnru/pyq/MainActivity.java'); + + // Subscription path never consults POST_NOTIFICATIONS (permission only + // governs the visual posting of notifications). + const subscribeFn = src.match(/public static void subscribeAllUsers[\s\S]*?\n \}/); + assert.ok(subscribeFn, 'subscribeAllUsers present'); + assert.ok(!subscribeFn[0].includes('POST_NOTIFICATIONS') && !subscribeFn[0].includes('checkSelfPermission'), + 'topic subscription is NOT blocked by the notification permission'); + + // Bootstrapped unconditionally at app start (no permission gate around it). + assert.match(activity, /FcmService\.subscribeAllUsers\(this, false\);/, + 'all_users subscribe bootstrapped in onCreate'); + assert.match(src, /onNewToken[\s\S]{0,200}subscribeAllUsers\(this, true\)/, + 'token rotation re-asserts the subscription (force)'); + + // Token lives ONLY in device-local prefs — never uploaded by the app. + assert.ok(!/firestore|Firestore|documents\/|runQuery/.test(subscribeFn[0]), 'no token sync to any backend'); + assert.match(src, /getSharedPreferences\(PREFS, (Context\.)?MODE_PRIVATE\)\.edit\(\)\.putString\("token", token\)/, + 'token cached device-locally for diagnostics only'); +}); + +test('FCM: the first-session permission dialog actually executes (and only once)', () => { + const activity = main('java/com/dsmnru/pyq/MainActivity.java'); + + // Scheduled from onResume → runs in a RESUMED activity state. + assert.match(activity, /protected void onResume\(\)|public void onResume\(\)[\s\S]{0,120}scheduleNotificationPermissionAsk/, + 'ask scheduled from onResume (valid resumed lifecycle state)'); + assert.match(activity, /mainHandler\.postDelayed\(permissionAsk, PERMISSION_ASK_DELAY_MS\)/, + 'delayed first-session ask (~9s) is actually scheduled'); + + // The dialog call is real, happens while the activity is alive, and the + // once-flag is written only AFTER the OS accepted the request. + const ask = activity.match(/private final Runnable permissionAsk[\s\S]*?\n };/); + assert.ok(ask, 'permissionAsk runnable present'); + assert.match(ask[0], /isFinishing\(\) \|\| isDestroyed\(\)/, 'never posts from a dying activity'); + assert.match(ask[0], /ActivityCompat\.requestPermissions\(this,[\s\S]{0,120}POST_NOTIFICATIONS[\s\S]{0,60}REQ_POST_NOTIFICATIONS\)/, + 'the REAL system dialog is requested'); + assert.ok(ask[0].indexOf('requestPermissions') < ask[0].indexOf('putBoolean(KEY_NOTIF_ASKED, true)'), + 'asked-flag persisted only after the request was handed to the OS (a failed call can never silence the dialog forever)'); + + // Already granted → no request; already asked → never again. + assert.match(ask[0], /notificationsGranted\(this\)\) return;/, 'already granted → no request'); + assert.match(ask[0], /getBoolean\(KEY_NOTIF_ASKED, false\)\) return;/, 'denied → never re-asked'); + + // Config gate now accepts the generated google_app_id resource too, so a + // google-services.json build always shows the dialog regardless of the + // runtime Firebase init order. + assert.match(activity, /hasFirebaseConfigResources/, + 'Firebase-config gate accepts the generated google_app_id resource'); +}); + +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..a655d20 --- /dev/null +++ b/android-app/test/features.test.mjs @@ -0,0 +1,855 @@ +/** + * 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, pathToFileURL } 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'); +}); + +// ── v1.3.3: single-source logo branding + in-app discussion + auth copy ── + +test('branding: the repository logo drives every brand surface; Home has ONE brand area', () => { + const logo = join(here, '../www/img/logo.png'); + const png = readFileSync(logo); + assert.ok(png.length > 10000, 'www/img/logo.png exists and is a real image'); + assert.equal(png.readUInt32BE(16), png.readUInt32BE(20), 'logo is square'); + assert.equal(png[25], 6, 'logo keeps RGBA transparency'); + + const css = readFileSync(join(here, '../www/css/app.css'), 'utf8'); + assert.ok(!css.includes('emblem.png'), 'old generated emblem is fully retired from CSS'); + assert.ok(css.includes("url('../img/logo.png')"), 'app bar / drawer brand surfaces use logo.png'); + assert.ok(!/\.hero\s*{/.test(css) && !css.includes('.hero-kicker') && !css.includes('.stat-pill ') && !css.includes('.search-entry'), + 'dead hero CSS fully removed (emblem/title kept for About/Profile)'); + assert.ok(!readdirSync(join(here, '../www/img')).includes('emblem.png'), + 'stale emblem asset removed from the bundle so old branding cannot resurface'); + const indexHtml = readFileSync(join(here, '../www/index.html'), 'utf8'); + assert.match(indexHtml, /rel="icon" href="img\/logo\.png"/, 'favicon is the repository logo'); + + const home = readFileSync(join(here, '../www/js/views/home.js'), 'utf8'); + assert.ok(!home.includes('hero-emblem'), 'Home renders NO duplicate brand block (app bar is the one brand area)'); + assert.ok(!home.includes('class="hero"') && !home.includes('hero-kicker'), + 'Home has NO hero section (greeting/search/stats removed)'); + assert.ok(!home.includes('search-entry') && !home.includes('home-search'), + 'Home has NO search field — the bottom-nav Search tab is the single full search'); + const firstSection = home.indexOf('id="home-courses"'); + assert.ok(firstSection !== -1 + && firstSection < home.indexOf('id="home-recent"') + && home.indexOf('id="home-recent"') < home.indexOf('id="home-trending"'), + 'Home order: Quick access → course cards → recently added → trending'); + + // Native launcher + adaptive + splash use the same logo (generated assets + // exist for every density). + for (const d of ['mdpi', 'hdpi', 'xhdpi', 'xxhdpi', 'xxxhdpi']) { + for (const f of ['ic_launcher.png', 'ic_launcher_round.png', 'ic_launcher_foreground.png']) { + assert.ok(readdirSync(join(here, `../android/app/src/main/res/mipmap-${d}`)).includes(f), `${d}/${f} exists`); + } + } + assert.ok(readdirSync(join(here, '../android/app/src/main/res/drawable')).includes('splash_icon.png'), + 'splash icon exists'); + // Splash = repository logo ONLY on the brand navy — no text layer, no old + // red/black surface anywhere in the launch chain. + const styles = readFileSync(join(here, '../android/app/src/main/res/values/styles.xml'), 'utf8'); + assert.match(styles, /windowSplashScreenAnimatedIcon">@drawable\/splash_icon@drawable\/splash /splash.*\.(png|jpg|webp)$/i.test(f) && f !== 'splash_icon.png'), + 'no stale splash bitmaps'); + const splashLayer = readFileSync(join(here, '../android/app/src/main/res/drawable/splash.xml'), 'utf8'); + assert.match(splashLayer, /@color\/splash_background/, 'splash background is the brand color'); + assert.match(splashLayer, /@drawable\/splash_icon/, 'legacy splash centres the logo icon'); + const colors = readFileSync(join(here, '../android/app/src/main/res/values/colors.xml'), 'utf8'); + assert.match(colors, /splash_background">#0B245B/, 'splash background = sampled logo navy (no red/black)'); + const cap = JSON.parse(readFileSync(join(here, '../capacitor.config.json'), 'utf8')); + assert.equal(cap.plugins.SplashScreen.androidSplashResourceName, 'splash', 'plugin splash resource wiring'); + assert.equal(String(cap.plugins.SplashScreen.backgroundColor).toUpperCase(), '#0F172A', + 'plugin splash background is the brand slate, not the old red/black'); +}); + +test('discussion: paper comments are IN-APP (same Firestore schema), never a website redirect', () => { + const paper = readFileSync(join(here, '../www/js/views/paper.js'), 'utf8'); + assert.match(paper, /data-act="disc-open"/, 'paper has a lazy in-app discussion section'); + assert.match(paper, /data-act="disc-post"/, 'composer can post in-app'); + assert.match(paper, /loadComments/, 'paper view loads comments through the discussion module'); + assert.ok(!paper.includes('data-act="web"'), 'no "Discussion on website" item anymore'); + + const disc = readFileSync(join(here, '../www/js/discussion.js'), 'utf8'); + assert.match(disc, /collectionId: 'comments'/, 'uses the SAME top-level comments collection as the website'); + assert.match(disc, /paperId/, 'same paperId field'); + assert.match(disc, /userEmail/, 'same field shape the website writes'); + assert.match(disc, /pyqs\/\$\{encodeURIComponent\(paperId\)\}/, 'same pyqs/{id}/comments fallback as the website'); + // v1.3.6 read-path fix: fallbacks trigger on ERROR (not just empty), the + // subcollection read is parent-scoped, failures are classified, one bad + // document never breaks the list, and lists dedupe by Firestore doc id. + assert.match(disc, /unorderedQueryBody/, 'website-style unordered fallback query exists'); + assert.match(disc, /if \(!list.length && !sawSuccess && failures.length\)/, + 'errors fall through to fallbacks; only a total failure throws'); + assert.match(disc, /function subcollectionQueryBody\(\)/, 'legacy subcollection query builder present'); + assert.ok(disc.includes('executeQuery(`/pyqs/${encodeURIComponent(paperId)}`, subcollectionQueryBody(), doFetch)'), + 'legacy subcollection read is parent-scoped (no paperId filter mismatch)'); + assert.match(disc, /discussionErrorMessage/, 'classified human error messages'); + for (const kind of ["'network'", "'permission'", "'query'", "'data'"]) { + assert.ok(disc.includes(kind), `failure class ${kind} distinguished`); + } + assert.match(disc, /skipping a malformed comment document/, 'malformed rows are skipped, not fatal'); + assert.match(disc, /dedupeById/, 'lists dedupe by Firestore document id'); + assert.match(disc, /console\.info\('discussion: loading comments for paper', paperId\)/, + 'resolved paper id logged (dev logs only)'); + + // Endpoints stay out of the UI layer (audit continuity with the no-URL rule). + const paperStripped = paper.replace(/\/\*[\s\S]*?\*\//g, '').split('\n') + .filter((l) => !/^\s*(\*|\/\/)/.test(l)).join('\n'); + assert.ok(!paperStripped.includes('firestore.googleapis'), 'discussion endpoints live in the logic module, not the view'); +}); + +test('discussion contract: index-failure fallback, error classes, malformed rows, dedupe, write shape', async () => { + const mod = await import(pathToFileURL(join(here, '../www/js/discussion.js')).href); + + // Scripted Firestore REST backend — records every call in order. + const made = []; + const scripted = (responses) => { + let i = 0; + return (url, opts = {}) => { + made.push({ url: String(url), body: String(opts.body || ''), method: opts.method || 'GET' }); + const r = responses[Math.min(i++, responses.length - 1)]; + return Promise.resolve(typeof r === 'function' ? r(url, opts) : r); + }; + }; + const okJson = (data) => ({ ok: true, status: 200, json: async () => data }); + const errJson = (status, message, statusName) => ({ ok: false, status, json: async () => ({ error: { message, status: statusName } }) }); + const row = (id, text, date) => ({ + document: { + name: `projects/x/databases/(default)/documents/comments/${id}`, + fields: { + paperId: { stringValue: 'p1' }, text: { stringValue: text }, + userId: { stringValue: 'u1' }, userName: { stringValue: 'Asha' }, + createdAt: date ? { timestampValue: date } : { nullValue: null }, // pending server timestamp + }, + }, + }); + const indexError = () => errJson(400, 'The query requires an INDEX.', 'FAILED_PRECONDITION'); + + // 1) Ordered query rejected (missing composite index) → the unordered + // fallback supplies comments; malformed rows skipped; client-side sort. + made.length = 0; + const fetch1 = scripted([ + indexError(), + okJson([ + row('a', 'great paper', '2026-09-01T10:00:00Z'), + row('b', 'thanks so much', ''), + { document: { name: 'projects/x/databases/(default)/documents/comments/z', fields: { paperId: { stringValue: 'p1' } } } }, + { document: {} }, + ]), + ]); + const items = await mod.loadComments({ paperId: 'p1' }, fetch1); + assert.equal(items.length, 2, 'malformed documents skipped, valid ones kept'); + assert.equal(items[0].id, 'a', 'newest first (client-side sort after unordered fetch)'); + assert.ok(made[0].url.includes('/documents:runQuery') && !made[0].url.includes('/pyqs/'), + 'read targets the SAME top-level comments collection'); + const secondBody = JSON.parse(made[1].body); + assert.equal((secondBody.structuredQuery.orderBy || []).length, 0, 'fallback drops the composite-index orderBy'); + assert.equal(secondBody.structuredQuery.where.fieldFilter.value.stringValue, 'p1', 'same paperId filter, same collection'); + assert.ok(!made.some((m) => m.url.includes('netlify') || m.url.includes('paper.html')), + 'no website anywhere in the read path'); + + // 2) Every path failing with the index precondition → QUERY class, and the + // UI message must NOT claim a network problem. + made.length = 0; + await assert.rejects(() => mod.loadComments({ paperId: 'p2' }, scripted([indexError()])), + (e) => e.kind === 'query' && mod.discussionErrorMessage(e) === 'Unable to load this discussion. Please try again.'); + assert.ok(!made.some(() => false), 'noop'); + assert.equal(JSON.parse(made[0].body).structuredQuery.where.fieldFilter.value.stringValue, 'p2', + 'the resolved paper id reaches the query'); + + // 3) Permission failure → its own class and message. + await assert.rejects(() => mod.loadComments({ paperId: 'p3' }, scripted([errJson(403, 'Permission denied', 'PERMISSION_DENIED')])), + (e) => e.kind === 'permission' && mod.discussionErrorMessage(e) === 'Unable to load this discussion right now.'); + + // 4) Offline (fetch throws) → network class and message. + await assert.rejects(() => mod.loadComments({ paperId: 'p4' }, scripted([() => { throw new TypeError('fetch failed'); }])), + (e) => e.kind === 'network' && mod.discussionErrorMessage(e) === 'Unable to load discussion. Check your connection and try again.'); + + // 5) Duplicate documents (same Firestore doc id) collapse to one. + const items5 = await mod.loadComments({ paperId: 'p5' }, scripted([ + indexError(), + okJson([row('dup', 'once upon a time', '2026-09-02T10:00:00Z'), row('dup', 'once upon a time', '2026-09-02T10:00:00Z')]), + ])); + assert.equal(items5.length, 1, 'doc-id dedupe'); + + // 6) Write: top-level collection, EXACT website field shape; resolves the + // real document id (for UI dedupe). + made.length = 0; + const user = { uid: 'u1', email: 'asha@b.in', name: 'Asha', idToken: 'T' }; + const fetch6 = scripted([(url, opts) => { + assert.ok(url.includes('/documents/comments?key='), 'write targets the top-level comments collection'); + assert.deepEqual(Object.keys(JSON.parse(opts.body).fields).sort(), + ['createdAt', 'paperId', 'text', 'userEmail', 'userId', 'userName'], 'exact website field shape'); + const fields = JSON.parse(opts.body).fields; + return okJson({ name: 'projects/x/databases/(default)/documents/comments/n1', fields }); + }]); + const written = await mod.postComment({ paperId: 'p1', text: 'hello there' }, user, fetch6); + assert.equal(written.id, 'n1', 'write resolves the real Firestore doc id'); + + // 7) Permission-denied write keeps its distinct verify-email message. + await assert.rejects(() => mod.postComment({ paperId: 'p1', text: 'hello there' }, user, scripted([errJson(403, 'denied', 'PERMISSION_DENIED')])), + (e) => e.kind === 'permission' && /Verify your email/.test(e.message)); +}); + +test('auth copy: google states + password reset success are human and explicit', () => { + const authui = readFileSync(join(here, '../www/js/authui.js'), 'utf8'); + for (const required of [ + 'Signing in with Google…', + 'Signed in successfully.', + 'Google sign-in was cancelled.', + 'Unable to sign in with Google. Please try again.', + ]) { + assert.ok(authui.includes(required), `google state copy present: ${required}`); + } + assert.match(authui, /data-reset-ok/, 'reset form has a success target shown only after Firebase resolves'); + assert.match(authui, /inbox and spam folder/, 'reset confirmation mentions the spam folder'); + const auth = readFileSync(join(here, '../www/js/auth.js'), 'utf8'); + assert.match(auth, /PASSWORD_RESET/, 'Firebase sendPasswordResetEmail (sendOobCode PASSWORD_RESET) is called'); + assert.match(auth, /EMAIL_NOT_FOUND/, 'unknown-email reset attempts get a readable message'); +}); + +// ── Native Google sign-in wiring (Credential Manager → Firebase) ──────── + +test('native Google sign-in: serverClientId is the WEB OAuth client, never the Android client', () => { + const jsDir = join(here, '../www/js'); + const plugin = readFileSync( + join(here, '../android/app/src/main/java/com/dsmnru/pyq/DsmnruAppPlugin.java'), 'utf8'); + + // The GENERATED default_web_client_id (written by the Google Services + // plugin from google-services.json's client_type:3 entry) is preferred. + assert.match(plugin, /getIdentifier\(\s*"default_web_client_id", "string"/, + 'uses the generated default_web_client_id resource (the WEB client from google-services.json)'); + // Manual google_web_client_id remains only as a fallback. + assert.match(plugin, /R\.string\.google_web_client_id/, + 'manual google_web_client_id kept as fallback'); + const genIdx = plugin.indexOf('default_web_client_id'); + const fbIdx = plugin.indexOf('R.string.google_web_client_id'); + assert.ok(genIdx !== -1 && fbIdx > genIdx, 'generated web client is resolved BEFORE the fallback'); + // No Android OAuth client is ever wired as serverClientId. + assert.ok(!/serverClientId\([^)]*android_client/i.test(plugin), 'Android client never used as serverClientId'); + // Flow shape intact: Credential Manager → Google ID token → Firebase IdP. + assert.match(plugin, /GetGoogleIdOption\.Builder\(\)/, 'Credential Manager option built'); + assert.match(plugin, /GoogleIdTokenCredential\.createFrom/, 'Google ID token extracted'); + const authjs = readFileSync(join(jsDir, 'auth.js'), 'utf8'); + assert.match(authjs, /signInWithIdp/, 'token exchanged with Firebase Identity Toolkit'); + assert.match(authjs, /providerId=google\.com/, 'google.com provider asserted to Firebase'); + // No website hand-off anywhere in the native google path. + const authui = readFileSync(join(jsDir, 'authui.js'), 'utf8'); + assert.ok(!authui.includes('netlify'), 'Google sign-in never mentions or opens the website'); + // Existing Google users + brand-new Google users both go through the same + // Identity Toolkit IdP exchange (sign-in and sign-up are the same call). + assert.match(authjs, /returnSecureToken: true/, 'session tokens requested (new users get accounts automatically)'); + // Profile schema reused: users/{uid} sync after Google authentication. + assert.match(authjs, /users\/\$\{encodeURIComponent\(user\.uid\)\}|users\/\$\{user\.uid\}/, + 'profile sync targets the SAME users/{uid} doc as the website'); +}); + +// ── 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 your account…/, 'signup busy state present'); + assert.match(authui, /Account created successfully\./, 'signup success 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\.4\.0/, 'app version visible on Profile'); + assert.match(profile, /avatar-img/, 'profile photo rendered where Firebase/Google provides one'); + assert.match(profile, /saveProfileEdits/, 'profile 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'); + // Profile edits stay inside the website's EXACT schema — no invented fields. + assert.match(profile, /#pf-course/, 'course field (existing website schema field)'); + assert.match(profile, /#pf-phone/, 'phone field (existing website schema field)'); + for (const invented of ['branch', 'semester', 'college']) { + assert.ok(!new RegExp(`pf-${invented}`).test(profile), `no invented profile field: ${invented}`); + } + assert.match(profile, /cannot be edited here/, 'email displayed read-only, no fake editable email'); + assert.match(profile, /'changepw'/, 'Change Password entry present'); + assert.match(profile, /Change Password/, 'Change Password label present'); + assert.match(profile, /Password changed successfully\./, 'password-change success state'); + assert.match(profile, /google\.com/, 'Google-only accounts get the no-password explainer, not a fake form'); + + 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'); +}); + +// ── v1.3.4: account management (Google on signup, password change, reset) ── + +test('account management: Google on BOTH auth forms (one native impl); password change re-authenticates; reset copy exact', () => { + const authui = readFileSync(join(here, '../www/js/authui.js'), 'utf8'); + const auth = readFileSync(join(here, '../www/js/auth.js'), 'utf8'); + + // Create Account page carries the SAME native Google button (same handler + // as Sign in — one Credential Manager implementation, never a second one). + const signupForm = authui.match(/[\s\S]*?<\/form>/) + || authui.match(//); + assert.ok(signupForm, 'signup form present'); + assert.match(signupForm[0], /data-act="google"/, 'Create account page offers Continue with Google'); + assert.match(signupForm[0], /auth-or/, 'OR divider separates Google from the email form'); + assert.match(signupForm[0], /Continue with Google/, 'signup Google label'); + const googleWiring = authui.includes('querySelectorAll(' + String.fromCharCode(39) + '[data-act=' + String.fromCharCode(34) + 'google' + String.fromCharCode(34) + ']' + String.fromCharCode(39) + ')'); + assert.ok(googleWiring, 'both Google buttons share ONE startGoogleSignIn handler'); + assert.equal((authui.match(/startGoogleSignIn\(\{ onAuthenticated \}\);/g) || []).length >= 1, true, + 'the handler is the shared startGoogleSignIn flow'); + assert.ok(!authui.includes('Create account on website'), 'no website hand-off for Google sign-up'); + + // Password change: current password is re-verified (fresh sign-in) BEFORE + // accounts:update — Firebase recent-auth done right; passwords never logged. + const reauth = auth.match(/async changePassword\([\s\S]*?\n },/); + assert.ok(reauth, 'changePassword implemented in the auth module'); + const body = reauth[0]; + assert.ok(body.indexOf('signInWithPassword') < body.indexOf("identity('update'"), + 're-authentication (fresh signInWithPassword) precedes the password update'); + assert.match(body, /password: next/, 'Firebase receives the new password'); + assert.match(body, /google\.com/, 'Google-only accounts are refused gracefully'); + for (const line of auth.split('\n')) { + if (/console\./.test(line)) { + assert.ok(!/password/i.test(line), 'passwords never reach the console log'); + } + } + + // Reset copy: exact loading/success states, validated email, in-app only. + assert.match(authui, /Sending reset email…/, 'exact reset busy label'); + assert.match(authui, /Password reset email sent\. Check your inbox and spam folder\./, + 'exact reset success copy'); + assert.match(authui, /Please enter a valid email address\./, 'reset validates email format first'); +}); + +// ── 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, /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 ccec85e..6bc60d4 100644 --- a/android-app/www/css/app.css +++ b/android-app/www/css/app.css @@ -106,12 +106,11 @@ input, textarea { user-select: text; } gap: 10px; } .appbar-title .brand-logo { - width: 30px; - height: 30px; + width: 32px; + height: 32px; border-radius: 9px; display: inline-block; - background: #0f1b34 url('../img/emblem.png') center / contain no-repeat; - border: 1px solid rgba(110, 231, 216, 0.25); + background: #0f1b34 url('../img/logo.png') center / contain no-repeat; flex: 0 0 auto; } .appbar-title small { @@ -205,51 +204,16 @@ input, textarea { user-select: text; } .link-btn .ic { width: 15px; height: 15px; } .link-btn:active { background: rgba(110, 231, 216, 0.1); } -/* ── home hero ───────────────────────────────────────────────────────── */ -.hero { - border-radius: 22px; - background: var(--grad-hero); - border: 1px solid var(--line); - padding: 18px 16px 16px; - position: relative; - overflow: hidden; -} -.hero-top { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; } +/* ── shared hero pieces (About emblem / Profile numbers; the Home hero + section itself was removed — the app bar is the one branding surface) ── */ .hero-emblem { - width: 46px; height: 46px; border-radius: 14px; flex: 0 0 auto; - background: rgba(9, 14, 30, 0.55) url('../img/emblem.png') center / contain no-repeat; - border: 1px solid rgba(110, 231, 216, 0.28); -} -.hero-kicker { font-size: 0.72rem; font-weight: 700; color: var(--muted); } -.hero-title { font-size: 1.32rem; font-weight: 800; letter-spacing: -0.015em; } -.hero-title em { font-style: normal; color: var(--mint); } - -.search-launch { - display: flex; align-items: center; gap: 10px; - width: 100%; - padding: 13px 14px; - border-radius: 15px; - border: 1px solid var(--line-strong); - background: rgba(8, 13, 26, 0.6); - color: var(--faint); - font-size: 0.92rem; font-weight: 600; - cursor: pointer; - font-family: inherit; - text-align: left; + width: 56px; height: 56px; border-radius: 16px; flex: 0 0 auto; + background: rgba(9, 14, 30, 0.55) url('../img/logo.png') center / contain no-repeat; } -.search-launch .ic { width: 19px; height: 19px; color: var(--mint); flex: 0 0 auto; } -.search-launch:active { border-color: rgba(110, 231, 216, 0.45); } - -.stat-pills { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; } -.stat-pill { - display: inline-flex; align-items: baseline; gap: 6px; - padding: 7px 12px; - border-radius: 999px; - background: rgba(8, 13, 26, 0.55); - border: 1px solid var(--line); - font-size: 0.74rem; font-weight: 700; color: var(--muted); +.hero-title { + font-size: 1.32rem; font-weight: 800; letter-spacing: -0.015em; + line-height: 1.3; margin: 0; } -.stat-pill b { color: var(--text); font-size: 0.86rem; font-weight: 800; } /* ── cards & lists ───────────────────────────────────────────────────── */ .card { @@ -479,6 +443,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 +631,335 @@ 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: 42px; height: 42px; border-radius: 12px; flex: 0 0 auto; + background: #0f1b34 url('../img/logo.png') center / contain no-repeat; +} +.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; } + +/* ══════════════════════════════════════════════════════════════════════ + 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; } + +/* 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. */ +.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; } + + +/* ══════════════════════════════════════════════════════════════════════ + v1.3.3 — home search entry, discussion, paper density + ══════════════════════════════════════════════════════════════════════ */ + +/* Discussion (in-app comments). */ +.disc-compose-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; } +.disc-compose-row .field-hint { flex: 1 1 auto; } +#disc-list { display: flex; flex-direction: column; gap: 14px; } +.disc-item { display: flex; gap: 10px; } +.disc-avatar { + width: 30px; height: 30px; border-radius: 10px; flex: 0 0 auto; + display: flex; align-items: center; justify-content: center; + background: rgba(20, 184, 166, 0.12); color: var(--teal); + font-size: 0.7rem; font-weight: 800; +} +.disc-head { display: flex; align-items: baseline; gap: 8px; font-size: 0.82rem; } +.disc-date { color: var(--faint); font-size: 0.68rem; } +.disc-text { margin: 2px 0 0; color: var(--muted); font-size: 0.82rem; line-height: 1.5; overflow-wrap: anywhere; } + +/* Paper detail: lighter chrome, clearer hierarchy. */ +.paper-hero { padding: 2px; } +.paper-hero h1 { font-size: 1.18rem; } +.meta-grid { gap: 8px; } +.meta-cell { padding: 8px 10px; border-radius: 10px; } +.action-grid { gap: 8px; } + + +/* Profile: personal-information rows (compact label/value list). */ +.pf-info-row { + display: flex; align-items: baseline; justify-content: space-between; gap: 12px; + padding: 9px 0; + border-bottom: 1px solid var(--line); + font-size: 0.85rem; +} +.pf-info-row:last-of-type { border-bottom: 0; } +.pf-info-row > span { color: var(--muted); flex: 0 0 auto; } +.pf-info-row b { font-weight: 600; text-align: right; overflow-wrap: anywhere; } +.pf-unset { color: var(--faint); font-weight: 400; } diff --git a/android-app/www/img/emblem.png b/android-app/www/img/emblem.png deleted file mode 100644 index 38ac7c4..0000000 Binary files a/android-app/www/img/emblem.png and /dev/null differ diff --git a/android-app/www/img/logo.png b/android-app/www/img/logo.png new file mode 100644 index 0000000..10dfc98 Binary files /dev/null and b/android-app/www/img/logo.png differ diff --git a/android-app/www/index.html b/android-app/www/index.html index 1f28a01..3fc4dba 100644 --- a/android-app/www/index.html +++ b/android-app/www/index.html @@ -6,7 +6,7 @@ DSMNRU PYQ - + @@ -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..3d672fc 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 = { @@ -91,9 +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) { + 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)}` : ''}`; @@ -123,6 +156,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 +255,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 +280,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 +295,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 +348,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..2bda506 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'; @@ -41,14 +41,16 @@ 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.', 'OPERATION_NOT_ALLOWED': 'Email sign-in is currently disabled for this project.', 'TOO_MANY_ATTEMPTS_TRY_LATER': 'Too many attempts. Please wait a minute and try again.', - 'NETWORK_REQUEST_FAILED': 'Firebase is unreachable right now. Check your connection.', + 'NETWORK_REQUEST_FAILED': 'Please check your internet connection and try again.', 'TOKEN_EXPIRED': 'Your session expired — please sign in again.', 'USER_NOT_FOUND': 'Your Firebase session is no longer valid — please sign in again.', 'INVALID_REFRESH_TOKEN': 'Your saved session is no longer valid — please sign in again.', @@ -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,12 @@ 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 } + // Session-scoped profile cache (see fetchUserProfile): one users/{uid} read + // per short window; invalidated by saveProfileEdits and signOut. + let profileCache = null; // { uid, at, data } function emit() { for (const fn of listeners) { @@ -108,14 +122,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 +181,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 */ }); @@ -282,6 +296,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 { @@ -293,14 +342,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 */ } @@ -308,9 +357,247 @@ export function createAuth(options = {}) { }, signOut() { + rewardCache = null; + profileCache = 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; + }, + + /** + * Read the SAME users/{uid} profile document the website edits + * (fields: name, signupName, email, signupEmail, course, signupCourse, + * phone, uid, createdAt). One owner-scoped read, cached per session — + * Profile revisits cost zero reads. NEVER called at startup or for + * signed-out users. + */ + async fetchUserProfile() { + if (!user || !user.uid) return null; + const nowMs = now(); + if (profileCache && profileCache.uid === user.uid && nowMs - profileCache.at < 5 * 60 * 1000) { + return profileCache.data; // short-window cache — like fetchRewardSummary + } + let data = null; + try { + const res = await fetchImpl(`${FS}/users/${encodeURIComponent(user.uid)}?key=${FIREBASE_WEB_API_KEY}`, { + headers: { Authorization: 'Bearer ' + user.idToken }, + }); + if (res.ok) { + const body = await res.json().catch(() => null); + const f = (body && body.fields) || {}; + const s = (k) => (f[k] && f[k].stringValue) || ''; + data = { + name: s('name') || s('signupName') || user.name || '', + email: s('email') || s('signupEmail') || user.email || '', + course: s('course') || s('signupCourse') || '', + phone: s('phone') || '', + }; // email is identity data — shown read-only, never edited here + } else if (res.status !== 404 && res.status !== 403) { + throw new Error('profile lookup failed'); + } + } catch (err) { + if (String(err && err.message) === 'profile lookup failed') throw err; + throw new Error('profile unavailable'); // network — caller humanizes + } + profileCache = { uid: user.uid, at: nowMs, data }; + return data; + }, + + /** + * Save profile edits to the SAME users/{uid} document the website uses — + * an owner-scoped PATCH limited to exactly the website's editable fields + * (name, course, phone) so no other field can be touched. The Firebase + * Auth display name is updated too, mirroring the website's + * updateProfile({ displayName }) behavior. + */ + async saveProfileEdits({ name, course, phone } = {}) { + if (!user || !user.uid) throw new Error('Sign in first to update your profile.'); + const nextName = String(name || '').trim(); + const nextCourse = String(course || '').trim(); + const nextPhone = String(phone || '').trim(); + if (nextName.length < 2 || nextName.length > 80) { + throw new Error('Name must be between 2 and 80 characters.'); + } + if (nextPhone && !/^[0-9+\-\s()]{6,15}$/.test(nextPhone)) { + throw new Error('That phone number does not look valid.'); + } + if (nextCourse && nextCourse.length > 80) { + throw new Error('Course must be 80 characters or fewer.'); + } + try { + if (nextName !== (user.name || '')) { + await identity('update', { idToken: user.idToken, displayName: nextName }); + } + } catch (err) { + throw new Error(friendly(err)); + } + try { + const mask = ['name', 'course', 'phone'] + .map((f) => `updateMask.fieldPaths=${f}`).join('&'); + await fetchImpl(`${FS}/users/${encodeURIComponent(user.uid)}?${mask}&key=${FIREBASE_WEB_API_KEY}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + user.idToken, + }, + body: JSON.stringify({ fields: { + name: { stringValue: nextName }, + course: { stringValue: nextCourse }, + phone: { stringValue: nextPhone }, + } }), + }); + } catch (err) { + console.warn('profile save failed:', err); // dev log only + throw new Error('Unable to save your profile. Please try again.'); + } + profileCache = null; // next Profile visit re-reads the saved document + const refreshed = await auth.reloadProfile(); + setUser({ ...(refreshed || user), name: nextName }); + return user; + }, + + /** + * Change password for email/password accounts — Firebase Auth only, + * passwords never stored or logged. Recent-authentication is handled the + * way Firebase requires: the current password is verified with a fresh + * signInWithPassword call (that IS the re-authentication), and its fresh + * token performs the accounts:update. Google-only accounts have no + * password credential — callers show an explainer instead of this method. + */ + async changePassword({ currentPassword, newPassword } = {}) { + if (!user || !user.email) throw new Error('Sign in first to change your password.'); + if (user.providerId === 'google.com') { + throw new Error('This account uses Google sign-in and has no separate password.'); + } + const current = String(currentPassword || ''); + const next = String(newPassword || ''); + if (!current) throw new Error('Please enter your current password.'); + if (!next) throw new Error('Please choose a new password.'); + if (next.length < 6) throw new Error('Please choose a password with at least 6 characters.'); + if (next === current) throw new Error('The new password must be different from the current one.'); + let fresh; + try { + fresh = await identity('signInWithPassword', { + email: user.email, + password: current, + returnSecureToken: true, + }); + } catch (err) { + throw new Error(friendly(err)); // wrong current password, network, … + } + try { + const data = await identity('update', { + idToken: fresh.idToken, + password: next, + returnSecureToken: true, + }); + // Keep the session fresh with the rotated tokens — the user stays + // signed in (Firebase does not force a sign-out here). + if (data && data.idToken) { + const v = viewFromTokens(data.idToken, data.refreshToken || user.refreshToken, now()); + setUser(v); + storeSession(v); + } + return true; + } catch (err) { + throw new Error(friendly(err) || 'Unable to change your password. Please try again.'); + } + }, + + /** + * 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 8e790b9..256779a 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,49 @@ 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(); + ui.toast('Signing in with Google…'); + 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 successfully.'); + if (onAuthenticated) onAuthenticated(); + return true; + } catch (err) { + // Firebase exchange failed — human message only, never raw errors. + console.warn('google exchange failed:', err); + ui.toast('Unable to sign in with Google. Please try again.', 'err'); + return false; + } + } + const code = (res && res.code) || ''; + if (code === 'GOOGLE_SIGNIN_CANCELLED') { + ui.toast('Google sign-in was cancelled.'); + return false; + } + googleInfoSheet({ code, onAuthenticated }); + return false; +} + function field(id, label, type, placeholder, autocomplete) { return `
@@ -24,20 +72,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; } @@ -56,19 +115,26 @@ export function openAuthSheet({ reason = '', onAuthenticated, mode = 'login' } = - - + ${field('auth-email', 'Email', 'email', 'you@student.edu', 'email')} ${field('auth-pass', 'Password', 'password', '••••••••', 'current-password')} -
+
or
+ +
-
+ + +
or
${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')} @@ -76,13 +142,16 @@ export function openAuthSheet({ reason = '', onAuthenticated, mode = 'login' } =
-

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"]'), @@ -100,9 +169,8 @@ export function openAuthSheet({ reason = '', onAuthenticated, mode = 'login' } = if (b) showForm(b.dataset.chip === 'signup' ? 'signup' : 'login'); }); node.querySelector('[data-act="forgot"]').addEventListener('click', () => showForm('reset')); - node.querySelector('[data-act="google"]').addEventListener('click', () => { - ui.closeSheet(); - openGoogleInfo(); + node.querySelectorAll('[data-act="google"]').forEach((b) => { + b.addEventListener('click', () => startGoogleSignIn({ onAuthenticated })); }); wireSubmit(forms.login, async (f) => { @@ -110,7 +178,7 @@ export function openAuthSheet({ reason = '', onAuthenticated, mode = 'login' } = ui.closeSheet(); ui.toast('Signed in — full archive unlocked'); if (onAuthenticated) onAuthenticated(); - }); + }, { busyLabel: 'Signing in…' }); wireSubmit(forms.signup, async (f) => { await auth.signUp({ @@ -119,15 +187,25 @@ export function openAuthSheet({ reason = '', onAuthenticated, mode = 'login' } = password: f.querySelector('#auth-s-pass').value, }); ui.closeSheet(); - ui.toast('Account created! Verify your email to unlock everything.'); + ui.toast('Account created successfully.'); if (onAuthenticated) onAuthenticated(); - }); + }, { busyLabel: 'Creating your account…' }); wireSubmit(forms.reset, async (f) => { - await auth.requestPasswordReset(f.querySelector('#auth-r-email').value); - ui.closeSheet(); - ui.toast('Reset link sent — check your inbox'); - }); + const email = String(f.querySelector('#auth-r-email').value || '').trim(); + if (!/^[^@\s]+@[^@\s]+\.[^@\s]{2,}$/.test(email)) { + throw new Error('Please enter a valid email address.'); + } + await auth.requestPasswordReset(email); + // The Firebase request resolved — the email is on its way. Show the + // confirmation in-form (not a toast) so it cannot be missed. + const ok = f.querySelector('[data-reset-ok]'); + if (ok) { + ok.textContent = 'Password reset email sent. Check your inbox and spam folder.'; + ok.hidden = false; + f.querySelector('[data-err]').hidden = true; + } + }, { busyLabel: 'Sending reset email…' }); sheetRef = ui.sheet({ title: 'DSMNRU account', @@ -137,31 +215,41 @@ 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:

+

Unable to sign in with Google. Please try again.

+ ${configured + ? `

Google sign-in runs with the device's own account chooser — no browser needed. + It isn't available right now (no Google account on the phone, or Play services needs updating).

` + : `

Google sign-in isn't set up in this build of the app yet. Your DSMNRU email + & password works right now:

`}
- + ${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/discussion.js b/android-app/www/js/discussion.js new file mode 100644 index 0000000..803281b --- /dev/null +++ b/android-app/www/js/discussion.js @@ -0,0 +1,256 @@ +/** + * DSMNRU PYQ Android — paper discussion (logic module). + * + * Uses the SAME Firestore `comments` data as the website: top-level + * `comments` documents { paperId, text, userId, userName, userEmail, + * createdAt } with a `pyqs/{id}/comments` legacy fallback, exactly like the + * site's paper page — same collection, same field names, no second database. + * + * Read path mirrors the website's resilient chain: the primary ordered query + * (paperId == …, orderBy createdAt DESC — needs the composite index) falls + * back ON ERROR OR EMPTY to the unordered query (still fully index-backed + * through the automatic single-field paperId index; sorted client-side), + * then to the legacy subcollection. Writes try the top-level collection and + * fall back to the subcollection, like the website. + * + * Failures are CLASSIFIED for humans (network vs permission vs query/data) + * and logged with technical detail only (Logcat/console). One-time fetches + * only — no realtime listeners. Views never touch these endpoints. + */ + +const FS_BASE = 'https://firestore.googleapis.com/v1/projects/dsmnru-data/databases/(default)/documents'; +const KEY = 'AIzaSyBRlsk-knQs-AMlaTFxlneBMTwlSfwyFaQ'; // public client config, same as the website + +function fail(kind, message) { + const e = new Error(message); + e.kind = kind; // 'network' | 'permission' | 'query' | 'data' | 'write' | 'validation' + return e; +} + +/** Human message for a classified failure — technical detail stays in logs. */ +export function discussionErrorMessage(err) { + if (err && err.kind === 'network') return 'Unable to load discussion. Check your connection and try again.'; + if (err && err.kind === 'permission') return 'Unable to load this discussion right now.'; + return 'Unable to load this discussion. Please try again.'; +} + +/** Query the website's paper page runs first (requires the composite index). */ +function orderedQueryBody(paperId) { + return { + structuredQuery: { + from: [{ collectionId: 'comments' }], + where: { fieldFilter: { field: { fieldPath: 'paperId' }, op: 'EQUAL', value: { stringValue: String(paperId) } } }, + orderBy: [{ field: { fieldPath: 'createdAt' }, direction: 'DESCENDING' }], + limit: 30, + }, + }; +} + +/** The website's own fallback: equality filter only (automatic single-field + * index — NOT a collection scan), newest-first applied client-side. */ +function unorderedQueryBody(paperId) { + return { + structuredQuery: { + from: [{ collectionId: 'comments' }], + where: { fieldFilter: { field: { fieldPath: 'paperId' }, op: 'EQUAL', value: { stringValue: String(paperId) } } }, + limit: 30, + }, + }; +} + +/** Legacy subcollection scan — the parent document scopes the paper, so no + * paperId filter (legacy rows may not carry the field). */ +function subcollectionQueryBody() { + return { structuredQuery: { from: [{ collectionId: 'comments' }], limit: 30 } }; +} + +/** + * Parse one REST document into a view comment. Returns null for anything + * unusable (missing name/text, unparseable shape) so ONE malformed document + * can never break the whole list. Tolerates pending/null server timestamps + * and missing dates on legacy rows. + */ +function docToComment(row) { + try { + const doc = row && row.document; + if (!doc || !doc.fields) return null; + const f = doc.fields; + const id = String(doc.name || '').split('/').pop(); + const text = (f.text && f.text.stringValue) || (f.comment && f.comment.stringValue) || ''; + if (!id || !text) return null; // incomplete/legacy row — skip it safely + const name = (f.userName && f.userName.stringValue) + || (f.author && f.author.stringValue) + || ((f.userEmail && f.userEmail.stringValue || '').split('@')[0]) + || 'Anonymous'; + let date = ''; + const rawDate = f.createdAt && (f.createdAt.timestampValue || ''); + if (rawDate) { + const t = new Date(rawDate).getTime(); + if (Number.isFinite(t)) date = rawDate; // null/pending server timestamps stay dateless + } + return { id, name, text, date }; + } catch (err) { + console.warn('skipping a malformed comment document:', err); // dev log only + return null; + } +} + +function sortNewestFirst(list) { + return [...list].sort((a, b) => (b.date ? new Date(b.date).getTime() : 0) - (a.date ? new Date(a.date).getTime() : 0)); +} + +function dedupeById(list) { + const seen = new Set(); + return list.filter((c) => (seen.has(c.id) ? false : (seen.add(c.id), true))); +} + +/** Execute one runQuery; classify every failure mode for the UI. */ +async function executeQuery(parent, body, doFetch) { + let res; + try { + res = await doFetch(`${FS_BASE}${parent}:runQuery?key=${KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch (err) { + throw fail('network', 'comments request did not leave the device'); + } + if (!res.ok) { + const detail = await res.json().catch(() => null); + const status = String((detail && detail.error && detail.error.status) || ''); + const msg = String((detail && detail.error && detail.error.message) || ''); + console.warn(`comments query failed: HTTP ${res.status} ${status || msg}`); // Logcat/dev only — never rendered + const kind = (res.status === 403 || status === 'PERMISSION_DENIED') ? 'permission' : 'query'; + throw fail(kind, msg || status || `HTTP ${res.status}`); + } + let rows; + try { + rows = await res.json(); + } catch (err) { + throw fail('data', 'unparsable comments response'); + } + if (!Array.isArray(rows)) throw fail('data', 'unexpected comments response shape'); + return rows.map(docToComment).filter(Boolean); +} + +/** + * Load the latest comments for a paper (public read — same rule as the + * website). One-time fetch, no listeners. Resolves a deduplicated, + * newest-first array (possibly empty); throws CLASSIFIED errors. + */ +export async function loadComments({ paperId }, fetchImpl) { + const doFetch = fetchImpl || ((...args) => fetch(...args)); + if (!paperId) throw fail('data', 'no paper id resolved for this discussion'); + console.info('discussion: loading comments for paper', paperId); // dev log only — never rendered + + const failures = []; + let sawSuccess = false; + let list = []; + + // 1) The website's primary query. + try { + list = await executeQuery('', orderedQueryBody(paperId), doFetch); + sawSuccess = true; + } catch (err) { + failures.push(err); + } + + // 2) On ERROR or EMPTY — the website's unordered fallback (client sort). + if (!list.length) { + try { + list = await executeQuery('', unorderedQueryBody(paperId), doFetch); + sawSuccess = true; + } catch (err) { + failures.push(err); + } + } + + // 3) Last resort: the legacy pyqs/{id}/comments subcollection. + if (!list.length) { + try { + list = await executeQuery(`/pyqs/${encodeURIComponent(paperId)}`, subcollectionQueryBody(), doFetch); + sawSuccess = true; + } catch (err) { + failures.push(err); + } + } + + // Only fail when EVERY path failed; if any read succeeded, an empty result + // is the truthful answer (the discussion simply has no comments). + if (!list.length && !sawSuccess && failures.length) { + const worst = failures.find((e) => e.kind === 'network') + || failures.find((e) => e.kind === 'permission') + || failures[0]; + throw worst; + } + return dedupeById(sortNewestFirst(list)).slice(0, 30); +} + +/** + * Post a comment as the signed-in (verified) user — SAME collection and + * field shape the website writes (rules: text 3..600, userId must match the + * caller). Tries the top-level collection, then the legacy subcollection + * (the website's write fallback). Resolves the freshly written comment WITH + * its real Firestore document id so the UI can dedupe; throws human errors. + */ +export async function postComment({ paperId, text }, user, fetchImpl) { + const doFetch = fetchImpl || ((...args) => fetch(...args)); + const body = String(text || '').trim(); + if (body.length < 3) throw fail('validation', 'Comment is a little too short.'); + if (body.length > 600) throw fail('validation', 'Please keep comments under 600 characters.'); + if (!user || !user.uid) throw fail('validation', 'Please sign in to join the discussion.'); + if (!paperId) throw fail('validation', 'Open the paper again and try posting once more.'); + const fields = { + paperId: { stringValue: String(paperId) }, + text: { stringValue: body }, + userId: { stringValue: user.uid }, + userName: { stringValue: user.name || (user.email || 'User').split('@')[0] || 'User' }, + userEmail: { stringValue: user.email || '' }, + createdAt: { timestampValue: new Date().toISOString() }, + }; + const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + user.idToken }; + + const attempt = async (parent) => { + let res; + try { + res = await doFetch(`${FS_BASE}${parent}/comments?key=${KEY}`, { + method: 'POST', headers, body: JSON.stringify({ fields }), + }); + } catch (err) { + throw fail('network', 'comment request did not leave the device'); + } + if (res.status === 403) { + throw fail('permission', 'Verify your email to join the discussion (same rule as the website).'); + } + if (!res.ok) { + const detail = await res.json().catch(() => null); + const msg = String((detail && detail.error && detail.error.message) || ''); + throw fail('write', msg || `HTTP ${res.status}`); + } + const written = await res.json().catch(() => null); + const comment = written ? docToComment({ document: written }) : null; + if (!comment) throw fail('data', 'unreadable write response'); + return comment; + }; + + try { + return await attempt(''); + } catch (err) { + if (err.kind === 'validation' || err.kind === 'permission') throw err; + if (err.kind === 'network') { + throw fail('network', "Couldn't post your comment. Check your connection and try again."); + } + console.warn('top-level comment write failed — trying the legacy subcollection:', err); // dev log + } + try { + return await attempt(`/pyqs/${encodeURIComponent(paperId)}`); + } catch (err) { + if (err.kind === 'validation' || err.kind === 'permission') throw err; + if (err.kind === 'network') { + throw fail('network', "Couldn't post your comment. Check your connection and try again."); + } + console.warn('comment write failed:', err); // Logcat/dev only — never rendered + throw fail('write', "Couldn't post your comment. Please try again."); + } +} 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/feedback.js b/android-app/www/js/feedback.js new file mode 100644 index 0000000..25c8712 --- /dev/null +++ b/android-app/www/js/feedback.js @@ -0,0 +1,41 @@ +/** + * DSMNRU PYQ Android — user feedback submissions (logic module). + * + * Writes to the SAME Firestore `feedback` queue the website's report form + * uses (verified sign-in per the existing rules). Views call this helper so + * endpoint details stay out of the UI layer entirely; failures surface as + * human-readable errors only (details stay in the console). + */ + +const FEEDBACK_URL = 'https://firestore.googleapis.com/v1/projects/dsmnru-data/databases/(default)/documents/feedback'; + +/** + * Submit a broken-link report for a paper. `user` is the auth session view + * (nullable). Resolves true when accepted; throws a human-readable Error + * otherwise. Field shape is exactly the website's (type/title/course/ + * details/email/userId/userEmail/createdAt/status). + */ +export async function submitBrokenLinkReport({ title, course = '', details }, user, fetchImpl) { + const doFetch = fetchImpl || ((...args) => fetch(...args)); + const fields = { + type: { stringValue: 'broken_link' }, + title: { stringValue: String(title || '') }, + course: { stringValue: String(course || '') }, + details: { stringValue: String(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; + try { + const res = await doFetch(FEEDBACK_URL, { method: 'POST', headers, body: JSON.stringify({ fields }) }); + if (!res.ok) throw new Error('rejected'); + return true; + } catch (err) { + console.warn('report submission failed:', err); // dev log only + throw new Error('Could not send the report. Please try again.'); + } +} 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..8f9c24c --- /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 } from '../api.js'; +import * as ui from '../ui.js'; + +const APP_VERSION = '1.4.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 (button below)
  • +
  • Email verification / password-reset links sent by Firebase
  • +
+
+ +
+

${ui.icon('info')} Data & accounts

+
    +
  • Papers, search and courses come from the shared archive service — the same data as the website
  • +
  • Accounts and uploads use the same account system as the website — nothing new is created
  • +
  • No second database, no mirrored PDFs, no duplicate backend
  • +
+
+ +
+ +

© DSMNRU Academic Archive

+
+
`; + + root.querySelector('#about-web').addEventListener('click', () => ctx.native.openExternal(SITE_ORIGIN + '/')); +} diff --git a/android-app/www/js/views/browse.js b/android-app/www/js/views/browse.js index 7fc8a78..78f1553 100644 --- a/android-app/www/js/views/browse.js +++ b/android-app/www/js/views/browse.js @@ -56,7 +56,7 @@ export default async function renderBrowse(root, ctx) { iconName: 'wifiOff', tone: 'error', title: 'Course list unavailable', - text: ctx.state.online ? String(err.message || err) : 'Offline and nothing cached yet.', + text: ctx.state.online ? 'Something went wrong. Please try again.' : 'Offline and nothing cached yet.', actionLabel: 'Retry', onAction: () => ctx.router.tab('browse'), })); diff --git a/android-app/www/js/views/contributors.js b/android-app/www/js/views/contributors.js new file mode 100644 index 0000000..b22a5ce --- /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 ? 'Something went wrong. Please try again.' : '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..88affea 100644 --- a/android-app/www/js/views/home.js +++ b/android-app/www/js/views/home.js @@ -3,63 +3,45 @@ * * Built from ONE Worker request (`GET /api/homepage`) served through the * app's cached client (instant re-render from cache, background revalidate, - * offline fallback). Nothing here reuses website markup: greeting hero, - * search launcher, stat pills, quick courses, continue-reading (device - * history), recent/trending rails and shortcuts are app-native sections. + * offline fallback). Nothing here reuses website markup: quick-access + * courses, recent/trending rails, continue-reading (device history) and + * shortcuts are app-native sections. There is deliberately NO hero/greeting + * block and NO second search field here — the app bar is the one branding + * surface and the bottom-navigation Search tab is the one full search. */ -import { SITE_ORIGIN } from '../api.js'; - export default async function renderHome(root, ctx) { const { ui, api, store } = ctx; ctx.setHeader({ title: 'DSMNRU PYQ', brand: true }); + // ONE branding surface per screen: the app bar already carries the logo. + // Home starts straight with the useful content — no hero, no duplicate + // search UI (the bottom-navigation Search tab is the single full search). root.innerHTML = `
-
-
- -
-
Bharatpur University archive
-
Find any PYQ in seconds
-
-
- -
-
- - -
+ +
`; - root.querySelector('#home-search').addEventListener('click', () => ctx.router.tab('search')); - - const hour = new Date().getHours(); - const greet = hour < 12 ? 'Good morning' : hour < 17 ? 'Good afternoon' : 'Good evening'; - root.querySelector('#home-greet').textContent = `${greet} — ${new Date().toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'short' })}`; - // One request for everything below (cached + SWR; 0 network on re-entry). let res; try { res = await api.homepage(); } catch (err) { - root.querySelector('#home-stats').innerHTML = ''; const box = document.createElement('div'); box.appendChild(ui.stateBlock({ iconName: 'wifiOff', tone: 'error', - title: 'Couldn\'t load the archive overview', - text: ctx.state.online ? (err.message || 'Network error') : 'You appear to be offline and no cached copy exists yet.', + title: "Couldn't load the archive overview", + text: ctx.state.online ? 'Something went wrong. Please try again.' : 'You appear to be offline and no cached copy exists yet.', actionLabel: 'Retry', onAction: () => ctx.router.tab('home'), })); @@ -68,7 +50,6 @@ export default async function renderHome(root, ctx) { } const data = res.data || {}; - renderStats(data); renderCourses(data); renderContinue(); renderRecent(data); @@ -80,7 +61,6 @@ export default async function renderHome(root, ctx) { if (res.revalidating) { res.revalidating.then((fresh) => { if (!fresh || JSON.stringify(fresh) === JSON.stringify(data)) return; - renderStats(fresh); renderRecent(fresh); renderTrending(fresh); renderCourses(fresh); @@ -90,7 +70,6 @@ export default async function renderHome(root, ctx) { ctx.setRefresh(() => { api.homepage({ force: true }).then((r) => { const fresh = r.data || {}; - renderStats(fresh); renderCourses(fresh); renderRecent(fresh); renderTrending(fresh); @@ -99,17 +78,9 @@ export default async function renderHome(root, ctx) { }); // ── sections ───────────────────────────────────────────────────────── - function renderStats(d) { - const stats = d.stats || {}; - const el = root.querySelector('#home-stats'); - el.innerHTML = ` - ${Number(stats.totalPyqs) || 0} papers - ${Number(stats.totalCourses) || 0} courses - ${res.stale ? ui.stalePill('Offline copy') : ''}`; - } - 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; @@ -139,6 +110,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'); @@ -152,22 +124,21 @@ 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, { - iconName, - actionLabel: items.length ? 'Browse all' : '', - onAction: () => ctx.router.tab('browse'), - }); - host.appendChild(head); if (!items.length) { - const p = document.createElement('p'); - p.className = 'h-sub'; - p.style.marginTop = '8px'; - p.textContent = d[key] ? 'Nothing here yet.' : 'Load once you are online.'; - host.appendChild(p); + // Empty rails stay invisible — no placeholder boxes, no filler text. + host.innerHTML = ''; + host.classList.add('hidden'); return; } + host.classList.remove('hidden'); + host.appendChild(ui.sectionHead(title, { + iconName, + actionLabel: 'Browse all', + onAction: () => ctx.router.tab('browse'), + })); const list = document.createElement('div'); list.style.marginTop = '10px'; ui.paperList(list, ctx, items); @@ -179,16 +150,19 @@ 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'); 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 === 'web') native.openExternal(siteUrl); - if (b.dataset.act === 'report') native.openExternal(siteUrl); + if (b.dataset.act === 'report') openReportSheet(); }); stack.appendChild(more); + // ── Discussion — IN-APP, lazy ───────────────────────────────────────── + // Same `comments` data the website's paper page uses. Nothing is fetched + // until the user opens the section, so paper browsing never pays for it. + const discussion = document.createElement('section'); + discussion.className = 'card card-pad'; + discussion.innerHTML = ` +
${ui.icon('users')}

Discussion

+
+ +
`; + discussion.addEventListener('click', (e) => { + const b = e.target.closest('[data-act]'); + if (!b) return; + if (b.dataset.act === 'disc-open') openDiscussion(); + if (b.dataset.act === 'disc-post') submitComment(); + }); + stack.appendChild(discussion); + + function composerHtml() { + const signedIn = !!auth.current(); + const verified = auth.canUnlockPrivileges(); + const hint = !signedIn + ? 'Sign in to join the discussion' + : (!verified ? 'Verify your email to join the discussion (same rule as the website)' : ''); + return ` + +
+ ${hint ? ui.esc(hint) : 'Be respectful — discussions are moderated.'} + +
+ `; + } + + function commentHtml(c) { + const initials = String(c.name || 'A').trim().split(/\s+/).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || 'A'; + return ` +
+
${ui.esc(initials)}
+
+
${ui.esc(c.name || 'Anonymous')}${c.date ? `${ui.esc(ui.fmtDate(c.date))}` : ''}
+

${ui.esc(c.text || '')}

+
+
`; + } + + async function openDiscussion() { + const body = discussion.querySelector('#disc-body'); + body.innerHTML = ` +
${composerHtml()}
+

Loading discussion…

${ui.skeletonRows(2)}
`; + const list = body.querySelector('#disc-list'); + try { + const items = await loadComments({ paperId: id }); + if (!list.isConnected) return; // navigated away meanwhile + if (!items.length) { + list.innerHTML = `

No comments yet. Start the discussion.

`; + return; + } + list.innerHTML = items.map(commentHtml).join(''); + } catch (err) { + if (!list.isConnected) return; + // Classified human message (network vs permission vs data) — the + // technical cause stays in the console/Logcat, never in the UI. + list.innerHTML = `

${ui.esc(discussionErrorMessage(err))}

+ `; + } + } + + async function submitComment() { + if (!auth.current()) { + ctx.requireAuth(() => openDiscussion()); + return; + } + const body = discussion.querySelector('#disc-body'); + const input = body.querySelector('#disc-text'); + const errEl = body.querySelector('[data-disc-err]'); + const btn = body.querySelector('[data-act="disc-post"]'); + errEl.hidden = true; + btn.disabled = true; + btn.textContent = 'Posting…'; + try { + const written = await postComment({ paperId: id, text: input.value }, auth.current()); + const list = body.querySelector('#disc-list'); + // Dedupe by the actual Firestore document id: if a refetch already + // surfaced this comment, never render it a second time. + const alreadyShown = Array.from(list.querySelectorAll('[data-comment-id]')) + .some((n) => n.dataset.commentId === written.id); + if (!alreadyShown) { + const emptyNote = list.querySelector('p.h-sub'); + if (emptyNote) emptyNote.remove(); + list.insertAdjacentHTML('afterbegin', commentHtml(written)); + } + input.value = ''; + ui.toast('Comment posted'); + } catch (err) { + errEl.textContent = String(err && err.message || "Couldn't post your comment. Please try again."); + errEl.hidden = false; + } finally { + btn.disabled = false; + btn.textContent = 'Post'; + } + } + + /** + * 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 { + await submitBrokenLinkReport({ title, course, details }, auth.current()); + ui.closeSheet(); + ui.toast('Report sent — thank you!'); + } catch (err) { + errEl.textContent = String(err && err.message || 'Could not send the report. Please try again.'); + 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..2cbb5a9 100644 --- a/android-app/www/js/views/profile.js +++ b/android-app/www/js/views/profile.js @@ -1,23 +1,37 @@ /** - * DSMNRU PYQ Android — Profile & app settings. + * DSMNRU PYQ Android — Profile, account management & rewards. * - * Auth = the existing Firebase project (email/password verified in-app via - * the Identity Toolkit REST endpoints — same accounts, same rules). Public - * archive data never touches Firestore, so this screen loads with zero - * database reads; the optional profile-row sync only ever happens right - * after a manual sign-in (see auth.js). + * Auth = the existing Firebase project (same accounts as the website). The + * profile row is the SAME users/{uid} document the website creates; name + * edits update the Firebase Auth display name plus that row (owner-writable + * per the existing rules). Reward/contribution data is the SAME email-keyed + * reward_accounts + point_transactions data the website's points card reads, + * fetched lazily (two reads, on this screen only — never at startup, never + * for signed-out users). No endpoint URLs or project identifiers are ever + * rendered — everything user-facing is human text. */ -const APP_VERSION = '1.1.0'; +const APP_VERSION = '1.4.0'; + +function esc(s) { return String(s == null ? '' : s); } + +function initialsOf(name) { + return String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2) + .map((w) => w[0]).join('').toUpperCase() || '?'; +} + +function dateLabel(iso) { + try { + return new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }); + } catch { return ''; } +} export default async function renderProfile(root, ctx) { - const { ui, auth, store, api, native } = ctx; + const { ui, auth, store, api, native, router } = ctx; ctx.setHeader({ title: 'Profile', brand: false }); const user = auth.current(); const privileged = auth.canUnlockPrivileges(); - const initials = (user && user.name ? user.name : '?') - .split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase(); root.innerHTML = ''; const stack = document.createElement('div'); @@ -27,9 +41,12 @@ export default async function renderProfile(root, ctx) { const idCard = document.createElement('section'); idCard.className = 'card card-pad'; if (user) { + const picture = user.picture && /^https:\/\//.test(user.picture) ? user.picture : ''; idCard.innerHTML = `
-
${ui.esc(initials)}
+
${picture + ? `` + : ui.esc(initialsOf(user.name))}
${ui.esc(user.name || 'Student')} ${privileged @@ -37,10 +54,12 @@ export default async function renderProfile(root, ctx) { : `${ui.icon('mail')}verify email`} ${user.admin ? 'admin' : ''}
-
${ui.esc(user.email || '')}${user.providerId === 'google.com' ? ' · Google account' : ' · DSMNRU Firebase account'}
- ${user.degraded ? '
Session refresh pending — online reconnect will renew it
' : ''} +
${ui.esc(user.email || '')}
+
${user.providerId === 'google.com' ? 'Google account' : 'DSMNRU account'}
+ ${user.degraded ? '
Session refresh pending — it renews when you are back online
' : ''}
-
`; + +

Account email • cannot be edited here

`; } else { idCard.innerHTML = `
@@ -58,58 +77,81 @@ export default async function renderProfile(root, ctx) { stack.appendChild(idCard); if (user && !privileged) { - const note = document.createElement('section'); - note.className = 'notice'; - note.innerHTML = `${ui.icon('mail')}
Verify ${ui.esc(user.email || 'your email')}. The website enforces verified accounts for search, pagination and downloads — the app keeps that rule.
-
`; - const btn = document.createElement('div'); - btn.className = 'notice-actions'; - btn.innerHTML = ` - - `; const wrap = document.createElement('section'); wrap.className = 'notice'; wrap.style.flexDirection = 'column'; - wrap.innerHTML = note.innerHTML; - wrap.appendChild(btn); + wrap.innerHTML = `${ui.icon('mail')}
Verify ${ui.esc(user.email || 'your email')}. Verified accounts unlock search, pagination and downloads — the same rule as the website.
+
+ + +
`; wrap.addEventListener('click', async (e) => { const b = e.target.closest('[data-act]'); if (!b) return; if (b.dataset.act === 'resend') { + b.disabled = true; try { await auth.resendVerification(); ui.toast('Verification email sent'); } catch (err) { ui.toast(String(err.message || err), 'err'); } + finally { b.disabled = false; } } else { + b.disabled = true; await auth.reloadProfile(); ui.toast('Checked'); + renderProfile(root, ctx); } }); stack.appendChild(wrap); } + // ── rewards / contributions (signed-in, lazy, cached per session) ──── + if (user) { + const rewardsCard = document.createElement('section'); + rewardsCard.className = 'card card-pad'; + rewardsCard.innerHTML = ` +
${ui.icon('star')}

Contribution

+
${ui.skeletonRows(2)}
`; + stack.appendChild(rewardsCard); + paintRewards(rewardsCard.querySelector('#pf-rewards'), ctx, { email: user.email }); + } + + // ── personal information (lazy users/{uid} read — same doc as website) ── + if (user) { + const infoCard = document.createElement('section'); + infoCard.className = 'card card-pad'; + infoCard.innerHTML = ` +
${ui.icon('user')}

Personal information

+
${ui.skeletonRows(2)}
`; + stack.appendChild(infoCard); + paintPersonalInfo(infoCard.querySelector('#pf-info'), ctx, () => renderProfile(root, ctx)); + } + // ── device data ────────────────────────────────────────────────────── const dataCard = document.createElement('section'); dataCard.className = 'card card-pad'; const savedCount = store.savedList().length; const recentCount = store.recentViews().length; dataCard.innerHTML = ` -
+
${ui.icon('bookmark')}

On this device

+
${savedCount}
saved papers
${recentCount}
recently viewed
-
- +
+
`; dataCard.addEventListener('click', async (e) => { const b = e.target.closest('[data-act]'); if (!b) return; if (b.dataset.act === 'cache') { - ui.toast('Refreshing homepage + catalog…'); + b.disabled = true; + ui.toast('Refreshing archive…'); try { await Promise.all([api.homepage({ force: true }), api.courses({ force: true })]); - ui.toast('Cache refreshed'); - } catch { ui.toast('Offline — kept existing cache', 'err'); } + ui.toast('Archive up to date'); + } catch { ui.toast("Couldn't refresh — you appear to be offline", 'err'); } + finally { b.disabled = false; } } if (b.dataset.act === 'clearq') { store.clearRecentQueries(); @@ -123,15 +165,16 @@ export default async function renderProfile(root, ctx) { more.className = 'card card-pad'; const items = []; if (user) { + items.push({ act: 'editprofile', icon: 'user', label: 'Edit Profile', sub: 'Name, course and phone — the same profile as the website' }); + items.push({ act: 'changepw', icon: 'lock', label: 'Change Password', sub: 'Account security — requires your current password' }); 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: 'Your Google account, right on this device' }); } 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' }); + items.push({ act: 'about', icon: 'info', label: 'About this app', sub: `Version ${APP_VERSION}` }); more.innerHTML = `
${items.map((it) => ` `).join('')}
`; more.addEventListener('click', (e) => { @@ -147,26 +190,29 @@ export default async function renderProfile(root, ctx) { }); break; case 'google': { - import('../authui.js').then(({ googleInfoSheet }) => googleInfoSheet()); + import('../authui.js').then(({ startGoogleSignIn }) => startGoogleSignIn({})); break; } + case 'editprofile': + openEditProfileSheet(ctx, () => renderProfile(root, ctx)); + break; + case 'changepw': + openChangePasswordSheet(ctx); + 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({ - title: `DSMNRU PYQ v${APP_VERSION}`, - content: `

A dedicated Android interface for the DSMNRU PYQ / Syllabus - archive — built for this app, not a copy of the website.

- · Public data (papers, search, courses, homepage) is served by the existing - Cloudflare Worker over Cloudflare KV. The app never reads the archive from Firestore.
- · Sign-in uses the same Firebase Authentication project as the website.
- · Saved papers and history are stored only on this device.
- · PDFs open from their original hosts — nothing is mirrored into app storage.

-

© DSMNRU Academic Archive · dsmnru-pyq.netlify.app

`, + title: `DSMNRU PYQ · Version ${APP_VERSION}`, + content: `

A dedicated Android app for the DSMNRU previous-year + question-paper archive — same data and accounts as the website, in a + native interface.

+ · Papers, search and courses come from the shared archive service.
+ · Sign-in uses the same account system as the website — nothing new is created.
+ · Saved papers and history stay only on this device.
+ · PDFs open from their original hosts — nothing is copied into app storage.

+

© DSMNRU Academic Archive

`, }); break; } @@ -182,15 +228,211 @@ export default async function renderProfile(root, ctx) { if (!b) return; import('../authui.js').then(({ openAuthSheet }) => openAuthSheet({ mode: b.dataset.act === 'signup' ? 'signup' : 'login', - reason: b.dataset.act === 'signup' - ? 'Create your DSMNRU account (works on the website too).' - : '', + reason: b.dataset.act === 'signup' ? 'Create your DSMNRU account — it works right here in the app.' : '', })); }); - } else { - const wrapBtn = idCard.querySelector('[data-act="verify"]'); - if (wrapBtn) wrapBtn.addEventListener('click', () => auth.reloadProfile()); } ctx.setRefresh(() => renderProfile(root, ctx, {})); } + +// ── edit profile (name / course / phone — the website's editable fields) ── + +async function openEditProfileSheet(ctx, onSaved) { + const { ui, auth } = ctx; + const user = auth.current(); + if (!user) return; + const node = document.createElement('div'); + node.innerHTML = ` + +

Loading your current profile…

`; + const s = ui.sheet({ title: 'Edit Profile', content: node }); + + // Load current Firestore values first (cached — one read per short window). + let profile = { name: user.name || '', course: '', phone: '' }; + try { + profile = (await auth.fetchUserProfile()) || profile; + } catch { /* fall back to session identity values */ } + if (!s.el.isConnected || !node.isConnected) return; // sheet already dismissed + + node.innerHTML = ` + +
+ + +

2–80 characters. Shown with your contributions.

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

Account email • cannot be edited here

+
+
+ + +
`; + const saveBtn = node.querySelector('#pf-save'); + const errEl = node.querySelector('[data-err]'); + saveBtn.addEventListener('click', async () => { + if (saveBtn.disabled) return; // no duplicate submissions + saveBtn.disabled = true; + saveBtn.textContent = 'Saving changes…'; + errEl.hidden = true; + try { + await auth.saveProfileEdits({ + name: node.querySelector('#pf-name').value, + course: node.querySelector('#pf-course').value, + phone: node.querySelector('#pf-phone').value, + }); + s.close(); + ui.toast('Profile updated successfully.'); + if (onSaved) onSaved(); // local UI updates immediately — no restart + } catch (err) { + errEl.textContent = String(err && err.message || 'Unable to save your profile. Please try again.'); + errEl.hidden = false; + saveBtn.disabled = false; + saveBtn.textContent = 'Save Changes'; + } + }); +} + +// ── change password (email/password accounts only — Firebase Auth) ───── + +function openChangePasswordSheet(ctx) { + const { ui, auth } = ctx; + const user = auth.current(); + if (!user) return; + const node = document.createElement('div'); + if (user.providerId === 'google.com') { + // No fake "current password" for Google-only accounts: this identity has + // no password credential — point to the real account-security option. + node.innerHTML = ` +

You sign in with Google, so this account doesn't have a + separate DSMNRU password to change.

+

To manage your account security, use your Google Account settings — + or set a password for your email on the website's account page if you also want + email & password sign-in.

`; + ui.sheet({ title: 'Change Password', content: node }); + return; + } + node.innerHTML = ` + +
+ + +
+
+ + +
+
+ + +
+
+ + +
`; + const s = ui.sheet({ title: 'Change Password', content: node }); + const saveBtn = node.querySelector('#pf-pw-save'); + const errEl = node.querySelector('[data-err]'); + saveBtn.addEventListener('click', async () => { + if (saveBtn.disabled) return; // no duplicate submissions + const cur = node.querySelector('#pf-pw-cur').value; + const next = node.querySelector('#pf-pw-new').value; + const conf = node.querySelector('#pf-pw-conf').value; + errEl.hidden = true; + if (!cur) { errEl.textContent = 'Please enter your current password.'; errEl.hidden = false; return; } + if (!next) { errEl.textContent = 'Please choose a new password.'; errEl.hidden = false; return; } + if (next !== conf) { errEl.textContent = 'The new passwords do not match.'; errEl.hidden = false; return; } + saveBtn.disabled = true; + saveBtn.textContent = 'Updating password…'; + try { + await auth.changePassword({ currentPassword: cur, newPassword: next }); + s.close(); + ui.toast('Password changed successfully.'); + } catch (err) { + errEl.textContent = String(err && err.message || 'Unable to change your password. Please try again.'); + errEl.hidden = false; + saveBtn.disabled = false; + saveBtn.textContent = 'Change Password'; + } + }); +} + +// ── personal information rows (same users/{uid} fields as the website) ── + +async function paintPersonalInfo(host, ctx, onEdited) { + const { ui, auth } = ctx; + const user = auth.current(); + if (!user) return; + let profile = null; + try { + profile = await auth.fetchUserProfile(); + } catch (err) { + if (!host.isConnected) return; + console.warn('profile load failed:', err); // dev log only — never rendered + } + if (!host.isConnected) return; // navigated away meanwhile + const row = (label, value) => ` +
${ui.esc(label)}${value}
`; + const course = profile && profile.course ? ui.esc(profile.course) : 'Not set'; + const phone = profile && profile.phone ? ui.esc(profile.phone) : 'Not set'; + const email = (profile && profile.email) || user.email || ''; + host.innerHTML = ` + ${row('Email', `${ui.esc(email)} • cannot be edited here`)} + ${row('Course', course)} + ${row('Phone', phone)} +
`; + const edit = host.querySelector('#pf-edit'); + if (edit) edit.addEventListener('click', () => openEditProfileSheet(ctx, onEdited)); +} + +// ── rewards / contributions ──────────────────────────────────────────── + +async function paintRewards(host, ctx, { email }) { + const { ui, auth, router } = ctx; + try { + const summary = await auth.fetchRewardSummary(); + if (!host.isConnected) return; // navigated away meanwhile + if (!summary) { + host.innerHTML = `

Sign in with the email you used to contribute to see your points.

`; + return; + } + const contributions = summary.transactions.length; + const head = ` +
+
${summary.points}
reward points
+
${contributions}
approved uploads
+
`; + if (!summary.points && !contributions) { + host.innerHTML = `${head}

No contributions yet — every approved upload earns 10 points.

+
`; + const btn = host.querySelector('#pf-upload'); + if (btn) btn.addEventListener('click', () => router.go('upload')); + return; + } + const rows = summary.transactions.slice(0, 3).map((t) => ` +
+${t.amount}${t.type === 'PYQ_UPLOAD' ? 'PYQ contribution' : ui.esc(t.type || 'Reward')}${ui.esc(dateLabel(t.date))}
`).join(''); + host.innerHTML = `${head}${rows ? `
${rows}
` : ''} + ${contributions > 3 ? `

Latest ${contributions} rewards tracked by the moderators

` : ''}`; + } catch (err) { + if (!host.isConnected) return; + host.innerHTML = `

Couldn't load your points right now. Check your connection and try again.

+
`; + const retry = host.querySelector('#pf-retry'); + if (retry) retry.addEventListener('click', () => { + host.innerHTML = ui.skeletonRows(2); + paintRewards(host, ctx, { email }); + }); + console.warn('reward summary failed:', err); // dev log only — never rendered + } +} diff --git a/android-app/www/js/views/search.js b/android-app/www/js/views/search.js index 10608c6..870d492 100644 --- a/android-app/www/js/views/search.js +++ b/android-app/www/js/views/search.js @@ -225,7 +225,8 @@ export default async function renderSearch(root, ctx, params = {}) { } catch (err) { if (err && err.name === 'AbortError') return; if (myGen !== gen) return; - s.error = err.message || 'Network problem — pull down to try again.'; + console.warn('search failed:', err); // detail in the log, never in the UI + s.error = 'Something went wrong. Please try again.'; } finally { if (myGen === gen) { s.loading = false; @@ -283,7 +284,8 @@ export default async function renderSearch(root, ctx, params = {}) { } catch (err) { if (err && err.name === 'AbortError') return; if (myGen !== gen) return; - s.error = err.message || 'Network problem'; + console.warn('search failed:', err); + s.error = "Couldn't load results. Check your connection and try again."; s.mode = 'results'; s.items = []; } finally { diff --git a/android-app/www/js/views/tools.js b/android-app/www/js/views/tools.js new file mode 100644 index 0000000..17ce72f --- /dev/null +++ b/android-app/www/js/views/tools.js @@ -0,0 +1,397 @@ +/** + * 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')} +
Runs fully offline on this device. Your marks, attendance and plans never leave 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..cd4962f --- /dev/null +++ b/android-app/www/js/views/upload.js @@ -0,0 +1,327 @@ +/** + * 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) { + const raw = String((err && err.message) || err); + const scrubbed = raw.replace(/https?:\/\/\S+/g, '').replace(/\s{2,}/g, ' ').trim(); + if (/failed to fetch|networkerror|load failed/i.test(raw)) { + setError('Please check your internet connection and try again.'); + } else { + setError(scrubbed || 'Something went wrong. Please try again.'); + } + } + }); + + ctx.setRefresh(() => renderUpload(root, ctx, {})); +}