Added About section to menu

This commit is contained in:
partisan 2025-05-15 22:23:07 +02:00
parent 1850641fdb
commit 72d4a797ea
8 changed files with 673 additions and 15 deletions

View file

@ -1,12 +0,0 @@
package partisan.weforge.xyz.pulse
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
class AboutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// placeholder
setContentView(androidx.appcompat.R.layout.abc_action_bar_title_item)
}
}

View file

@ -0,0 +1,65 @@
package partisan.weforge.xyz.pulse
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewStub
import androidx.fragment.app.Fragment
import partisan.weforge.xyz.pulse.databinding.FragmentAboutBinding
class AboutFragment : Fragment() {
private var _binding: FragmentAboutBinding? = null
private val binding get() = _binding!!
private var tapCount = 0
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentAboutBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.sourceButton.setOnClickListener {
openUrl("https://weforge.xyz/partisan/Pulse")
}
binding.licenseButton.setOnClickListener {
openUrl("https://www.gnu.org/licenses/gpl-3.0.en.html")
}
binding.appIcon.setOnClickListener {
tapCount++
if (tapCount >= 5) {
binding.secretButton.visibility = View.VISIBLE
}
}
binding.secretButton.setOnClickListener {
requireActivity().supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, SecretFragment())
.addToBackStack(null)
.commit()
}
}
private fun openUrl(url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View file

@ -48,7 +48,10 @@ class MainActivity : AppCompatActivity() {
true
}
R.id.action_about -> {
startActivity(Intent(this, AboutActivity::class.java))
supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, AboutFragment())
.addToBackStack(null)
.commit()
true
}
R.id.action_contacts -> {

View file

@ -0,0 +1,18 @@
package partisan.weforge.xyz.pulse
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
class SecretFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return SecretView(requireContext())
}
}

View file

