Integrations

Penanganan CAPTCHA di Flutter WebViews dengan CaptchaAI

Cara mengatasi reCAPTCHA v2 atau Cloudflare Turnstile yang muncul di WebView Flutter adalah dengan mendeteksinya lewat JavaScript channel, mengirim sitekey ke backend yang memanggil CaptchaAI, lalu menyuntikkan token hasil solve kembali ke halaman sebelum callback dipicu. WebView yang dimuat lewat webview_flutter atau flutter_inappwebview merender halaman apa adanya — kalau ada gateway pembayaran, portal booking, atau halaman login pihak ketiga yang memasang CAPTCHA, aplikasi Anda akan menemui tantangan yang sama persis seperti pengguna di browser desktop.

CaptchaAI menyelesaikan tantangan ini lewat API, sehingga aplikasi Flutter Anda bisa mendeteksi, solve, dan menyuntikkan token secara otomatis ke dalam WebView. Panduan ini mencakup deteksi CAPTCHA via JavaScript channel, integrasi backend solver, dan cara menyuntikkan token untuk reCAPTCHA v2 dan Cloudflare Turnstile.


Kenapa CAPTCHA di WebView Berbeda dari CAPTCHA Native

WebView memuat halaman web apa adanya, lengkap dengan widget CAPTCHA berbasis JavaScript yang sama seperti di browser desktop — bukan SDK attestation native yang dipakai sejumlah aplikasi Android/iOS murni. Konsekuensinya, Flutter tidak punya cara bawaan untuk tahu bahwa ada tantangan CAPTCHA di halaman: Anda harus menyuntikkan JavaScript untuk mendeteksi elemen .g-recaptcha atau .cf-turnstile, mengirim datanya keluar dari WebView lewat channel komunikasi, menyelesaikannya di sisi server, lalu menyuntikkan token itu kembali ke DOM halaman. Empat langkah ini — deteksi, kirim, solve, suntik — adalah inti dari seluruh panduan ini, dan urutannya berlaku sama baik Anda memakai webview_flutter maupun flutter_inappwebview.


Studi Kasus: reCAPTCHA v2 di WebView Payment Gateway

Bayangkan aplikasi Flutter Anda menyematkan halaman pembayaran pihak ketiga di dalam WebView — pola umum di aplikasi e-commerce, travel, dan ticketing Indonesia yang mengandalkan payment gateway eksternal. Sebelum memproses transaksi, gateway menampilkan tantangan reCAPTCHA v2. Untuk kebutuhan QA maupun automation testing di alur ini, Anda perlu:

  1. Deteksi widget CAPTCHA setelah WebView selesai dimuat
  2. Ekstrak sitekey lewat JavaScript channel
  3. Solve via CaptchaAI dari layanan backend
  4. Suntikkan token dan picu callback-nya

Environment: Flutter 3.16+, webview_flutter 4.x, backend Dart atau Node.js/Python, CaptchaAI API.


Langkah 1: Pasang JavaScript Channel di WebView

Gunakan webview_flutter dengan JavaScript channel untuk menerima pesan deteksi CAPTCHA dari halaman yang sedang dimuat. Channel ini adalah jembatan satu arah dari JavaScript di dalam WebView menuju kode Dart.

Begitu skrip deteksi menemukan elemen .g-recaptcha atau .cf-turnstile, ia mengirim sitekey dan URL halaman lewat CaptchaChannel.postMessage(), lalu widget Flutter menangkapnya di callback onMessageReceived berikut:

// captcha_webview.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:http/http.dart' as http;

class CaptchaWebView extends StatefulWidget {
  final String url;
  const CaptchaWebView({super.key, required this.url});

  @override
  State<CaptchaWebView> createState() => _CaptchaWebViewState();
}

class _CaptchaWebViewState extends State<CaptchaWebView> {
  late final WebViewController _controller;
  bool _solving = false;

  @override
  void initState() {
    super.initState();
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..addJavaScriptChannel(
        'CaptchaChannel',
        onMessageReceived: _onCaptchaMessage,
      )
      ..setNavigationDelegate(
        NavigationDelegate(
          onPageFinished: (_) => _detectCaptcha(),
        ),
      )
      ..loadRequest(Uri.parse(widget.url));
  }

