diff --git a/android/app/build.gradle b/android/app/build.gradle index 5bd19e9..7406bea 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "ar.edu.unicaba.cartelera" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 14 - versionName "1.2.1" + versionCode 15 + versionName "1.2.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/package.json b/package.json index f5b71fd..da60931 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aulas-unicaba", - "version": "1.2.1", + "version": "1.2.2", "author": "Ionic Framework", "homepage": "https://ionicframework.com/", "scripts": { diff --git a/src/app/components/info-modal/info-modal.component.html b/src/app/components/info-modal/info-modal.component.html new file mode 100644 index 0000000..56a0d71 --- /dev/null +++ b/src/app/components/info-modal/info-modal.component.html @@ -0,0 +1,90 @@ + + + Aulas UNICABA + + Cerrar + + + + + + Manual + + + Privacidad + + + + + + + + +
+
+ +

Guía del Estudiante

+

Aprendé a usar tu Ecosistema Digital

+
+ +
+
+

1. Mi Cartelera (Horarios y Aulas)

+

Visualizá todas las materias organizadas por día. Usá la barra de búsqueda para filtrar rápido por nombre, aula o código.

+
    +
  • Favoritos: Toca la estrella ⭐ para activar notificaciones automáticas y recordatorios 30 min antes de clase.
  • +
  • Sincronización: Desliza hacia abajo para traer los últimos datos oficiales.
  • +
+
+ +
+

2. Mi Carrera (Seguimiento)

+

Accedé desde el botón inferior para ver tu plan de estudios.

+
    +
  • Estados: Marcá materias como Aprobadas (Fucsia) o En Curso (Celeste).
  • +
  • Optativas: Elegí tus materias directamente desde el selector para completar tu progreso.
  • +
+
+ +
+

3. Agenda y Exámenes

+

En el menú de cada materia (`...`) podés agendar Parciales, Finales o TPs. Verás insignias de colores avisándote la proximidad.

+
+ +
+

4. Credencial Digital QR

+

Tocá el logo arriba a la izquierda para abrir tu credencial. La app subirá el brillo automáticamente para facilitar el escaneo.

+
+ +
+

5. Mantené tus datos seguros

+

Usa Perfil > Guardar Respaldo para exportar un archivo CSV. Si cambias de teléfono, podés restaurarlo desde el mismo lugar.

+
+
+
+ + +
+
+ +

Privacidad Zero-Trust

+

Tus datos son tuyos y de nadie más

+
+ +
+

Sin Intermediarios

+

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.

+ +

Tratamiento de Credenciales

+

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.

+ +

Analíticas y Rastreos

+

No usamos Google Analytics ni SDKs de seguimiento. No hay publicidad. Cero telemetría invasiva.

+ + +
+
+ +
diff --git a/src/app/components/info-modal/info-modal.component.scss b/src/app/components/info-modal/info-modal.component.scss new file mode 100644 index 0000000..dce64e5 --- /dev/null +++ b/src/app/components/info-modal/info-modal.component.scss @@ -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); } +} diff --git a/src/app/components/info-modal/info-modal.component.ts b/src/app/components/info-modal/info-modal.component.ts new file mode 100644 index 0000000..c15cd17 --- /dev/null +++ b/src/app/components/info-modal/info-modal.component.ts @@ -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(); + } +} diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index c48307b..aab2b82 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -1,19 +1,21 @@ -
- - - UNICABA - +
+
+ +
+ - - + + - - - - +
+ +
+ + +
@@ -37,69 +39,6 @@ - - - - - Información y Privacidad - - Cerrar - - - - -

¿Qué hace esta aplicación?

-

Aulas UNICABA es el visor especializado, independiente y offline-first 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.

- -

Nuevas Características y Funciones

-
    -
  • Cartelera Rápida y Offline: Consulta de materias organizada por días y cacheada al - instante. Muestra datos rápido aunque estés bajo tierra sin red.
  • -
  • Mi Carrera y Trackeo: Ingresá a tu Plan de Estudios interactivo. Usá los Action Sheets - nativos para marcar materias como 'Aprobadas' (Fucsia) o 'En Curso' - (Violeta), y gestioná tus Materias Optativas desde el catálogo - directamente con un clic.
  • -
  • Próxima Clase Inteligente: 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.
  • -
  • Alertas Automáticas: 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.
  • -
- -

Agenda de Hitos Académicos

-
    -
  • Accedé rápidamente para agendar Parciales / Trabajos Prácticos / Finales desde cualquier - materia para llevar un control estricto.
  • -
  • El robusto sistema de calendario nativo "Wheels" evitará borrados accidentales de tus fechas importantes. -
  • -