@ -0,0 +1,493 @@
package partisan.weforge.xyz.pulse
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.*
import android.util.AttributeSet
import android.view.Choreographer
import android.view.MotionEvent
import android.view.View
import com.google.android.material.color.MaterialColors
import kotlin.math.*
import kotlin.random.Random
class SecretView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs), Choreographer.FrameCallback {
private val bulletPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
strokeWidth = 6f
}
private val starPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
alpha = 40
style = Paint.Style.FILL
}
private val playerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 4f
}
private val enemyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 4f
}
private val rocketPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
style = Paint.Style.STROKE
strokeWidth = 3f
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textAlign = Paint.Align.CENTER
textSize = 64f
typeface = Typeface.DEFAULT_BOLD
}
private val retryRect = RectF()
private val retryPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.DKGRAY
}
private val retryTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textAlign = Paint.Align.CENTER
textSize = 48f
}
private var playerX = 0f
private var viewWidth = 0f
private var viewHeight = 0f
private var isTouching = false
private var bulletCooldownMs = 0L
private var gameOver = false
private var score = 0
private val bullets = mutableListOf<Bullet>()
private val mediumBulletsToFire = mutableListOf<Bullet>()
private val enemies = mutableListOf<Enemy>()
private val rockets = mutableListOf<Rocket>()
private val stars = mutableListOf<Star>()
private val explosions = mutableListOf<Explosion>()
private val rocketTrails = mutableListOf<Pair<Float, Float>>()
private data class Bullet(var x: Float, var y: Float, val dy: Float = -15f)
private data class Star(var x: Float, var y: Float, val radius: Float, val speed: Float)
private data class Rocket(var x: Float, var y: Float, var angle: Float, val trail: MutableList<Pair<Float, Float>> = mutableListOf())
private data class Explosion(var x: Float, var y: Float, var timer: Int = 12)
private var lastLogicTime = 0L
private val logicStepMs = 16L
private var waveTimer = 0L
private var currentWave = 0
private var enemiesLeftInWave = 0
private var currentWaveType = ""
init {
for (i in 0 until 50) {
stars.add(Star(Random.nextFloat() * 1080f, Random.nextFloat() * 1920f, Random.nextFloat() * 2f + 1f, Random.nextFloat() * 2f + 0.5f))
}
val colorAttrs = intArrayOf(com.google.android.material.R.attr.colorPrimaryVariant)
context.obtainStyledAttributes(colorAttrs).use {
val primary = it.getColor(0, Color.CYAN)
playerPaint.color = primary
enemyPaint.color = primary
}
Choreographer.getInstance().postFrameCallback(this)
lastLogicTime = System.nanoTime()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
viewWidth = w.toFloat()
viewHeight = h.toFloat()
// Kinda hacky way to center things, but I'd rather do this here than in update(), since it can't be in init() as the screen size isn't initialized at that point.
playerX = viewWidth / 2f
retryRect.set(viewWidth / 2f - 120f, viewHeight / 2f + 60f, viewWidth / 2f + 120f, viewHeight / 2f + 130f)
}
override fun doFrame(frameTimeNanos: Long) {
if (!gameOver) {
val now = System.nanoTime()
while ((now - lastLogicTime) / 1_000_000 >= logicStepMs) {
update(logicStepMs)
lastLogicTime += logicStepMs * 1_000_000
}
invalidate()
Choreographer.getInstance().postFrameCallback(this)
}
}
private fun update(deltaMs: Long) {
stars.forEach {
it.y += it.speed * deltaMs / 16f
if (it.y > viewHeight) {
it.y = 0f
it.x = Random.nextFloat() * viewWidth
}
}
rockets.forEach { rocket ->
rocket.trail.add(0, rocket.x to rocket.y)
if (rocket.trail.size > 20) {
rocket.trail.removeLast()
}
}
explosions.forEach { it.timer-- }
explosions.removeIf { it.timer <= 0 }
bullets.forEach { it.y += it.dy * deltaMs / 16f }
bullets.removeIf { it.y < 0 }
bulletCooldownMs -= deltaMs
if (isTouching && bulletCooldownMs <= 0) {
bullets.add(Bullet(playerX, viewHeight - 130f))
bulletCooldownMs = 150L
}
enemies.forEach {
it.update(deltaMs)
it.x = max(20f, min(it.x, viewWidth - 20f))
if (it.y > viewHeight + 100f) it.y = -40f // respawn at top
}
rockets.forEach {
val targetAngle = atan2(viewHeight - 100f - it.y, playerX - it.x)
it.angle += ((targetAngle - it.angle + PI).mod(2 * PI) - PI).toFloat() * 0.1f
it.x += cos(it.angle) * 6f
it.y += sin(it.angle) * 6f
}
rockets.removeIf { it.x < 0 || it.x > viewWidth || it.y < 0 || it.y > viewHeight }
bullets.addAll(mediumBulletsToFire)
mediumBulletsToFire.clear()
checkCollisions()
spawnEnemies(deltaMs)
}
private fun checkCollisions() {
val enemyIter = enemies.iterator()
while (enemyIter.hasNext()) {
val enemy = enemyIter.next()
if (hypot(playerX - enemy.x, viewHeight - 100f - enemy.y) < 40f) {
explosions.add(Explosion(playerX, viewHeight - 100f))
gameOver = true
return
}
for (b in bullets) {
if (hypot(b.x - enemy.x, b.y - enemy.y) < 30f) {
explosions.add(Explosion(enemy.x, enemy.y))
enemyIter.remove()
score += 10
break
}
}
}
val rocketIter = rockets.iterator()
while (rocketIter.hasNext()) {
val rocket = rocketIter.next()
if (hypot(playerX - rocket.x, viewHeight - 100f - rocket.y) < 30f) {
explosions.add(Explosion(playerX, viewHeight - 100f))
gameOver = true
return
}
for (b in bullets) {
if (hypot(b.x - rocket.x, b.y - rocket.y) < 20f) {
explosions.add(Explosion(rocket.x, rocket.y))
rocketIter.remove()
score += 15
break
}
}
}
}
private fun spawnEnemies(deltaMs: Long) {
waveTimer -= deltaMs
// Passive asteroids
if (Random.nextFloat() < 0.002f) {
enemies.add(EnemyAsteroid(Random.nextFloat() * viewWidth, -40f, viewWidth))
}
// Setup new wave
if (enemiesLeftInWave <= 0 && waveTimer <= 0) {
currentWave++
currentWaveType = when (currentWave % 3) {
0 -> "easy"
1 -> "medium"
else -> "hard"
}
enemiesLeftInWave = when (currentWaveType) {
"easy" -> 3 + currentWave
"medium" -> 2 + currentWave / 2
"hard" -> 1 + currentWave / 4
else -> 3
}
waveTimer = 3000L
}
// Spawn enemies in group
if (enemiesLeftInWave > 0 && enemies.count { it !is EnemyAsteroid } < 3) {
val baseX = Random.nextFloat() * (viewWidth - 100f) + 50f
val baseY = -40f
val spacing = 35f
val sharedOffset = Random.nextFloat() * 1000f
val sharedFireTime = Random.nextLong(2000L, 4000L)
val formationSize = when (currentWaveType) {
"easy" -> 5
"medium" -> 3
"hard" -> 1
else -> 3
}
for (i in 0 until min(formationSize, enemiesLeftInWave)) {
val offsetX = (i - (formationSize - 1) / 2f) * spacing
val x = baseX + offsetX
// The group sync isn't working as enemies gradually get out of sync, but whatever, it's fine.
val enemy = when (currentWaveType) {
"easy" -> EnemyEasy(x, baseY)
"medium" -> EnemyMedium(x, baseY, sharedOffset, sharedFireTime) { mediumBulletsToFire.add(it) }
"hard" -> EnemyHard(x, baseY, { rockets.add(it) }, sharedOffset, sharedFireTime)
else -> EnemyEasy(x, baseY)
}
enemies.add(enemy)
enemiesLeftInWave--
}
}
}
private fun resetGame() {
bullets.clear()
enemies.clear()
rockets.clear()
explosions.clear()
rocketTrails.clear()
score = 0
currentWave = 0
enemiesLeftInWave = 0
gameOver = false
playerX = viewWidth / 2f
}
override fun onDraw(canvas: Canvas) {
viewWidth = width.toFloat()
viewHeight = height.toFloat()
canvas.drawColor(Color.parseColor("#121212"))
stars.forEach { canvas.drawCircle(it.x, it.y, it.radius, starPaint) }
rockets.forEach { rocket ->
rocket.trail.forEachIndexed { index, (x, y) ->
val alpha = ((1f - index / 20f.toFloat()) * 255).toInt()
rocketPaint.alpha = alpha
canvas.drawCircle(x, y, 2f, rocketPaint)
}
}
rocketPaint.alpha = 255
explosions.forEach {
val radius = 40f * (1f - it.timer / 12f.toFloat())
val alpha = (255 * (it.timer / 12f.toFloat())).toInt()
val paint = Paint().apply {
color = Color.YELLOW
this.alpha = alpha
style = Paint.Style.STROKE
strokeWidth = 3f
}
canvas.drawCircle(it.x, it.y, radius, paint)
}
bullets.forEach { canvas.drawLine(it.x, it.y, it.x, it.y - 20f, bulletPaint) }
enemies.forEach { it.draw(canvas, enemyPaint) }
rockets.forEach { canvas.drawCircle(it.x, it.y, 10f, rocketPaint) }
if (!gameOver) {
val baseY = viewHeight - 100f
val safeX = max(30f, min(playerX, viewWidth - 30f))
playerX = safeX
val path = Path().apply {
moveTo(safeX, baseY - 30f)
lineTo(safeX - 30f, baseY + 30f)
lineTo(safeX + 30f, baseY + 30f)
close()
}
canvas.drawPath(path, playerPaint)
} else {
canvas.drawText("Game Over", viewWidth / 2f, viewHeight / 2f - 60f, textPaint)
canvas.drawText("Score: $score", viewWidth / 2f, viewHeight / 2f + 10f, textPaint)
canvas.drawRoundRect(retryRect, 20f, 20f, retryPaint)
canvas.drawText("Retry", retryRect.centerX(), retryRect.centerY() + 16f, retryTextPaint)
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
if (gameOver && event.action == MotionEvent.ACTION_DOWN) {
if (retryRect.contains(event.x, event.y)) {
resetGame()
return true
}
}
when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
playerX = max(30f, min(event.x, width - 30f))
isTouching = true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> isTouching = false
}
return true
}
private interface Enemy {
var x: Float
var y: Float
fun update(deltaMs: Long)
fun draw(canvas: Canvas, paint: Paint)
}
private class EnemyAsteroid(
override var x: Float,
override var y: Float,
private val screenWidth: Float
) : Enemy {
private var vx = Random.nextFloat() * 2f - 1f
private val vy = Random.nextFloat() * 2f + 2f
private var angle = Random.nextFloat() * 360f
private val rotationSpeed = Random.nextFloat() * 2f * if (Random.nextBoolean()) 1 else -1
override fun update(deltaMs: Long) {
x += vx * deltaMs / 16f
y += vy * deltaMs / 16f
angle = (angle + rotationSpeed * deltaMs / 16f) % 360f
if (x < 20f || x > screenWidth - 20f) {
vx = -vx
x = max(20f, min(x, screenWidth - 20f))
}
}
override fun draw(canvas: Canvas, paint: Paint) {
canvas.save()
canvas.rotate(angle, x, y)
val path = Path().apply {
moveTo(x - 20f, y)
lineTo(x - 10f, y - 15f)
lineTo(x + 10f, y - 15f)
lineTo(x + 20f, y)
lineTo(x + 10f, y + 15f)
lineTo(x - 10f, y + 15f)
close()
}
canvas.drawPath(path, paint)
canvas.restore()
}
}
private class EnemyEasy(override var x: Float, override var y: Float) : Enemy {
private val offset = Random.nextFloat() * 1000f
override fun update(deltaMs: Long) {
y += 2f * deltaMs / 16f
x += sin((y + offset) / 50f) * 2f
}
override fun draw(canvas: Canvas, paint: Paint) {
val path = Path().apply {
moveTo(x, y - 20f)
lineTo(x + 20f, y)
lineTo(x, y + 20f)
lineTo(x - 20f, y)
close()
}
canvas.drawPath(path, paint)
}
}
private class EnemyMedium(
override var x: Float,
override var y: Float,
private val offset: Float,
private var fireTimer: Long,
val fireBullet: (Bullet) -> Unit
) : Enemy {
override fun update(deltaMs: Long) {
y += 2.5f * deltaMs / 16f
x += sin((y + offset) / 40f) * 3f
fireTimer -= deltaMs
if (fireTimer <= 0) {
fireBullet(Bullet(x, y + 30f, dy = 10f))
fireTimer = Random.nextLong(3000L, 6000L)
}
}
override fun draw(canvas: Canvas, paint: Paint) {
val path = Path().apply {
moveTo(x, y - 25f)
lineTo(x + 15f, y)
lineTo(x, y + 25f)
lineTo(x - 15f, y)
close()
}
canvas.drawPath(path, paint)
}
}
private class EnemyHard(
override var x: Float,
override var y: Float,
val fireRocket: (Rocket) -> Unit,
private val offset: Float = Random.nextFloat() * 1000f,
private var fireTimer: Long = Random.nextLong(2000L, 5000L)
) : Enemy {
private var cooldown = 0L
private var firing = false
override fun update(deltaMs: Long) {
if (firing) {
cooldown += deltaMs
if (cooldown > 500 && cooldown < 1300) {
fireRocket(Rocket(x, y - 30f, -PI.toFloat() / 2))
cooldown = 1300
} else if (cooldown > 2000) {
cooldown = 0L
fireTimer = Random.nextLong(15000L, 25000L)
firing = false
}
return
}
y += 3f * deltaMs / 16f
x += sin((y + offset) / 25f) * 4f
fireTimer -= deltaMs
if (fireTimer <= 0) {
firing = true
cooldown = 0L
}
}
override fun draw(canvas: Canvas, paint: Paint) {
val path = Path().apply {
moveTo(x, y - 25f)
lineTo(x + 10f, y - 10f)
lineTo(x + 20f, y + 10f)
lineTo(x, y + 25f)
lineTo(x - 20f, y + 10f)
lineTo(x - 10f, y - 10f)
close()
}
canvas.drawPath(path, paint)
}
}
}