  Future<void> _detectCaptcha() async {
    await _controller.runJavaScript('''
      (function() {
        var recaptcha = document.querySelector('.g-recaptcha');
        if (recaptcha) {
          CaptchaChannel.postMessage(JSON.stringify({
            type: 'captcha_detected',
            captchaType: 'recaptcha_v2',
            sitekey: recaptcha.getAttribute('data-sitekey'),
            pageurl: window.location.href
          }));
          return;
        }

        var turnstile = document.querySelector('.cf-turnstile');
        if (turnstile) {
          CaptchaChannel.postMessage(JSON.stringify({
            type: 'captcha_detected',
            captchaType: 'turnstile',
            sitekey: turnstile.getAttribute('data-sitekey'),
            pageurl: window.location.href
          }));
          return;
        }

        CaptchaChannel.postMessage(JSON.stringify({type: 'no_captcha'}));
      })();
    ''');
  }

  Future<void> _onCaptchaMessage(JavaScriptMessage message) async {
    final data = jsonDecode(message.message);
    if (data['type'] != 'captcha_detected') return;

    setState(() => _solving = true);

    try {
      final token = await _solveCaptcha(
        data['captchaType'],
        data['sitekey'],
        data['pageurl'],
      );
      await _injectToken(data['captchaType'], token);
    } catch (e) {
      debugPrint('CAPTCHA solve failed: $e');
    } finally {
      setState(() => _solving = false);
    }
  }

  Future<String> _solveCaptcha(
    String captchaType, String sitekey, String pageurl,
  ) async {
    final response = await http.post(
      Uri.parse('https://your-backend.com/api/solve-captcha'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({
        'captchaType': captchaType,
        'sitekey': sitekey,
        'pageurl': pageurl,
      }),
    );
    final result = jsonDecode(response.body);
    if (result['token'] == null) {
      throw Exception(result['error'] ?? 'No token returned');
    }
    return result['token'];
  }

  Future<void> _injectToken(String captchaType, String token) async {
    if (captchaType == 'recaptcha_v2') {
      await _controller.runJavaScript('''
        document.getElementById('g-recaptcha-response').value = '$token';
        if (typeof ___grecaptcha_cfg !== 'undefined') {
          Object.keys(___grecaptcha_cfg.clients).forEach(function(key) {
            var client = ___grecaptcha_cfg.clients[key];
            Object.keys(client).forEach(function(k) {
              if (client[k] && client[k].callback) {
                client[k].callback('$token');
              }
            });
          });
        }
      ''');
    } else if (captchaType == 'turnstile') {
      await _controller.runJavaScript('''
        var input = document.querySelector('[name="cf-turnstile-response"]');
        if (input) input.value = '$token';
        var cb = document.querySelector('.cf-turnstile')
          ?.getAttribute('data-callback');
        if (cb && typeof window[cb] === 'function') window[cb]('$token');
      ''');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        WebViewWidget(controller: _controller),
        if (_solving)
          const Center(child: CircularProgressIndicator()),
      ],
    );
  }
}

JavaScriptMode.unrestricted wajib diaktifkan agar runJavaScript dan channel-nya berjalan — tanpa mode ini, baik deteksi maupun pengaturan token tidak akan berfungsi. Ingat juga bahwa token reCAPTCHA v2 hanya berlaku sekitar 120 detik: kalau WebView menahan hasil solve terlalu lama sebelum form di dalamnya disubmit, solve ulang saja daripada memaksakan token yang sudah kedaluwarsa.


Langkah 2: Backend Solver dengan Python

API key CaptchaAI Anda tidak boleh ikut ter-bundle di dalam biner aplikasi — siapa pun bisa membongkarnya lewat reverse engineering APK atau IPA. Backend inilah yang menjaga key tetap aman sekaligus menangani seluruh komunikasi dengan CaptchaAI.

Backend mengikuti pola empat langkah standar CaptchaAI: kirim task ke in.php, simpan task_id yang dikembalikan, polling res.php sampai statusnya 1, lalu pakai token yang didapat:

