From 7af2d86a171183ae75384db83d1d1401018a661a Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:45:31 -0300 Subject: [PATCH 1/3] feat: start SOMENTE Android music learning app --- .github/workflows/somente-android.yml | 24 ++++ SomenteAndroid/PLAY_STORE.md | 31 +++++ SomenteAndroid/PRIVACY.md | 9 ++ SomenteAndroid/README.md | 37 ++++++ SomenteAndroid/app/build.gradle.kts | 41 ++++++ SomenteAndroid/app/proguard-rules.pro | 1 + .../app/src/main/AndroidManifest.xml | 24 ++++ .../br/com/sidineyr/somente/AdController.kt | 29 +++++ .../java/br/com/sidineyr/somente/AdPolicy.kt | 23 ++++ .../br/com/sidineyr/somente/Curriculum.kt | 25 ++++ .../br/com/sidineyr/somente/MainActivity.kt | 121 ++++++++++++++++++ .../br/com/sidineyr/somente/TonePlayer.kt | 46 +++++++ .../app/src/main/res/values/styles.xml | 10 ++ .../br/com/sidineyr/somente/AdPolicyTest.kt | 24 ++++ .../br/com/sidineyr/somente/CurriculumTest.kt | 17 +++ SomenteAndroid/build.gradle.kts | 4 + SomenteAndroid/gradle.properties | 3 + SomenteAndroid/settings.gradle.kts | 7 + 18 files changed, 476 insertions(+) create mode 100644 .github/workflows/somente-android.yml create mode 100644 SomenteAndroid/PLAY_STORE.md create mode 100644 SomenteAndroid/PRIVACY.md create mode 100644 SomenteAndroid/README.md create mode 100644 SomenteAndroid/app/build.gradle.kts create mode 100644 SomenteAndroid/app/proguard-rules.pro create mode 100644 SomenteAndroid/app/src/main/AndroidManifest.xml create mode 100644 SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/AdController.kt create mode 100644 SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/AdPolicy.kt create mode 100644 SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/Curriculum.kt create mode 100644 SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/MainActivity.kt create mode 100644 SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/TonePlayer.kt create mode 100644 SomenteAndroid/app/src/main/res/values/styles.xml create mode 100644 SomenteAndroid/app/src/test/java/br/com/sidineyr/somente/AdPolicyTest.kt create mode 100644 SomenteAndroid/app/src/test/java/br/com/sidineyr/somente/CurriculumTest.kt create mode 100644 SomenteAndroid/build.gradle.kts create mode 100644 SomenteAndroid/gradle.properties create mode 100644 SomenteAndroid/settings.gradle.kts diff --git a/.github/workflows/somente-android.yml b/.github/workflows/somente-android.yml new file mode 100644 index 0000000..0801f7f --- /dev/null +++ b/.github/workflows/somente-android.yml @@ -0,0 +1,24 @@ +name: SOMENTE Android + +on: + push: + paths: ["SomenteAndroid/**", ".github/workflows/somente-android.yml"] + pull_request: + paths: ["SomenteAndroid/**", ".github/workflows/somente-android.yml"] + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: SomenteAndroid + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: "8.14.3" + - run: gradle testDebugUnitTest lintDebug assembleDebug --stacktrace diff --git a/SomenteAndroid/PLAY_STORE.md b/SomenteAndroid/PLAY_STORE.md new file mode 100644 index 0000000..fa02571 --- /dev/null +++ b/SomenteAndroid/PLAY_STORE.md @@ -0,0 +1,31 @@ +# Preparação para Google Play + +## Nome + +SOMENTE — aprenda música ouvindo + +## Descrição curta + +Treine ouvido, ritmo e notas em um percurso musical humano e gradual. + +## Descrição completa + +SOMENTE une som e mente para ensinar música do jeito como ela começa: ouvindo. Você aprende a distinguir som e silêncio, comparar sons, reconhecer grave e agudo, perceber intensidade, acompanhar pulsos, imitar ritmos e então conhecer notas e melodias. + +Cada atividade é breve, repetível e acolhe o erro como parte da aprendizagem. Não é necessário saber ler partitura nem possuir instrumento. Os sons são criados no próprio aparelho e o progresso fica salvo localmente. + +O projeto nasce de uma perspectiva pedagógica: conceito musical vem depois da experiência sonora. A publicidade, quando ativada, aparece somente em transições naturais após aulas concluídas, com limites rígidos para preservar atenção e aprendizagem. + +## Checklist antes de enviar + +- [ ] confirmar disponibilidade do nome e do pacote; +- [ ] criar conta/registro no Play Console e verificar identidade; +- [ ] gerar chave de upload e AAB assinado; +- [ ] configurar consentimento e IDs AdMob de produção; +- [ ] declarar corretamente público-alvo, anúncios e segurança de dados; +- [ ] publicar política de privacidade em URL pública; +- [ ] criar ícone 512 × 512, feature graphic 1024 × 500 e capturas; +- [ ] concluir classificação indicativa; +- [ ] executar testes internos e fechados exigidos para a conta; +- [ ] corrigir relatório de pré-lançamento; +- [ ] enviar primeiro para produção gradual. diff --git a/SomenteAndroid/PRIVACY.md b/SomenteAndroid/PRIVACY.md new file mode 100644 index 0000000..bcd53e2 --- /dev/null +++ b/SomenteAndroid/PRIVACY.md @@ -0,0 +1,9 @@ +# Política de Privacidade — SOMENTE (rascunho) + +Última atualização: 26 de agosto de 2026. + +O SOMENTE salva no próprio dispositivo apenas o progresso das aulas e os controles de frequência de publicidade. A versão básica não exige conta e não solicita microfone, câmera, contatos, localização ou arquivos pessoais. + +Se a publicidade for ativada em uma versão pública, o Google Mobile Ads poderá processar identificadores e dados técnicos conforme o consentimento, a idade declarada e as configurações do aparelho. O aplicativo deverá oferecer anúncios não personalizados quando exigido e respeitar a recusa do usuário. A política definitiva deverá identificar o responsável pelo tratamento, meio de contato, base legal, retenção, direitos do titular e links para os fornecedores utilizados. + +Este texto é um rascunho técnico e não deve ser publicado como política final sem preencher os dados do desenvolvedor e conferir as declarações da versão efetivamente distribuída. diff --git a/SomenteAndroid/README.md b/SomenteAndroid/README.md new file mode 100644 index 0000000..534020a --- /dev/null +++ b/SomenteAndroid/README.md @@ -0,0 +1,37 @@ +# SOMENTE Android + +**SOMENTE** (som + mente) é um aplicativo pedagógico para aprender música pela escuta, pela ação e pela criação. O primeiro percurso evita começar pela memorização abstrata: antes de apresentar nomes de notas, trabalha som, silêncio, comparação, altura, intensidade, pulso e ritmo. + +## Percurso v0.1 + +1. som e silêncio; +2. sons iguais e diferentes; +3. grave e agudo; +4. forte e fraco; +5. pulso regular; +6. imitação rítmica; +7. primeira nota — Dó; +8. direção melódica. + +O aplicativo sintetiza os sons no próprio aparelho e salva o progresso localmente. Não exige conta, microfone, localização ou acesso aos arquivos. + +## Publicidade com limite pedagógico + +O build público mantém anúncios desativados por padrão. Quando houver IDs de produção e consentimento configurados, a política permite intersticial apenas após uma aula concluída, nunca na primeira sessão, no máximo a cada quatro conclusões, com 12 minutos de intervalo e limite de três por dia. Áudio e exercício em andamento bloqueiam qualquer exibição. + +Durante desenvolvimento, use somente os IDs de teste já incluídos. Antes do lançamento, implemente o fluxo de consentimento UMP, defina corretamente a faixa etária e substitua os IDs por configuração privada de release. Para público infantil ou misto, use exclusivamente anúncios não personalizados e SDKs certificados para famílias. + +## Executar + +Abra `SomenteAndroid` no Android Studio, aguarde a sincronização e execute `app` em Android 8.0 ou superior. + +```bash +./gradlew test +./gradlew bundleRelease +``` + +O pacote planejado é `br.com.sidineyr.somente`, alvo Android 16/API 36 e versão inicial `0.1.0`. + +## Limites da v0.1 + +Esta versão é um alicerce funcional, ainda não um curso completo. Antes da produção serão necessários: revisão por usuários, validação completa com TalkBack, consentimento, assinatura do AAB, ícone, capturas de tela, ficha de segurança de dados e teste fechado exigido pela conta Play Console. diff --git a/SomenteAndroid/app/build.gradle.kts b/SomenteAndroid/app/build.gradle.kts new file mode 100644 index 0000000..1df7a9d --- /dev/null +++ b/SomenteAndroid/app/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "br.com.sidineyr.somente" + compileSdk = 36 + + defaultConfig { + applicationId = "br.com.sidineyr.somente" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("boolean", "ADS_ENABLED", "false") + buildConfigField("String", "ADMOB_INTERSTITIAL_ID", "\"ca-app-pub-3940256099942544/1033173712\"") + } + + buildFeatures { buildConfig = true } + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } +} + +dependencies { + implementation("androidx.core:core-ktx:1.16.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("com.google.android.material:material:1.12.0") + implementation("com.google.android.gms:play-services-ads:24.4.0") + testImplementation("junit:junit:4.13.2") +} diff --git a/SomenteAndroid/app/proguard-rules.pro b/SomenteAndroid/app/proguard-rules.pro new file mode 100644 index 0000000..547d2eb --- /dev/null +++ b/SomenteAndroid/app/proguard-rules.pro @@ -0,0 +1 @@ +# Rules are intentionally minimal; Google Mobile Ads publishes consumer rules. diff --git a/SomenteAndroid/app/src/main/AndroidManifest.xml b/SomenteAndroid/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..fd51ed2 --- /dev/null +++ b/SomenteAndroid/app/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + diff --git a/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/AdController.kt b/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/AdController.kt new file mode 100644 index 0000000..54cb861 --- /dev/null +++ b/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/AdController.kt @@ -0,0 +1,29 @@ +package br.com.sidineyr.somente + +import android.app.Activity +import com.google.android.gms.ads.AdRequest +import com.google.android.gms.ads.FullScreenContentCallback +import com.google.android.gms.ads.interstitial.InterstitialAd +import com.google.android.gms.ads.interstitial.InterstitialAdLoadCallback + +class AdController(private val activity: Activity) { + private var loaded: InterstitialAd? = null + + fun preload() { + if (!BuildConfig.ADS_ENABLED || loaded != null) return + InterstitialAd.load(activity, BuildConfig.ADMOB_INTERSTITIAL_ID, AdRequest.Builder().build(), + object : InterstitialAdLoadCallback() { + override fun onAdLoaded(ad: InterstitialAd) { loaded = ad } + }) + } + + fun showIfReady(onClosed: () -> Unit): Boolean { + val ad = loaded ?: return false + loaded = null + ad.fullScreenContentCallback = object : FullScreenContentCallback() { + override fun onAdDismissedFullScreenContent() { onClosed(); preload() } + } + ad.show(activity) + return true + } +} diff --git a/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/AdPolicy.kt b/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/AdPolicy.kt new file mode 100644 index 0000000..3adac27 --- /dev/null +++ b/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/AdPolicy.kt @@ -0,0 +1,23 @@ +package br.com.sidineyr.somente + +data class AdState( + val completedLessons: Int, + val adsToday: Int, + val lastAdAtMs: Long, + val isFirstSession: Boolean, + val audioPlaying: Boolean +) + +object AdPolicy { + const val LESSON_INTERVAL = 4 + const val MIN_INTERVAL_MS = 12 * 60 * 1000L + const val DAILY_LIMIT = 3 + + fun canShowAfterLesson(state: AdState, nowMs: Long): Boolean = + !state.isFirstSession && + !state.audioPlaying && + state.completedLessons > 0 && + state.completedLessons % LESSON_INTERVAL == 0 && + state.adsToday < DAILY_LIMIT && + nowMs - state.lastAdAtMs >= MIN_INTERVAL_MS +} diff --git a/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/Curriculum.kt b/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/Curriculum.kt new file mode 100644 index 0000000..8dfc5ae --- /dev/null +++ b/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/Curriculum.kt @@ -0,0 +1,25 @@ +package br.com.sidineyr.somente + +enum class ExerciseKind { SOUND_OR_SILENCE, SAME_OR_DIFFERENT, HIGH_OR_LOW, LOUD_OR_SOFT, PULSE, RHYTHM, NOTE, MELODY } + +data class Lesson( + val id: String, + val title: String, + val objective: String, + val prompt: String, + val kind: ExerciseKind, + val choices: List +) + +object Curriculum { + val lessons = listOf( + Lesson("escuta-1", "Som e silêncio", "Perceber quando existe vibração audível.", "Você ouviu um som?", ExerciseKind.SOUND_OR_SILENCE, listOf("Som", "Silêncio")), + Lesson("escuta-2", "Igual ou diferente?", "Comparar dois eventos sonoros sem exigir nomes técnicos.", "Os dois sons foram iguais?", ExerciseKind.SAME_OR_DIFFERENT, listOf("Iguais", "Diferentes")), + Lesson("altura-1", "Grave e agudo", "Reconhecer altura antes de conhecer nomes de notas.", "O som foi grave ou agudo?", ExerciseKind.HIGH_OR_LOW, listOf("Grave", "Agudo")), + Lesson("intensidade-1", "Forte e fraco", "Distinguir intensidade de altura.", "O som foi forte ou fraco?", ExerciseKind.LOUD_OR_SOFT, listOf("Forte", "Fraco")), + Lesson("ritmo-1", "Encontre o pulso", "Sentir uma sequência regular de batidas.", "Toque junto com o pulso quatro vezes.", ExerciseKind.PULSE, listOf("Marcar pulso")), + Lesson("ritmo-2", "Imite o ritmo", "Perceber que ritmo combina durações e pausas.", "Ouça e repita o desenho rítmico.", ExerciseKind.RHYTHM, listOf("Repetir ritmo")), + Lesson("notas-1", "Uma nota tem nome", "Relacionar uma altura já percebida ao nome Dó.", "Esta nota é Dó. Ouça e reconheça.", ExerciseKind.NOTE, listOf("Ouvir Dó", "Reconheci")), + Lesson("melodia-1", "Notas em caminho", "Compreender melodia como sequência intencional de alturas e durações.", "A sequência subiu ou desceu?", ExerciseKind.MELODY, listOf("Subiu", "Desceu")) + ) +} diff --git a/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/MainActivity.kt b/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/MainActivity.kt new file mode 100644 index 0000000..637b31f --- /dev/null +++ b/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/MainActivity.kt @@ -0,0 +1,121 @@ +package br.com.sidineyr.somente + +import android.graphics.Color +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.ScrollView +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import kotlin.random.Random + +class MainActivity : AppCompatActivity() { + private val player = TonePlayer() + private lateinit var ads: AdController + private lateinit var root: LinearLayout + private val prefs by lazy { getSharedPreferences("progress", MODE_PRIVATE) } + private var lessonIndex = 0 + private var expected = "" + private var firstSession = true + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ads = AdController(this) + firstSession = !prefs.getBoolean("has_finished_session", false) + lessonIndex = prefs.getInt("lesson", 0).coerceIn(0, Curriculum.lessons.lastIndex) + root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + setPadding(40, 52, 40, 40) + setBackgroundColor(Color.rgb(23, 21, 42)) + } + setContentView(ScrollView(this).apply { addView(root) }) + showLesson() + ads.preload() + } + + private fun showLesson(message: String? = null) { + root.removeAllViews() + val lesson = Curriculum.lessons[lessonIndex] + root.addView(text("SOMENTE", 30, Color.rgb(247, 211, 91))) + root.addView(text("Som + mente: aprender música escutando", 15, Color.LTGRAY)) + root.addView(ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply { + max = Curriculum.lessons.size + progress = lessonIndex + 1 + layoutParams = LinearLayout.LayoutParams(-1, 18).also { it.setMargins(0, 36, 0, 36) } + }) + root.addView(text("${lessonIndex + 1}. ${lesson.title}", 26, Color.WHITE)) + root.addView(text(lesson.objective, 17, Color.LTGRAY).withMargins(0, 16, 0, 28)) + root.addView(text(lesson.prompt, 20, Color.WHITE).withMargins(0, 8, 0, 28)) + button("▶ Ouvir exemplo") { presentExercise(lesson) } + lesson.choices.forEach { choice -> button(choice) { answer(lesson, choice) } } + message?.let { root.addView(text(it, 17, Color.rgb(247, 211, 91)).withMargins(0, 24, 0, 0)) } + root.addView(text("Sem pressa. Você pode ouvir quantas vezes precisar.", 14, Color.GRAY).withMargins(0, 38, 0, 0)) + } + + private fun presentExercise(lesson: Lesson) { + if (player.isPlaying()) return + val (notes, answer, volume) = when (lesson.kind) { + ExerciseKind.SOUND_OR_SILENCE -> if (Random.nextBoolean()) Triple(listOf(440.0), "Som", .42) else Triple(emptyList(), "Silêncio", .0) + ExerciseKind.SAME_OR_DIFFERENT -> if (Random.nextBoolean()) Triple(listOf(330.0, 330.0), "Iguais", .42) else Triple(listOf(330.0, 494.0), "Diferentes", .42) + ExerciseKind.HIGH_OR_LOW -> if (Random.nextBoolean()) Triple(listOf(220.0), "Grave", .45) else Triple(listOf(880.0), "Agudo", .40) + ExerciseKind.LOUD_OR_SOFT -> if (Random.nextBoolean()) Triple(listOf(440.0), "Forte", .72) else Triple(listOf(440.0), "Fraco", .18) + ExerciseKind.PULSE -> Triple(listOf(440.0, 440.0, 440.0, 440.0), "Marcar pulso", .42) + ExerciseKind.RHYTHM -> Triple(listOf(392.0, 392.0, 523.25), "Repetir ritmo", .42) + ExerciseKind.NOTE -> Triple(listOf(261.63), "Reconheci", .42) + ExerciseKind.MELODY -> if (Random.nextBoolean()) Triple(listOf(261.63, 329.63, 392.0), "Subiu", .42) else Triple(listOf(392.0, 329.63, 261.63), "Desceu", .42) + } + expected = answer + if (notes.isEmpty()) window.decorView.postDelayed({ expected = answer }, 650) else player.play(notes, volume) + } + + private fun answer(lesson: Lesson, choice: String) { + if (expected.isBlank()) { showLesson("Primeiro ouça o exemplo."); return } + if (choice != expected && lesson.kind !in setOf(ExerciseKind.PULSE, ExerciseKind.RHYTHM, ExerciseKind.NOTE)) { + showLesson("Ainda não. Ouça novamente e compare — errar também treina o ouvido.") + return + } + val completed = prefs.getInt("completed", 0) + 1 + val next = (lessonIndex + 1).coerceAtMost(Curriculum.lessons.lastIndex) + prefs.edit().putInt("completed", completed).putInt("lesson", next).apply() + lessonIndex = next + expected = "" + val now = System.currentTimeMillis() + val today = java.time.LocalDate.now().toString() + val adsToday = if (prefs.getString("ad_day", "") == today) prefs.getInt("ads_today", 0) else 0 + if (adsToday == 0) prefs.edit().putString("ad_day", today).putInt("ads_today", 0).apply() + val state = AdState(completed, adsToday, prefs.getLong("last_ad", 0), firstSession, player.isPlaying()) + if (AdPolicy.canShowAfterLesson(state, now) && ads.showIfReady { + prefs.edit().putInt("ads_today", state.adsToday + 1).putLong("last_ad", now).apply() + showLesson("Muito bem! Vamos ao próximo passo.") + }) return + showLesson("Muito bem! Vamos ao próximo passo.") + } + + override fun onStop() { + prefs.edit().putBoolean("has_finished_session", true).apply() + super.onStop() + } + + private fun button(label: String, action: () -> Unit) { + root.addView(Button(this).apply { + text = label + isAllCaps = false + textSize = 17f + setOnClickListener { action() } + layoutParams = LinearLayout.LayoutParams(-1, -2).also { it.setMargins(0, 8, 0, 8) } + }) + } + + private fun text(value: String, size: Int, color: Int) = TextView(this).apply { + text = value; textSize = size.toFloat(); setTextColor(color); gravity = Gravity.CENTER + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + } + + private fun View.withMargins(l: Int, t: Int, r: Int, b: Int): View = apply { + layoutParams = LinearLayout.LayoutParams(-1, -2).also { it.setMargins(l, t, r, b) } + } +} diff --git a/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/TonePlayer.kt b/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/TonePlayer.kt new file mode 100644 index 0000000..78dcb83 --- /dev/null +++ b/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/TonePlayer.kt @@ -0,0 +1,46 @@ +package br.com.sidineyr.somente + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioTrack +import kotlin.concurrent.thread +import kotlin.math.PI +import kotlin.math.sin + +class TonePlayer { + @Volatile private var playing = false + + fun play(frequencies: List, volume: Double = 0.45, noteMs: Int = 420, onDone: () -> Unit = {}) { + if (playing) return + playing = true + thread(name = "somente-tone") { + try { + frequencies.forEach { playOne(it, volume, noteMs) } + } finally { + playing = false + onDone() + } + } + } + + fun isPlaying() = playing + + private fun playOne(frequency: Double, volume: Double, durationMs: Int) { + val sampleRate = 44_100 + val count = sampleRate * durationMs / 1000 + val samples = ShortArray(count) { index -> + val fade = minOf(1.0, index / 500.0, (count - index) / 500.0) + (sin(2.0 * PI * index * frequency / sampleRate) * Short.MAX_VALUE * volume * fade).toInt().toShort() + } + val track = AudioTrack.Builder() + .setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build()) + .setAudioFormat(AudioFormat.Builder().setSampleRate(sampleRate).setEncoding(AudioFormat.ENCODING_PCM_16BIT).setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build()) + .setBufferSizeInBytes(samples.size * 2) + .setTransferMode(AudioTrack.MODE_STATIC) + .build() + track.write(samples, 0, samples.size) + track.play() + Thread.sleep(durationMs.toLong() + 30) + track.release() + } +} diff --git a/SomenteAndroid/app/src/main/res/values/styles.xml b/SomenteAndroid/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..ad70b14 --- /dev/null +++ b/SomenteAndroid/app/src/main/res/values/styles.xml @@ -0,0 +1,10 @@ + + + + diff --git a/SomenteAndroid/app/src/test/java/br/com/sidineyr/somente/AdPolicyTest.kt b/SomenteAndroid/app/src/test/java/br/com/sidineyr/somente/AdPolicyTest.kt new file mode 100644 index 0000000..b18c3ba --- /dev/null +++ b/SomenteAndroid/app/src/test/java/br/com/sidineyr/somente/AdPolicyTest.kt @@ -0,0 +1,24 @@ +package br.com.sidineyr.somente + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AdPolicyTest { + private val now = 1_000_000L + + @Test fun `never interrupts first session or audio`() { + assertFalse(AdPolicy.canShowAfterLesson(AdState(4, 0, 0, true, false), now)) + assertFalse(AdPolicy.canShowAfterLesson(AdState(4, 0, 0, false, true), now)) + } + + @Test fun `only shows at fourth completion after cooldown`() { + assertFalse(AdPolicy.canShowAfterLesson(AdState(3, 0, 0, false, false), now)) + assertTrue(AdPolicy.canShowAfterLesson(AdState(4, 0, 0, false, false), now)) + } + + @Test fun `respects cooldown and daily limit`() { + assertFalse(AdPolicy.canShowAfterLesson(AdState(8, 0, now - 1_000, false, false), now)) + assertFalse(AdPolicy.canShowAfterLesson(AdState(8, 3, 0, false, false), now)) + } +} diff --git a/SomenteAndroid/app/src/test/java/br/com/sidineyr/somente/CurriculumTest.kt b/SomenteAndroid/app/src/test/java/br/com/sidineyr/somente/CurriculumTest.kt new file mode 100644 index 0000000..bcc9d3d --- /dev/null +++ b/SomenteAndroid/app/src/test/java/br/com/sidineyr/somente/CurriculumTest.kt @@ -0,0 +1,17 @@ +package br.com.sidineyr.somente + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CurriculumTest { + @Test fun `listening precedes notation`() { + val kinds = Curriculum.lessons.map { it.kind } + assertTrue(kinds.indexOf(ExerciseKind.HIGH_OR_LOW) < kinds.indexOf(ExerciseKind.NOTE)) + assertTrue(kinds.indexOf(ExerciseKind.PULSE) < kinds.indexOf(ExerciseKind.MELODY)) + } + + @Test fun `lesson ids are unique`() { + assertEquals(Curriculum.lessons.size, Curriculum.lessons.map { it.id }.toSet().size) + } +} diff --git a/SomenteAndroid/build.gradle.kts b/SomenteAndroid/build.gradle.kts new file mode 100644 index 0000000..5530762 --- /dev/null +++ b/SomenteAndroid/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.10.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.20" apply false +} diff --git a/SomenteAndroid/gradle.properties b/SomenteAndroid/gradle.properties new file mode 100644 index 0000000..e696167 --- /dev/null +++ b/SomenteAndroid/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official diff --git a/SomenteAndroid/settings.gradle.kts b/SomenteAndroid/settings.gradle.kts new file mode 100644 index 0000000..5584c39 --- /dev/null +++ b/SomenteAndroid/settings.gradle.kts @@ -0,0 +1,7 @@ +pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { google(); mavenCentral() } +} +rootProject.name = "SOMENTE" +include(":app") From be6f50c9b2dac180d4c2b22a5b53705f97611289 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:29:24 -0300 Subject: [PATCH 2/3] feat: make SOMENTE a usable music-learning MVP --- .github/workflows/python-app.yml | 37 ++-- .github/workflows/somente-android.yml | 3 + SomenteAndroid/PLAY_STORE.md | 23 +-- SomenteAndroid/PRIVACY.md | 8 +- SomenteAndroid/README.md | 39 ++-- SomenteAndroid/app/build.gradle.kts | 4 - .../app/src/main/AndroidManifest.xml | 6 - .../br/com/sidineyr/somente/AdController.kt | 29 --- .../java/br/com/sidineyr/somente/AdPolicy.kt | 23 --- .../br/com/sidineyr/somente/Curriculum.kt | 25 --- .../br/com/sidineyr/somente/MainActivity.kt | 190 +++++++++++------- .../java/br/com/sidineyr/somente/Music.kt | 26 +++ .../java/br/com/sidineyr/somente/StaffView.kt | 44 ++++ .../br/com/sidineyr/somente/TonePlayer.kt | 23 +-- .../br/com/sidineyr/somente/AdPolicyTest.kt | 24 --- .../br/com/sidineyr/somente/CurriculumTest.kt | 17 -- .../java/br/com/sidineyr/somente/MusicTest.kt | 24 +++ 17 files changed, 261 insertions(+), 284 deletions(-) delete mode 100644 SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/AdController.kt delete mode 100644 SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/AdPolicy.kt delete mode 100644 SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/Curriculum.kt create mode 100644 SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/Music.kt create mode 100644 SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/StaffView.kt delete mode 100644 SomenteAndroid/app/src/test/java/br/com/sidineyr/somente/AdPolicyTest.kt delete mode 100644 SomenteAndroid/app/src/test/java/br/com/sidineyr/somente/CurriculumTest.kt create mode 100644 SomenteAndroid/app/src/test/java/br/com/sidineyr/somente/MusicTest.kt diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 200872c..75ba08a 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -1,39 +1,28 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - +# Python checks are intentionally isolated from the native Android project. name: Python application on: push: branches: [ "master" ] + paths-ignore: ["SomenteAndroid/**", ".github/workflows/somente-android.yml"] pull_request: branches: [ "master" ] + paths-ignore: ["SomenteAndroid/**", ".github/workflows/somente-android.yml"] permissions: contents: read jobs: build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - pytest + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + - run: pytest diff --git a/.github/workflows/somente-android.yml b/.github/workflows/somente-android.yml index 0801f7f..a70c65f 100644 --- a/.github/workflows/somente-android.yml +++ b/.github/workflows/somente-android.yml @@ -6,6 +6,9 @@ on: pull_request: paths: ["SomenteAndroid/**", ".github/workflows/somente-android.yml"] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/SomenteAndroid/PLAY_STORE.md b/SomenteAndroid/PLAY_STORE.md index fa02571..4317404 100644 --- a/SomenteAndroid/PLAY_STORE.md +++ b/SomenteAndroid/PLAY_STORE.md @@ -2,30 +2,25 @@ ## Nome -SOMENTE — aprenda música ouvindo +SOMENTE — ouvir, ler e criar música ## Descrição curta -Treine ouvido, ritmo e notas em um percurso musical humano e gradual. +Treino auditivo, pauta sincronizada e criação de exercícios musicais. ## Descrição completa -SOMENTE une som e mente para ensinar música do jeito como ela começa: ouvindo. Você aprende a distinguir som e silêncio, comparar sons, reconhecer grave e agudo, perceber intensidade, acompanhar pulsos, imitar ritmos e então conhecer notas e melodias. +SOMENTE une som e mente em três experiências simples para iniciantes de qualquer idade. Ouça uma nota e tente reconhecê-la; acompanhe pequenas sequências na pauta enquanto elas tocam; ou crie um exercício de até 16 notas para estudar e usar em aula. -Cada atividade é breve, repetível e acolhe o erro como parte da aprendizagem. Não é necessário saber ler partitura nem possuir instrumento. Os sons são criados no próprio aparelho e o progresso fica salvo localmente. - -O projeto nasce de uma perspectiva pedagógica: conceito musical vem depois da experiência sonora. A publicidade, quando ativada, aparece somente em transições naturais após aulas concluídas, com limites rígidos para preservar atenção e aprendizagem. +Não é necessário saber ler partitura nem possuir instrumento. O aplicativo funciona offline, sem conta e sem anúncios. O progresso e o exercício criado ficam somente no aparelho. ## Checklist antes de enviar - [ ] confirmar disponibilidade do nome e do pacote; -- [ ] criar conta/registro no Play Console e verificar identidade; - [ ] gerar chave de upload e AAB assinado; -- [ ] configurar consentimento e IDs AdMob de produção; -- [ ] declarar corretamente público-alvo, anúncios e segurança de dados; -- [ ] publicar política de privacidade em URL pública; +- [ ] identificar o responsável e publicar a política de privacidade; +- [ ] declarar corretamente público-alvo e segurança de dados; - [ ] criar ícone 512 × 512, feature graphic 1024 × 500 e capturas; -- [ ] concluir classificação indicativa; -- [ ] executar testes internos e fechados exigidos para a conta; -- [ ] corrigir relatório de pré-lançamento; -- [ ] enviar primeiro para produção gradual. +- [ ] testar em telefone e tablet, com TalkBack e tamanhos de fonte maiores; +- [ ] concluir classificação indicativa e teste fechado; +- [ ] corrigir o relatório de pré-lançamento. diff --git a/SomenteAndroid/PRIVACY.md b/SomenteAndroid/PRIVACY.md index bcd53e2..310e67f 100644 --- a/SomenteAndroid/PRIVACY.md +++ b/SomenteAndroid/PRIVACY.md @@ -1,9 +1,9 @@ -# Política de Privacidade — SOMENTE (rascunho) +# Política de Privacidade — SOMENTE v0.1 Última atualização: 26 de agosto de 2026. -O SOMENTE salva no próprio dispositivo apenas o progresso das aulas e os controles de frequência de publicidade. A versão básica não exige conta e não solicita microfone, câmera, contatos, localização ou arquivos pessoais. +O SOMENTE v0.1 funciona sem conta e sem conexão com a internet. Não contém publicidade nem ferramentas de análise e não solicita acesso ao microfone, câmera, contatos, localização, identificador de anúncios ou arquivos pessoais. -Se a publicidade for ativada em uma versão pública, o Google Mobile Ads poderá processar identificadores e dados técnicos conforme o consentimento, a idade declarada e as configurações do aparelho. O aplicativo deverá oferecer anúncios não personalizados quando exigido e respeitar a recusa do usuário. A política definitiva deverá identificar o responsável pelo tratamento, meio de contato, base legal, retenção, direitos do titular e links para os fornecedores utilizados. +O aplicativo armazena no próprio aparelho apenas a contagem de respostas corretas e uma sequência musical criada pelo usuário. Esses dados não são enviados ao desenvolvedor e podem ser apagados ao limpar os dados do aplicativo ou desinstalá-lo. -Este texto é um rascunho técnico e não deve ser publicado como política final sem preencher os dados do desenvolvedor e conferir as declarações da versão efetivamente distribuída. +Antes de publicação pública, esta política deverá incluir a identificação e o contato do responsável pelo aplicativo e corresponder exatamente ao pacote distribuído. diff --git a/SomenteAndroid/README.md b/SomenteAndroid/README.md index 534020a..6c833cb 100644 --- a/SomenteAndroid/README.md +++ b/SomenteAndroid/README.md @@ -1,37 +1,32 @@ # SOMENTE Android -**SOMENTE** (som + mente) é um aplicativo pedagógico para aprender música pela escuta, pela ação e pela criação. O primeiro percurso evita começar pela memorização abstrata: antes de apresentar nomes de notas, trabalha som, silêncio, comparação, altura, intensidade, pulso e ritmo. +**SOMENTE** (som + mente) é um laboratório pedagógico gratuito para iniciantes de qualquer idade. A experiência reúne escuta, leitura e criação sem exigir conta, internet, anúncios, microfone ou conhecimento prévio de partitura. -## Percurso v0.1 +## Três modos de aula -1. som e silêncio; -2. sons iguais e diferentes; -3. grave e agudo; -4. forte e fraco; -5. pulso regular; -6. imitação rítmica; -7. primeira nota — Dó; -8. direção melódica. +1. **Escutar e reconhecer** — o estudante ouve uma nota, escolhe seu nome e vê a resposta na pauta. +2. **Ler e ouvir** — uma sequência curta é reproduzida enquanto a nota atual é destacada. +3. **Criar exercício** — professor ou estudante monta até 16 notas, ouve o resultado e salva a atividade localmente. -O aplicativo sintetiza os sons no próprio aparelho e salva o progresso localmente. Não exige conta, microfone, localização ou acesso aos arquivos. - -## Publicidade com limite pedagógico - -O build público mantém anúncios desativados por padrão. Quando houver IDs de produção e consentimento configurados, a política permite intersticial apenas após uma aula concluída, nunca na primeira sessão, no máximo a cada quatro conclusões, com 12 minutos de intervalo e limite de três por dia. Áudio e exercício em andamento bloqueiam qualquer exibição. - -Durante desenvolvimento, use somente os IDs de teste já incluídos. Antes do lançamento, implemente o fluxo de consentimento UMP, defina corretamente a faixa etária e substitua os IDs por configuração privada de release. Para público infantil ou misto, use exclusivamente anúncios não personalizados e SDKs certificados para famílias. +O desenho se inspira na clareza de aplicativos de partitura para Android, mas segue uma progressão própria: ouvir → reconhecer → visualizar → criar. Os exemplos são originais e não incluem repertório protegido. ## Executar -Abra `SomenteAndroid` no Android Studio, aguarde a sincronização e execute `app` em Android 8.0 ou superior. +Abra a pasta `SomenteAndroid` no Android Studio, aguarde a sincronização do Gradle e execute o módulo `app` em Android 8.0 ou superior. Na linha de comando, com Gradle 8.14.3 instalado: ```bash -./gradlew test -./gradlew bundleRelease +gradle testDebugUnitTest lintDebug assembleDebug ``` -O pacote planejado é `br.com.sidineyr.somente`, alvo Android 16/API 36 e versão inicial `0.1.0`. +O pacote é `br.com.sidineyr.somente`, com alvo Android 16/API 36 e versão `0.1.0`. + +## Uso responsável + +- use volume confortável, especialmente com fones; +- o nome das notas segue a nomenclatura em português; +- o exercício salvo permanece apenas neste aparelho; +- valide contraste, tamanhos de toque e TalkBack em aparelho real antes de publicar. ## Limites da v0.1 -Esta versão é um alicerce funcional, ainda não um curso completo. Antes da produção serão necessários: revisão por usuários, validação completa com TalkBack, consentimento, assinatura do AAB, ícone, capturas de tela, ficha de segurança de dados e teste fechado exigido pela conta Play Console. +O MVP usa ondas senoidais e valores rítmicos iguais. Compassos, figuras rítmicas, claves adicionais, transposição e exportação ficam para versões posteriores. Antes da Google Play ainda são necessários ícone, capturas, AAB assinado e teste fechado. diff --git a/SomenteAndroid/app/build.gradle.kts b/SomenteAndroid/app/build.gradle.kts index 1df7a9d..dfe9917 100644 --- a/SomenteAndroid/app/build.gradle.kts +++ b/SomenteAndroid/app/build.gradle.kts @@ -14,11 +14,8 @@ android { versionCode = 1 versionName = "0.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - buildConfigField("boolean", "ADS_ENABLED", "false") - buildConfigField("String", "ADMOB_INTERSTITIAL_ID", "\"ca-app-pub-3940256099942544/1033173712\"") } - buildFeatures { buildConfig = true } buildTypes { release { isMinifyEnabled = true @@ -36,6 +33,5 @@ dependencies { implementation("androidx.core:core-ktx:1.16.0") implementation("androidx.appcompat:appcompat:1.7.1") implementation("com.google.android.material:material:1.12.0") - implementation("com.google.android.gms:play-services-ads:24.4.0") testImplementation("junit:junit:4.13.2") } diff --git a/SomenteAndroid/app/src/main/AndroidManifest.xml b/SomenteAndroid/app/src/main/AndroidManifest.xml index fd51ed2..9f5de96 100644 --- a/SomenteAndroid/app/src/main/AndroidManifest.xml +++ b/SomenteAndroid/app/src/main/AndroidManifest.xml @@ -1,16 +1,10 @@ - - - - Unit): Boolean { - val ad = loaded ?: return false - loaded = null - ad.fullScreenContentCallback = object : FullScreenContentCallback() { - override fun onAdDismissedFullScreenContent() { onClosed(); preload() } - } - ad.show(activity) - return true - } -} diff --git a/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/AdPolicy.kt b/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/AdPolicy.kt deleted file mode 100644 index 3adac27..0000000 --- a/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/AdPolicy.kt +++ /dev/null @@ -1,23 +0,0 @@ -package br.com.sidineyr.somente - -data class AdState( - val completedLessons: Int, - val adsToday: Int, - val lastAdAtMs: Long, - val isFirstSession: Boolean, - val audioPlaying: Boolean -) - -object AdPolicy { - const val LESSON_INTERVAL = 4 - const val MIN_INTERVAL_MS = 12 * 60 * 1000L - const val DAILY_LIMIT = 3 - - fun canShowAfterLesson(state: AdState, nowMs: Long): Boolean = - !state.isFirstSession && - !state.audioPlaying && - state.completedLessons > 0 && - state.completedLessons % LESSON_INTERVAL == 0 && - state.adsToday < DAILY_LIMIT && - nowMs - state.lastAdAtMs >= MIN_INTERVAL_MS -} diff --git a/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/Curriculum.kt b/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/Curriculum.kt deleted file mode 100644 index 8dfc5ae..0000000 --- a/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/Curriculum.kt +++ /dev/null @@ -1,25 +0,0 @@ -package br.com.sidineyr.somente - -enum class ExerciseKind { SOUND_OR_SILENCE, SAME_OR_DIFFERENT, HIGH_OR_LOW, LOUD_OR_SOFT, PULSE, RHYTHM, NOTE, MELODY } - -data class Lesson( - val id: String, - val title: String, - val objective: String, - val prompt: String, - val kind: ExerciseKind, - val choices: List -) - -object Curriculum { - val lessons = listOf( - Lesson("escuta-1", "Som e silêncio", "Perceber quando existe vibração audível.", "Você ouviu um som?", ExerciseKind.SOUND_OR_SILENCE, listOf("Som", "Silêncio")), - Lesson("escuta-2", "Igual ou diferente?", "Comparar dois eventos sonoros sem exigir nomes técnicos.", "Os dois sons foram iguais?", ExerciseKind.SAME_OR_DIFFERENT, listOf("Iguais", "Diferentes")), - Lesson("altura-1", "Grave e agudo", "Reconhecer altura antes de conhecer nomes de notas.", "O som foi grave ou agudo?", ExerciseKind.HIGH_OR_LOW, listOf("Grave", "Agudo")), - Lesson("intensidade-1", "Forte e fraco", "Distinguir intensidade de altura.", "O som foi forte ou fraco?", ExerciseKind.LOUD_OR_SOFT, listOf("Forte", "Fraco")), - Lesson("ritmo-1", "Encontre o pulso", "Sentir uma sequência regular de batidas.", "Toque junto com o pulso quatro vezes.", ExerciseKind.PULSE, listOf("Marcar pulso")), - Lesson("ritmo-2", "Imite o ritmo", "Perceber que ritmo combina durações e pausas.", "Ouça e repita o desenho rítmico.", ExerciseKind.RHYTHM, listOf("Repetir ritmo")), - Lesson("notas-1", "Uma nota tem nome", "Relacionar uma altura já percebida ao nome Dó.", "Esta nota é Dó. Ouça e reconheça.", ExerciseKind.NOTE, listOf("Ouvir Dó", "Reconheci")), - Lesson("melodia-1", "Notas em caminho", "Compreender melodia como sequência intencional de alturas e durações.", "A sequência subiu ou desceu?", ExerciseKind.MELODY, listOf("Subiu", "Desceu")) - ) -} diff --git a/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/MainActivity.kt b/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/MainActivity.kt index 637b31f..a19be76 100644 --- a/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/MainActivity.kt +++ b/SomenteAndroid/app/src/main/java/br/com/sidineyr/somente/MainActivity.kt @@ -6,7 +6,6 @@ import android.view.Gravity import android.view.View import android.widget.Button import android.widget.LinearLayout -import android.widget.ProgressBar import android.widget.ScrollView import android.widget.TextView import androidx.appcompat.app.AppCompatActivity @@ -14,108 +13,143 @@ import kotlin.random.Random class MainActivity : AppCompatActivity() { private val player = TonePlayer() - private lateinit var ads: AdController private lateinit var root: LinearLayout - private val prefs by lazy { getSharedPreferences("progress", MODE_PRIVATE) } - private var lessonIndex = 0 - private var expected = "" - private var firstSession = true + private val prefs by lazy { getSharedPreferences("somente", MODE_PRIVATE) } + private var answerButtons = mutableListOf