# Deep links — Deeniyat Plus (`https://admin.deenlearning.in/DeeniyatPlus`)

**Date:** 2026-09-22
**Status:** Files ready in `deeplinks/`. **Not deployed.** Needs one Apache change on the server, plus confirmation of two app-signing values from the RN dev before the app release ships.

---

## 1. What the backend owns

The RN dev's doc ("Deep Link Setup with Instant App Open and Store Fallback") needs three URLs on `admin.deenlearning.in`:

| URL | Purpose | File in this repo |
|---|---|---|
| `/.well-known/assetlinks.json` | Android App Links verification | `deeplinks/assetlinks.json` |
| `/.well-known/apple-app-site-association` | iOS Universal Links verification | `deeplinks/apple-app-site-association` |
| `/DeeniyatPlus`, `/DeeniyatPlus/*` | Fallback page when the OS did not open the app | `deeplinks/DeeniyatPlus.html` |

Plus `deeplinks/apache-deeplinks.conf`, the Apache snippet that serves them.

### Why this is Apache config and not an Express route

`admin.deenlearning.in`, `api.deeniyatplus.com` and `deenlearning.in` all resolve to the same EC2 host (3.6.55.1). **Port 443 on that host is Apache 2.4.41**, serving the admin panel (a create-react-app build titled "Deeniyat Self Learning"). This Node app listens separately on `:3010` with a certificate for `api.deeniyatplus.com` only.

- Android fetches `https://<host>/.well-known/assetlinks.json` on the default port only.
- iOS `applinks:` domains cannot carry a port, and Apple's CDN fetches from port 443.
- Neither follows redirects.

So Node on `:3010` can never answer these requests. Apache has to. Proxying from Apache to Node would work, but it would make link verification depend on the Node process (run under `nodemon`) and need `SSLProxy*` relaxations for its certificate. Three static files served by Apache have no moving parts. **`index.js` is unchanged.**

The files live in `/srv/deeplinks`, **outside** the admin panel's build folder, so a frontend redeploy cannot delete them.

## 2. Live state before this change (checked 2026-09-22)

Every one of the three URLs returns the admin SPA's `index.html` (200, `text/html`, 651 bytes). The catch-all serves it for any path that is not a real file.