View file

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/rootLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="32dp">
<LinearLayout
android:id="@+id/contentWrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<ImageView
android:id="@+id/appIcon"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_marginBottom="8dp"
android:contentDescription="@string/app_name"
android:src="@mipmap/ic_launcher" />
<TextView
android:id="@+id/appName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="20sp"
android:textStyle="bold"
android:layout_marginTop="8dp" />
<TextView
android:id="@+id/appDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/description"
android:textAlignment="center"
android:textSize="14sp"
android:layout_marginTop="8dp" />
<Button
android:id="@+id/sourceButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/source_code"
android:layout_marginTop="24dp"
app:cornerRadius="24dp" />
<Button
android:id="@+id/licenseButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/license"
android:layout_marginTop="8dp"
app:cornerRadius="24dp" />
<Button
android:id="@+id/secretButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Secret"
android:layout_marginTop="16dp"
android:visibility="gone"
app:cornerRadius="24dp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/secretRoot"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#CC000000">
<partisan.weforge.xyz.pulse.SecretView
android:id="@+id/secretView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Pulse</string>
<string name="description">App will try to redirect outgoing calls to E2EE apps if available.</string>
<string name="description">Redirects outgoing calls to E2EE apps if available.</string>
<string name="popup">Redirecting to %1$s</string>
<string name="destination_signal">Signal</string>
<string name="destination_telegram">Telegram</string>
@ -10,11 +10,13 @@
<string name="redirection_delay_description">The delay before a call will be redirected.</string>
<string name="popup_position">Popup position</string>
<string name="fallback">Fallback</string>
<string name="activate_description">To start, grant the required permissions and tap the Activate button.</string>
<string name="activate_description">To start, grant the required permissions by tapping the Activate button.</string>
<string name="activate">Activate</string>
<string name="navigation_drawer_open">Open menu</string>
<string name="navigation_drawer_close">Close menu</string>
<string name="popup_settings_description">Configure popup behavior, position, and delay.</string>
<string name="popup_enabled">Popup enabled</string>
<string name="test">Test</string>
<string name="source_code">Source Code</string>
<string name="license">License</string>
</resources>