今日已更新 327 条资讯 | 累计 23910 条内容
关于我们

标签:#pos

找到 98 篇相关文章

AI 资讯

Defeating the Multi-Tenant SaaS Concurrency Trap in PostgreSQL

Most backend engineers implement multi-tenant quota checks using a standard "read-then-write" pattern. In production, this pattern is highly unsafe: SELECT grading_scans_remaining FROM profiles; If greater than 0, execute the application logic. UPDATE profiles SET grading_scans_remaining = grading_scans_remaining - 1; Under high volume or rapid concurrent requests, two independent processes will read the exact same balance before either one deducts usage. This race condition allows multi-tenant users to bypass your billing gates entirely. To solve this, you have to bypass the frontend and application-level checks, enforcing an atomic database operation that serializes the row update first. I have open-sourced a reference framework that outlines explicit subscription enums, core multi-tenant schemas, and a native VS Code / Cursor snippets configuration to speed up your local database modeling. 📂 Check out the repository on GitHub: { https://github.com/dollykm49/PostgreSQL-SaaS-Multi-Tenant-Subscription-Architecture-reference-framework- } What's inside the repository: Strictly Typed Enums: Centralized business rules handled natively by the database engine. Granular Balance Tracking: Optimized data-layer mapping for profiles and reset states. postgres-saas.code-snippets Engine: A local IDE configuration file that lets you deploy this core schema straight from your code editor by typing pg- shortcuts. For teams building commercial applications looking to skip weeks of writing custom migrations, testing concurrency edge-cases, and debugging row-locking security rules, the repository also includes a link to the extended 28-page production system bundle. Feedback on the multi-tier validation parameters is highly welcome!

2026-07-25 原文 →
AI 资讯

Your LLM Fallback Probably Isn't a Fallback

At 04:00 UTC, every model call through our LLM gateway started returning HTTP 400. Not some calls. All of them. Our tier-1 CI gate flagged it, and the fix was committed at 04:26 UTC the same morning — about 26 minutes end to end. This is the post-mortem. What happened DeepSeek retired two API model names — deepseek-chat and deepseek-reasoner — at their V4 cutover around 2026-07-24 15:59 UTC. The replacements are deepseek-v4-pro and deepseek-v4-flash . Our gateway config still declared both retired names. Starting roughly twelve hours after the retirement, every model request routed through the gateway hit a 400 with the body: The supported API model names are deepseek-v4-pro or deepseek-v4-flash, but you passed . A live API check confirmed the shape of the cutover with four requests, same valid key: Model name Response deepseek-v4-pro HTTP 200 deepseek-v4-flash HTTP 200 deepseek-chat HTTP 400 deepseek-v4-pro-quantized HTTP 400 The two working names are the replacements. The two retired names — the ones our config referenced — returned 400. The fourth row is a name that does not exist at all, included because an earlier reading of a truncated error message had suggested it; shipping it would have left the platform broken. We'll come back to that. Why the fallback didn't help We had a fallback configured. Three separate model references in our policy config — the default CLI/workflow model, the chat model, and the shared fallback model — all pointed at the two retired names. All three lived under the same vendor and the same API key. When the primary call returned 400, the gateway tried the fallback. The log told the story in two adjacent lines: the 400 from the provider, and then Error doing the fallback: carrying the identical error. The fallback died in the same instant as the primary because it was the same thing wearing a different label. This is the structural problem. A fallback that shares a provider and an API key with its primary is not resilience. It protec

2026-07-25 原文 →
AI 资讯

You Might Not Need Kafka: Building a Job Queue with PostgreSQL

It's easy to reach for the popular tool before asking what your system actually needs. For job queueing, the usual advice is to use RabbitMQ or Kafka. The underlying burden of using these tools will be additional processes to deploy, monitor and reason about. But what if using your existing database is possible? I built a job queueing system with a PostgreSQL database that cleared the bar without adding infrastructure. The next question will be, does this solution meet the criteria of a job queue? A job needs a few things to execute properly within a system. It needs to be persistent, surviving unforeseen crashes. Jobs must not be processed more than once by workers; one job should be processed once by one worker. Also, a job's state has to be tracked through every step. A job state must show when it's pending, completed or failed. That's the bar any solution must clear. With the Postgres approach, persistence comes free. Jobs live in a table so when a worker dies mid-job, the job still exists in a row in the db. Whereas with in-memory queues, a crash loses everything still in memory. A broker like RabbitMQ has to be configured for persistence and if configured wrongly, jobs get lost. A database however is fundamentally built for durability. Now, let's say three workers poll the queue at the same instant and run the same query. They'll see the same pending job at the top and nothing stops them all from grabbing it. If that job is a payment, the customer gets charged three times for one service. All the workers successfully process the job with no indication of an error or alerts. This is the requirement that seems to demand a real message broker, and it's exactly where people assume a database can't compete. It can. Postgres has a specific tool for exactly this. The SQL clause FOR UPDATE is used to lock rows. This can be called on a job when a worker picks it up to process. By default other workers will get blocked during this process, they'll wait for the lock to r

2026-07-25 原文 →
AI 资讯

How a Single beforeEach Killed Our CI for 36 Hours

Six failed CI runs. Thirty-six hours of GitHub Actions time. Every run timing out at exactly the 6-hour limit. The culprit was one line in tests/setup.js . The Setup We were building a multi-tenant platform with a PostgreSQL backend — around 76 database models handling everything from user accounts and billing to visitor logs and real-time notifications. The test suite had grown to roughly 1,140 test cases across 36 files. Standard stuff. CI ran on every PR. Tests passed locally. And then one day, CI just... never finished. The Anti-Pattern Here's what the test setup looked like: // tests/setup.js beforeEach ( async () => { const tableNames = await getTableNames (); // 76 tables await sequelize . query ( `TRUNCATE TABLE ${ tableNames . join ( ' , ' )} CASCADE;` ); }); The intent was clean isolation — every test starts with a blank slate. Reasonable in theory. Catastrophic in practice. The Math Do the multiplication: 76 tables × 1,140 tests = 86,640 TRUNCATE operations Each TRUNCATE TABLE ... CASCADE is not a cheap operation. PostgreSQL has to: Acquire exclusive locks on all referenced tables Walk the foreign key graph to find dependent tables Truncate each in dependency order Release locks With a moderately complex schema where most tables reference others (users → societies → members → invoices → payments → ...), a single TRUNCATE ... CASCADE on a central table can fan out into dozens of implicit truncations. Multiply that by 86,640 and you have a test suite that will never complete within any reasonable timeout. Why It Wasn't Caught Sooner Two reasons: 1. It used to be fast. When the suite had 50 tests and 20 tables, this pattern worked fine. 50 × 20 = 1,000 truncations — uncomfortable but survivable. Nobody noticed when the suite crossed a tipping point. 2. Local runs used a different database state. Locally, developers often ran a subset of tests with --grep or file-specific runs. The full suite was only ever run on CI, and CI was slow enough that most assumed i

2026-07-24 原文 →
AI 资讯

onPreviewKeyEvent vs onKeyEvent on Android TV: A Subtle D-Pad Bug

The bug report was simple: long-pressing the d-pad center button on a channel card should toggle the favorite — it wasn't working reliably. On some devices it fired once and stopped. On others it didn't fire at all on long-press. The fix was changing onKeyEvent to onPreviewKeyEvent in one Composable. The reason why is worth understanding. Background: Two Event Handlers in Compose Jetpack Compose exposes two modifier-level hooks for key input: Modifier . onKeyEvent { keyEvent -> .. . } Modifier . onPreviewKeyEvent { keyEvent -> .. . } They sound equivalent. They're not. The difference is where they sit in the event propagation chain . How Android TV Routes D-Pad Events When a user presses a key on a TV remote, Android routes the event through a dispatch tree: Activity └─ ViewGroup (root) └─ FocusedComposable └─ Child Composables The event travels down first (capture phase), then up (bubble phase): Capture (top → focused node): onPreviewKeyEvent handlers fire here, outermost first. Bubble (focused node → top): onKeyEvent handlers fire here, innermost first. onPreviewKeyEvent is the capture phase. onKeyEvent is the bubble phase. Why This Matters for Long-Press Android TV handles long-press recognition at the framework level. When you hold the d-pad center button: A KeyEvent.ACTION_DOWN fires immediately. If the key is held, the framework generates repeated ACTION_DOWN events at the key repeat rate. ACTION_UP fires when the button is released. The long-press callback that Compose's focus system uses for "confirm" actions (select, activate) consumes ACTION_DOWN during the bubble phase — specifically to prevent the holding action from also triggering the tap action. When the ChannelCard had a click handler wired for the primary action and onKeyEvent for the long-press toggle, the click handler's bubble-phase consumption of ACTION_DOWN was racing with the long-press handler. On some devices the click handler won, swallowing the event before the long-press code ran. The Fix

2026-07-24 原文 →
AI 资讯

B+tree height after delete: PostgreSQL fast root

Many databases use B+tree indexes, but they all differ. It's a sorted structure. The leaf pages are logically sorted so that a specific key value belongs to one page. A lookup by value reaches a single leaf page and either directly finds an entry for that value or immediately knows there's no entry with that key. When a page becomes full, it is split into two pages, each covering its own dedicated range. To find the right page, an internal page holds the range of values for the pages below. This internal page can become full, and a new level is added above it. Finally, at the highest level, there's a single internal page that is the root. A lookup always starts at the root and goes down to the leaves, following the branches of internal pages. In a traditional B+tree lookup, the cost is proportional to the height of the tree because the search starts at the root and descends to a leaf: 1 page to read when all fits in one leaf that is also the root (0 levels of internal pages, total height is 1). With small keys, this level can typically index hundreds of rows. 2 pages to read when there's one root that can list all leaf pages (1 level of internal page, total height is 2). With small keys, this level can typically index tens or hundreds of thousands of rows. 3 pages to read when there's one level of branches under the root (so 2 levels of internal pages, total height is 3). With small keys, this level can typically index millions of rows. This means that finding one key within ten million rows may require traversing 3 index pages, where most of them are probably in cache given the small number of branches compared to the leaves. For a given index size, whatever the value you are looking for, it's always the same number of pages to read because the index is balanced (the commonly accepted meaning of the B in B+tree). This property is maintained because any page can split, but only splitting the root adds another level. I've described how the height of an index can incr

2026-07-24 原文 →
AI 资讯

NocoBase and the mystery of the shifted timestamps: MySQL vs PostgreSQL, measured

There's a class of bug reports that keeps coming back in the NocoBase community, especially in the Chinese-language forum: "all my times are off by 8 hours" or "dates show up as the day before." China is UTC+8, so the shift is 8 hours there. I run my instances at UTC+9, and sure enough — my shift is 9 hours. Whatever your offset is, that's the size of your shift. That pattern is a strong hint that this isn't random corruption. It's a mechanism. I set up NocoBase 2.x against both PostgreSQL and MySQL and measured what actually gets stored and how it gets reinterpreted, until the mystery had a concrete answer. Test setup: NocoBase 2.0.51 and 2.1.23 (official Docker images) × PostgreSQL 16 and MySQL 8.4. All data written and read through the REST API, with the server timezone controlled via the container's TZ environment variable. I'm deliberately ignoring the browser-side rendering here — this is about what the server stores and how it interprets it. Background: 2.x has four datetime field types NocoBase 2.x collections offer four datetime-ish field types ( official list — though several of the per-type detail pages still say "To be added", which is exactly why I measured instead): Type What it's for Datetime (with time zone) Absolute instants — event start times, logs Datetime (without time zone) Wall-clock times you want preserved as-is Date only Birthdays, due dates, anniversaries Unix timestamp System integration Measurement 1: what each type actually stores I imported "2026-07-12 09:00" via xlsx and looked at the raw values in each database (identical on 2.0.51 and 2.1.23): Field type PostgreSQL MySQL Datetime (with TZ) timestamptz → 2026-07-12 09:00:00+09 ( an absolute instant, offset included ) DATETIME → 2026-07-12 09:00:00 ( wall clock only — no offset information ) Datetime (without TZ) timestamp → 09:00:00 DATETIME → 09:00:00 Date only date → 2026-07-12 date → 2026-07-12 The first row is the whole story. The same field type — "Datetime (with time zone)" — i

2026-07-23 原文 →
AI 资讯

Catch the dangerous Postgres migration at the CI step

-- looks routine in review, locks your table in production: CREATE INDEX idx_orders_customer ON orders ( customer_id ); ALTER TABLE users ALTER COLUMN email SET NOT NULL ; Both of those statements hold a lock that blocks traffic while they scan or build against the table. On a small table you never notice. On a busy production table it is downtime, and a diff review does not catch it because the SQL looks fine. pg-migration-guard is a free tool that reads the migration and flags these before they reach production: $ pg-migration-guard V42__orders.sql WARN IDX01 CREATE INDEX without CONCURRENTLY [becomes a blocker on a large or busy table] Why: Plain CREATE INDEX takes a lock that blocks all writes until the build finishes. Safe: CREATE INDEX CONCURRENTLY idx ON tbl (col); -- outside a transaction WARN NN01 SET NOT NULL scans the whole table under ACCESS EXCLUSIVE Safe: ADD CONSTRAINT c CHECK (col IS NOT NULL) NOT VALID; VALIDATE CONSTRAINT c; then SET NOT NULL; Summary: 0 blocker, 2 warn, 0 advisory Why migrations bite Whether a Postgres migration is safe has almost nothing to do with intent and everything to do with locks. A migration is dangerous when it holds a strong lock while it scans or rewrites a large table. ACCESS EXCLUSIVE blocks reads and writes; SHARE blocks writes. The change that reads fine in review is the one that quietly takes that lock. Put it in CI The most useful place for this is the pull request, so a risky migration fails the build before anyone merges it. There is a GitHub Action: name : migration-guard on : pull_request : paths : [ " db/migration/**.sql" ] jobs : guard : runs-on : ubuntu-latest steps : - uses : actions/checkout@v4 - uses : actions/setup-python@v5 with : python-version : " 3.x" - uses : technical-turtle/pg-migration-guard@v1 with : path : db/migration fail-on : blocker Each finding shows up as an inline annotation on the diff, so the author sees exactly which line is the problem and what the safe rewrite is. Or run it locall

2026-07-22 原文 →
AI 资讯

Running PostgreSQL with Docker

Installing Postgres directly on your machine works, but it gets messy fast once you're juggling multiple projects that each want different versions, extensions, or seed data. Docker sidesteps all of that you get a clean, disposable Postgres instance per project, and your host machine stays untouched. This guide covers running Postgres in Docker for local development: quick one-off containers, docker-compose for anything you'll come back to, persistent data, and a few things that trip people up. 1. The quickest way to get a Postgres instance running docker run --name my-postgres \ -e POSTGRES_PASSWORD = secret \ -e POSTGRES_USER = devuser \ -e POSTGRES_DB = myapp \ -p 5432:5432 \ -d postgres:16 Breaking that down: --name my-postgres — a friendly name so you can reference the container later instead of a random hash POSTGRES_PASSWORD — required; the container won't start without it POSTGRES_USER / POSTGRES_DB — optional; default to postgres if omitted -p 5432:5432 — maps container port 5432 to host port 5432 -d — detached, runs in the background postgres:16 — pin a version; avoid latest since it can silently jump major versions later Check it's running: docker ps Connect with psql (if installed locally) or from inside the container: docker exec -it my-postgres psql -U devuser -d myapp 2. Using docker-compose for anything persistent For a real project, docker-compose.yml is the better default , it's version-controlled, reproducible, and easy to extend with more services later (Redis, pgAdmin, your app itself). services : db : image : postgres:16 container_name : myapp-postgres restart : unless-stopped environment : POSTGRES_USER : devuser POSTGRES_PASSWORD : secret POSTGRES_DB : myapp ports : - " 5432:5432" volumes : - pgdata:/var/lib/postgresql/data volumes : pgdata : Start it: docker compose up -d Stop it (keeps data): docker compose down Stop and wipe data: docker compose down -v 3. Why the volume matters Without a named volume, all data lives inside the container's

2026-07-22 原文 →
AI 资讯

GORM: Dev's Guide to Go's Most Popular ORM

If you're working with Go services that talk to a relational database, chances are you've bumped into GORM . It's the most widely used ORM in the Go ecosystem, and for good reason. It wraps a lot of the tedium of database/sql ,manual scanning, hand-written migrations, string-built queries in a much friendlier API. This article walks through GORM from setup to the patterns you'll actually use day to day: models, migrations, CRUD, associations, transactions, and a few gotchas that trip people up. Why reach for an ORM in Go ? Go's standard database/sql package is deliberately low-level. You write SQL strings, manually scan rows into structs, and manage connections yourself. That's fine for small projects, but it gets repetitive fast once you have a dozen tables and endpoints that all need similar create/read/update/delete logic. GORM sits on top of database/sql and gives you: Struct-based models mapped to tables Auto migrations A chainable query builder Associations (has-one, has-many, many-to-many, belongs-to) Hooks (before/after create, update, delete) Built-in support for transactions, connection pooling, and prepared statements It supports PostgreSQL, MySQL, SQLite, SQL Server, and more, through swappable drivers. Installation go get -u gorm.io/gorm go get -u gorm.io/driver/postgres Swap postgres for mysql , sqlite , or sqlserver depending on your database. Connecting to a database package main import ( "log" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" ) func main () { dsn := "host=localhost user=postgres password=secret dbname=myapp port=5432 sslmode=disable" db , err := gorm . Open ( postgres . Open ( dsn ), & gorm . Config { Logger : logger . Default . LogMode ( logger . Info ), }) if err != nil { log . Fatalf ( "failed to connect to database: %v" , err ) } sqlDB , err := db . DB () if err != nil { log . Fatalf ( "failed to get generic db object: %v" , err ) } sqlDB . SetMaxOpenConns ( 25 ) sqlDB . SetMaxIdleConns ( 10 ) } That db.DB() call gi

2026-07-22 原文 →
开发者

Why wordpress.org Won't Let You Install Composer Packages From a Plugin

Cross-posted from the Loopress docs Loopress is a toolset to make WordPress reproducible and reviewable via Git . Versioned snippets, Composer without SSH, and more coming... Check Loopress WordPress doesn't have a package manager. If you want a PHP library in your project, be it Guzzle for HTTP calls or a PDF generation library in a snippet, you're either vendoring the code by hand or running Composer somewhere the WordPress admin can't see. We built a feature to fix that: a Composer UI inside the WordPress admin. Search Packagist, install a package, audit it for known vulnerabilities, all without SSH access ( full walkthrough here ). Before shipping it, we asked the wordpress.org plugin review team whether it would be acceptable in the official directory. The answer was no. The rule Here's the relevant line from Guideline 8 of the wordpress.org plugin directory: "Plugins may not send executable code via third-party systems." Installing a PHP package from Packagist is, by definition, downloading executable code from a third party and writing it to disk. It doesn't matter that our plugin never calls the installed code's autoloader itself, that the user has to load it deliberately from their own snippet. The review team was clear: the indirection changes nothing. The mere presence of that capability is enough to trigger the rule, whether or not it's ever used. There's no folder you can hide it in that makes it acceptable, they weren't shy about saying that outright. If you want PHP dependencies in a plugin distributed on wordpress.org, the only accepted path is to vendor them at submission time: ship the code, with a compatible license and readable source, not fetch it dynamically. For us, that meant Composer package management could never live in the same plugin that ships on wordpress.org. Full stop, no clever workaround changes that. What we tried first, and undid Our first move was the obvious one: split into two plugins. A "Core" plugin with snippet sync, distri

2026-07-20 原文 →
AI 资讯

Does Prisma respect Supabase RLS? No — here's why

Prisma and Drizzle connect as the postgres role and bypass Supabase RLS entirely, so your policies never protect ORM queries. Here's the fix. TL;DR: No. Prisma and Drizzle open their own direct Postgres connection and log in as the postgres role, which owns your tables and carries BYPASSRLS — so Row Level Security is skipped on every ORM query. Point your app's connection at a dedicated, non-owner NOBYPASSRLS role (and keep the auth check in your code), not at postgres . If you built your Supabase project assuming RLS is a safety net on the data itself, adding an ORM quietly punches a hole straight through it. Your policies are still there. They just never run for the ORM's connection. Here's the mechanism, the myth to unlearn, and three fixes in order of how much you should reach for them. Does Prisma respect Supabase RLS? No. RLS is not a global property of the database — Postgres enforces it per role, per statement . A policy only bites for a role that is (a) not the table's owner, (b) has no BYPASSRLS attribute, and (c) is not a superuser. The Supabase JS client satisfies all three because it reaches Postgres through PostgREST, which runs your query as the unprivileged anon or authenticated role. Prisma and Drizzle satisfy none of them: they read DATABASE_URL and open a raw SQL connection as postgres , which owns virtually every table you migrated and holds BYPASSRLS . Either fact alone is enough for Postgres to skip your policies. So the same query that returns one tenant's rows through supabase-js returns every tenant's rows through Prisma. That is not a bug in your policy — it's the connection role. Two doors into the same database There are two completely different paths to your data, and they authenticate as different roles. The supabase-js path (RLS enforced). supabase-js talks HTTP to PostgREST, not to Postgres directly. PostgREST connects as authenticator , validates the request JWT, and does a SET ROLE into anon or authenticated for the statement. Those

2026-07-20 原文 →
AI 资讯

I Spent Two Years Deleting My Backend. This Is What's Left

What happens when you stop writing controllers, services, repositories and mappers - and let PostgreSQL be the backend. MIT-licensed, and yes, I built it. Full disclosure right away: I built the thing I'm about to show you. It's called NpgsqlRest , it's MIT-licensed, there is no paid tier, no telemetry, no "book a demo" button. I'm just a guy who spent two years deleting layers from his stack and now wants to show someone the hole where the backend used to be. The standard pattern The standard data access pattern for modern business applications looks like this: UI → Fetch → Controller → Service → Repository → ORM → SQL → Database Seven arrows. And if you look closely at what most of those layers actually do - they take data from one side and pass it to the other side, slightly renamed. The Controller maps the request to a DTO. The Service passes it to the Repository. The Repository asks the ORM nicely. The ORM generates SQL that you then inspect in a log because you don't trust it (correctly). We built entire careers on maintaining this pipeline. I know because I did, for decades. But once you realize database-aware tests are trivial to wire up, you can drop the Repository. Once you get good at SQL, you can drop the ORM. And then you look at the Controller and realize it's just boring glue code that ships bytes between HTTP and the database. Glue code can be automated. So: UI → Database That's it. That's the architecture. Show me or it didn't happen Fine. This is a file called users.sql . Not a function, not a stored procedure - a plain SQL file sitting in your repo: /* HTTP GET /users/ @authorize admin, user @cached @cache_expires_in 30sec @timeout 5min @param $1 department_id text */ select id , name , email , role from users where $ 1 is null or department_id = $ 1 ; You run npgsqlrest (a single native executable, no runtime to install) pointed at your PostgreSQL, and: $ curl -s 'localhost:8080/users/?department_id=1' | jq [ { "id": 1, "name": "Alice", "email":

2026-07-17 原文 →
AI 资讯

I tried to build a DNA-inspired database. I accidentally made a Postgres index checker.

This project was supposed to be a quick experiment. I had been reading about DNA storage and got carried away with the idea. DNA can pack a ridiculous amount of information into a tiny space. I started wondering if a database could behave a bit like a living system: the schema as its basic code, the queries as signals, and indexes changing as the workload changed. I spent two or three days building around that idea. Then I had to admit something fairly obvious. Most of what I was reading described DNA as an archival medium . It involves writing and reading physical DNA. That is nowhere near the speed or simplicity needed by a normal application database. Also, I am not a PostgreSQL expert. I was building an algorithmic-trading system, learning as I went, and trying to solve a problem I did not fully understand yet. When the sprint ended, I could not tell whether I had built something useful or just wrapped a lot of code around a clever-sounding metaphor. The literal DNA idea had to go. One smaller question survived: When somebody adds a new Postgres index, how do we know it is useful and not just valid-looking SQL? That question became IndexPilot. The problem I kept running into Adding an index looks simple: CREATE INDEX orders_customer_created_idx ON public . orders ( customer_id , created_at ); I could read that line. I could understand the intention. What I could not tell was whether the real database needed it. Maybe the query it is meant to help hardly ever runs. Maybe another index already covers the same columns. Maybe PostgreSQL would ignore it. Maybe it helps reads but adds more cost to every write. The SQL can look completely reasonable while the decision is still wrong. That felt like a useful place for a small tool. Not a tool that changes the database automatically. Just something that collects enough evidence to make the next review less guessy. What IndexPilot does IndexPilot reviews the exact CREATE INDEX statement in a migration. It can compare that

2026-07-17 原文 →
AI 资讯

Building Nexo Player: An Offline-First Android Media App with PDF-to-Audiobook Support

Most Android media apps solve only one part of the problem. A video player plays videos. A music player handles songs. A PDF reader displays documents. A text-to-speech app reads text. A vault hides private files. But real media libraries are not separated that neatly. My phone may contain downloaded movies, music, lecture notes, ebooks, PDFs, recordings, subtitles, and files I do not want exposed in the normal gallery. Constantly moving between different apps creates friction and breaks playback or reading continuity. That is why I built Nexo Player : an offline-first Android media app that brings local playback, document reading, audiobook generation, text-to-speech, and private storage into one experience. What Nexo Player does Nexo Player currently supports: Local video and audio playback PDF and EPUB reading PDF, EPUB, and text narration Background audiobook generation MP3, M4B, and ZIP export Multiple narrator voices Resume playback and reading progress Equalizer, sleep timer, subtitles, and playback-speed controls Picture-in-Picture Secure Vault protected with PIN or biometrics The app is built natively for Android using Kotlin , Jetpack Compose , and Android Media3 . The main product idea: local-first media The core rule behind the app is simple: A local file should remain local unless the user explicitly chooses otherwise. This rule influenced the entire product. Opening a downloaded video should not require an account. Reading a PDF should not require uploading it to a server. Listening to a generated audiobook should remain possible without a permanent internet connection. Private files should not leak into normal galleries, thumbnails, or recent-history screens. Offline-first is not only about caching data. It means the main workflow must remain useful, understandable, and recoverable without depending on the network. Building the playback layer For video and audio playback, I used Android Media3 as the foundation. The visible player looks simple, but a

2026-07-16 原文 →