💡 Executive Summary
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.
Rewarded Ad Monetization and 4-Layer Anti-Abuse Architecture
▲ Non-intrusive opt-in rewarded ad separation and our 4-layer client-to-server anti-abuse architecture

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:

🛡️ Three Golden Rules of Monetization UX
  1. Zero Intrusive Ads: Never display pop-up or interstitial ads on the Studio (Timer) or Sound Mixer tabs.
  2. 100% Opt-In Rewarded Ads: Only play a Google AdMob RewardedAd when the user explicitly visits the [Reward Lounge] tab and taps "Watch Ad for Bonus Ticket".
  3. 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 INSERT 10,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:

sequenceDiagram autonumber participant User as User / Timer Engine participant App as Flutter AppStateService (Layers 1–3) participant RLS as Supabase RLS Policy (Layer 4a) participant RPC as PostgreSQL Security Definer RPC (Layer 4b) User->>App: Focus Session Completed Event Note over App: [Layer 1] Verify configured focus >= 20 min Note over App: [Layer 2] Verify daily focus tickets < 3 Note over App: [Layer 3] Verify Wall-Clock elapsed >= 80% of target App->>RLS: Call rpc('claim_focus_ticket', params) Note over RLS: Direct table INSERT by anon role is strictly BLOCKED! RLS->>RPC: Execute server-side verification as definer Note over RPC: 1. Re-verify p_focus_minutes >= 20
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.