- [Part 1] Rebuilding an Abandoned Pomodoro App — Why 'Lo-Fi Focus Studio' and 'Focus-to-Earn'?
- [Part 2] Balancing Monetization and Fairness — Rewarded Ads & a 4-Layer Anti-Abuse Architecture (Current Post)
- [Part 3] "Synthetic Audio Sounds Fake" — Adopting Google Studio Field Recordings & a Multi-Channel Ambient Mixer
- [Part 4] Bypassing the iOS 26 JIT Memory Protection Crash & Crafting the Apple Watch Series 10 Circular UI
- [Part 5] A Timer That Never Stops — Mastering Android Doze, iOS Silent Audio Loops, and Bidirectional Watch Sync
The hardest product challenge in a focus utility app is two-fold: "How do we monetize without breaking the user's deep focus?" and "How do we stop bots and packet tampering from rigging the reward pool?" In Part 2, we dissect our 100% opt-in Google AdMob Rewarded Ad placement and walk through the 4-layer anti-abuse defense pipeline spanning Dart wall-clock verification to Supabase RLS and PostgreSQL
SECURITY DEFINER stored procedures.
1. The Focus App Dilemma: "What If an Ad Blasts When the Timer Ends?"
Funding weekly lottery rewards sustainably requires ad monetization (Google AdMob). Yet many free utility apps make a fatal mistake: the moment a 25-minute focus session ends, they blast a full-screen interstitial video ad with loud audio.
To someone studying in a quiet library or coding at 2 AM with rain sounds playing, an unexpected interstitial ad is an instant uninstall trigger. For v2.0, we established three non-negotiable rules:
- Zero Intrusive Ads: Never display pop-up or interstitial ads on the Studio (Timer) or Sound Mixer tabs.
- 100% Opt-In Rewarded Ads: Only play a Google AdMob
RewardedAdwhen the user explicitly visits the [Reward Lounge] tab and taps "Watch Ad for Bonus Ticket". - Graceful Fallback: Handle ad no-fill or offline states cleanly without crashing or blocking the UI.
2. "Bots and Packet Forgers Will Come" — The 4-Layer Anti-Abuse Pipeline
Any system offering real-world prizes attracts abuse. During architecture design, we modeled four primary attack vectors:
- Attack Vector A (Micro-Timer Farming): Setting the focus duration to 1 minute and farming 3 tickets in 3 minutes.
- Attack Vector B (Auto-Clicker Macros): Running an emulator or spare phone 24/7 with a macro to accumulate hundreds of tickets a day.
- Attack Vector C (Clock Manipulation / Fast-Forwarding): Advancing the system clock or invoking completion callbacks immediately after starting a session.
- Attack Vector D (Direct API / Packet Forgery): Extracting the Supabase public Anon Key from the app binary and running a Python/cURL script to
INSERT10,000 tickets directly into the database.
To neutralize all four vectors, we engineered a 4-Layer Defense-in-Depth pipeline stretching from the Flutter client down to PostgreSQL kernel policies:
2. Re-verify today's device tickets < 3
3. Enforce >= 15 min cooldown since last ticket RPC-->>App: Issue verified ticket code
Layers 1 & 2: The 20-Minute Cutoff and Separate Daily Hard Caps
First, only focus sessions configured for 20 minutes or longer qualify for a lottery ticket (shorter sessions still count toward focus statistics, but mint zero tickets). Second, daily caps are strictly segregated: a maximum of 3 tickets/day from focus sessions and 5 tickets/day from rewarded ads.
Layer 3: Client Wall-Clock (80% Physical Elapsed Time) Verification
Reaching 00:00 on the UI is not enough. AppStateService (lib/service/app_state_service.dart) compares the physical epoch timestamp when the session started against the completion timestamp, verifying that at least 80% of the target duration (e.g., 1,200 physical seconds for a 25-minute session) actually elapsed:
// lib/service/app_state_service.dart — Layers 1–3 verification excerpt
class AppStateService extends ChangeNotifier {
static const int minFocusMinutesForTicket = 20;
static const int maxFocusTicketsPerDay = 3;
static const int maxAdTicketsPerDay = 5;
Future<TicketClaimResult> recordCompletedFocusSession({
required int minutes,
required int actualElapsedSeconds,
}) async {
_checkAndResetCycles();
// [Layer 1] Sessions under 20 minutes are logged for stats only (no ticket)
if (minutes < minFocusMinutesForTicket) {
return TicketClaimResult.minDurationNotMet;
}
// [Layer 2] Enforce daily hard cap of 3 focus tickets
if (_todayFocusTickets >= maxFocusTicketsPerDay) {
return TicketClaimResult.dailyLimitReached;
}
// [Layer 3] Wall-Clock elapsed time check (>= 80% physical duration required)
final int minRequiredSeconds = (minutes * 60 * 0.8).floor();
if (actualElapsedSeconds < minRequiredSeconds) {
return TicketClaimResult.suspiciousFastCompletion;
}
// Passed all client checks -> mint 6-char Hex ticket & sync via Supabase RPC
final ticket = _issueNewTicket(source: 'FOCUS');
await _syncTicketViaSupabaseRpc(ticket, minutes, actualElapsedSeconds);
return TicketClaimResult.issued(ticket);
}
}
Layer 4: Blocking Direct RLS Inserts & PostgreSQL SECURITY DEFINER RPC
Client-side checks alone cannot stop an attacker who extracts the Supabase URL and Anon Key and sends raw HTTP requests. In supabase_schema.sql, we enabled Row Level Security (RLS) on public.focus_tickets and omitted any direct INSERT policy for the anon role. The only way to insert a ticket row is by invoking the server-side claim_focus_ticket RPC:
-- supabase_schema.sql — Layer 4: RLS Direct Insert Block & Server RPC Verification
ALTER TABLE public.focus_tickets ENABLE ROW LEVEL SECURITY;
-- ⚠️ CRITICAL: No INSERT policy exists for 'anon'. Direct table inserts are rejected!
-- Tickets can only be inserted inside the SECURITY DEFINER function below:
CREATE OR REPLACE FUNCTION public.claim_focus_ticket(
p_device_id TEXT,
p_ticket_code TEXT,
p_source TEXT,
p_week_key TEXT,
p_focus_minutes INT DEFAULT 25,
p_elapsed_seconds INT DEFAULT 1500
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_today_count INT;
v_last_created_at TIMESTAMPTZ;
v_daily_limit INT;
BEGIN
-- 1. Enforce per-source daily cap (FOCUS: 3, AD: 5)
IF p_source = 'FOCUS' THEN
v_daily_limit := 3;
-- Server-side re-verification of >= 20m and >= 80% elapsed seconds
IF p_focus_minutes < 20 OR p_elapsed_seconds < (p_focus_minutes * 48) THEN
RETURN jsonb_build_object('success', false, 'reason', 'INVALID_FOCUS_DURATION');
END IF;
ELSIF p_source = 'AD' THEN
v_daily_limit := 5;
ELSE
RETURN jsonb_build_object('success', false, 'reason', 'INVALID_SOURCE');
END IF;
-- 2. Count tickets issued today for this device_id and source
SELECT COUNT(*), MAX(created_at)
INTO v_today_count, v_last_created_at
FROM public.focus_tickets
WHERE device_id = p_device_id
AND source = p_source
AND created_at >= date_trunc('day', now());
IF v_today_count >= v_daily_limit THEN
RETURN jsonb_build_object('success', false, 'reason', 'DAILY_LIMIT_REACHED');
END IF;
-- 3. [Rate Limiting] Enforce a strict 15-minute (900s) cooldown between FOCUS tickets
IF p_source = 'FOCUS' AND v_last_created_at IS NOT NULL THEN
IF EXTRACT(EPOCH FROM (now() - v_last_created_at)) < 900 THEN
RETURN jsonb_build_object('success', false, 'reason', 'COOLDOWN_ACTIVE');
END IF;
END IF;
-- 4. Insert verified ticket record
INSERT INTO public.focus_tickets (device_id, ticket_code, source, week_key, focus_minutes)
VALUES (p_device_id, p_ticket_code, p_source, p_week_key, p_focus_minutes);
RETURN jsonb_build_object('success', true, 'ticket_code', p_ticket_code);
END;
$$;
Notice step 3: Server-Side Rate Limiting. Even if a script calls claim_focus_ticket 100 times per second via cURL, PostgreSQL checks EXTRACT(EPOCH FROM (now() - v_last_created_at)) < 900 and rejects any follow-up call within 15 minutes of the previous ticket, hard-capping total daily mints at 3.
3. Up Next: "Why Does Mathematical White Noise Sound Like Radio Static?"
With rewards and security locked down, we turned to the heart of the Lo-Fi Focus Studio: the multi-channel ambient sound mixer. Initially, we tried generating rain and campfire audio mathematically in PCM WAV format to save bundle size—and the result sounded like harsh radio static.
In [Part 3: "Synthetic Audio Sounds Fake" — Adopting Google Studio Field Recordings & a Multi-Channel Ambient Mixer], we share how we replaced 24MB of synthetic WAV files with 3.1MB of authentic Google Actions Sound Library OGG field recordings and built a 24-hour gapless 5-channel audio mixer.