A lightweight PDF reader for Android, built as an installable PWA with an optional Android app wrapper. Tap a PDF in WhatsApp and pick Lembar in "Open with" (Android app), or use Share → Lembar (PWA). Read offline and pick up where you left off.
Everything runs on the device. Files are never uploaded anywhere.
- Reading: continuous scroll, pinch-to-zoom, fit-to-width, page slider, jump to page, tap to hide the toolbars
- Zoom remembered per document, stored relative to fit-width so it carries over between phone, tablet and rotation
- Two-page spread on tablets and in landscape (automatic at 900px+ wide, or forced on/off with the view button). Page 1 stays alone like a book cover.
- Open from other apps: "Open with" for PDFs via the Android app (TWA), and the share sheet via the Web Share Target
- Recently read: thumbnails, progress, and auto-resume at the last page (keeps the 15 most recent files)
- Search: full-text search across all pages, with result snippets and in-page highlights
- Bookmarks per document, plus the PDF's own table of contents when it has one
- Night mode: inverts page colours for reading in the dark
- Offline: app shell and pdf.js are precached by the service worker
- Password-protected PDFs are supported
- On desktop Chrome/Edge, the installed app also registers as a
.pdffile handler
sequenceDiagram
participant WA as WhatsApp
participant SW as sw.js
participant App as index.html
WA->>SW: POST /share-target (multipart, field "pdf")
SW->>SW: store file in Cache Storage ("lembar-share")
SW-->>App: 303 redirect to /?share=1
App->>SW: read and delete the cached file
App->>App: save to IndexedDB history, render with pdf.js
If the service worker is not active yet, src/worker.js catches the POST on Cloudflare and redirects to /?share=error instead of returning a 405.
- Vanilla HTML/CSS/JS in one
index.html. No framework, no build step. - pdf.js 3.11.174, vendored in
public/vendor/so it works offline - IndexedDB for file history and bookmarks,
localStoragefor the night-mode preference - Service worker: precached shell, stale-while-revalidate updates, Share Target receiver
.
├── public/ # deploy this folder as-is
│ ├── index.html # the whole app
│ ├── sw.js # service worker (offline + share target)
│ ├── manifest.webmanifest # install metadata, share_target, file_handlers
│ ├── _headers # Cloudflare cache/content-type headers
│ ├── sample.pdf # built-in guide document
│ ├── icons/ # any + maskable icons (192, 512)
│ └── vendor/ # pdf.min.js, pdf.worker.min.js
├── src/
│ └── worker.js # Worker: serves public/, assetlinks.json, share fallback
├── android/
│ └── twa-manifest.json # Bubblewrap config for the Android app
├── .github/workflows/
│ └── android.yml # builds and signs the APK
├── deploy/
│ └── Caddyfile # alternative: self-host behind Caddy
└── wrangler.toml # Workers + static assets config
python3 -m http.server 8080 -d public
# or: npx serve publicOpen http://localhost:8080. Service workers are allowed on localhost. Anywhere else they need HTTPS.
The app is deployed as a Worker with static assets. wrangler.toml serves public/, and public/_headers sets the cache and content-type headers.
Option A: Git integration (recommended)
- Cloudflare dashboard → Workers & Pages → Create → Import a repository → pick this repo.
- Build settings:
- Build command: (leave empty)
- Deploy command:
npx wrangler deploy
- Make sure
nameinwrangler.tomlmatches the Worker's name in the dashboard (currentlypdf-reader). - Every push to
mainredeploys automatically. - Optional: Settings → Domains & Routes → add a custom domain such as
lembar.erwarx.com.
Option B: from your machine
npx wrangler deployTest locally with the same runtime:
npx wrangler devCopy public/ to the server and use deploy/Caddyfile. Caddy handles HTTPS automatically.
- Open the deployed URL in Chrome for Android.
- Tap Install on the page (or ⋮ → Install app). It has to be a real install, not "Add to Home screen", because only installed WebAPKs appear in the share sheet.
- In WhatsApp: long-press a PDF → Share → Lembar.
A PWA can't appear in Android's "Open with" list, so the repo also builds a small Android app. It's a WebView shell: a full-screen WebView that loads this same site, with reading history, bookmarks and the offline cache stored inside the app. It registers as a handler for application/pdf ("Open with") and as a share target.
The project is generated with Bubblewrap (icons, signing, Gradle setup), then android/patch_project.py replaces its Trusted Web Activity launcher with android/LauncherActivity.java. A TWA was tried first, but Chrome dropped PDFs handed over from WhatsApp on some phones (Galaxy A05s), both through launchQueue and the share target.
sequenceDiagram
participant WA as WhatsApp
participant A as LauncherActivity
participant W as WebView (index.html)
WA->>A: ACTION_VIEW / ACTION_SEND content://… (application/pdf)
A->>A: copy to cache/shared_pdfs/
A->>W: load https://HOST/ (or evaluateJavascript if already open)
W->>A: LembarNative.pending() → {id, name, size}
loop chunks of ≤512 KB
W->>A: LembarNative.read(id, offset, length) → base64
end
W->>A: LembarNative.done(id)
W->>W: open with pdf.js
The app opens https://lembar.erwarx.com (set in the workflow; a TWA_HOST repository variable overrides it). The WebView shell doesn't need Digital Asset Links; the Worker still serves /.well-known/assetlinks.json from wrangler.toml in case you go back to a TWA.
The only thing GitHub needs is the signing key, as two repository secrets (Settings → Secrets and variables → Actions → Secrets):
ANDROID_KEYSTORE_BASE64: the keystore file, base64-encoded (base64 -i lembar.keystoreon macOS,base64 -w0 lembar.keystoreon Linux)ANDROID_KEYSTORE_PASSWORD: the keystore password
Keep a backup of the keystore and password somewhere safe. Every future update must be signed with the same key, or Android will refuse to install it over the old version.
Create a new one, update both secrets, and replace TWA_SHA256 in wrangler.toml (both [vars] and [previews.vars]) with the new fingerprint. Phones with the old build have to uninstall it first.
keytool -genkeypair -keystore lembar.keystore -storetype PKCS12 -alias lembar \
-keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Lembar"
keytool -list -v -keystore lembar.keystore -alias lembar | grep SHA256Push a tag such as v1.0.0. The workflow builds a signed APK and attaches it to a GitHub Release. Download it on the phone, open it, and allow installing from that source when Android asks.
Pull requests that touch android/ build a test APK signed with a throwaway key. It's useful to check that the build works, but not for installing.
- Android System WebView up to date (updated through the Play Store)
- Android 7.0+ (
minSdkVersion24, required by androidbrowserhelper)
Bump VERSION in public/sw.js (e.g. lembar-v4) on every deploy that changes cached files. This makes the old cache get dropped. sw.js, / and the manifest are served with Cache-Control: no-cache, so clients pick up the new worker on their next visit.
- "Open with" needs the Android app. Chrome on Android doesn't support
file_handlersfor installed PWAs, so only the Android app appears there. The plain PWA gets the share sheet. - Separate storage. The Android app keeps its own history and bookmarks, separate from the PWA in Chrome.
- iOS supports neither "Open with" nor share targets for web apps. Use the in-app Buka PDF button.
- Scanned PDFs are images with no text layer, so search finds nothing in them.
- Storage is per browser. History and bookmarks live in that browser's IndexedDB and are lost if site data is cleared.
- Fonts load from Google Fonts and are cached after the first visit. On a cold offline start, system fonts are used.
- TWA wrapper (Bubblewrap) +
/.well-known/assetlinks.jsonfor "Open with" - Remember zoom level per document
- Two-page spread on tablets / landscape
- Publish the Android app on Google Play