- -

Credencial Inmediata y Exportación

-
    -
  • Credencial Digital: 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.
  • -
  • Respaldo Portable Seguro: 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.
  • -
- -

Privacidad Zero-Trust

-

Sin Servidores Intermediarios: 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. -

- -

Tu ecosistema digital universitario, diseñado por y - para estudiantes.

- -

Versión 1.2.1

- -
-
-
- Plan de Estudios / Recorrido
- - -

Selecciona tu carrera para guardar tu avance y ver el recorrido sugerido, toca materias diff --git a/src/app/home/home.page.scss b/src/app/home/home.page.scss index 1b47669..8fcde53 100644 --- a/src/app/home/home.page.scss +++ b/src/app/home/home.page.scss @@ -29,20 +29,44 @@ } // Header styles -.header-content { +.header-container-flex { display: flex; 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 { - height: 36px; - width: auto; - background-color: white; + height: 38px; + background: white; padding: 4px; - border-radius: 5px; - position: absolute; - left: 10px; - z-index: 10; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + } +} + +.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; } } diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index fb344db..77e3455 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -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 { Subscription } from 'rxjs'; import { DataService, ClaseHorario, EventoAcademico } from '../services/data'; import { Preferences } from '@capacitor/preferences'; @@ -13,8 +14,9 @@ import { LocalStorageService } from '../services/local-storage.service'; import { EventosService } from '../services/eventos.service'; import { EventoModalComponent } from '../components/evento-modal/evento-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 { AuthService } from '../services/auth.service'; +import { AuthService, AuthState } from '../services/auth.service'; export interface ProximaClaseInfo { @@ -44,6 +46,9 @@ export class HomePage implements OnInit { errorMsg: string = ''; isInfoModalOpen: boolean = false; + authState: AuthState | null = null; + private authSub: Subscription | null = null; + proximaClase: ProximaClaseInfo | null = null; private countdownTimer: any = null; @@ -95,6 +100,10 @@ export class HomePage implements OnInit { async ngOnInit() { + this.authSub = this.authService.authState$.subscribe(state => { + this.authState = state; + }); + this.route.queryParams.subscribe(params => { this.highlightedCodigo = params['highlight'] || null; if (this.highlightedCodigo) { @@ -118,10 +127,8 @@ export class HomePage implements OnInit { this.favoritos = userData.favoritos || []; this.optativasSeleccionadas = userData.optativas || []; - // Crucial: Recargar los eventos al objeto local 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) { await this.autoCompletarDesdeSiu(); } @@ -173,8 +180,6 @@ export class HomePage implements OnInit { if (favPref.value && JSON.parse(favPref.value).length > 0) { this.favoritos = JSON.parse(favPref.value); this.viewMode = 'favoritos'; - } else { - await this.promptForRestore(); } this.eventos = await this.eventosService.loadEventos(); } @@ -191,6 +196,13 @@ export class HomePage implements OnInit { // 3. Fetch from Server 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) { @@ -245,46 +257,6 @@ export class HomePage implements OnInit { 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() { let baseList = this.horarios; @@ -581,6 +553,20 @@ export class HomePage implements OnInit { 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) --- @@ -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() { this.navCtrl.navigateForward('/perfil'); diff --git a/src/app/perfil/perfil.page.html b/src/app/perfil/perfil.page.html index 7e3ec99..03ec65c 100644 --- a/src/app/perfil/perfil.page.html +++ b/src/app/perfil/perfil.page.html @@ -4,6 +4,11 @@ Configuración de Perfil + + + + + @@ -34,7 +39,8 @@ -

Conectá con el SIU para traer tu historial y que la app auto-favoritee tus materias en curso.

+

Conectá con el SIU para traer tu historial y que la app auto-favoritee tus materias en + curso.

@@ -42,7 +48,8 @@ - + @@ -83,7 +90,8 @@ -

Vinculá tu cuenta del campus para generar tu credencial digital offline y habilitar alertas de actividades.

+

Vinculá tu cuenta del campus para generar tu credencial digital offline y habilitar alertas + de actividades.

@@ -91,7 +99,8 @@ - + @@ -119,9 +128,50 @@
+ + + +
+ + Mi Credencial Digital + +
+ Acceso rápido con QR y modo sin conexión +
+ +

Toca para abrir tu credencial. El brillo se ajustará automáticamente.

+
+
+ + + + +
+ + Datos y Respaldo +
+ Asegurá tu progreso académico +
+ +

Exportá tus materias favoritas y aprobadas para restaurarlas en otro dispositivo.

