Permisos del sistema y ciclo de vida en Appium: QR Scanner, Geo Location y BaseTest

Diálogos de permisos de Android con manejarPermiso() varargs, BaseTest para eliminar duplicación, runAppInBackground y terminateApp. 25 tests.

Permiso de ubicación
Permiso de ubicación

CONTEXTO

En el post anterior W3C Actions en Appium armé DrawingPage con W3C Actions. Al entrar a Drawing, la app pedía permiso de acceso a fotos y lo manejé con un try/catch puntual dentro de irADrawing(). Funcionaba, pero era código específico para una sola pantalla.

El menú lateral de My Demo App tiene más pantallas que piden permisos: QR Code Scanner necesita la cámara, Geo Location necesita la ubicación. Cada una con un diálogo del sistema diferente. Si repito el try/catch en cada irA...(), termino con el mismo código pegado en 3 lugares.

Aparte, tenía otra deuda técnica: el setUp/tearDown de los tests. Las 6 clases de test tenían el mismo @BeforeMethod y @AfterMethod copiado — crear driver, configurar capabilities, esperar que cargue la app, cerrar driver. Lo mismo repetido 6 veces.

Este post resuelve las dos cosas: centralizar permisos y eliminar duplicación en tests.


BaseTest: eliminar la duplicación

Antes, cada clase de test tenía esto:

public class GestosTest {

    private AndroidDriver driver;
    private WebDriverWait wait;
    private ProductsPage productsPage;

    @BeforeMethod
    public void setUp() throws Exception {
        UiAutomator2Options options = new UiAutomator2Options();
        options.setUdid("ZY32FJFXNF");
        options.setApp(new File("apk/mda-2.2.0-25.apk").getAbsolutePath());
        options.setCapability("appium:autoGrantPermissions", false);
        options.setAutomationName("UiAutomator2");
        options.setNewCommandTimeout(Duration.ofSeconds(120));
        options.setCapability("appium:appWaitActivity", "*");

        driver = new AndroidDriver(new URL("http://127.0.0.1:4723"), options);
        wait = new WebDriverWait(driver, Duration.ofSeconds(10));
        productsPage = new ProductsPage(driver, wait);

        wait.until(ExpectedConditions.visibilityOfElementLocated(
                AppiumBy.accessibilityId("Displays all products of catalog")
        ));
    }

    @AfterMethod
    public void tearDown() {
        if (driver != null) {
            driver.quit();
        }
    }

    // tests...
}

Esto estaba copiado en CartTest, LoginTest, GestosTest, CheckoutTest, ProductDetailTest. Lo mismo, 6 veces. Si cambiaba el UDID del dispositivo o el timeout, tenía que editar 6 archivos.

Creé BaseTest:

package tests;

import io.appium.java_client.AppiumBy;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import pages.ProductsPage;

import java.io.File;
import java.net.URL;
import java.time.Duration;

public class BaseTest {

    protected AndroidDriver driver;
    protected WebDriverWait wait;
    protected ProductsPage productsPage;

    @BeforeMethod
    public void setUp() throws Exception {
        UiAutomator2Options options = new UiAutomator2Options();
        options.setUdid("ZY32FJFXNF");
        options.setApp(new File("apk/mda-2.2.0-25.apk").getAbsolutePath());
        options.setCapability("appium:autoGrantPermissions", false);
        options.setAutomationName("UiAutomator2");
        options.setNewCommandTimeout(Duration.ofSeconds(120));
        options.setCapability("appium:appWaitActivity", "*");

        driver = new AndroidDriver(new URL("http://127.0.0.1:4723"), options);
        wait = new WebDriverWait(driver, Duration.ofSeconds(10));
        productsPage = new ProductsPage(driver, wait);

        wait.until(ExpectedConditions.visibilityOfElementLocated(
                AppiumBy.accessibilityId("Displays all products of catalog")
        ));
    }

    @AfterMethod
    public void tearDown() {
        if (driver != null) {
            driver.quit();
        }
    }
}

Los campos son protected para que las subclases accedan a driver, wait y productsPage directamente.

Ahora GestosTest queda así:

public class GestosTest extends BaseTest {

    @Test
    public void navegarADrawing() {
        DrawingPage drawingPage = productsPage.irADrawing();
        Assert.assertEquals(drawingPage.obtenerTitulo(), "Drawing");
    }

    @Test
    public void dibujarYGuardar() {
        DrawingPage drawingPage = productsPage.irADrawing();
        drawingPage.dibujar(200, 600, 800, 1200);
        String mensaje = drawingPage.guardarYObtenerMensaje();
        Assert.assertEquals(mensaje, "Drawing saved successfully to gallery");
    }