# solver_api.py — Flask backend
import os
import time
import requests
from flask import Flask, request, jsonify

app = Flask(__name__)
API_KEY = os.environ.get("CAPTCHAAI_API_KEY", "YOUR_API_KEY")

@app.route("/api/solve-captcha", methods=["POST"])
def solve_captcha():
    data = request.json
    captcha_type = data.get("captchaType")
    sitekey = data.get("sitekey")
    pageurl = data.get("pageurl")

    # Submit task
    params = {"key": API_KEY, "pageurl": pageurl, "json": "1"}

    if captcha_type == "recaptcha_v2":
        params["method"] = "userrecaptcha"
        params["googlekey"] = sitekey
    elif captcha_type == "turnstile":
        params["method"] = "turnstile"
        params["sitekey"] = sitekey
    else:
        return jsonify({"error": f"Unsupported type: {captcha_type}"}), 400

    resp = requests.get("https://ocr.captchaai.com/in.php", params=params)
    result = resp.json()

    if result.get("status") != 1:
        return jsonify({"error": result.get("request", "Submit failed")}), 400

    task_id = result["request"]

    # Poll for result
    for _ in range(30):
        time.sleep(5)
        poll_resp = requests.get(
            "https://ocr.captchaai.com/res.php",
            params={
                "key": API_KEY,
                "action": "get",
                "id": task_id,
                "json": "1",
            },
        )
        poll_result = poll_resp.json()

        if poll_result.get("status") == 1:
            return jsonify({"token": poll_result["request"]})

        if poll_result.get("request") != "CAPCHA_NOT_READY":
            return jsonify({"error": poll_result["request"]}), 400

    return jsonify({"error": "Timeout — CAPTCHA not solved"}), 408

if __name__ == "__main__":
    app.run(port=3000)

Untuk tim yang menguji dari jaringan mobile dengan latensi bervariasi seperti di Indonesia, deploy backend ini di region terdekat — AWS ap-southeast-1 (Singapura) atau ap-southeast-3 (Jakarta), maupun GCP asia-southeast2 (Jakarta) — memangkas round-trip polling dibanding region default seperti US. Ini tidak mengubah waktu solve CaptchaAI itu sendiri, hanya jarak jaringan antara backend Anda dan API-nya.


Langkah 3: Alternatif flutter_inappwebview untuk Kontrol Lebih Detail

Jika Anda butuh kontrol lebih besar — intercept request jaringan, kelola cookie secara eksplisit, atau menjalankan beberapa WebView sekaligus — flutter_inappwebview adalah pilihan yang lebih tepat dibanding webview_flutter.

Package ini mengganti channel komunikasi dengan evaluateJavascript(), yang langsung mengembalikan hasil tanpa perlu bolak-balik lewat message channel terpisah:

// Using flutter_inappwebview for advanced CAPTCHA handling
import 'package:flutter_inappwebview/flutter_inappwebview.dart';

InAppWebView(
  initialUrlRequest: URLRequest(url: WebUri(widget.url)),
  initialSettings: InAppWebViewSettings(
    javaScriptEnabled: true,
    userAgent: 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36',
  ),
  onLoadStop: (controller, url) async {
    // Evaluate JavaScript and get result directly
    final result = await controller.evaluateJavascript(source: '''
      (function() {
        var el = document.querySelector('.g-recaptcha');
        if (el) return JSON.stringify({
          sitekey: el.getAttribute('data-sitekey'),
          pageurl: window.location.href
        });
        return null;
      })();
    ''');

    if (result != null) {
      final data = jsonDecode(result);
      // Solve and inject token
      final token = await _solveCaptcha(
        'recaptcha_v2', data['sitekey'], data['pageurl'],
      );
      await controller.evaluateJavascript(source: '''
        document.getElementById('g-recaptcha-response').value = '$token';
      ''');
    }
  },
)

Troubleshooting: Masalah yang Sering Muncul

Sebagian besar kegagalan di alur ini berasal dari tiga sumber: channel yang tidak terhubung, sitekey yang salah diekstrak, atau token yang sudah tersimpan tapi callback-nya tidak terpicu. Tabel berikut merangkum gejala, penyebab, dan perbaikannya — termasuk satu isu spesifik untuk tim yang men-deploy backend solver di region yang jauh dari pengguna.