+
+ + + Guardar Respaldo + + + + Restaurar Datos + +
+
+
+ +
- -

Tus credenciales se almacenan de forma segura y local en tu dispositivo. No son enviadas a servidores externos ajenos a la universidad.

+ +

Tus credenciales y datos se almacenan de forma segura y local en el almacenamiento persistente de tu teléfono. +

- + \ No newline at end of file diff --git a/src/app/perfil/perfil.page.scss b/src/app/perfil/perfil.page.scss index a18e94e..eb2873b 100644 --- a/src/app/perfil/perfil.page.scss +++ b/src/app/perfil/perfil.page.scss @@ -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 { color: var(--ion-color-danger); font-size: 0.85rem; @@ -180,15 +245,13 @@ text-align: center; gap: 10px; opacity: 0.7; - ion-icon { font-size: 30px; } - p { font-size: 0.8rem; margin: 0; line-height: 1.4; - max-width: 250px; + max-width: 280px; } } diff --git a/src/app/perfil/perfil.page.ts b/src/app/perfil/perfil.page.ts index 40bffcc..8370d00 100644 --- a/src/app/perfil/perfil.page.ts +++ b/src/app/perfil/perfil.page.ts @@ -1,9 +1,14 @@ 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 { SiuService } from '../services/siu-guarani.service'; import { CredencialService } from '../services/credencial.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'; @Component({ @@ -32,9 +37,12 @@ export class PerfilPage implements OnInit, OnDestroy { private siuService: SiuService, private credencialService: CredencialService, private localStorageService: LocalStorageService, + private eventosService: EventosService, private toastCtrl: ToastController, private loadingCtrl: LoadingController, private alertCtrl: AlertController, + private actionSheetCtrl: ActionSheetController, + private modalCtrl: ModalController, private navCtrl: NavController ) { } @@ -115,16 +123,19 @@ export class PerfilPage implements OnInit, OnDestroy { await loader.present(); try { - // 1. Validar y generar credencial (descarga PDF) - await this.credencialService.generateCredential(this.moodleDni, this.moodlePass); - - // 2. Obtener Perfil (Nombre y Foto) - const profile = await this.credencialService.getUserProfile(this.moodleDni, this.moodlePass); - await this.authService.saveUserProfile(profile.name, profile.photo); + // setupMoodleAccount hace UNA sola autenticación y luego descarga la credencial + // 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 + ); - // 3. Guardar credenciales de acceso + await this.authService.saveUserProfile(result.profile.name, result.profile.photo); await this.authService.saveMoodleCredentials(this.moodleDni, this.moodlePass); - + this.showToast('¡Perfil sincronizado y Campus conectado!', 'success'); this.moodlePass = ''; } catch (e: any) { @@ -136,6 +147,7 @@ export class PerfilPage implements OnInit, OnDestroy { } } + async logoutMoodle() { const alert = await this.alertCtrl.create({ header: 'Cerrar Sesión en Campus', @@ -156,6 +168,121 @@ export class PerfilPage implements OnInit, OnDestroy { 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() { this.navCtrl.back(); } diff --git a/src/app/services/credencial.service.ts b/src/app/services/credencial.service.ts index f6fb8da..f51dfda 100644 --- a/src/app/services/credencial.service.ts +++ b/src/app/services/credencial.service.ts @@ -4,10 +4,6 @@ import { AuthService } from './auth.service'; import { Preferences } from '@capacitor/preferences'; 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({ providedIn: 'root' }) @@ -15,7 +11,7 @@ export class CredencialService { private readonly LOGIN_URL = 'https://campus.udelaciudad.edu.ar/login/index.php'; private readonly CREDENTIAL_URL = 'https://campus.udelaciudad.edu.ar/mod/customcert/view.php?id=209134&downloadown=1'; - + private readonly PREF_IMAGE = 'credencial_base64'; constructor(private authService: AuthService) {} @@ -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 -> Scrape Token -> Submit Login -> Download PDF -> Render Base64 + * Executes the full flow: Login -> Download PDF -> Render Base64 */ async generateCredential(dni: string, pass: string, onStatusChange?: (status: string) => void): Promise { try { 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...'); - // 3. Download the PDF + // Download the PDF, forwarding the authenticated session cookie 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' + '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)...'); - // 4. Render PDF to Base64 Image using pdf.js + // Render PDF to Base64 Image using pdf.js const imgBase64 = await this.renderPdfToBase64(pdfData); - // Save to cache securely + // Save to cache await Preferences.set({ key: this.PREF_IMAGE, value: imgBase64 }); if (onStatusChange) onStatusChange('¡Listo!'); @@ -75,89 +70,231 @@ 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}> { try { - await this.loginMoodle(dni, pass); - - const profileRes: HttpResponse = await CapacitorHttp.get({ - url: 'https://campus.udelaciudad.edu.ar/user/profile.php', - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - } - }); - - const parser = new DOMParser(); - const doc = parser.parseFromString(profileRes.data, 'text/html'); - - // 1. Extraer Nombre - const nameTag = doc.querySelector('.card-profile h3'); - const userName = nameTag ? nameTag.textContent?.trim() || '' : ''; - - // 2. Extraer Foto - const imgTag = doc.querySelector('.userpicture img') as HTMLImageElement; - let userPhotoBase64: string | null = null; - - if (imgTag && imgTag.src) { - try { - // Descargar la imagen y convertir a base64 - const photoRes: HttpResponse = await CapacitorHttp.get({ - url: imgTag.src, - responseType: 'arraybuffer' - }); - userPhotoBase64 = `data:image/jpeg;base64,${photoRes.data}`; - } catch (imgError) { - console.error('Error downloading profile photo:', imgError); - } - } - - return { name: userName, photo: userPhotoBase64 }; + const sessionCookie = await this.loginMoodle(dni, pass); + return this.fetchProfileWithSession(sessionCookie); } catch (e) { console.error('Error fetching user profile:', e); throw e; } } - private async loginMoodle(dni: string, pass: string): Promise { - // 1. Initial GET to fetch the login page and token - const initialGet: HttpResponse = await CapacitorHttp.get({ - url: this.LOGIN_URL, + /** + * 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({ + url: 'https://campus.udelaciudad.edu.ar/user/profile.php', 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 doc = parser.parseFromString(initialGet.data, 'text/html'); - const tokenInput = doc.querySelector('input[name="logintoken"]') as HTMLInputElement; - - if (!tokenInput || !tokenInput.value) { + const doc = parser.parseFromString(profileRes.data, 'text/html'); + + const nameTag = doc.querySelector('.card-profile h3'); + const userName = nameTag ? nameTag.textContent?.trim() || '' : ''; + + const imgTag = doc.querySelector('.userpicture img') as HTMLImageElement; + let userPhotoBase64: string | null = null; + + if (imgTag && imgTag.src) { + try { + const photoRes: HttpResponse = await CapacitorHttp.get({ + url: imgTag.src, + responseType: 'arraybuffer', + headers: { + ...(sessionCookie ? { 'Cookie': sessionCookie } : {}) + } + }); + userPhotoBase64 = `data:image/jpeg;base64,${photoRes.data}`; + } catch (imgError) { + console.error('Error downloading profile photo:', imgError); + } + } + + return { name: userName, photo: userPhotoBase64 }; + } + + + + /** + * 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 { + // 1. GET login page — get the logintoken and the pre-session cookie + const initialGet: HttpResponse = await CapacitorHttp.get({ + url: this.LOGIN_URL, + headers: { + '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 doc = parser.parseFromString(initialGet.data, 'text/html'); + 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 (!loginToken) { 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(); params.append('username', dni); params.append('password', pass); params.append('logintoken', loginToken); + const postHeaders: Record = { + '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({ url: this.LOGIN_URL, data: params.toString(), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - } + headers: postHeaders }); - // Verify if login was successful + // Verify login success 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/')) { - throw new Error('DNI o Clave incorrectos en el Campus.'); - } + if (!loginPost.data.includes('user/profile.php') && !loginPost.data.includes('my/')) { + 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 | 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 { @@ -171,22 +308,17 @@ export class CredencialService { } private async renderPdfToBase64(pdfData: Uint8Array): Promise { - // 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; pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl; const loadingTask = pdfjsLib.getDocument({ data: pdfData }); const pdf = await loadingTask.promise; - - // Get the first (and only) page + const page = await pdf.getPage(1); - - // Render at 2x scale for better resolution (similar to fitz.Matrix(2,2)) + const scale = 2.0; const viewport = page.getViewport({ scale }); - - // Create a canvas to render the PDF + const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); canvas.height = viewport.height; @@ -194,23 +326,17 @@ export class CredencialService { if (!context) throw new Error('Could not create Canvas context'); - // Render PDF page into canvas context const renderContext = { canvasContext: context, viewport: viewport, canvas: canvas }; - + 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 base64 = dataUri.split(',')[1]; - + return base64; } } diff --git a/src/main.ts b/src/main.ts index d8ea2c3..a1805f9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { AppModule } from './app/app.module'; diff --git a/test_login.py b/test_login.py new file mode 100644 index 0000000..56e0949 --- /dev/null +++ b/test_login.py @@ -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])