se ha integrado el login con SIU Guarani y se agrego un manual de usuario en info.

This commit is contained in:
2026-03-30 14:03:45 -03:00
parent 678cc9898f
commit 460fbf1634
14 changed files with 815 additions and 332 deletions
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "ar.edu.unicaba.cartelera" applicationId "ar.edu.unicaba.cartelera"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 14 versionCode 15
versionName "1.2.1" versionName "1.2.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aulas-unicaba", "name": "aulas-unicaba",
"version": "1.2.1", "version": "1.2.2",
"author": "Ionic Framework", "author": "Ionic Framework",
"homepage": "https://ionicframework.com/", "homepage": "https://ionicframework.com/",
"scripts": { "scripts": {
@@ -0,0 +1,90 @@
<ion-header>
<ion-toolbar color="primary">
<ion-title>Aulas UNICABA</ion-title>
<ion-buttons slot="end">
<ion-button (click)="close()">Cerrar</ion-button>
</ion-buttons>
</ion-toolbar>
<ion-toolbar color="primary">
<ion-segment [(ngModel)]="selectedSegment" mode="md">
<ion-segment-button value="manual">
<ion-label>Manual</ion-label>
</ion-segment-button>
<ion-segment-button value="privacy">
<ion-label>Privacidad</ion-label>
</ion-segment-button>
</ion-segment>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding privacy-content">
<!-- VISTA: MANUAL DE USUARIO -->
<div *ngIf="selectedSegment === 'manual'" class="fade-in">
<div class="logo-container ion-text-center">
<img src="assets/logo_unicaba_mini.png" class="info-logo" alt="UNICABA Logo">
<h2>Guía del Estudiante</h2>
<p class="subtitle">Aprendé a usar tu Ecosistema Digital</p>
</div>
<div class="info-body">
<section>
<h3>1. Mi Cartelera (Horarios y Aulas)</h3>
<p>Visualizá todas las materias organizadas por día. Usá la barra de búsqueda para filtrar rápido por nombre, aula o código.</p>
<ul>
<li><strong>Favoritos:</strong> Toca la estrella ⭐ para activar notificaciones automáticas y recordatorios 30 min antes de clase.</li>
<li><strong>Sincronización:</strong> Desliza hacia abajo para traer los últimos datos oficiales.</li>
</ul>
</section>
<section>
<h3>2. Mi Carrera (Seguimiento)</h3>
<p>Accedé desde el botón inferior para ver tu plan de estudios.</p>
<ul>
<li><strong>Estados:</strong> Marcá materias como <em>Aprobadas</em> (Fucsia) o <em>En Curso</em> (Celeste).</li>
<li><strong>Optativas:</strong> Elegí tus materias directamente desde el selector para completar tu progreso.</li>
</ul>
</section>
<section>
<h3>3. Agenda y Exámenes</h3>
<p>En el menú de cada materia (`...`) podés agendar <strong>Parciales, Finales o TPs</strong>. Verás insignias de colores avisándote la proximidad.</p>
</section>
<section>
<h3>4. Credencial Digital QR</h3>
<p>Tocá el logo arriba a la izquierda para abrir tu credencial. La app subirá el brillo automáticamente para facilitar el escaneo.</p>
</section>
<section>
<h3>5. Mantené tus datos seguros</h3>
<p>Usa <strong>Perfil > Guardar Respaldo</strong> para exportar un archivo CSV. Si cambias de teléfono, podés restaurarlo desde el mismo lugar.</p>
</section>
</div>
</div>
<!-- VISTA: PRIVACIDAD -->
<div *ngIf="selectedSegment === 'privacy'" class="fade-in">
<div class="logo-container ion-text-center">
<ion-icon name="shield-checkmark-outline" size="large" color="success"></ion-icon>
<h2>Privacidad Zero-Trust</h2>
<p class="subtitle">Tus datos son tuyos y de nadie más</p>
</div>
<div class="info-body">
<h3>Sin Intermediarios</h3>
<p>Aulas UNICABA no tiene servidores propios. Cada vez que sincronizás la cartelera o tu credencial, la conexión viaja directo de tu teléfono al servidor de la Universidad.</p>
<h3>Tratamiento de Credenciales</h3>
<p>Tus claves de Moodle o SIU Guaraní se guardan de forma encriptada en el almacenamiento seguro de tu dispositivo. Nunca salen de tu teléfono hacia terceros.</p>
<h3>Analíticas y Rastreos</h3>
<p>No usamos Google Analytics ni SDKs de seguimiento. No hay publicidad. Cero telemetría invasiva.</p>
<div class="footer-info ion-text-center">
<p><small>Aulas UNICABA v1.2.2 • Independiente y Offline-First</small></p>
</div>
</div>
</div>
</ion-content>
@@ -0,0 +1,91 @@
.privacy-content {
--background: var(--ion-color-light);
}
ion-segment {
--background: var(--ion-color-primary-shade);
border-radius: 0;
ion-segment-button {
--color: rgba(255,255,255,0.7);
--color-checked: white;
--indicator-color: white;
}
}
.logo-container {
padding: 10px 0 20px;
.info-logo {
height: 80px;
background: white;
padding: 10px;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
margin-bottom: 15px;
}
ion-icon {
font-size: 64px;
margin-bottom: 5px;
}
h2 {
font-weight: 800;
margin: 0;
color: var(--ion-color-primary);
}
.subtitle {
margin: 5px 0 0;
color: var(--ion-color-medium);
font-size: 0.9rem;
font-weight: 500;
}
}
.info-body {
background: white;
border-radius: 20px;
padding: 20px;
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
margin-bottom: 20px;
section {
margin-bottom: 25px;
&:last-child { margin-bottom: 0; }
}
h3 {
font-weight: 700;
font-size: 1rem;
color: var(--ion-color-dark);
margin: 0 0 10px;
}
p, li {
font-size: 0.9rem;
line-height: 1.5;
color: var(--ion-color-medium-shade);
}
ul {
padding-left: 20px;
margin: 10px 0;
li { margin-bottom: 8px; }
}
}
.footer-info {
margin-top: 30px;
opacity: 0.6;
}
.fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@@ -0,0 +1,21 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule, ModalController } from '@ionic/angular';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-info-modal',
templateUrl: './info-modal.component.html',
styleUrls: ['./info-modal.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule, FormsModule]
})
export class InfoModalComponent {
selectedSegment: string = 'manual';
constructor(private modalCtrl: ModalController) {}
close() {
this.modalCtrl.dismiss();
}
}
+11 -76
View File
@@ -1,19 +1,21 @@
<ion-header [translucent]="true"> <ion-header [translucent]="true">
<ion-toolbar color="secondary"> <ion-toolbar color="secondary">
<div class="header-content"> <div class="header-container-flex">
<div class="header-logo-section" (click)="openInfo()">
<img src="assets/logo_unicaba_mini.png" class="header-logo" alt="UNICABA Logo"> <img src="assets/logo_unicaba_mini.png" class="header-logo" alt="UNICABA Logo">
<ion-title class="ion-text-center"> </div>
UNICABA
</ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button *ngIf="viewMode === 'favoritos'" (click)="importarRespaldoManual()" title="Importar Respaldo"> <ion-button (click)="openCredencial()" title="Mi Credencial QR">
<ion-icon slot="icon-only" name="cloud-download-outline"></ion-icon> <ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
</ion-button> </ion-button>
<ion-button (click)="goToPerfil()" title="Mi Perfil"> <ion-button (click)="goToPerfil()" title="Mi Perfil">
<div class="user-avatar-small" *ngIf="authState?.userPhoto; else defaultIcon">
<img [src]="authState?.userPhoto" />
</div>
<ng-template #defaultIcon>
<ion-icon slot="icon-only" name="person-circle-outline"></ion-icon> <ion-icon slot="icon-only" name="person-circle-outline"></ion-icon>
</ion-button> </ng-template>
<ion-button (click)="isInfoModalOpen = true">
<ion-icon slot="icon-only" name="information-circle-outline"></ion-icon>
</ion-button> </ion-button>
</ion-buttons> </ion-buttons>
</div> </div>
@@ -37,69 +39,6 @@
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-modal [isOpen]="isInfoModalOpen" (didDismiss)="isInfoModalOpen = false">
<ng-template>
<ion-header>
<ion-toolbar color="primary">
<ion-title>Información y Privacidad</ion-title>
<ion-buttons slot="end">
<ion-button (click)="isInfoModalOpen = false">Cerrar</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding privacy-content">
<h2>¿Qué hace esta aplicación?</h2>
<p><strong>Aulas UNICABA</strong> es el visor especializado, independiente y <strong>offline-first</strong> para
la cartelera de horarios, campus virtual y seguimiento de tu progreso académico de la Universidad de la Ciudad.
Desarrollado pensando en
el uso en movimiento, el ahorro de batería y la mala conectividad.</p>
<h3>Nuevas Características y Funciones</h3>
<ul>
<li><strong>Cartelera Rápida y Offline:</strong> Consulta de materias organizada por días y cacheada al
instante. Muestra datos rápido aunque estés bajo tierra sin red.</li>
<li><strong>Mi Carrera y Trackeo:</strong> Ingresá a tu Plan de Estudios interactivo. Usá los Action Sheets
nativos para marcar materias como <strong>'Aprobadas'</strong> (Fucsia) o <strong>'En Curso'</strong>
(Violeta), y gestioná tus <strong>Materias Optativas</strong> desde el catálogo
directamente con un clic.</li>
<li><strong>Próxima Clase Inteligente:</strong> Un banner destacado superior en Favoritos te avisa cuándo es y
cuánto falta
para tu próxima clase, con un reloj regresivo dinámico en vivo.</li>
<li><strong>Alertas Automáticas:</strong> Si la universidad cambia repentinamente el número de aula o el link de
Meet/Zoom de tus materias, serás notificado. También recibís pre-alertas de 30 minutos antes de cursar.</li>
</ul>
<h3>Agenda de Hitos Académicos</h3>
<ul>
<li>Accedé rápidamente para agendar <strong>Parciales / Trabajos Prácticos / Finales</strong> desde cualquier
materia para llevar un control estricto.</li>
<li>El robusto sistema de calendario nativo "Wheels" evitará borrados accidentales de tus fechas importantes.
</li>
</ul>
<h3>Credencial Inmediata y Exportación</h3>
<ul>
<li><strong>Credencial Digital:</strong> Tocando el logo superior izquierdo obtenés tu QR offline y se sube el
brillo de tu pantalla al máximo de forma automática para facilitar la lectura.</li>
<li><strong>Respaldo Portable Seguro:</strong> Apretando la "Nubecita" o "Disquete" podés emitir un archivo de
backup (JSON seguro) hacia la memoria o nube de tu teléfono, para que restaures de inmediato toda la
configuración si mudás a otro equipo.</li>
</ul>
<h3>Privacidad Zero-Trust</h3>
<p><strong>Sin Servidores Intermediarios:</strong> No dependemos de bases de datos centralizadas, ni recopilamos
credenciales. Cada sincronización viaja directo desde Aulas UNICABA hasta el servidor del SIU/Google localmente.
</p>
<p class="ion-text-center" style="margin-top: 30px;"><small>Tu ecosistema digital universitario, diseñado por y
para estudiantes.</small></p>
<p class="ion-text-center" style="margin-top: 30px;"><small><strong>Versión 1.2.1</strong></small></p>
</ion-content>
</ng-template>
</ion-modal>
<ion-content [fullscreen]="true" color="light" (swipe)="onSwipe($event)"> <ion-content [fullscreen]="true" color="light" (swipe)="onSwipe($event)">
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)"> <ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="Desliza para actualizar" refreshingSpinner="crescent" <ion-refresher-content pullingText="Desliza para actualizar" refreshingSpinner="crescent"
@@ -209,10 +148,6 @@
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span class="selector-title" style="margin: 0;">Plan de Estudios / Recorrido</span> <span class="selector-title" style="margin: 0;">Plan de Estudios / Recorrido</span>
<div style="display: flex; gap: 8px;"> <div style="display: flex; gap: 8px;">
<ion-button fill="outline" color="primary" size="small" (click)="forzarRespaldo()" style="margin: 0;"
title="Guardar Manualmente" class="header-btn">
<ion-icon name="save-outline" slot="icon-only"></ion-icon>
</ion-button>
</div> </div>
</div> </div>
<p class="selector-desc">Selecciona tu carrera para guardar tu avance y ver el recorrido sugerido, toca materias <p class="selector-desc">Selecciona tu carrera para guardar tu avance y ver el recorrido sugerido, toca materias
+33 -9
View File
@@ -29,20 +29,44 @@
} }
// Header styles // Header styles
.header-content { .header-container-flex {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 10px; justify-content: space-between;
padding: 0 8px;
width: 100%;
}
.header-logo-section {
display: flex;
align-items: center;
cursor: pointer;
padding: 8px 0;
.header-logo { .header-logo {
height: 36px; height: 38px;
width: auto; background: white;
background-color: white;
padding: 4px; padding: 4px;
border-radius: 5px; border-radius: 8px;
position: absolute; box-shadow: 0 2px 8px rgba(0,0,0,0.1);
left: 10px; }
z-index: 10; }
.user-avatar-small {
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
border: 2px solid white;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
} }
} }
+32 -137
View File
@@ -1,5 +1,6 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { ToastController, ModalController, AlertController, ActionSheetController, NavController } from '@ionic/angular'; import { ToastController, ModalController, AlertController, ActionSheetController, NavController } from '@ionic/angular';
import { Subscription } from 'rxjs';
import { DataService, ClaseHorario, EventoAcademico } from '../services/data'; import { DataService, ClaseHorario, EventoAcademico } from '../services/data';
import { Preferences } from '@capacitor/preferences'; import { Preferences } from '@capacitor/preferences';
@@ -13,8 +14,9 @@ import { LocalStorageService } from '../services/local-storage.service';
import { EventosService } from '../services/eventos.service'; import { EventosService } from '../services/eventos.service';
import { EventoModalComponent } from '../components/evento-modal/evento-modal.component'; import { EventoModalComponent } from '../components/evento-modal/evento-modal.component';
import { AgendaModalComponent } from '../components/agenda-modal/agenda-modal.component'; import { AgendaModalComponent } from '../components/agenda-modal/agenda-modal.component';
import { InfoModalComponent } from '../components/info-modal/info-modal.component';
import { SiuService } from '../services/siu-guarani.service'; import { SiuService } from '../services/siu-guarani.service';
import { AuthService } from '../services/auth.service'; import { AuthService, AuthState } from '../services/auth.service';
export interface ProximaClaseInfo { export interface ProximaClaseInfo {
@@ -44,6 +46,9 @@ export class HomePage implements OnInit {
errorMsg: string = ''; errorMsg: string = '';
isInfoModalOpen: boolean = false; isInfoModalOpen: boolean = false;
authState: AuthState | null = null;
private authSub: Subscription | null = null;
proximaClase: ProximaClaseInfo | null = null; proximaClase: ProximaClaseInfo | null = null;
private countdownTimer: any = null; private countdownTimer: any = null;
@@ -95,6 +100,10 @@ export class HomePage implements OnInit {
async ngOnInit() { async ngOnInit() {
this.authSub = this.authService.authState$.subscribe(state => {
this.authState = state;
});
this.route.queryParams.subscribe(params => { this.route.queryParams.subscribe(params => {
this.highlightedCodigo = params['highlight'] || null; this.highlightedCodigo = params['highlight'] || null;
if (this.highlightedCodigo) { if (this.highlightedCodigo) {
@@ -118,10 +127,8 @@ export class HomePage implements OnInit {
this.favoritos = userData.favoritos || []; this.favoritos = userData.favoritos || [];
this.optativasSeleccionadas = userData.optativas || []; this.optativasSeleccionadas = userData.optativas || [];
// Crucial: Recargar los eventos al objeto local
this.eventos = await this.eventosService.loadEventos(); this.eventos = await this.eventosService.loadEventos();
// Disparamos la lógica de auto-completar para mapear optativas y estados académicos
if (this.siuMaterias.length > 0) { if (this.siuMaterias.length > 0) {
await this.autoCompletarDesdeSiu(); await this.autoCompletarDesdeSiu();
} }
@@ -173,8 +180,6 @@ export class HomePage implements OnInit {
if (favPref.value && JSON.parse(favPref.value).length > 0) { if (favPref.value && JSON.parse(favPref.value).length > 0) {
this.favoritos = JSON.parse(favPref.value); this.favoritos = JSON.parse(favPref.value);
this.viewMode = 'favoritos'; this.viewMode = 'favoritos';
} else {
await this.promptForRestore();
} }
this.eventos = await this.eventosService.loadEventos(); this.eventos = await this.eventosService.loadEventos();
} }
@@ -191,6 +196,13 @@ export class HomePage implements OnInit {
// 3. Fetch from Server // 3. Fetch from Server
this.syncData(); this.syncData();
// 4. Mostrar Info Modal automáticamente solo la PRIMERA VEZ (Onboarding)
const infoSeen = await Preferences.get({ key: 'info_modal_v1_seen' });
if (!infoSeen.value) {
setTimeout(() => this.openInfo(), 1000); // Pequeño delay para que la UI se asiente
await Preferences.set({ key: 'info_modal_v1_seen', value: 'true' });
}
} }
syncData(event?: any) { syncData(event?: any) {
@@ -245,46 +257,6 @@ export class HomePage implements OnInit {
this.syncData(event); this.syncData(event);
} }
async promptForRestore() {
const alert = await this.alertCtrl.create({
header: '¡Datos no encontrados!',
message: 'No encontramos tu configuración de favoritos. Si tienes un archivo CSV de respaldo (aulas_unicaba_datos.csv) en Google Drive, iCloud, o en las descargas de tu dispositivo, puedes restaurarlo manualmente.',
buttons: [
{ text: 'Empezar de cero', role: 'cancel' },
{
text: 'Buscar Respaldo',
handler: async () => {
const imported = await this.localStorageService.importBackup();
if (imported && (imported.favoritos.length > 0 || imported.eventos.length > 0 || imported.aprobadas?.length > 0 || imported.optativas?.length > 0 || imported.encurso?.length > 0)) {
this.favoritos = imported.favoritos;
this.aprobadas = imported.aprobadas || [];
this.encurso = imported.encurso || [];
this.optativasSeleccionadas = imported.optativas || [];
this.siuMaterias = imported.siuMaterias || [];
this.siuLastSync = imported.siuLastSync || null;
// Sincronizar eventos cargados desde el CSV con el servicio de eventos
for (const ev of imported.eventos) {
await this.eventosService.saveEvento(ev);
}
this.eventos = await this.eventosService.loadEventos();
this.viewMode = 'favoritos';
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
this.filterHorarios();
const toast = await this.toastCtrl.create({ message: 'Respaldo restaurado con éxito.', duration: 2500, color: 'success' });
toast.present();
} else {
const toast = await this.toastCtrl.create({ message: 'No se pudo leer el archivo o está vacío.', duration: 2500, color: 'danger' });
toast.present();
}
}
}
]
});
await alert.present();
}
filterHorarios() { filterHorarios() {
let baseList = this.horarios; let baseList = this.horarios;
@@ -581,6 +553,20 @@ export class HomePage implements OnInit {
window.open(url, '_system'); window.open(url, '_system');
} }
async openInfo() {
const modal = await this.modalCtrl.create({
component: InfoModalComponent
});
await modal.present();
}
async openCredencial() {
const modal = await this.modalCtrl.create({
component: CredencialModalComponent
});
await modal.present();
}
// --- Hitos Académicos (Agenda) --- // --- Hitos Académicos (Agenda) ---
@@ -920,97 +906,6 @@ export class HomePage implements OnInit {
}); });
} }
async forzarRespaldo() {
this.guardarProgreso();
const data = {
favoritos: this.favoritos,
eventos: this.eventos,
aprobadas: this.aprobadas,
optativas: this.optativasSeleccionadas,
encurso: this.encurso,
siuMaterias: this.siuMaterias,
siuLastSync: this.siuLastSync
};
const actionSheet = await this.actionSheetCtrl.create({
header: 'Guardar Respaldo',
subHeader: '¿A dónde querés exportar tus materias guardadas?',
buttons: [
{
text: 'Compartir (Drive, WhatsApp, Mail...)',
icon: 'share-social-outline',
handler: async () => {
await this.ejecutarExportacion(data, false);
}
},
{
text: 'Descargar Offline (A la carpeta Descargas)',
icon: 'download-outline',
handler: async () => {
await this.ejecutarExportacion(data, true);
}
},
{
text: 'Cancelar',
icon: 'close',
role: 'cancel'
}
]
});
await actionSheet.present();
}
private async ejecutarExportacion(data: any, localMode: boolean) {
const ok = await this.localStorageService.exportBackup(data, localMode);
if (ok) {
const toast = await this.toastCtrl.create({
message: localMode ? 'Archivo descargado en tu teléfono.' : 'Respaldo emitido correctamente.',
duration: 3500,
color: 'success',
icon: 'save'
});
toast.present();
await Haptics.impact({ style: ImpactStyle.Heavy }).catch(() => { });
}
}
async importarRespaldoManual() {
const imported = await this.localStorageService.importBackup();
if (imported && (imported.favoritos.length > 0 || imported.eventos.length > 0 || imported.aprobadas?.length > 0 || imported.optativas?.length > 0 || imported.encurso?.length > 0)) {
this.favoritos = imported.favoritos;
this.aprobadas = imported.aprobadas || [];
this.encurso = imported.encurso || [];
this.optativasSeleccionadas = imported.optativas || [];
this.siuMaterias = imported.siuMaterias || [];
this.siuLastSync = imported.siuLastSync || null;
for (const ev of imported.eventos) {
await this.eventosService.saveEvento(ev);
}
this.eventos = await this.eventosService.loadEventos();
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
this.guardarProgreso();
this.filterHorarios();
const toast = await this.toastCtrl.create({
message: '¡Respaldo importado correctamente!',
duration: 3000,
color: 'success',
icon: 'checkmark-circle'
});
toast.present();
await Haptics.impact({ style: ImpactStyle.Heavy }).catch(() => { });
} else {
const toast = await this.toastCtrl.create({
message: 'No se procesó ningún archivo válido.',
duration: 2500,
color: 'warning',
icon: 'alert-circle'
});
toast.present();
}
}
goToPerfil() { goToPerfil() {
this.navCtrl.navigateForward('/perfil'); this.navCtrl.navigateForward('/perfil');
+56 -6
View File
@@ -4,6 +4,11 @@
<ion-back-button defaultHref="/home"></ion-back-button> <ion-back-button defaultHref="/home"></ion-back-button>
</ion-buttons> </ion-buttons>
<ion-title>Configuración de Perfil</ion-title> <ion-title>Configuración de Perfil</ion-title>
<ion-buttons slot="end">
<ion-button (click)="openInfo()">
<ion-icon slot="icon-only" name="information-circle-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
@@ -34,7 +39,8 @@
</ion-card-header> </ion-card-header>
<ion-card-content *ngIf="!authState?.siuConnected"> <ion-card-content *ngIf="!authState?.siuConnected">
<p class="auth-desc">Conectá con el SIU para traer tu historial y que la app auto-favoritee tus materias en curso.</p> <p class="auth-desc">Conectá con el SIU para traer tu historial y que la app auto-favoritee tus materias en
curso.</p>
<ion-list lines="inset" class="auth-form"> <ion-list lines="inset" class="auth-form">
<ion-item> <ion-item>
<ion-icon name="person-outline" slot="start" color="medium"></ion-icon> <ion-icon name="person-outline" slot="start" color="medium"></ion-icon>
@@ -42,7 +48,8 @@
</ion-item> </ion-item>
<ion-item> <ion-item>
<ion-icon name="lock-closed-outline" slot="start" color="medium"></ion-icon> <ion-icon name="lock-closed-outline" slot="start" color="medium"></ion-icon>
<ion-input [type]="showSiuPass ? 'text' : 'password'" placeholder="Contraseña de SIU" [(ngModel)]="siuPass"></ion-input> <ion-input [type]="showSiuPass ? 'text' : 'password'" placeholder="Contraseña de SIU"
[(ngModel)]="siuPass"></ion-input>
<ion-button slot="end" fill="clear" (click)="showSiuPass = !showSiuPass" color="medium"> <ion-button slot="end" fill="clear" (click)="showSiuPass = !showSiuPass" color="medium">
<ion-icon [name]="showSiuPass ? 'eye-off-outline' : 'eye-outline'"></ion-icon> <ion-icon [name]="showSiuPass ? 'eye-off-outline' : 'eye-outline'"></ion-icon>
</ion-button> </ion-button>
@@ -83,7 +90,8 @@
</ion-card-header> </ion-card-header>
<ion-card-content *ngIf="!authState?.moodleConnected"> <ion-card-content *ngIf="!authState?.moodleConnected">
<p class="auth-desc">Vinculá tu cuenta del campus para generar tu credencial digital offline y habilitar alertas de actividades.</p> <p class="auth-desc">Vinculá tu cuenta del campus para generar tu credencial digital offline y habilitar alertas
de actividades.</p>
<ion-list lines="inset" class="auth-form"> <ion-list lines="inset" class="auth-form">
<ion-item> <ion-item>
<ion-icon name="person-outline" slot="start" color="medium"></ion-icon> <ion-icon name="person-outline" slot="start" color="medium"></ion-icon>
@@ -91,7 +99,8 @@
</ion-item> </ion-item>
<ion-item> <ion-item>
<ion-icon name="lock-closed-outline" slot="start" color="medium"></ion-icon> <ion-icon name="lock-closed-outline" slot="start" color="medium"></ion-icon>
<ion-input [type]="showMoodlePass ? 'text' : 'password'" placeholder="Contraseña de Campus" [(ngModel)]="moodlePass"></ion-input> <ion-input [type]="showMoodlePass ? 'text' : 'password'" placeholder="Contraseña de Campus"
[(ngModel)]="moodlePass"></ion-input>
<ion-button slot="end" fill="clear" (click)="showMoodlePass = !showMoodlePass" color="medium"> <ion-button slot="end" fill="clear" (click)="showMoodlePass = !showMoodlePass" color="medium">
<ion-icon [name]="showMoodlePass ? 'eye-off-outline' : 'eye-outline'"></ion-icon> <ion-icon [name]="showMoodlePass ? 'eye-off-outline' : 'eye-outline'"></ion-icon>
</ion-button> </ion-button>
@@ -119,9 +128,50 @@
</ion-card-content> </ion-card-content>
</ion-card> </ion-card>
<!-- Sección de Credencial Digital (Solo si está vinculado) -->
<ion-card class="auth-card credencial-card" *ngIf="authState?.moodleConnected" (click)="openCredencial()">
<ion-card-header>
<div class="card-title-row">
<ion-icon name="qr-code" color="primary" class="service-icon"></ion-icon>
<ion-card-title>Mi Credencial Digital</ion-card-title>
<ion-icon name="chevron-forward" color="medium"></ion-icon>
</div>
<ion-card-subtitle>Acceso rápido con QR y modo sin conexión</ion-card-subtitle>
</ion-card-header>
<ion-card-content>
<p class="auth-desc">Toca para abrir tu credencial. El brillo se ajustará automáticamente.</p>
</ion-card-content>
</ion-card>
<!-- Sección de Respaldo y Datos -->
<ion-card class="auth-card backup-card">
<ion-card-header>
<div class="card-title-row">
<ion-icon name="cloud-upload" color="tertiary" class="service-icon"></ion-icon>
<ion-card-title>Datos y Respaldo</ion-card-title>
</div>
<ion-card-subtitle>Asegurá tu progreso académico</ion-card-subtitle>
</ion-card-header>
<ion-card-content>
<p class="auth-desc">Exportá tus materias favoritas y aprobadas para restaurarlas en otro dispositivo.</p>
<div class="backup-buttons">
<ion-button expand="block" fill="outline" (click)="forzarRespaldo()">
<ion-icon name="save-outline" slot="start"></ion-icon>
Guardar Respaldo
</ion-button>
<ion-button expand="block" fill="outline" color="secondary" (click)="importarRespaldoManual()">
<ion-icon name="cloud-download-outline" slot="start"></ion-icon>
Restaurar Datos
</ion-button>
</div>
</ion-card-content>
</ion-card>
<div class="security-info ion-padding"> <div class="security-info ion-padding">
<ion-icon name="shield-checkmark" color="medium"></ion-icon> <ion-icon name="lock-closed" color="medium"></ion-icon>
<p>Tus credenciales se almacenan de forma segura y local en tu dispositivo. No son enviadas a servidores externos ajenos a la universidad.</p> <p>Tus credenciales y datos se almacenan de forma segura y local en el almacenamiento persistente de tu teléfono.
</p>
</div> </div>
</div> </div>
</ion-content> </ion-content>
+66 -3
View File
@@ -165,6 +165,71 @@
} }
} }
.credencial-card {
background: linear-gradient(to right, rgba(var(--ion-color-primary-rgb), 0.05), transparent);
border-left: 6px solid var(--ion-color-primary) !important;
cursor: pointer;
&:active {
opacity: 0.7;
}
}
.backup-card {
.backup-buttons {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
ion-button {
--border-radius: 12px;
margin: 0;
font-weight: 600;
}
}
}
.info-section-container {
background: white;
border-radius: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
.section-title {
font-weight: 700;
font-size: 1.1rem;
color: var(--ion-color-dark);
margin: 0 0 15px 0;
}
.info-item {
font-size: 0.9rem;
line-height: 1.5;
color: var(--ion-color-medium);
margin-bottom: 15px;
display: flex;
align-items: flex-start;
gap: 12px;
ion-icon {
font-size: 1.4rem;
flex-shrink: 0;
}
strong {
color: var(--ion-color-dark);
}
}
.version-info {
margin-top: 20px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
padding-top: 15px;
color: var(--ion-color-medium);
opacity: 0.6;
}
}
.error-text { .error-text {
color: var(--ion-color-danger); color: var(--ion-color-danger);
font-size: 0.85rem; font-size: 0.85rem;
@@ -180,15 +245,13 @@
text-align: center; text-align: center;
gap: 10px; gap: 10px;
opacity: 0.7; opacity: 0.7;
ion-icon { ion-icon {
font-size: 30px; font-size: 30px;
} }
p { p {
font-size: 0.8rem; font-size: 0.8rem;
margin: 0; margin: 0;
line-height: 1.4; line-height: 1.4;
max-width: 250px; max-width: 280px;
} }
} }
+135 -8
View File
@@ -1,9 +1,14 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { ToastController, LoadingController, AlertController, NavController } from '@ionic/angular'; import { ToastController, LoadingController, AlertController, NavController, ActionSheetController, ModalController } from '@ionic/angular';
import { AuthService, AuthState } from '../services/auth.service'; import { AuthService, AuthState } from '../services/auth.service';
import { SiuService } from '../services/siu-guarani.service'; import { SiuService } from '../services/siu-guarani.service';
import { CredencialService } from '../services/credencial.service'; import { CredencialService } from '../services/credencial.service';
import { LocalStorageService, AppData } from '../services/local-storage.service'; import { LocalStorageService, AppData } from '../services/local-storage.service';
import { EventosService } from '../services/eventos.service';
import { CredencialModalComponent } from '../components/credencial-modal/credencial-modal.component';
import { InfoModalComponent } from '../components/info-modal/info-modal.component';
import { Haptics, ImpactStyle } from '@capacitor/haptics';
import { Preferences } from '@capacitor/preferences';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
@Component({ @Component({
@@ -32,9 +37,12 @@ export class PerfilPage implements OnInit, OnDestroy {
private siuService: SiuService, private siuService: SiuService,
private credencialService: CredencialService, private credencialService: CredencialService,
private localStorageService: LocalStorageService, private localStorageService: LocalStorageService,
private eventosService: EventosService,
private toastCtrl: ToastController, private toastCtrl: ToastController,
private loadingCtrl: LoadingController, private loadingCtrl: LoadingController,
private alertCtrl: AlertController, private alertCtrl: AlertController,
private actionSheetCtrl: ActionSheetController,
private modalCtrl: ModalController,
private navCtrl: NavController private navCtrl: NavController
) { } ) { }
@@ -115,14 +123,17 @@ export class PerfilPage implements OnInit, OnDestroy {
await loader.present(); await loader.present();
try { try {
// 1. Validar y generar credencial (descarga PDF) // setupMoodleAccount hace UNA sola autenticación y luego descarga la credencial
await this.credencialService.generateCredential(this.moodleDni, this.moodlePass); // y el perfil usando la misma sesión. Evita el bug de doble login donde la segunda
// llamada a loginMoodle() encuentra al usuario ya autenticado y no encuentra el
// logintoken en la página → "No se pudo obtener el token de seguridad del campus."
const result = await this.credencialService.setupMoodleAccount(
this.moodleDni,
this.moodlePass,
(status) => loader.message = status // actualiza el mensaje del loader en tiempo real
);
// 2. Obtener Perfil (Nombre y Foto) await this.authService.saveUserProfile(result.profile.name, result.profile.photo);
const profile = await this.credencialService.getUserProfile(this.moodleDni, this.moodlePass);
await this.authService.saveUserProfile(profile.name, profile.photo);
// 3. Guardar credenciales de acceso
await this.authService.saveMoodleCredentials(this.moodleDni, this.moodlePass); await this.authService.saveMoodleCredentials(this.moodleDni, this.moodlePass);
this.showToast('¡Perfil sincronizado y Campus conectado!', 'success'); this.showToast('¡Perfil sincronizado y Campus conectado!', 'success');
@@ -136,6 +147,7 @@ export class PerfilPage implements OnInit, OnDestroy {
} }
} }
async logoutMoodle() { async logoutMoodle() {
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
header: 'Cerrar Sesión en Campus', header: 'Cerrar Sesión en Campus',
@@ -156,6 +168,121 @@ export class PerfilPage implements OnInit, OnDestroy {
await alert.present(); await alert.present();
} }
// --- NUEVAS FUNCIONES CONSOLIDADAS ---
async openInfo() {
const modal = await this.modalCtrl.create({
component: InfoModalComponent
});
await modal.present();
}
async openCredencial() {
const modal = await this.modalCtrl.create({
component: CredencialModalComponent
});
await modal.present();
}
async forzarRespaldo() {
// Obtenemos los datos actuales desde el service para asegurar que el backup sea completo
const data = await this.localStorageService.loadData() || this.getDefaultData();
const actionSheet = await this.actionSheetCtrl.create({
header: 'Guardar Respaldo',
subHeader: '¿A dónde querés exportar tus materias guardadas?',
buttons: [
{
text: 'Compartir (Drive, WhatsApp, Mail...)',
icon: 'share-social-outline',
handler: async () => {
await this.ejecutarExportacion(data, false);
}
},
{
text: 'Descargar Offline (A la carpeta Descargas)',
icon: 'download-outline',
handler: async () => {
await this.ejecutarExportacion(data, true);
}
},
{
text: 'Cancelar',
icon: 'close',
role: 'cancel'
}
]
});
await actionSheet.present();
}
private async ejecutarExportacion(data: any, localMode: boolean) {
const ok = await this.localStorageService.exportBackup(data, localMode);
if (ok) {
const toast = await this.toastCtrl.create({
message: localMode ? 'Archivo descargado en tu teléfono.' : 'Respaldo emitido correctamente.',
duration: 3500,
color: 'success',
icon: 'save'
});
toast.present();
await Haptics.impact({ style: ImpactStyle.Heavy }).catch(() => { });
}
}
async importarRespaldoManual() {
const imported = await this.localStorageService.importBackup();
if (imported && (imported.favoritos?.length > 0 || imported.eventos?.length > 0 || imported.aprobadas?.length > 0)) {
// Guardamos los eventos uno por uno (el service se encarga de la persistencia de eventos)
if (imported.eventos) {
for (const ev of imported.eventos) {
await this.eventosService.saveEvento(ev);
}
}
// Guardamos el resto de los datos en el LocalStorageService
// Nota: No sobreescribimos TODO el objeto para no borrar fotos de perfil si las hubiera,
// pero sí actualizamos los datos académicos.
const currentData = await this.localStorageService.loadData() || this.getDefaultData();
const newData = {
...currentData,
favoritos: imported.favoritos || currentData.favoritos,
aprobadas: imported.aprobadas || currentData.aprobadas,
encurso: imported.encurso || currentData.encurso,
optativas: imported.optativas || currentData.optativas,
siuMaterias: imported.siuMaterias || currentData.siuMaterias,
siuLastSync: imported.siuLastSync || currentData.siuLastSync
};
await this.localStorageService.saveData(newData);
// Sincronizar favoritos con Capacitor Preferences para que el resto de la app los vea
if (imported.favoritos) {
await Preferences.set({ key: 'favoritos', value: JSON.stringify(imported.favoritos) });
}
const toast = await this.toastCtrl.create({
message: '¡Respaldo importado correctamente!',
duration: 3000,
color: 'success',
icon: 'checkmark-circle'
});
toast.present();
await Haptics.impact({ style: ImpactStyle.Heavy }).catch(() => { });
// Opcional: Podríamos forzar un refresh del authState si fuera necesario
} else {
const toast = await this.toastCtrl.create({
message: 'No se procesó ningún archivo válido.',
duration: 2500,
color: 'warning',
icon: 'alert-circle'
});
toast.present();
}
}
goBack() { goBack() {
this.navCtrl.back(); this.navCtrl.back();
} }
+172 -46
View File
@@ -4,10 +4,6 @@ import { AuthService } from './auth.service';
import { Preferences } from '@capacitor/preferences'; import { Preferences } from '@capacitor/preferences';
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
// Set up PDF.js worker. If not set, it falls back to a fake worker, which is fine for small files.
// For best performance without messing with angular.json assets, we can let it use the fake worker
// or set it to null. We'll let it fallback automatically.
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
@@ -30,25 +26,24 @@ export class CredencialService {
} }
/** /**
* Executes the full flow: Login -> Scrape Token -> Submit Login -> Download PDF -> Render Base64 * Executes the full flow: Login -> Download PDF -> Render Base64
*/
/**
* Executes the full flow: Login -> Scrape Token -> Submit Login -> Download PDF -> Render Base64
*/ */
async generateCredential(dni: string, pass: string, onStatusChange?: (status: string) => void): Promise<string> { async generateCredential(dni: string, pass: string, onStatusChange?: (status: string) => void): Promise<string> {
try { try {
if (onStatusChange) onStatusChange('Conectando con el campus...'); if (onStatusChange) onStatusChange('Conectando con el campus...');
await this.loginMoodle(dni, pass); // Login and get the authenticated session cookie
const sessionCookie = await this.loginMoodle(dni, pass);
if (onStatusChange) onStatusChange('Descargando PDF...'); if (onStatusChange) onStatusChange('Descargando PDF...');
// 3. Download the PDF // Download the PDF, forwarding the authenticated session cookie
const pdfRes: HttpResponse = await CapacitorHttp.get({ const pdfRes: HttpResponse = await CapacitorHttp.get({
url: this.CREDENTIAL_URL, url: this.CREDENTIAL_URL,
responseType: 'arraybuffer', responseType: 'arraybuffer',
headers: { headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
...(sessionCookie ? { 'Cookie': sessionCookie } : {})
} }
}); });
@@ -62,10 +57,10 @@ export class CredencialService {
if (onStatusChange) onStatusChange('Generando imagen de credencial (esto puede tardar unos segundos)...'); if (onStatusChange) onStatusChange('Generando imagen de credencial (esto puede tardar unos segundos)...');
// 4. Render PDF to Base64 Image using pdf.js // Render PDF to Base64 Image using pdf.js
const imgBase64 = await this.renderPdfToBase64(pdfData); const imgBase64 = await this.renderPdfToBase64(pdfData);
// Save to cache securely // Save to cache
await Preferences.set({ key: this.PREF_IMAGE, value: imgBase64 }); await Preferences.set({ key: this.PREF_IMAGE, value: imgBase64 });
if (onStatusChange) onStatusChange('¡Listo!'); if (onStatusChange) onStatusChange('¡Listo!');
@@ -75,34 +70,92 @@ export class CredencialService {
} }
} }
/**
* Combined setup: ONE login -> download credential PDF + fetch user profile.
* Use this instead of calling generateCredential() + getUserProfile() separately,
* which would trigger two separate logins. The second login fails because
* Moodle redirects the GET /login to the dashboard (already logged in),
* so no logintoken is found.
*/
async setupMoodleAccount(
dni: string,
pass: string,
onStatusChange?: (status: string) => void
): Promise<{ credentialImage: string; profile: { name: string; photo: string | null } }> {
if (onStatusChange) onStatusChange('Conectando con el campus...');
// ONE login for everything
const sessionCookie = await this.loginMoodle(dni, pass);
// 1. Download credential PDF
if (onStatusChange) onStatusChange('Descargando credencial...');
const pdfRes: HttpResponse = await CapacitorHttp.get({
url: this.CREDENTIAL_URL,
responseType: 'arraybuffer',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
...(sessionCookie ? { 'Cookie': sessionCookie } : {})
}
});
const contentType = pdfRes.headers && (pdfRes.headers['Content-Type'] || pdfRes.headers['content-type']);
if (!contentType || !contentType.includes('application/pdf')) {
throw new Error(`El campus no devolvió un PDF. Recibido: ${contentType}`);
}
if (onStatusChange) onStatusChange('Generando imagen de credencial...');
const pdfData = this.base64ToUint8Array(pdfRes.data);
const imgBase64 = await this.renderPdfToBase64(pdfData);
await Preferences.set({ key: this.PREF_IMAGE, value: imgBase64 });
// 2. Fetch profile (same session cookie — no second login)
if (onStatusChange) onStatusChange('Obteniendo perfil...');
const profile = await this.fetchProfileWithSession(sessionCookie);
if (onStatusChange) onStatusChange('¡Listo!');
return { credentialImage: imgBase64, profile };
}
async getUserProfile(dni: string, pass: string): Promise<{name: string, photo: string | null}> { async getUserProfile(dni: string, pass: string): Promise<{name: string, photo: string | null}> {
try { try {
await this.loginMoodle(dni, pass); const sessionCookie = await this.loginMoodle(dni, pass);
return this.fetchProfileWithSession(sessionCookie);
} catch (e) {
console.error('Error fetching user profile:', e);
throw e;
}
}
/**
* Fetches the Moodle user profile (name + photo) using an already-authenticated session cookie.
* Does NOT perform a new login — use this after loginMoodle() to avoid a double-login.
*/
private async fetchProfileWithSession(sessionCookie: string): Promise<{name: string, photo: string | null}> {
const profileRes: HttpResponse = await CapacitorHttp.get({ const profileRes: HttpResponse = await CapacitorHttp.get({
url: 'https://campus.udelaciudad.edu.ar/user/profile.php', url: 'https://campus.udelaciudad.edu.ar/user/profile.php',
headers: { headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
...(sessionCookie ? { 'Cookie': sessionCookie } : {})
} }
}); });
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(profileRes.data, 'text/html'); const doc = parser.parseFromString(profileRes.data, 'text/html');
// 1. Extraer Nombre
const nameTag = doc.querySelector('.card-profile h3'); const nameTag = doc.querySelector('.card-profile h3');
const userName = nameTag ? nameTag.textContent?.trim() || '' : ''; const userName = nameTag ? nameTag.textContent?.trim() || '' : '';
// 2. Extraer Foto
const imgTag = doc.querySelector('.userpicture img') as HTMLImageElement; const imgTag = doc.querySelector('.userpicture img') as HTMLImageElement;
let userPhotoBase64: string | null = null; let userPhotoBase64: string | null = null;
if (imgTag && imgTag.src) { if (imgTag && imgTag.src) {
try { try {
// Descargar la imagen y convertir a base64
const photoRes: HttpResponse = await CapacitorHttp.get({ const photoRes: HttpResponse = await CapacitorHttp.get({
url: imgTag.src, url: imgTag.src,
responseType: 'arraybuffer' responseType: 'arraybuffer',
headers: {
...(sessionCookie ? { 'Cookie': sessionCookie } : {})
}
}); });
userPhotoBase64 = `data:image/jpeg;base64,${photoRes.data}`; userPhotoBase64 = `data:image/jpeg;base64,${photoRes.data}`;
} catch (imgError) { } catch (imgError) {
@@ -111,53 +164,137 @@ export class CredencialService {
} }
return { name: userName, photo: userPhotoBase64 }; return { name: userName, photo: userPhotoBase64 };
} catch (e) {
console.error('Error fetching user profile:', e);
throw e;
}
} }
private async loginMoodle(dni: string, pass: string): Promise<void> {
// 1. Initial GET to fetch the login page and token
/**
* Performs the full Moodle login flow:
* 1. GET login page -> extract logintoken + session cookie
* 2. POST credentials (forwarding session cookie) -> receive authenticated session cookie
*
* Returns the authenticated session cookie string to be used in subsequent requests.
*
* IMPORTANT: CapacitorHttp in Capacitor 8+ does NOT maintain a shared cookie jar between
* requests. The logintoken is bound to the pre-login session cookie returned by the GET.
* Without forwarding that cookie in the POST, Moodle rejects the token as invalid.
* After a successful login, the POST response returns a new (authenticated) session cookie
* that must be forwarded to all subsequent authenticated requests.
*/
private async loginMoodle(dni: string, pass: string): Promise<string> {
// 1. GET login page — get the logintoken and the pre-session cookie
const initialGet: HttpResponse = await CapacitorHttp.get({ const initialGet: HttpResponse = await CapacitorHttp.get({
url: this.LOGIN_URL, url: this.LOGIN_URL,
headers: { headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'es-AR,es;q=0.9'
} }
}); });
// Debug: log what CapacitorHttp actually received
console.log('[CredencialService] GET status:', initialGet.status);
console.log('[CredencialService] GET headers:', JSON.stringify(initialGet.headers));
const dataType = typeof initialGet.data;
const dataPreview = typeof initialGet.data === 'string'
? initialGet.data.substring(0, 300)
: `[non-string: ${dataType}]`;
console.log('[CredencialService] GET data preview:', dataPreview);
// Extract the pre-session cookie from the GET response headers
const preSessionCookie = this.extractCookies(initialGet.headers);
console.log('[CredencialService] Pre-session cookie:', preSessionCookie);
// Extract logintoken using REGEX — more robust than DOMParser on Android WebView,
// which can silently fail when CapacitorHttp returns HTML with encoding quirks.
let loginToken: string | null = null;
// Try multiple regex patterns to handle attribute order variations
const regexPatterns = [
/name="logintoken"\s+value="([^"]+)"/,
/value="([^"]+)"\s+name="logintoken"/,
/name='logintoken'\s+value='([^']+)'/,
/value='([^']+)'\s+name='logintoken'/,
];
if (typeof initialGet.data === 'string') {
for (const pattern of regexPatterns) {
const m = initialGet.data.match(pattern);
if (m && m[1]) {
loginToken = m[1];
console.log('[CredencialService] Token found via regex:', loginToken);
break;
}
}
// Fallback: DOMParser
if (!loginToken) {
try {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(initialGet.data, 'text/html'); const doc = parser.parseFromString(initialGet.data, 'text/html');
const tokenInput = doc.querySelector('input[name="logintoken"]') as HTMLInputElement; const tokenInput = doc.querySelector('input[name="logintoken"]') as HTMLInputElement;
loginToken = tokenInput?.value || null;
console.log('[CredencialService] Token via DOMParser:', loginToken);
} catch (parseErr) {
console.error('[CredencialService] DOMParser error:', parseErr);
}
}
} else {
console.error('[CredencialService] GET data is not a string! Type:', dataType);
}
if (!tokenInput || !tokenInput.value) { if (!loginToken) {
throw new Error('No se pudo obtener el token de seguridad del campus.'); throw new Error('No se pudo obtener el token de seguridad del campus.');
} }
const loginToken = tokenInput.value;
// 2. POST to login
// 2. POST login — forward the pre-session cookie so Moodle can validate the token
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('username', dni); params.append('username', dni);
params.append('password', pass); params.append('password', pass);
params.append('logintoken', loginToken); params.append('logintoken', loginToken);
const postHeaders: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
};
if (preSessionCookie) {
postHeaders['Cookie'] = preSessionCookie;
}
const loginPost: HttpResponse = await CapacitorHttp.post({ const loginPost: HttpResponse = await CapacitorHttp.post({
url: this.LOGIN_URL, url: this.LOGIN_URL,
data: params.toString(), data: params.toString(),
headers: { headers: postHeaders
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
}); });
// Verify if login was successful // Verify login success
if (loginPost.data && (loginPost.data.includes('loginerrormessage') || loginPost.data.includes('Inicie sesión en su cuenta'))) { if (loginPost.data && (loginPost.data.includes('loginerrormessage') || loginPost.data.includes('Inicie sesión en su cuenta'))) {
// En algunos casos Moodle no tira loginerrormessage pero vuelve a la pág de login
if (!loginPost.data.includes('user/profile.php') && !loginPost.data.includes('my/')) { if (!loginPost.data.includes('user/profile.php') && !loginPost.data.includes('my/')) {
throw new Error('DNI o Clave incorrectos en el Campus.'); throw new Error('DNI o Clave incorrectos en el Campus.');
} }
} }
// Return the authenticated session cookie from the POST response for use in further requests
const authenticatedCookie = this.extractCookies(loginPost.headers);
return authenticatedCookie || preSessionCookie;
}
/**
* Extracts cookies from response headers as a single "name=value; name2=value2" string.
* Handles both lowercase and uppercase "set-cookie" header names.
*/
private extractCookies(headers: Record<string, string> | undefined): string {
if (!headers) return '';
const rawSetCookie: string = headers['set-cookie'] || headers['Set-Cookie'] || '';
if (!rawSetCookie) return '';
// Multiple cookies may be joined by comma (not within a value), or in a single string
return rawSetCookie
.split(/,(?=[^ ])/) // split cookies separated by ", " (RFC 6265)
.map((c: string) => c.trim().split(';')[0]) // take only name=value part
.filter((c: string) => c.includes('='))
.join('; ');
} }
private base64ToUint8Array(base64: string): Uint8Array { private base64ToUint8Array(base64: string): Uint8Array {
@@ -171,22 +308,17 @@ export class CredencialService {
} }
private async renderPdfToBase64(pdfData: Uint8Array): Promise<string> { private async renderPdfToBase64(pdfData: Uint8Array): Promise<string> {
// Configure pdf.js to use the standard worker from local assets.
// Use an absolute URL based on the origin to avoid "Failed to resolve module specifier" on Emulators/Android.
const workerUrl = new URL('assets/pdf.worker.min.mjs', window.location.origin).href; const workerUrl = new URL('assets/pdf.worker.min.mjs', window.location.origin).href;
pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl; pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl;
const loadingTask = pdfjsLib.getDocument({ data: pdfData }); const loadingTask = pdfjsLib.getDocument({ data: pdfData });
const pdf = await loadingTask.promise; const pdf = await loadingTask.promise;
// Get the first (and only) page
const page = await pdf.getPage(1); const page = await pdf.getPage(1);
// Render at 2x scale for better resolution (similar to fitz.Matrix(2,2))
const scale = 2.0; const scale = 2.0;
const viewport = page.getViewport({ scale }); const viewport = page.getViewport({ scale });
// Create a canvas to render the PDF
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
canvas.height = viewport.height; canvas.height = viewport.height;
@@ -194,7 +326,6 @@ export class CredencialService {
if (!context) throw new Error('Could not create Canvas context'); if (!context) throw new Error('Could not create Canvas context');
// Render PDF page into canvas context
const renderContext = { const renderContext = {
canvasContext: context, canvasContext: context,
viewport: viewport, viewport: viewport,
@@ -203,11 +334,6 @@ export class CredencialService {
await page.render(renderContext).promise; await page.render(renderContext).promise;
// Convert canvas to base64 image (PNG)
// format "data:image/png;base64,..."
// We return just the base64 part to match python script, or the full data URI.
// The python script returned the raw base64, but the HTML added 'data:image/png...'.
// Let's return the naked base64 to be consistent.
const dataUri = canvas.toDataURL('image/png'); const dataUri = canvas.toDataURL('image/png');
const base64 = dataUri.split(',')[1]; const base64 = dataUri.split(',')[1];
+16
View File
@@ -1,3 +1,19 @@
// Polyfill for Uint8Array.prototype.toHex — required by pdfjs-dist v5+.
// This method (part of TC39 proposal) is only natively available in Chrome 140+
// and is not yet supported by Android WebViews used in Capacitor apps.
if (!('toHex' in Uint8Array.prototype)) {
Object.defineProperty(Uint8Array.prototype, 'toHex', {
value(this: Uint8Array): string {
return Array.from(this)
.map(byte => byte.toString(16).padStart(2, '0'))
.join('');
},
writable: true,
configurable: true,
enumerable: false
});
}
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
+45
View File
@@ -0,0 +1,45 @@
import requests
from bs4 import BeautifulSoup
import sys
url = 'https://campus.udelaciudad.edu.ar/login/index.php'
session = requests.Session()
response = session.get(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
soup = BeautifulSoup(response.text, 'html.parser')
token_input = soup.find('input', {'name': 'logintoken'})
if not token_input:
print("logintoken not found.")
sys.exit(1)
logintoken = token_input.get('value')
print(f"Token: {logintoken}")
# POST login
login_data = {
'username': '34247025',
'password': 'SOY*Poderoso22',
'logintoken': logintoken
}
post_response = session.post(url, data=login_data, headers={
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
})
print(f"Login POST status: {post_response.status_code}")
if 'loginerrormessage' in post_response.text:
print("Login error message found!")
else:
print("No login errormessage found. Maybe success?")
# Try downloading the PDF
pdf_url = 'https://campus.udelaciudad.edu.ar/mod/customcert/view.php?id=209134&downloadown=1'
pdf_response = session.get(pdf_url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
print(f"PDF GET status: {pdf_response.status_code}")
print(f"Content-Type: {pdf_response.headers.get('Content-Type')}")
if 'application/pdf' in pdf_response.headers.get('Content-Type', ''):
print("Successfully downloaded PDF!")
else:
print("Did not download PDF. HTML start:")
print(pdf_response.text[:500])