    @Test
    public void dibujarLimpiarYGuardar() {
        DrawingPage drawingPage = productsPage.irADrawing();
        drawingPage.dibujar(200, 600, 800, 1200);
        drawingPage.limpiarCanvas();
        String mensaje = drawingPage.guardarYObtenerMensaje();
        Assert.assertEquals(mensaje, "Drawing saved successfully to gallery");
    }
}

Sin setUp, sin tearDown, sin campos duplicados. Lo mismo hice con las otras 5 clases. Es el mismo patrón que BasePage aplica a las pages — herencia para centralizar lo que se repite.


autoGrantPermissions: true vs false

En BaseTest configuré:

options.setCapability("appium:autoGrantPermissions", false);

Con true (o sin configurarlo), Android otorga todos los permisos automáticamente al instalar la APK. Los diálogos de permisos nunca aparecen. Los tests funcionan, pero no prueban nada relacionado con permisos.

Con false, la app se instala sin permisos. Cada vez que una pantalla necesita un recurso (cámara, ubicación, fotos), el sistema muestra el diálogo. Los tests tienen que manejarlo.

Para un post de permisos, false es obligatorio. Pero hay algo más: con false, los tests de Drawing y QR Scanner y Geo Location necesitan manejar el diálogo cada vez que corren, porque @BeforeMethod reinstala la app limpia en cada test.


Explorando con Inspector: no todos los diálogos son iguales

Antes de codear, exploré QR Code Scanner y Geo Location con Appium Inspector. Acá descubrí algo que no esperaba.

QR Code Scanner — permiso de cámara

Diálogo del sistema Android pidiendo permiso de cámara con tres opciones y resource-ids del permissioncontroller
Permiso de cámara: 3 opciones. El resource-id del botón "Mientras la app está en uso" es permission_allow_foreground_only_button.

El diálogo de cámara tiene 3 botones:

  • "Mientras la app está en uso" → permission_allow_foreground_only_button
  • "Solo esta vez" → permission_allow_one_time_button
  • "No permitir" → permission_deny_button

Después de aceptar, la pantalla QR Scanner carga con el preview de la cámara y un título con id qrCodeTV.

Geo Location — permiso de ubicación

Diálogo del sistema Android pidiendo permiso de ubicación con opciones Precisa y Aproximada más tres botones de acción
Permiso de ubicación: más complejo que el de cámara. Tiene RadioButtons para precisión (Precisa/Aproximada) además de los 3 botones estándar.

El diálogo de ubicación es más complejo. Tiene los mismos 3 botones, pero además tiene RadioButtons:

  • "Precisa" → permission_location_accuracy_radio_fine
  • "Aproximada" → permission_location_accuracy_radio_coarse

Después de aceptar, Geo Location muestra coordenadas reales (en mi caso, latitud -31.313295 y longitud -64.2676153 — Córdoba), botones Start/Stop Observing, y un link.

Drawing — permiso de fotos (el que ya conocía)

Diálogo del sistema Android pidiendo permiso de fotos con solo dos opciones y resource-id permission_allow_button
Permiso de fotos: solo 2 opciones. El id es permission_allow_button, no permission_allow_foreground_only_button. Diálogo diferente al de cámara y ubicación.

El diálogo de fotos tiene solo 2 botones:

  • "Permitir" → permission_allow_button
  • "No permitir" → permission_deny_button

Acá está el hallazgo: no todos los diálogos de permisos de Android tienen los mismos botones. El de fotos/media tiene 2 opciones con permission_allow_button. El de cámara y ubicación tiene 3 opciones con permission_allow_foreground_only_button. Son ids diferentes del sistema.

Si hardcodeás un solo id para manejar permisos, va a fallar en alguna pantalla.


manejarPermiso() con varargs

En el post de Drawing, el manejo del permiso era un try/catch con un id fijo dentro de irADrawing(). Ahora necesitaba manejar permisos en 3 pantallas, con ids diferentes. La solución: un método genérico en BasePage que acepta múltiples ids.

/**
 * Maneja diálogo de permisos del sistema Android.
 * Si el diálogo aparece, toca el botón indicado.
 * Si no aparece (permiso ya otorgado), continúa sin error.
 */
public void manejarPermiso(String... botonesPermiso) {
    WebDriverWait shortWait = new WebDriverWait(driver, Duration.ofSeconds(3));
    for (String boton : botonesPermiso) {
        try {
            shortWait.until(ExpectedConditions.visibilityOfElementLocated(
                    AppiumBy.id(boton)
            )).click();
            return;
        } catch (Exception e) {
            // Este botón no apareció, probar el siguiente
        }
    }
}