Masalah Penyebab Perbaikan
JavaScript channel tidak menerima pesan Nama channel tidak cocok Pastikan CaptchaChannel sama persis antara Dart dan JS
ERROR_BAD_TOKEN_OR_PAGEURL dari CaptchaAI Sitekey diambil dari iframe yang salah Ekstrak sitekey dari iframe CAPTCHA-nya sendiri, bukan parent frame
Token disuntikkan tapi tidak berpengaruh Textarea tersembunyi atau callback tidak terpicu Set nilai g-recaptcha-response DAN jalankan fungsi callback-nya
CAPCHA_NOT_READY terus-menerus saat polling Solve lambat atau parameter tidak valid Verifikasi sitekey dan pageurl; naikkan batas maksimum percobaan polling
WebView crash di halaman CAPTCHA Masalah memori pada halaman berat Pakai flutter_inappwebview dengan useHybridComposition: true di Android
Polling terasa lambat dari jaringan mobile Indonesia Backend solver di-deploy di region yang jauh Pindahkan backend ke region terdekat, mis. AWS ap-southeast-1/ap-southeast-3 atau GCP asia-southeast2

Pertanyaan yang Sering Diajukan

Ringkasan jawaban untuk pertanyaan yang paling sering muncul soal integrasi CaptchaAI di Flutter WebView.

Apakah CAPTCHA di WebView Flutter beda penanganannya dari SDK reCAPTCHA native?

Ya. WebView memuat halaman web biasa, sehingga CAPTCHA yang muncul adalah widget reCAPTCHA v2/Turnstile standar berbasis JavaScript — sama seperti di browser desktop, bukan SDK attestation native yang dipakai sejumlah aplikasi native murni. Karena itu deteksi dan pengaturan token harus lewat JavaScript channel seperti pada panduan ini, bukan lewat SDK Google atau Cloudflare native.

Kapan sebaiknya pilih flutter_inappwebview daripada webview_flutter?

webview_flutter sudah cukup untuk sebagian besar kasus. Pindah ke flutter_inappwebview saat Anda butuh manajemen cookie eksplisit, intercept request jaringan, atau evaluasi JavaScript langsung yang mengembalikan nilai tanpa channel terpisah.

Amankah memanggil CaptchaAI langsung dari kode Dart tanpa backend?

Secara teknis bisa, tapi API key Anda akan ikut ter-bundle di dalam biner aplikasi dan bisa diekstrak lewat reverse engineering. Untuk aplikasi produksi, selalu routing lewat backend agar API key tidak pernah menyentuh sisi klien.

Berapa biaya solve CAPTCHA untuk kebutuhan testing volume tinggi di Flutter?

CaptchaAI memakai paket berbasis thread, bukan per-solve, jadi biaya bulanan tetap dapat diprediksi meski volume testing naik:

  • BASIC ($15/bulan, 5 thread) — cukup untuk volume kecil atau tim yang baru mulai
  • ADVANCE ($90/bulan, 50 thread) — untuk tim QA yang menjalankan banyak sesi paralel

Karena satu thread bisa dipakai solve tanpa batas selama masih aktif, tidak ada biaya tambahan per CAPTCHA yang diselesaikan.

Bisakah satu backend solver dipakai untuk beberapa aplikasi Flutter sekaligus?

Bisa. Backend di Langkah 2 tidak spesifik untuk satu aplikasi — endpoint /api/solve-captcha menerima captchaType, sitekey, dan pageurl sebagai parameter, jadi beberapa aplikasi atau environment (staging, produksi) bisa memanggil instance backend yang sama selama API key dan batas thread-nya mencukupi.


Artikel Terkait


Langkah Selanjutnya

Mulai solve CAPTCHA di aplikasi Flutter Anda sendiri — dapatkan API key CaptchaAI Anda dan sambungkan ke backend solver dari Langkah 2.

Panduan Terkait Lainnya

Komentar dinonaktifkan untuk artikel ini.