From 64030a038c6407b7c72efb7c580bddaaea67e8b4 Mon Sep 17 00:00:00 2001 From: austin Date: Thu, 16 Oct 2025 11:13:27 +0000 Subject: [PATCH] Polycentric Moderation --- app/src/main/AndroidManifest.xml | 4 + .../PolycentricModerationActivity.kt | 147 +++++++++++ .../activities/PolycentricProfileActivity.kt | 16 +- .../polycentric/ModerationsManager.kt | 80 ++++++ .../futo/platformplayer/states/StateApp.kt | 25 ++ .../platformplayer/states/StatePolycentric.kt | 19 ++ .../res/drawable/background_slider_value.xml | 6 + .../activity_polycentric_moderation.xml | 228 ++++++++++++++++++ .../layout/activity_polycentric_profile.xml | 16 +- dep/polycentricandroid | 2 +- 10 files changed, 523 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/activities/PolycentricModerationActivity.kt create mode 100644 app/src/main/java/com/futo/platformplayer/polycentric/ModerationsManager.kt create mode 100644 app/src/main/res/drawable/background_slider_value.xml create mode 100644 app/src/main/res/layout/activity_polycentric_moderation.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c5c20aec..826ceb29 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -238,5 +238,9 @@ android:name=".activities.SyncShowPairingCodeActivity" android:screenOrientation="sensorPortrait" android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" /> + diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricModerationActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricModerationActivity.kt new file mode 100644 index 00000000..f4baf163 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricModerationActivity.kt @@ -0,0 +1,147 @@ +package com.futo.platformplayer.activities + +import android.content.Context +import android.os.Bundle +import android.widget.ImageButton +import android.widget.SeekBar +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import com.futo.platformplayer.polycentric.ModerationsManager +import com.futo.platformplayer.R +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.setNavigationBarColorAndIcons + +class PolycentricModerationActivity : AppCompatActivity() { + private lateinit var _seekbarOffensive: SeekBar + private lateinit var _seekbarExplicit: SeekBar + private lateinit var _seekbarViolence: SeekBar + private lateinit var _textOffensiveDesc: TextView + private lateinit var _textExplicitDesc: TextView + private lateinit var _textViolenceDesc: TextView + private lateinit var _textOffensiveValue: TextView + private lateinit var _textExplicitValue: TextView + private lateinit var _textViolenceValue: TextView + private lateinit var _moderationsManager: ModerationsManager + + override fun attachBaseContext(newBase: Context?) { + super.attachBaseContext(StateApp.instance.getLocaleContext(newBase)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_polycentric_moderation) + setNavigationBarColorAndIcons() + + _moderationsManager = ModerationsManager.getInstance() + try { + _moderationsManager = ModerationsManager.getInstance() + } catch (e: IllegalStateException) { + finish() + return + } + + _seekbarOffensive = findViewById(R.id.seekbar_offensive) + _seekbarExplicit = findViewById(R.id.seekbar_explicit) + _seekbarViolence = findViewById(R.id.seekbar_violence) + _textOffensiveDesc = findViewById(R.id.text_offensive_desc) + _textExplicitDesc = findViewById(R.id.text_explicit_desc) + _textViolenceDesc = findViewById(R.id.text_violence_desc) + _textOffensiveValue = findViewById(R.id.text_offensive_value) + _textExplicitValue = findViewById(R.id.text_explicit_value) + _textViolenceValue = findViewById(R.id.text_violence_value) + + findViewById(R.id.button_back).setOnClickListener { + finish() + } + + loadSettings() + setupListeners() + } + + private fun loadSettings() { + val levels = _moderationsManager.moderationLevels.value ?: mapOf() + + val offensiveLevel = levels["hate"] ?: 2 + val explicitLevel = levels["sexual"] ?: 1 + val violenceLevel = levels["violence"] ?: 1 + + _seekbarOffensive.progress = offensiveLevel + _seekbarExplicit.progress = explicitLevel + _seekbarViolence.progress = violenceLevel + + updateDescriptionText(_seekbarOffensive, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions()) + updateDescriptionText(_seekbarExplicit, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions()) + updateDescriptionText(_seekbarViolence, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions()) + } + + private fun setupListeners() { + _seekbarOffensive.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + updateDescriptionText(seekBar, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions()) + if (fromUser) { + _moderationsManager.setModerationLevel("hate", progress) + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) + + _seekbarExplicit.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + updateDescriptionText(seekBar, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions()) + if (fromUser) { + _moderationsManager.setModerationLevel("sexual", progress) + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) + + _seekbarViolence.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + updateDescriptionText(seekBar, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions()) + if (fromUser) { + _moderationsManager.setModerationLevel("violence", progress) + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) + } + + private fun updateDescriptionText(seekBar: SeekBar?, textDesc: TextView, textValue: TextView, descriptions: Array) { + val progress = seekBar?.progress ?: 0 + textDesc.text = descriptions[progress] + textValue.text = progress.toString() + } + + private fun getOffensiveDescriptions(): Array { + return arrayOf( + "Neutral, general terms, no bias or hate.", + "Mildly sensitive, factual.", + "Potentially offensive content", + "Offensive content" + ) + } + + private fun getExplicitDescriptions(): Array { + return arrayOf( + "No explicit content", + "Mildly suggestive, factual or educational", + "Moderate sexual content, non-graphic", + "Explicit sexual content" + ) + } + + private fun getViolenceDescriptions(): Array { + return arrayOf( + "Non-violent", + "Mild violence, factual or contextual", + "Moderate violence, some graphic content.", + "Graphic violence" + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt index 3493363e..b935c428 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt @@ -49,7 +49,7 @@ class PolycentricProfileActivity : AppCompatActivity() { private lateinit var _buttonHelp: ImageButton; private lateinit var _editName: EditText; private lateinit var _buttonExport: BigButton; - private lateinit var _buttonOpenHarborProfile: BigButton; + private lateinit var _buttonModeration: BigButton; private lateinit var _buttonLogout: BigButton; private lateinit var _buttonDelete: BigButton; private lateinit var _username: String; @@ -71,7 +71,7 @@ class PolycentricProfileActivity : AppCompatActivity() { _imagePolycentric = findViewById(R.id.image_polycentric); _editName = findViewById(R.id.edit_profile_name); _buttonExport = findViewById(R.id.button_export); - _buttonOpenHarborProfile = findViewById(R.id.button_open_harbor_profile); + _buttonModeration = findViewById(R.id.button_moderation); _buttonLogout = findViewById(R.id.button_logout); _buttonDelete = findViewById(R.id.button_delete); _loaderOverlay = findViewById(R.id.loader_overlay); @@ -99,15 +99,9 @@ class PolycentricProfileActivity : AppCompatActivity() { startActivity(Intent(this, PolycentricBackupActivity::class.java)); }; - _buttonOpenHarborProfile.onClick.subscribe { - val processHandle = StatePolycentric.instance.processHandle!!; - processHandle?.let { - val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(it.system)); - val url = it.system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable()); - val navUrl = "https://harbor.social/" + url.substring("polycentric://".length) - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl))) - } - } + _buttonModeration.onClick.subscribe { + startActivity(Intent(this, PolycentricModerationActivity::class.java)); + }; _buttonLogout.onClick.subscribe { StatePolycentric.instance.setProcessHandle(null); diff --git a/app/src/main/java/com/futo/platformplayer/polycentric/ModerationsManager.kt b/app/src/main/java/com/futo/platformplayer/polycentric/ModerationsManager.kt new file mode 100644 index 00000000..5d33861a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/polycentric/ModerationsManager.kt @@ -0,0 +1,80 @@ +package com.futo.platformplayer.polycentric + +import android.content.Context +import android.content.SharedPreferences +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import org.json.JSONObject + +class ModerationsManager private constructor(context: Context) { + private val prefs: SharedPreferences = context.getSharedPreferences("polycentric_moderation", Context.MODE_PRIVATE) + + private val _moderationLevels = MutableLiveData>() + val moderationLevels: LiveData> = _moderationLevels + + init { + loadModerationLevels() + } + + private fun loadModerationLevels() { + val levels = mutableMapOf() + levels["hate"] = prefs.getInt("offensive_level", 2) + levels["sexual"] = prefs.getInt("explicit_level", 1) + levels["violence"] = prefs.getInt("violence_level", 1) + _moderationLevels.value = levels + } + + fun setModerationLevel(category: String, level: Int) { + when (category) { + "hate" -> prefs.edit().putInt("offensive_level", level).apply() + "sexual" -> prefs.edit().putInt("explicit_level", level).apply() + "violence" -> prefs.edit().putInt("violence_level", level).apply() + } + + val currentMap = _moderationLevels.value?.toMutableMap() ?: mutableMapOf() + currentMap[category] = level + _moderationLevels.value = currentMap + } + + fun getModerationLevelsJson(): String { + val json = JSONObject() + moderationLevels.value?.forEach { (key, value) -> + json.put(key, value) + } + return json.toString() + } + + fun shouldFilter(category: String, contentLevel: Int): Boolean { + val userLevel = when (category) { + "hate" -> prefs.getInt("offensive_level", 2) + "sexual" -> prefs.getInt("explicit_level", 1) + "violence" -> prefs.getInt("violence_level", 1) + else -> 3 + } + + return contentLevel > userLevel + } + + fun getCurrentModerationLevels(): Map? { + return moderationLevels.value + } + + companion object { + @Volatile + private var instance: ModerationsManager? = null + + fun initialize(context: Context) { + if (instance == null) { + synchronized(this) { + if (instance == null) { + instance = ModerationsManager(context.applicationContext) + } + } + } + } + + fun getInstance(): ModerationsManager { + return instance ?: throw IllegalStateException("ModerationsManager not initialized") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 7883b4fd..61a902bc 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -49,6 +49,8 @@ import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.views.ToastView import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.toBase64Url +import com.futo.platformplayer.polycentric.ModerationsManager import kotlinx.coroutines.* import java.io.File import java.util.* @@ -385,6 +387,29 @@ class StateApp { _cacheDirectory?.let { ApiMethods.initCache(it) }; } + Logger.i(TAG, "MainApp Starting: Initializing [ModerationsManager]"); + ModerationsManager.initialize(context); + + Logger.i(TAG, "MainApp Starting: Setting [ModerationLevelProvider]"); + ApiMethods.setModerationLevelProvider { + try { + ModerationsManager.getInstance().getCurrentModerationLevels() + } catch (e: IllegalStateException) { + Logger.e(TAG, "Failed to get moderation levels from manager", e); + null + } + } + + Logger.i(TAG, "MainApp Starting: Setting [ModerationExemptSystemProvider]"); + ApiMethods.setModerationExemptSystemProvider { + try { + StatePolycentric.instance.processHandle?.system?.toProto()?.toByteArray()?.toBase64Url() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to get moderation exempt system from manager", e); + null + } + } + val logFile = File(context.filesDir, "log.txt"); if (Settings.instance.logging.logLevel > LogLevel.NONE.value) { val fileLogConsumer = FileLogConsumer(logFile, LogLevel.fromInt(Settings.instance.logging.logLevel), false); diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt index 86ae541a..02a522da 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt @@ -28,6 +28,7 @@ import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringStorage import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.ensureServerAndBackfill import com.futo.polycentric.core.ClaimType import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.Opinion @@ -46,8 +47,10 @@ import com.google.protobuf.ByteString import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import userpackage.Protocol import userpackage.Protocol.Reference @@ -67,6 +70,8 @@ class StatePolycentric { private val _commentPool = ForkJoinPool(2); private val _commentPoolDispatcher = _commentPool.asCoroutineDispatcher(); + private val _backgroundJob = SupervisorJob() + private val _backgroundScope = CoroutineScope(_backgroundJob + Dispatchers.IO) fun load(context: Context) { if (!enabled) { @@ -173,6 +178,15 @@ class StatePolycentric { } _likeDislikeMap = newMap + + // Ensure current server is registered & synced + _backgroundScope.launch { + try { + processHandle.ensureServerAndBackfill() + } catch (e: Throwable) { + Logger.w(TAG, "Failed to ensure server and backfill: "+e.message) + } + } } else { _activeProcessHandle.setAndSave(""); _likeDislikeMap = hashMapOf() @@ -559,6 +573,11 @@ class StatePolycentric { }; } + fun cleanup() { + _backgroundJob.cancel() + _commentPool.shutdown() + } + companion object { private const val TAG = "StatePolycentric"; diff --git a/app/src/main/res/drawable/background_slider_value.xml b/app/src/main/res/drawable/background_slider_value.xml new file mode 100644 index 00000000..f3001037 --- /dev/null +++ b/app/src/main/res/drawable/background_slider_value.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_polycentric_moderation.xml b/app/src/main/res/layout/activity_polycentric_moderation.xml new file mode 100644 index 00000000..e68110da --- /dev/null +++ b/app/src/main/res/layout/activity_polycentric_moderation.xml @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_polycentric_profile.xml b/app/src/main/res/layout/activity_polycentric_profile.xml index d9266fd0..91c82362 100644 --- a/app/src/main/res/layout/activity_polycentric_profile.xml +++ b/app/src/main/res/layout/activity_polycentric_profile.xml @@ -73,7 +73,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:fontFamily="@font/inter_regular" - android:text="Further customize your profile, make platform claims, and other creator-specific features in the Harbor app." + android:text="Further customize your profile, make platform claims, and other creator-specific features in the Polycentric app." android:textSize="12dp" android:linksClickable="true" android:paddingLeft="20dp" @@ -90,7 +90,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:fontFamily="@font/inter_regular" - android:text="https://harbor.social" + android:text="https://polycentric.io" android:textSize="12dp" android:linksClickable="true" android:paddingLeft="20dp" @@ -107,7 +107,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:fontFamily="@font/inter_regular" - android:text="After you've installed Harbor you can export this profile to Harbor using the Export button." + android:text="After you've installed Polycentric you can export this profile to Polycentric using the Export button." android:textSize="12dp" android:linksClickable="true" android:paddingLeft="20dp" @@ -133,13 +133,13 @@ app:layout_constraintBottom_toBottomOf="parent"> + app:buttonSubText="Set moderation settings for polycentric comments" + android:layout_marginTop="8dp" + app:buttonIcon="@drawable/ic_settings" + app:buttonText="Moderation Settings" />