String... botonesPermiso (varargs) permite pasar uno o más ids. El método prueba cada uno: si encuentra el primero, hace click y sale. Si no lo encuentra, prueba el siguiente. Si ninguno aparece, continúa sin error (el permiso ya fue otorgado).

En ProductsPage, los métodos quedan así:

private static final String PERMISO_ALLOW =
    "com.android.permissioncontroller:id/permission_allow_foreground_only_button";

public DrawingPage irADrawing() {
    abrirMenu();
    // ... seleccionar Drawing del menú ...

    // Drawing usa permission_allow_button (2 opciones)
    // QR/Geo usan permission_allow_foreground_only_button (3 opciones)
    manejarPermiso(PERMISO_ALLOW,
        "com.android.permissioncontroller:id/permission_allow_button");

    wait.until(ExpectedConditions.visibilityOfElementLocated(
            AppiumBy.id("com.saucelabs.mydemoapp.android:id/drawingTV")
    ));
    return new DrawingPage(driver, wait);
}

public QRScannerPage irAQRScanner() {
    abrirMenu();
    // ... seleccionar QR Code Scanner del menú ...

    manejarPermiso(PERMISO_ALLOW);

    wait.until(ExpectedConditions.visibilityOfElementLocated(
            AppiumBy.id("com.saucelabs.mydemoapp.android:id/qrCodeTV")
    ));
    return new QRScannerPage(driver, wait);
}

public GeoLocationPage irAGeoLocation() {
    abrirMenu();
    // ... seleccionar Geo Location del menú ...

    manejarPermiso(PERMISO_ALLOW);

    wait.until(ExpectedConditions.visibilityOfElementLocated(
            AppiumBy.id("com.saucelabs.mydemoapp.android:id/locationTV")
    ));
    return new GeoLocationPage(driver, wait);
}

irADrawing() pasa 2 ids porque el diálogo puede tener cualquiera de los dos formatos. irAQRScanner() e irAGeoLocation() pasan solo PERMISO_ALLOW porque ambos usan el diálogo de 3 opciones.


Hallazgo: denegar permiso de cámara

Armé un test para denegar el permiso de QR Scanner y verificar qué pasaba. Esperaba que la app mostrara un mensaje de error o volviera a Products.

Lo que pasó: al denegar, la app vuelve a pedir el permiso inmediatamente. No hay pantalla alternativa, no hay mensaje de error. Sin permiso no entrás a QR Scanner.

El test quedaba en un loop: denegaba → aparecía de nuevo → timeout. Lo eliminé. No hay assertion útil que hacer — el comportamiento de la app es "sin permiso no entrás". Lo documento como hallazgo en el post, no como test.


QRScannerPage

package pages;

import io.appium.java_client.AppiumBy;
import io.appium.java_client.android.AndroidDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

public class QRScannerPage extends BasePage {

    private static final String TITLE = "com.saucelabs.mydemoapp.android:id/qrCodeTV";

    public QRScannerPage(AndroidDriver driver, WebDriverWait wait) {
        super(driver, wait);
    }

    public String obtenerTitulo() {
        return wait.until(ExpectedConditions.visibilityOfElementLocated(
                AppiumBy.id(TITLE)
        )).getText();
    }
}

La page más simple del framework. Solo tiene el título. El preview de la cámara es un View genérico sin id útil — no se pueden hacer assertions sobre la imagen de la cámara.

El valor de esta page no está en su complejidad. Está en lo que pasa para llegar a ella: abrir menú → seleccionar item → manejar permiso de cámara → verificar que cargó.


GeoLocationPage

package pages;

import io.appium.java_client.AppiumBy;
import io.appium.java_client.android.AndroidDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

public class GeoLocationPage extends BasePage {

    private static final String TITLE = "com.saucelabs.mydemoapp.android:id/locationTV";
    private static final String START_BTN = "com.saucelabs.mydemoapp.android:id/startBtn";
    private static final String STOP_BTN = "com.saucelabs.mydemoapp.android:id/stopBtn";
    private static final String LATITUDE_TV = "com.saucelabs.mydemoapp.android:id/latitudeTV";
    private static final String LONGITUDE_TV = "com.saucelabs.mydemoapp.android:id/longitudeTV";

    public GeoLocationPage(AndroidDriver driver, WebDriverWait wait) {
        super(driver, wait);
    }

