There are already great ways to add document scanning to an Android app. Google's ML Kit has a ready-made scanner screen, OpenCV can find a page in a few lines, and plenty of libraries write PDFs.
For Keepscan, I used none of them for the core. The page detection, the camera screen, the perspective correction and the PDF writer are all plain Kotlin on top of platform APIs. The only ML library in the app is on-device text recognition, for copying text out of a scan.
That wasn't purity. A scanner app is its engine, and I wanted to be able to change any part of it later without waiting on someone else's roadmap. Here's what building it involved, and the parts that surprised me.
Finding the page: lines, not contours
The classic tutorial approach is: edge detection, find the largest closed contour with four corners, done. It works beautifully on a clean page on a dark desk.
Real photos aren't like that. A thumb covers a corner, a shadow falls across an edge, a white page sits on a light table. Any gap breaks the contour, and the page is lost.
So Keepscan looks for straight lines instead, and assembles the page from them:
- Shrink the frame to 256 px on its long side and blur it. Page edges survive easily; text strokes and paper texture mostly don't.
- Canny edge detection: Sobel gradients, thinned to one-pixel ridges, strong ones kept.
- A Hough transform turns edge pixels into line candidates. A line survives gaps that would break a contour.
- Try every pair of roughly horizontal lines against every pair of roughly vertical ones, and score each quadrilateral.
Two details made the difference.
Rank each direction separately. Rows of text produce lots of strong horizontal lines. Ranked together, they crowd out the page's vertical edges entirely. Keeping the top 10 of each direction fixed that.
Score like a page, not like a line. A pen lying on the desk is a long straight edge too. What a pen doesn't have is paper on one side and desk on the other. So each side of a candidate is sampled at 48 points, and a point only counts if it sits on an edge pixel and there's a consistent brightness step across it: the page side brighter (or darker) than the outside, with the same polarity along the whole side. The weakest side weighs heavily in the score, because three good sides and one fake one isn't a page.
val support = (total / 4) * 0.6f + weakest * 0.4f
return support * (0.55f + 0.45f * sqrt(q.area())) // bigger pages get a modest bonus
One more refinement: Hough lines are quantised to whole degrees, and over a page-length edge a fraction of a degree moves the ends by several pixels, enough for the scorer to sample beside the real edge. So each winning line is refitted to the edge pixels within 2 px of it with a total least-squares fit (the direction of least spread in their covariance). That puts it exactly on the edge.
All of this is pure functions on plain IntArrays, so it runs identically on a camera frame, a gallery photo and a unit test.
Making it feel steady
Per-frame detection flickers. A tracker sits between the detector and the screen:
- A new outline only replaces the shown one after it's been seen twice in a row, so one odd frame doesn't make the overlay jump. Small movements are eased rather than snapped.
- A few missed frames keep the last outline, so a passing shadow doesn't blink it away.
- It reports how long the page has held still. Auto-capture fires after 1.1 seconds of stillness.
Flattening the page
Once you have four corners, the platform already has what you need: Matrix.setPolyToPoly with four point pairs is a full perspective transform. The output keeps the page's real proportions (width from the longer of the top and bottom edges, height from the longer side), so a receipt stays receipt-shaped instead of being stretched to A4.
Writing PDFs by hand
Android has PdfDocument, and I started with it. The problem: it re-encodes bitmaps losslessly. A phone scan came out at several megabytes per page, and no quality setting could shrink it.
A PDF page that's just an image is a small amount of structure around the image bytes, and PDF can embed a JPEG as-is with the /DCTDecode filter. So Keepscan writes the file itself: catalog, page tree, and for each page a page object, a one-line content stream that draws the image, and the JPEG bytes untouched.
val content = "q ${w} 0 0 ${h} 0 0 cm /Im0 Do Q\n" // scale the image to the page, draw it
stream("<< /Type /XObject /Subtype /Image /Width ${page.widthPx} /Height ${page.heightPx} " +
"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpeg.size} >>", jpeg)
The file ends up the size of its images, which is what the size presets promise. Each page is sized so its long side matches A4's (842 pt), keeping the scan's own aspect ratio.
"Exactly under 2 MB"
Upload forms love hard limits: "max 2 MB". So Keepscan can save a PDF that's the best quality that still fits under a size you pick.
There's no formula from settings to file size; it depends on what's on the pages. So there's a ladder of resolution and JPEG-quality pairs, and the exporter binary-searches it: render at the middle rung, check the size, go up or down. A long document is rendered a few times instead of once per rung. Each attempt is written to a file rather than held in memory, because a 30-page document at a high rung is large. If even the smallest rung is too big, you get that file plus an honest "didn't fit".
Password-protected PDFs without a PDF library
I added PDFBox for encryption, then removed it the same day. It was a big dependency for one feature. Keepscan only ever needs to encrypt its own files, never open someone else's, and that turns out to be a well-specified, contained job.
It's the PDF Standard security handler, revision 6 (AES-256), from ISO 32000-2, written on javax.crypto:
- One random 256-bit file key encrypts every stream with AES-256-CBC and a random IV in front.
- The user password unlocks that key.
- The owner password also unlocks it, so it's generated randomly and thrown away. There's no second, hidden way into the file.
The bugs a real phone found
Two came only from testing on an actual device:
Camera callbacks after close. Leaving the scanner the moment it opened crashed with "CameraDevice was already closed". The screen closes the camera on the main thread while Camera2 callbacks are still arriving on the camera thread. The fix is a generation counter: every open() increments it, and every callback checks it still belongs to the current opening before touching anything.
The crop handles you couldn't drag. On my Xiaomi, dragging a corner near the screen edge triggered the system back gesture instead. setSystemGestureExclusionRects is the documented fix, and HyperOS ignores it. I moved the handles 40 dp inward instead. It's less elegant, and it works on every phone.
Was it worth it?
It took longer than wiring up libraries would have. But every piece of the pipeline is now code I can read, test and change, and the APK doesn't carry an image-processing library for one screen.
Keepscan is on Google Play: free to scan, edit and share with no watermark, and Pro is a one-time payment. If you've built something similar and solved detection differently, I'd like to hear how.