- Google's Digital Asset Links API: `ERROR_CODE_WRONG_CONTENT_TYPE` ("expected application/json, found text/html").
- Apple's CDN (`app-site-association.cdn-apple.com/a/v1/admin.deenlearning.in`): cached failure `SWCERR00401 Bad JSON content`.
- The `admin.deenlearning.in` certificate is valid (Let's Encrypt, expires 2026-11-07). The apex `deenlearning.in` presents a certificate for `api.deenlearning.in`, so it cannot be a link host.
- **A second `/DeeniyatPlus` page already exists on another host.** `adminquran.deenlearning.in` serves the actively deployed build of the same admin panel (repo `deeniyat-adminquran.deenlearning.in-web`). Commit `19254914` "DeepLinking" (2026-03-18) added a public `/DeeniyatPlus` route there (`src/App.js:112`, `src/pages/DeeniyatPlusRedirect.js`). It always redirects to a store and has no assetlinks or AASA, so it can never open the app. `admin.deenlearning.in` serves an older build (2024-04-12) that has no such route. We standardise on `admin.deenlearning.in`, as the RN doc does. See section 10 for `adminquran` links already in circulation.

## 3. What was wrong in the RN doc

| # | Doc says | Problem | What we ship |
|---|---|---|---|
| 1 | AASA `appID: "Z7YJAJM35A.com.hl.deeniyat.deeniyatmaktab"` | That is the **Android package name**. No iOS app has that bundle ID, so Universal Links would never activate. | `Z7YJAJM35A.com.deeniyat.DeeniyatMaktab`: iOS Deeniyat Plus bundle ID (App Store id1463273247) and Team ID from the app's Xcode project. |
| 2 | App Store link `apps.apple.com/in/app/deen-learning/id6447339483` | That is **"Deen Learning"** (`com.deeniyat.deenlearningapp`), a different app. | `https://apps.apple.com/app/id1463273247` (Deeniyat Plus). |
| 3 | Fallback page sets `window.location` to its own URL on load | It reloads itself and cancels the 1.5 s store timer, so users loop on "Opening…" and never reach a store. It also cannot open the app: iOS never opens a Universal Link for a same-domain navigation, and Android has already declined the link by the time the page shows. | New page (section 6): never navigates to itself. |
| 4 | iOS detection `indexOf("iphone") \|\| indexOf("ipad")` | iPadOS 13+ Safari reports a **Mac** user agent, so iPads fall through to no branch. | Treats a touch-screen Mac UA as an iPad. |
| 5 | No desktop branch | Desktop visitors loop forever. | Desktop shows both store buttons and never redirects. |
| 6 | Hard-coded redirect to `/DeeniyatPlus` | Drops sub-paths and query strings, although the AASA allows `/DeeniyatPlus/*`. | Full path and query are forwarded to the app. |
| 7 | Legacy AASA format (`apps`, `appID`, `paths`) | Still accepted, but Apple's TN3155 says not to mix formats, and the app's minimum iOS (15.1) reads the modern one. | Modern format: `appIDs` and `components`. |
| 8 | Notes list HTTPS, `.well-known`, and no `.json` extension | Correct, but incomplete. The rules that decide pass or fail are missing: HTTP 200, `Content-Type: application/json`, **no redirects**, valid certificate for the exact host, reachable by any client. | Enforced by `apache-deeplinks.conf`. |

The doc's `assetlinks.json` is structurally correct. Its fingerprint still needs confirming (section 4).

## 4. Values that must be confirmed before the app release

1. **Android SHA-256 fingerprint.** `76:D7:A2:…:DE:5D` must be the **Play App Signing key**. In Play Console go to Protected with Play → Play Store distribution → Go to Play app signing → "App signing key" section. Play also shows a ready-made Digital Asset Links JSON snippet there; copying it is the safest option. If Play lists more than one app-signing SHA-256, include all of them. If the app is not enrolled in Play App Signing, use the key releases are signed with. It must not be the upload key. If it is the upload key, verification fails for every user who installs from Play. The upload key may be added as a second entry so locally signed release builds also verify. Never add the React Native template `debug.keystore`: its key is public.
2. **iOS App ID prefix `Z7YJAJM35A`.** The local copy of the RN repo (`D:\Deeniyat Plus\deeniyatplus`, last commit 2024-11-20) builds `com.deeniyat.DeeniyatMaktab` with `DEVELOPMENT_TEAM = Z7YJAJM35A`. Confirm it in developer.apple.com → Identifiers → `com.deeniyat.DeeniyatMaktab` → "App ID Prefix". A wrong prefix fails silently.
3. **Android package.** Play lists Deeniyat Plus as `com.hl.deeniyat.deeniyatmaktab`. That same 2024 local copy has `applicationId "com.deeniyatplus"`. The build the RN dev ships must be `com.hl.deeniyat.deeniyatmaktab`.

If a value changes, edit the file in `deeplinks/` and redeploy it (section 5, step 2). Content changes do not need an Apache reload.

## 5. Deploying on the server

Run from a checkout of this repo on the server (or `scp` the `deeplinks/` folder up first).

```bash
# 1. Find the admin.deenlearning.in :443 vhost file (usually a certbot *-le-ssl.conf)
sudo apache2ctl -S 2>/dev/null | grep -i 'admin.deenlearning.in'
#    Note the file:line it prints, then look at how the SPA catch-all is done:
sudo grep -nE 'DocumentRoot|RewriteEngine|RewriteRule|FallbackResource|ErrorDocument|Alias|Include' <vhost-file>

# 2. Put the files in place (copy, don't symlink: the snippet uses Options None)
sudo install -d -m 755 /srv/deeplinks
sudo install -m 644 deeplinks/assetlinks.json deeplinks/apple-app-site-association \
    deeplinks/DeeniyatPlus.html deeplinks/apache-deeplinks.conf /srv/deeplinks/

# 3. mod_headers is off on stock Ubuntu (only needed for the Cache-Control lines)
sudo a2enmod headers

# 4. Back up the vhost, then add ONE line as the FIRST line inside <VirtualHost *:443>,
#    immediately after the opening tag, above every other directive:
#        Include /srv/deeplinks/apache-deeplinks.conf
#    sites-enabled entries are symlinks: resolve the real file, or the backup is just another link.
VHOST=$(readlink -f <vhost-file>)
sudo cp -p "$VHOST" "/root/$(basename "$VHOST").bak.$(date +%F-%H%M)"
sudo nano "$VHOST"

# 5. Test, then apply
sudo apache2ctl configtest && sudo systemctl restart apache2
```

The `Include` must come before the SPA's own rules. A vhost-level `RewriteRule` catch-all runs **before** `Alias` whatever its position in the file. The snippet's own `RewriteRule … - [END]` lines let our paths through, but only if they run first. Do not anchor on `ServerName`: certbot sometimes appends it as the vhost's last line. If the catch-all lives in `.htaccess` or is `FallbackResource`, the Alias wins anyway.

**Rollback:** `sudo cp -p /root/<name>.bak.<stamp> "$VHOST"`, then `configtest` and restart.

## 6. What the fallback page does

The page only loads when the OS did **not** hand the link to the app: the app is not installed, verification failed, the link was opened in an in-app browser, the URL was typed or pasted, the iOS user once chose "Open in Safari", or the visitor is on desktop.

| Visitor | Behaviour |
|---|---|
| Android, Chrome and Chromium browsers whose UA carries `Chrome/<n>` (Edge, Opera, Brave, Vivaldi, Yandex, Huawei…) | Immediately redirects to `intent://admin.deenlearning.in/DeeniyatPlus…;package=com.hl.deeniyat.deeniyatmaktab;S.browser_fallback_url=<Play>`. Opens the app if installed, even if App Links verification failed; otherwise opens the Play Store. |
| Android, Samsung Internet / UC / MIUI / HeyTap, Firefox, any UA without `Chrome/` | Goes to Google Play once the page has loaded (Firefox shows the Play web page in the tab). Back returns here, with "Open in the app" and "Get it on Google Play". |
| Android in-app browser (Instagram, Facebook…) | No redirect. "Open in the app" and "Get it on Google Play" buttons, plus an "Open in browser" hint. |
| Android tablet in Chrome's default desktop mode (Linux desktop UA + touch) | No redirect (touch-screen Linux laptops look the same). "Open in the app" plus both store buttons. |
| iPhone / iPad | Goes to the App Store once the page has loaded (its page shows "Open" if the app is installed). Back returns here, where Safari shows the Smart App Banner. |
| Desktop | No redirect. Both store buttons. |
| Reload, or back button to the page | No second redirect. Buttons only. |

The full path and query (e.g. `/DeeniyatPlus/lesson/12?x=1`) go into the intent and into the Play `referrer` (`utm_content`), so the app can read it after install via the Play Install Referrer API. Links typed in the wrong case (`/deeniyatplus/...`) also get this page, and the case is corrected before handing off to Android. The app's own link matching is case-sensitive.

Known limits:
- The Smart App Banner's `app-argument` is the bare `/DeeniyatPlus`, because a static file cannot echo the request path.
- A link typed into Chrome's address bar goes to the Play Store web page even when the app is installed. Chrome only launches apps on navigations the user started with a tap.
- iOS cannot tell from a web page whether the app is installed. A button linking back to `admin.deenlearning.in` cannot open the app, because Safari keeps same-domain navigations in the browser (Apple TN3155). In Safari, the Smart App Banner's OPEN does open the app, but not in in-app browsers or other browsers, and not after the user dismisses it. A button to a Universal Link on a *different* associated host would also work (section 10).

## 7. Verifying

```bash
for p in /.well-known/assetlinks.json /.well-known/apple-app-site-association \
         /DeeniyatPlus /DeeniyatPlus/lesson/1 /deeniyatplus /DeeniyatPlusXYZ /; do
  echo "== $p"; curl -s -o /dev/null -D - "https://admin.deenlearning.in$p" \
    | grep -iE '^(HTTP|content-type|content-length|location|cache-control)'
done
```

| Path | Expected |
|---|---|
| `/.well-known/assetlinks.json` | 200, `application/json`, 338 bytes, no `Location` |
| `/.well-known/apple-app-site-association` | 200, `application/json`, 346 bytes, no `Location` |
| `/DeeniyatPlus`, `/DeeniyatPlus/lesson/1`, `/deeniyatplus` | 200, `text/html`, the new page (not 651 bytes) |
| `/DeeniyatPlusXYZ`, `/` | Unchanged: the 651-byte admin SPA |

Then:

```bash
# Google: must list our statement with no errorCode
curl -s "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://admin.deenlearning.in&relation=delegate_permission/common.handle_all_urls"

# Apple CDN: picks the file up within ~24 h; must be 200 with our JSON and no Apple-Failure-Reason header
curl -si https://app-site-association.cdn-apple.com/a/v1/admin.deenlearning.in

# Certificate renewal still works after the Apache change
sudo certbot renew --dry-run
```

A broken deploy fails silently: the app still works, and links just open in the browser. Adding the first `curl` loop to an uptime monitor (expecting `200` + `application/json` for the two JSON URLs) catches that.

## 8. Rollout order — backend first

1. RN dev confirms the values in section 4. We update `deeplinks/` if needed.
2. Deploy (section 5) and verify (section 7): Google `statements:list` clean, and the Apple CDN serving our JSON.
3. **Only then** the RN dev ships the release with `autoVerify` and Associated Domains, including TestFlight builds.

The order matters because devices do not re-check quickly. Android 14 and lower verify only at install or update, so a user who installs while our files are broken stays unverified until the next app update. iOS devices take the AASA from Apple's CDN at install and re-check about weekly; the CDN cannot be flushed.

## 9. Handoff to the RN dev

- **Android manifest.** Add an `autoVerify="true"` intent-filter with VIEW, DEFAULT and BROWSABLE, `<data>` for schemes `http` and `https`, host `admin.deenlearning.in`, `android:path="/DeeniyatPlus"` and `android:pathPrefix="/DeeniyatPlus/"`. **The path restriction is mandatory.** `assetlinks.json` delegates the whole host, and this host is the admin panel: without the restriction, every admin-panel link opens the app on any phone that has it. Do not add `deenlearning.in`, `www` or wildcard hosts; they have no valid certificate or assetlinks, which breaks verification on Android 11 and lower. Keep any custom scheme in a separate filter.
- **iOS.** Associated Domains entitlement `applinks:admin.deenlearning.in` on `com.deeniyat.DeeniyatMaktab`. Debug builds may add `applinks:admin.deenlearning.in?mode=developer` to bypass Apple's CDN.
- **App code.** Handle `Linking.getInitialURL()` and the `url` event, and forward both `continueUserActivity` (Universal Links) and `openURL` (Smart App Banner) to `RCTLinkingManager` in the AppDelegate. Expect `http(s)://admin.deenlearning.in/DeeniyatPlus[/anything][?query]`. Android also delivers `http://`, because the filter declares both schemes and messaging apps often linkify bare domains as `http://`. iOS delivers `https://` only. Match on host and path, not scheme, e.g. React Navigation `prefixes: ['https://admin.deenlearning.in', 'http://admin.deenlearning.in']`.
- **Don't use the doc's fallback script.** It is replaced by `deeplinks/DeeniyatPlus.html` (section 3, rows 3–6).
- **Testing.** Tap the link from another app (WhatsApp, Notes, Messages), or use `adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://admin.deenlearning.in/DeeniyatPlus"`. Typing the URL into a browser always stays in the browser and looks like a failure. Android 12+: run `adb shell pm verify-app-links --re-verify com.hl.deeniyat.deeniyatmaktab`, wait about a minute, then `adb shell pm get-app-links com.hl.deeniyat.deeniyatmaktab`; it should show `admin.deenlearning.in: verified`. Re-verify on any device that installed the build before our files were deployed. Android 11 and lower: `adb shell dumpsys package domain-preferred-apps`; the package's entry should list the host with `Status: always`.

## 10. Open questions — the host

**Links already shared on `adminquran.deenlearning.in`.** Since March 2026 the admin SPA there has served `/DeeniyatPlus` (section 2). Before deploying, ask the frontend team and whoever sends Deeniyat Plus links which host they have used.
- **If `adminquran` links are out there:** add the same `Include /srv/deeplinks/apache-deeplinks.conf` as the first line of the `adminquran.deenlearning.in` `<VirtualHost *:443>`, and run the section 7 checks against that host. The snippet is host-agnostic. The page's intent always targets `admin.deenlearning.in`, so Android Chrome still opens the app. For those links to open the app directly (without the page), the RN dev would also add `applinks:adminquran.deenlearning.in` and a second intent-filter host with the same path restriction, backend first as in section 8.
- **Frontend clean-up:** remove the SPA's `DeeniyatPlus` route and `DeeniyatPlusRedirect.js` only after `adminquran` carries the Include, or once it is confirmed no `adminquran` links were shared. Otherwise existing links fall into the panel's login routing. From then on, share only `https://admin.deenlearning.in/DeeniyatPlus`.

**The host itself.** `admin.deenlearning.in` works, but it is an admin-panel host serving a 2024 build while the live panel is on `adminquran`. Deeniyat Plus links now depend on it: if it is decommissioned or re-pointed, `/srv/deeplinks` and the `Include` line must be carried over. `admin.deenlearning.in` is the **Deen Learning** admin panel (its bundle calls `api.deenlearning.in:3006`, i.e. `cx-api.deenlearning.in-backend`). A Deeniyat Plus host already exists on the same Apache: **`admin.deeniyatplus.com`**. It serves the live Deeniyat Plus admin panel, which calls this backend, and has its own valid certificate (SAN `admin.deeniyatplus.com`, expires 2026-11-11). Using `https://admin.deeniyatplus.com/DeeniyatPlus` needs no DNS or certificate work. The same `Include` goes into that vhost instead, and `HOST` in `DeeniyatPlus.html` (plus its Smart App Banner `app-argument`) changes. The RN dev's intent-filter and entitlement would name that host. Settle this with the RN dev **before** the app ships, because changing the host afterwards needs another app release.