    public String obtenerTitulo() {
        return wait.until(ExpectedConditions.visibilityOfElementLocated(
                AppiumBy.id(TITLE)
        )).getText();
    }

    public String obtenerLatitud() {
        return wait.until(ExpectedConditions.visibilityOfElementLocated(
                AppiumBy.id(LATITUDE_TV)
        )).getText();
    }

    public String obtenerLongitud() {
        return wait.until(ExpectedConditions.visibilityOfElementLocated(
                AppiumBy.id(LONGITUDE_TV)
        )).getText();
    }

    public void tapStartObserving() {
        wait.until(ExpectedConditions.elementToBeClickable(
                AppiumBy.id(START_BTN)
        )).click();
    }

    public void tapStopObserving() {
        wait.until(ExpectedConditions.elementToBeClickable(
                AppiumBy.id(STOP_BTN)
        )).click();
    }
}
Pantalla Geo Location de My Demo App mostrando latitud y longitud de Córdoba con botones Start y Stop Observing
Geo Location después de otorgar permiso. Las coordenadas son reales — el dispositivo reporta la ubicación de Córdoba. Start/Stop Observing controlan el tracking.

La pantalla más rica de este post. Tiene coordenadas reales del dispositivo, botones para controlar el tracking, y un link. Las coordenadas que aparecen en mi Motorola (-31.313295, -64.2676153) son Córdoba — el GPS del dispositivo funciona.


PermisosTest

package tests;

import org.testng.Assert;
import org.testng.annotations.Test;
import pages.GeoLocationPage;
import pages.QRScannerPage;

public class PermisosTest extends BaseTest {

    @Test
    public void navegarAQRScanner() {
        QRScannerPage qrPage = productsPage.irAQRScanner();
        Assert.assertEquals(qrPage.obtenerTitulo(), "QR Code Scanner");
    }

    @Test
    public void navegarAGeoLocation() {
        GeoLocationPage geoPage = productsPage.irAGeoLocation();
        Assert.assertEquals(geoPage.obtenerTitulo(), "Geo Location");
    }

    @Test
    public void verificarCoordenadasGeoLocation() {
        GeoLocationPage geoPage = productsPage.irAGeoLocation();
        String latitud = geoPage.obtenerLatitud();
        String longitud = geoPage.obtenerLongitud();

        Assert.assertFalse(latitud.isEmpty(), "Latitud no debería estar vacía");
        Assert.assertFalse(longitud.isEmpty(), "Longitud no debería estar vacía");
    }

    @Test
    public void startStopObserving() {
        GeoLocationPage geoPage = productsPage.irAGeoLocation();
        geoPage.tapStartObserving();

        String latitud = geoPage.obtenerLatitud();
        Assert.assertFalse(latitud.isEmpty(),
            "Latitud no debería estar vacía después de Start");

        geoPage.tapStopObserving();
    }
}

Cuatro tests. Cada uno maneja permisos internamente vía irAQRScanner() / irAGeoLocation(). Los tests no saben cómo se manejan los permisos — eso queda en ProductsPage. POM en acción: los tests llaman métodos de page, los locators y la lógica de permisos quedan encapsulados.


LifecycleTest

package tests;

import org.testng.Assert;
import org.testng.annotations.Test;

import java.time.Duration;

public class LifecycleTest extends BaseTest {

    @Test
    public void appEnBackground() {
        Assert.assertTrue(productsPage.obtenerTitulo().contains("Products"));

        driver.runAppInBackground(Duration.ofSeconds(5));

        Assert.assertTrue(productsPage.obtenerTitulo().contains("Products"));
    }

    @Test
    public void terminateYReactivar() {
        String appPackage = "com.saucelabs.mydemoapp.android";

        driver.terminateApp(appPackage);
        driver.activateApp(appPackage);

        Assert.assertTrue(productsPage.obtenerTitulo().contains("Products"));
    }
}

Dos tests de ciclo de vida:

appEnBackground: runAppInBackground(Duration.ofSeconds(5)) manda la app a segundo plano por 5 segundos. Android la mantiene en memoria. Al volver, tiene que seguir en Products. Simula cuando el usuario cambia de app y vuelve.

terminateYReactivar: terminateApp() cierra la app completamente. activateApp() la reabre. Es un kill real — la app arranca desde cero. Simula cuando Android mata la app por falta de memoria.

Ambos tests acceden a driver directamente — por eso los campos de BaseTest son protected. Si fueran private, LifecycleTest no compilaría.


25 tests verdes

IntelliJ IDEA con resultado de 25 tests pasados incluyendo CartTest, LoginTest, GestosTest, CheckoutTest, PermisosTest, LifecycleTest y ProductDetailTest
25 de 25. PermisosTest y LifecycleTest verdes, y los 19 tests existentes no se rompieron con el refactor a BaseTest.
Tests passed: 25 of 25 tests – 15 min 23 sec

CartTest                    2 min 19 sec
  agregarDosProductosDistintos
  eliminarProductoDelCarrito
  verificarCantidadMultipleEnCarrito
  verificarProductoEnCarrito

LoginTest                   2 min 7 sec
  loginConCamposVacios
  loginConClearYReingreso
  loginExitoso
  scrollHastaProducto

GestosTest                  1 min 45 sec
  dibujarLimpiarYGuardar
  dibujarYGuardar
  navegarADrawing

CheckoutTest                3 min 55 sec
  checkoutCompleto
  continuarComprandoVuelveAProducts
  verificarCheckoutComplete
  verificarDatosEnReview

PermisosTest                2 min 7 sec
  navegarAGeoLocation          3 sec
  navegarAQRScanner            3 sec
  startStopObserving           4 sec
  verificarCoordenadasGeoLoc   3 sec

LifecycleTest               1 min 10 sec
  appEnBackground              8 sec
  terminateYReactivar          6 sec

ProductDetailTest           2 min 1 sec
  agregarAlCarrito
  modificarCantidad
  seleccionarColor
  verificarDetalleProducto

Los tests de permisos son rápidos (~3-4 sec cada uno). Los de lifecycle son algo más lentos (~6-8 sec) por el tiempo de background y reinicio de la app.


Errores del post

Error Causa Solución
3 tests de GestosTest fallaron después del refactor manejarPermiso(PERMISO_ALLOW) usaba permission_allow_foreground_only_button pero Drawing tiene permission_allow_button Pasar ambos ids con varargs: manejarPermiso(PERMISO_ALLOW, "...permission_allow_button")
Test de denegar permiso QR Scanner en timeout La app re-pide el permiso inmediatamente al denegar — no hay pantalla alternativa Eliminar el test; documentar el comportamiento como hallazgo
SessionNotCreatedException en corrida completa Appium server no estaba corriendo o dispositivo desconectado Verificar adb devices y reiniciar Appium server

Estructura actual

appium-java-framework/
├── src/
│   ├── main/java/
│   │   └── pages/
│   │       ├── BasePage.java                (+ manejarPermiso con varargs)
│   │       ├── CartPage.java
│   │       ├── CheckoutCompletePage.java
│   │       ├── CheckoutPaymentPage.java
│   │       ├── CheckoutShippingPage.java
│   │       ├── DrawingPage.java
│   │       ├── GeoLocationPage.java         ← nuevo
│   │       ├── LoginPage.java
│   │       ├── ProductDetailPage.java
│   │       ├── ProductsPage.java            (+ irAQRScanner, irAGeoLocation)
│   │       ├── QRScannerPage.java           ← nuevo
│   │       └── ReviewOrderPage.java
│   └── test/java/
│       └── tests/
│           ├── BaseTest.java                ← nuevo
│           ├── CartTest.java                (extends BaseTest)
│           ├── CheckoutTest.java            (extends BaseTest)
│           ├── GestosTest.java              (extends BaseTest)
│           ├── LifecycleTest.java           ← nuevo
│           ├── LoginTest.java               (extends BaseTest)
│           ├── PermisosTest.java            ← nuevo
│           ├── PrimerTest.java
│           └── ProductDetailTest.java       (extends BaseTest)
├── apk/
│   └── mda-2.2.0-25.apk
└── pom.xml

12 pages, 9 test classes, 25 tests.


Estado actual

  • 12 pages: BasePage, ProductsPage, LoginPage, ProductDetailPage, CartPage, CheckoutShippingPage, CheckoutPaymentPage, ReviewOrderPage, CheckoutCompletePage, DrawingPage, QRScannerPage, GeoLocationPage
  • 25 tests: 4 login, 4 product detail, 4 cart, 4 checkout, 3 gestos, 4 permisos, 2 lifecycle
  • Nuevo: BaseTest (herencia para tests), manejarPermiso() con varargs, permisos del sistema Android, runAppInBackground, terminateApp/activateApp
  • Hallazgo: los diálogos de permisos de Android tienen ids diferentes según el recurso (fotos vs cámara vs ubicación)
  • Próximo post: WebViews en Appium — cambio de contexto nativo a web

Repo: github.com/cesarbeassuarez/appium-java-framework