diff --git a/build.gradle b/build.gradle index b2f51e7f527..28475ad9274 100644 --- a/build.gradle +++ b/build.gradle @@ -49,5 +49,6 @@ allprojects { google() mavenCentral() maven { url = "https://developer.huawei.com/repo/" } + maven { url = uri("https://repository.kotzilla.io/repository/Koin-Embedded/") } } } diff --git a/catroid/build.gradle b/catroid/build.gradle index 6ec3ce14092..a31440b6df8 100644 --- a/catroid/build.gradle +++ b/catroid/build.gradle @@ -38,6 +38,7 @@ plugins { id "io.gitlab.arturbosch.detekt" version "1.23.7" id 'com.google.devtools.ksp' version '2.0.21-1.0.25' apply true id 'checkstyle' + id 'org.jetbrains.kotlin.plugin.compose' version '2.0.21' } repositories { @@ -113,6 +114,7 @@ android { buildFeatures { buildConfig = true viewBinding = true + compose = true } defaultConfig { @@ -161,7 +163,7 @@ android { buildConfigField "boolean", "FEATURE_USER_REPORTERS_ENABLED", "true" buildConfigField "boolean", "FEATURE_MULTIPLAYER_VARIABLES_ENABLED", "true" buildConfigField "boolean", "FEATURE_TESTBRICK_ENABLED", "true" - buildConfigField "boolean", "FEATURE_AI_ASSIST_ENABLED", "false" + buildConfigField "boolean", "FEATURE_AI_ASSIST_ENABLED", "true" resValue "string", "FEATURE_EMBROIDERY_PREFERENCES_ENABLED", "false" resValue "string", "FEATURE_PHIRO_PREFERENCES_ENABLED", "false" resValue "string", "FEATURE_MINDSTORMS_PREFERENCES_ENABLED", "false" @@ -222,7 +224,7 @@ android { debug { buildConfigField "boolean", "USE_ANDROID_LOCALES_FOR_SCREENSHOTS", useAndroidLocales() resValue "string", "DEBUG_MODE", "true" - buildConfigField "boolean", "FEATURE_AI_ASSIST_ENABLED", "false" + buildConfigField "boolean", "FEATURE_AI_ASSIST_ENABLED", "true" signingConfig = signingConfigs.debug enableAndroidTestCoverage = true } @@ -472,6 +474,17 @@ dependencies { implementation "com.google.android.gms:play-services-auth:17.0.0" + // Catrobat AI Tutor + implementation("org.catrobat:aitutor:0.0.1") + + // Compose + implementation "androidx.compose.ui:ui:1.7.5" + implementation "androidx.compose.runtime:runtime:1.7.5" + implementation "androidx.compose.foundation:foundation:1.7.5" + implementation "androidx.compose.material3:material3:1.3.1" + implementation "androidx.compose.ui:ui-tooling-preview:1.7.5" + debugImplementation "androidx.compose.ui:ui-tooling:1.7.5" + androidTestImplementation('tools.fastlane:screengrab:2.1.1') { // https://issuetracker.google.com/issues/123060356 exclude group: 'com.android.support.test.uiautomator', module: 'uiautomator-v18' diff --git a/catroid/src/main/java/org/catrobat/catroid/camera/CameraManager.kt b/catroid/src/main/java/org/catrobat/catroid/camera/CameraManager.kt index 7620150823d..3236aaf9e81 100644 --- a/catroid/src/main/java/org/catrobat/catroid/camera/CameraManager.kt +++ b/catroid/src/main/java/org/catrobat/catroid/camera/CameraManager.kt @@ -47,7 +47,7 @@ import org.koin.java.KoinJavaComponent.get class CameraManager(private val stageActivity: StageActivity) : LifecycleOwner { private val cameraProvider = ProcessCameraProvider.getInstance(stageActivity).get() - private val lifecycle = LifecycleRegistry(this) + private val lifecycleRegistry = LifecycleRegistry(this) val previewView = PreviewView(stageActivity).apply { visibility = View.INVISIBLE } @@ -90,14 +90,14 @@ class CameraManager(private val stageActivity: StageActivity) : LifecycleOwner { CameraSelector.DEFAULT_BACK_CAMERA } currentCameraSelector = defaultCameraSelector - lifecycle.currentState = Lifecycle.State.CREATED + lifecycleRegistry.currentState = Lifecycle.State.CREATED } val isCameraFacingFront: Boolean get() = currentCameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA val isCameraActive: Boolean - get() = lifecycle.currentState in listOf(Lifecycle.State.STARTED, Lifecycle.State.RESUMED) && + get() = lifecycleRegistry.currentState in listOf(Lifecycle.State.STARTED, Lifecycle.State.RESUMED) && (cameraProvider.isBound(previewUseCase) || cameraProvider.isBound(analysisUseCase)) @Synchronized @@ -111,17 +111,17 @@ class CameraManager(private val stageActivity: StageActivity) : LifecycleOwner { @Synchronized fun destroy() { - lifecycle.currentState = Lifecycle.State.DESTROYED + lifecycleRegistry.currentState = Lifecycle.State.DESTROYED } @Synchronized fun pause() { - lifecycle.currentState = Lifecycle.State.CREATED + lifecycleRegistry.currentState = Lifecycle.State.CREATED } @Synchronized fun resume() { - lifecycle.currentState = Lifecycle.State.RESUMED + lifecycleRegistry.currentState = Lifecycle.State.RESUMED currentCamera?.cameraControl?.enableTorch(flashOn) } @@ -255,7 +255,7 @@ class CameraManager(private val stageActivity: StageActivity) : LifecycleOwner { useCase ) currentCamera?.cameraControl?.enableTorch(flashOn) - lifecycle.currentState = Lifecycle.State.STARTED + lifecycleRegistry.currentState = Lifecycle.State.STARTED true } catch (exception: IllegalStateException) { Log.e(TAG, "Could not bind use case.", exception) @@ -282,5 +282,5 @@ class CameraManager(private val stageActivity: StageActivity) : LifecycleOwner { destroy() } - override fun getLifecycle() = lifecycle + override val lifecycle: Lifecycle get() = lifecycleRegistry } diff --git a/catroid/src/main/java/org/catrobat/catroid/content/actions/LookRequestAction.kt b/catroid/src/main/java/org/catrobat/catroid/content/actions/LookRequestAction.kt index 4681ef8469c..a1f82f95428 100644 --- a/catroid/src/main/java/org/catrobat/catroid/content/actions/LookRequestAction.kt +++ b/catroid/src/main/java/org/catrobat/catroid/content/actions/LookRequestAction.kt @@ -111,7 +111,7 @@ open class LookRequestAction : WebAction() { } override fun onRequestSuccess(httpResponse: Response) { - response = httpResponse.body()?.byteStream() + response = httpResponse.body?.byteStream() val fileName = Utils.getFileNameFromHttpResponse(httpResponse) ?: Utils.getFileNameFromURL(url) fileName.split('.', limit = 2).let { name -> lookName = name[0] diff --git a/catroid/src/main/java/org/catrobat/catroid/content/actions/WebRequestAction.kt b/catroid/src/main/java/org/catrobat/catroid/content/actions/WebRequestAction.kt index 45b537c94e1..502402eb219 100644 --- a/catroid/src/main/java/org/catrobat/catroid/content/actions/WebRequestAction.kt +++ b/catroid/src/main/java/org/catrobat/catroid/content/actions/WebRequestAction.kt @@ -53,7 +53,7 @@ class WebRequestAction : WebAction() { override fun onRequestSuccess(httpResponse: Response) { response = try { - httpResponse.body()?.string() ?: "" + httpResponse.body?.string() ?: "" } catch (exception: IOException) { Log.d(javaClass.simpleName, "HTTP reponse body is empty", exception) "" diff --git a/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java b/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java index f207b837aec..b23d52c9cfc 100644 --- a/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java +++ b/catroid/src/main/java/org/catrobat/catroid/io/XstreamSerializer.java @@ -915,6 +915,27 @@ public String getXmlAsStringFromProject(Project project) { return xmlString; } + public String getXmlAsStringFromSprite(Sprite sprite) { + loadSaveLock.lock(); + try { + return xstream.toXML(sprite); + } finally { + loadSaveLock.unlock(); + } + } + + public Sprite getSpriteFromXmlString(String xml) { + loadSaveLock.lock(); + try { + return (Sprite) xstream.fromXML(xml); + } catch (Exception e) { + Log.e(TAG, "Failed to parse sprite XML from string.", e); + return null; + } finally { + loadSaveLock.unlock(); + } + } + public static String extractDefaultSceneNameFromXml(File projectDir) { File xmlFile = new File(projectDir, CODE_XML_FILE_NAME); diff --git a/catroid/src/main/java/org/catrobat/catroid/retrofit/ErrorInterceptor.kt b/catroid/src/main/java/org/catrobat/catroid/retrofit/ErrorInterceptor.kt index e77ee06ba69..ec98ee37d0f 100644 --- a/catroid/src/main/java/org/catrobat/catroid/retrofit/ErrorInterceptor.kt +++ b/catroid/src/main/java/org/catrobat/catroid/retrofit/ErrorInterceptor.kt @@ -33,12 +33,12 @@ class ErrorInterceptor : Interceptor { val response = chain.proceed(chain.request()) if (response.isSuccessful.not() and response.isRedirect.not()) { - val contentType = response.body()?.contentType() - val body = response.body()?.toString() ?: "" + val contentType = response.body?.contentType() + val body = response.body?.toString() ?: "" return response.newBuilder() .body(ResponseBody.create(contentType, body)) - .code(response.code()) + .code(response.code) .build() } return response diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java index f499330634c..0a3f3f55494 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java +++ b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivity.java @@ -53,11 +53,14 @@ import org.catrobat.catroid.formulaeditor.UserList; import org.catrobat.catroid.formulaeditor.UserVariable; import org.catrobat.catroid.io.StorageOperations; +import org.catrobat.catroid.io.XstreamSerializer; import org.catrobat.catroid.io.asynctask.ProjectSaver; import org.catrobat.catroid.pocketmusic.PocketMusicActivity; import org.catrobat.catroid.soundrecorder.SoundRecorderActivity; import org.catrobat.catroid.stage.StageActivity; import org.catrobat.catroid.stage.TestResult; +import org.catrobat.catroid.ui.aiassist.overlay.AiAssistOverlayCallbacks; +import org.catrobat.catroid.ui.aiassist.overlay.AiAssistOverlayHelper; import org.catrobat.catroid.ui.controller.RecentBrickListManager; import org.catrobat.catroid.ui.fragment.AddBrickFragment; import org.catrobat.catroid.ui.fragment.BrickCategoryFragment; @@ -83,6 +86,7 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import androidx.compose.ui.platform.ComposeView; import androidx.fragment.app.Fragment; import static org.catrobat.catroid.common.Constants.DEFAULT_IMAGE_EXTENSION; @@ -161,6 +165,8 @@ public class SpriteActivity extends BaseActivity { private boolean isUndoMenuItemVisible = false; + private ComposeView aiOverlay; + @Override public void onCreate(Bundle savedInstanceState) { if (savedInstanceState != null) { @@ -178,6 +184,7 @@ public void onCreate(Bundle savedInstanceState) { currentScene = projectManager.getCurrentlyEditedScene(); setContentView(R.layout.activity_sprite); + aiOverlay = findViewById(R.id.compose_ai_overlay); setSupportActionBar(findViewById(R.id.toolbar)); getSupportActionBar().setDisplayHomeAsUpEnabled(true); getSupportActionBar().setTitle(createActionBarTitle()); @@ -295,6 +302,11 @@ protected void onSaveInstanceState(Bundle outState) { @Override public void onBackPressed() { + if (isAiOverlayVisible()) { + hideAiOverlay(); + return; + } + saveProject(); Fragment currentFragment = getCurrentFragment(); @@ -639,7 +651,38 @@ private void addSoundFromUri(Uri uri) { } public void handleAiAssistButton(View view) { - Log.d(TAG, "Here a Flutter module will be called in the future."); + if (aiOverlay == null) { + return; + } + Sprite sprite = projectManager.getCurrentSprite(); + String spriteXml = XstreamSerializer.getInstance().getXmlAsStringFromSprite(sprite); + + aiOverlay.setVisibility(View.VISIBLE); + AiAssistOverlayHelper.show(aiOverlay, spriteXml, sprite, new AiAssistOverlayCallbacks() { + @Override + public void applySprite(String newSpriteXml) { + Fragment current = getCurrentFragment(); + if (current instanceof ScriptFragment scriptfragment) { + scriptfragment.applyProjectFromAiTutor(newSpriteXml); + } + } + + @Override + public void close() { + hideAiOverlay(); + } + }); + } + + public boolean isAiOverlayVisible() { + return aiOverlay != null && aiOverlay.getVisibility() == View.VISIBLE; + } + + public void hideAiOverlay() { + if (aiOverlay != null) { + aiOverlay.setVisibility(View.GONE); + aiOverlay.disposeComposition(); + } } public void handleAddButton(View view) { diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivityOnTabSelectedListener.kt b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivityOnTabSelectedListener.kt index 127725f1bc4..c19a74b3dd9 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivityOnTabSelectedListener.kt +++ b/catroid/src/main/java/org/catrobat/catroid/ui/SpriteActivityOnTabSelectedListener.kt @@ -39,6 +39,7 @@ import org.catrobat.catroid.ui.SpriteActivity.FRAGMENT_SOUNDS import org.catrobat.catroid.ui.recyclerview.fragment.LookListFragment import org.catrobat.catroid.ui.recyclerview.fragment.ScriptFragment import org.catrobat.catroid.ui.recyclerview.fragment.SoundListFragment +import org.catrobat.catroid.BuildConfig import kotlin.reflect.KFunction1 @SuppressWarnings("EmptyFunctionBlock") @@ -82,16 +83,26 @@ fun SpriteActivity.loadFragment(fragmentPosition: Int) { when (fragmentPosition) { FRAGMENT_SCRIPTS -> showScripts(fragmentTransaction) - FRAGMENT_LOOKS -> fragmentTransaction.replace( - R.id.fragment_container, - LookListFragment(), - LookListFragment.TAG - ) - FRAGMENT_SOUNDS -> fragmentTransaction.replace( - R.id.fragment_container, - SoundListFragment(), - SoundListFragment.TAG - ) + FRAGMENT_LOOKS -> { + if (BuildConfig.FEATURE_AI_ASSIST_ENABLED) { + BottomBar.hideAiAssistButton(this) + } + fragmentTransaction.replace( + R.id.fragment_container, + LookListFragment(), + LookListFragment.TAG + ) + } + FRAGMENT_SOUNDS -> { + if (BuildConfig.FEATURE_AI_ASSIST_ENABLED) { + BottomBar.hideAiAssistButton(this) + } + fragmentTransaction.replace( + R.id.fragment_container, + SoundListFragment(), + SoundListFragment.TAG + ) + } else -> throw IllegalArgumentException("Invalid fragmentPosition in Activity.") } diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt new file mode 100644 index 00000000000..4f55ee2b464 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/AiTutorDiffScreen.kt @@ -0,0 +1,699 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.diff + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.catrobat.catroid.R +import org.catrobat.catroid.content.Script +import org.catrobat.catroid.content.Sprite +import org.catrobat.catroid.content.StartScript +import org.catrobat.catroid.content.WhenScript +import org.catrobat.catroid.content.bricks.Brick +import org.catrobat.catroid.content.bricks.ChangeXByNBrick +import org.catrobat.catroid.content.bricks.HideBrick +import org.catrobat.catroid.content.bricks.SetXBrick +import org.catrobat.catroid.content.bricks.SetYBrick +import org.catrobat.catroid.content.bricks.WaitBrick +import org.catrobat.catroid.formulaeditor.Formula +import org.catrobat.catroid.io.XstreamSerializer + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AiTutorDiffScreen( + currentSprite: Sprite, + newSpriteXml: String, + onAccept: () -> Unit, + onReject: () -> Unit +) { + val context = LocalContext.current + + val background = colorResource(R.color.app_background) + val barColor = colorResource(R.color.toolbar_background) + val white = colorResource(R.color.solid_white) + val accent = colorResource(R.color.accent) + val actionColor = colorResource(R.color.action_button) + + val newSprite = remember(newSpriteXml) { + XstreamSerializer.getInstance().getSpriteFromXmlString(newSpriteXml) + } + val rows = remember(currentSprite, newSprite) { + if (newSprite == null) emptyList() else buildDiffRows(currentSprite, newSprite, context) + } + + var selected by remember { mutableStateOf(null) } + + val added = rows.count { it.status == DiffStatus.ADDED } + val removed = rows.count { it.status == DiffStatus.REMOVED } + val modified = rows.count { it.status == DiffStatus.MODIFIED } + + Scaffold( + containerColor = background, + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + "Review AI changes", + color = white, + fontWeight = FontWeight.SemiBold + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = barColor, + titleContentColor = white + ) + ) + }, + bottomBar = { + Row( + modifier = Modifier + .background(barColor) + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onReject, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors(contentColor = white) + ) { Text("Reject") } + Button( + onClick = onAccept, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = actionColor, + contentColor = white + ) + ) { Text("Accept", fontWeight = FontWeight.Bold) } + } + } + ) { padding -> + if (newSprite == null) { + CenteredMessage( + text = "Could not read the AI's response as a sprite.", + color = white, + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) + } else if (rows.all { it.status == DiffStatus.UNCHANGED }) { + CenteredMessage( + text = "The AI returned the same sprite,\nnothing would change.", + color = white, + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 12.dp), + contentPadding = PaddingValues(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + SummaryAndLegend( + added = added, + removed = removed, + modified = modified, + white = white, + accent = accent + ) + } + itemsIndexed(rows) { _, row -> + if (isScriptHeaderRow(row)) { + Spacer(modifier = Modifier.height(8.dp)) + ScriptHeaderRow( + row = row, + context = context, + accent = accent, + onClick = { selected = row } + ) + } else { + BrickDiffRow( + row = row, + context = context, + white = white, + accent = accent, + onClick = { selected = row } + ) + } + } + } + } + } + + selected?.let { row -> + BrickDiffDialog( + row = row, + white = white, + accent = accent, + actionColor = actionColor, + onDismiss = { selected = null } + ) + } +} + +@Composable +private fun CenteredMessage(text: String, color: Color, modifier: Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text( + text = text, + color = color, + textAlign = TextAlign.Center, + modifier = Modifier.padding(32.dp) + ) + } +} + +@Composable +private fun SummaryAndLegend( + added: Int, + removed: Int, + modified: Int, + white: Color, + accent: Color +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "$added added · $removed removed · $modified modified", + color = accent, + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ) + Spacer(Modifier.height(8.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + LegendChip(DiffStatus.ADDED, white) + LegendChip(DiffStatus.REMOVED, white) + LegendChip(DiffStatus.MODIFIED, white) + LegendChip(DiffStatus.UNCHANGED, white) + } + } +} + +@Composable +private fun LegendChip(status: DiffStatus, textColor: Color) { + val color = statusColor(status) + val iconRes = statusIcon(status) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(16.dp) + .clip(RoundedCornerShape(4.dp)) + .then( + if (color != null) { + Modifier.background(color) + } else { + Modifier.border( + 1.dp, + colorResource(R.color.button_background), + RoundedCornerShape(4.dp) + ) + } + ), + contentAlignment = Alignment.Center + ) { + if (iconRes != null) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, // the label text already names the status + tint = textColor, + modifier = Modifier.size(11.dp) + ) + } + } + Spacer(Modifier.width(4.dp)) + Text(statusLabel(status), color = textColor, fontSize = 12.sp) + } +} + +@Composable +private fun ScriptHeaderRow(row: DiffRow, context: Context, accent: Color, onClick: () -> Unit) { + val brick = row.new ?: row.old + val title = brick?.let { scriptHeaderTitle(it, context) } ?: "Script" + val tint = statusColor(row.status) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(size = 6.dp)) + .clickable(onClick = onClick) + .background(colorResource(id = R.color.button_background)) + .then( + if (tint != null) Modifier.border( + 2.dp, + tint, + RoundedCornerShape(size = 6.dp) + ) else Modifier + ) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + statusIcon(row.status)?.let { iconRes -> + Icon( + painter = painterResource(iconRes), + contentDescription = statusLabel(row.status), + tint = tint ?: accent, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + } + Text(title, color = accent, fontWeight = FontWeight.Bold, fontSize = 14.sp) + } + } +} + +@Composable +private fun BrickDiffRow( + row: DiffRow, + context: Context, + white: Color, + accent: Color, + onClick: () -> Unit +) { + when (row.status) { + DiffStatus.UNCHANGED -> UnchangedDiffRow( + row = row, + context = context, + white = white, + accent = accent, + onClick = onClick + ) + + DiffStatus.MODIFIED -> statusColor(row.status)?.let { + ModifiedDiffRow( + row = row, + context = context, + tint = it, + white = white, + accent = accent, + onClick = onClick + ) + } + // ADDED / REMOVED + else -> statusColor(row.status)?.let { + SingleDiffRow( + row = row, + context = context, + tint = it, + white = white, + accent = accent, + onClick = onClick + ) + } + } +} + +@Composable +private fun StatusContainer( + tint: Color, + iconRes: Int?, + iconDesc: String?, + onClick: () -> Unit, + content: @Composable RowScope.() -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onClick) + .background(tint.copy(alpha = ROW_TINT_ALPHA)) // 10% status tint, GitHub diff style + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically + ) { + // Solid status stripe, flush to the rounded-left edge. + Box( + modifier = Modifier + .width(STRIPE_WIDTH) + .fillMaxHeight() + .background(tint) + ) + Spacer(Modifier.width(8.dp)) + if (iconRes != null) { + Box(modifier = Modifier.size(18.dp), contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(iconRes), + contentDescription = iconDesc, + tint = tint, + modifier = Modifier.size(16.dp) + ) + } + Spacer(Modifier.width(6.dp)) + } + content() + Spacer(Modifier.width(8.dp)) + } +} + +@Composable +private fun SingleDiffRow( + row: DiffRow, + context: Context, + tint: Color, + white: Color, + accent: Color, + onClick: () -> Unit +) { + val brick = row.new ?: row.old ?: return + StatusContainer( + tint = tint, + iconRes = statusIcon(row.status), + iconDesc = statusLabel(row.status), + onClick = onClick + ) { + BrickContent( + brick = brick, + context = context, + labelColor = white, + valueColor = accent, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun ModifiedDiffRow( + row: DiffRow, + context: Context, + tint: Color, + white: Color, + accent: Color, + onClick: () -> Unit +) { + val newBrick = row.new ?: return + val inspectionMode = LocalInspectionMode.current + StatusContainer( + tint = tint, + iconRes = statusIcon(row.status), + iconDesc = statusLabel(row.status), + onClick = onClick + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp) + ) { + ModifiedValues( + oldText = row.old?.let { brickEditorLabel(it, context, inspectionMode) }.orEmpty(), + newText = phraseAnnotated( + brickPhraseTokens(newBrick, context, row.old, inspectionMode), + staticColor = white, + dynamicColor = accent + ), + oldColor = white.copy(alpha = 0.45f), + newColor = accent, + arrowColor = white.copy(alpha = 0.5f) + ) + } + } +} + +/** + * Renders a brick's [brickPhraseTokens] as an [AnnotatedString]: static words in [staticColor], each + * dynamic chunk (value / spinner selection) in [dynamicColor] — medium weight normally, bold + underlined + * when it changed from the old brick — so the data reads like the editor's input fields. + */ +private fun phraseAnnotated( + tokens: List, + staticColor: Color, + dynamicColor: Color +): AnnotatedString = buildAnnotatedString { + tokens.forEachIndexed { index, token -> + if (index > 0) append(" ") + val style = when { + !token.dynamic -> SpanStyle(color = staticColor) + token.changed -> SpanStyle( + color = dynamicColor, + fontWeight = FontWeight.Bold, + textDecoration = TextDecoration.Underline + ) + + else -> SpanStyle(color = dynamicColor, fontWeight = FontWeight.Medium) + } + withStyle(style) { append(token.text) } + } +} + +@Composable +private fun ModifiedValues( + oldText: String, + newText: AnnotatedString, + oldColor: Color, + newColor: Color, + arrowColor: Color +) { + val measurer = rememberTextMeasurer() + val style = TextStyle(fontSize = 12.sp) + val density = LocalDensity.current + val arrowSpace = with(density) { (14.dp + 16.dp).roundToPx() } // icon + 8.dp padding each side + + val oldWidth = + remember(oldText) { measurer.measure(AnnotatedString(oldText), style).size.width } + val newWidth = remember(newText) { measurer.measure(newText, style).size.width } + + var rowWidth by remember { mutableIntStateOf(0) } + val half = (rowWidth - arrowSpace) / 2 + val sideBySide = rowWidth == 0 || (oldWidth <= half && newWidth <= half) + + Box( + modifier = Modifier + .fillMaxWidth() + .onSizeChanged { rowWidth = it.width }) { + if (sideBySide) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = oldText, + color = oldColor, + fontSize = 12.sp, + maxLines = 1, + textAlign = TextAlign.Start, + modifier = Modifier.weight(1f) + ) + Icon( + painter = painterResource(R.drawable.ic_arrow_forward_vector), + contentDescription = null, + tint = arrowColor, + modifier = Modifier + .padding(horizontal = 8.dp) + .size(14.dp) + ) + Text( + text = newText, + color = newColor, + fontSize = 12.sp, + maxLines = 1, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f) + ) + } + } else { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = oldText, + color = oldColor, + fontSize = 12.sp, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + Icon( + painter = painterResource(R.drawable.ic_arrow_forward_vector), + contentDescription = null, + tint = arrowColor, + modifier = Modifier + .padding(vertical = 8.dp) + .size(14.dp) + .rotate(90f) // → becomes ↓ when stacked + ) + Text( + text = newText, + color = newColor, + fontSize = 12.sp, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} + +@Composable +private fun UnchangedDiffRow( + row: DiffRow, + context: Context, + white: Color, + accent: Color, + onClick: () -> Unit +) { + val brick = row.new ?: row.old ?: return + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .border(1.dp, colorResource(R.color.button_background), RoundedCornerShape(6.dp)) + .clickable(onClick = onClick) + .padding(horizontal = CONTENT_INDENT, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BrickContent(brick, context, white, accent, Modifier.weight(1f)) + } +} + +@Composable +private fun BrickContent( + brick: Brick, + context: Context, + labelColor: Color, + valueColor: Color, + modifier: Modifier +) { + val inspectionMode = LocalInspectionMode.current + Column(modifier = modifier.padding(vertical = 8.dp)) { + Text( + text = phraseAnnotated( + brickPhraseTokens(brick, context, null, inspectionMode), + staticColor = labelColor, + dynamicColor = valueColor + ), + fontSize = 14.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} + +private fun spriteOf(name: String, vararg scripts: Script): Sprite = Sprite(name).apply { + scripts.forEach { addScript(it) } +} + +private fun startScript(vararg bricks: Brick): Script = + StartScript().apply { bricks.forEach { addBrick(it) } } + +private fun whenScript(vararg bricks: Brick): Script = + WhenScript().apply { bricks.forEach { addBrick(it) } } + +@Preview(name = "Changes", showBackground = true) +@Composable +private fun AiTutorDiffScreenPreview() { + val longOld = Formula("playerStartingHorizontalPositionBeforeOffset") + val longNew = Formula("playerComputedHorizontalPositionAfterApplyingOffsetAndClamp") + val current = spriteOf( + "current", + // SetX 0→100 (short modified), Wait unchanged, SetY 10→200 (short modified), ChangeX removed. + startScript(SetXBrick(0), WaitBrick(1000), SetYBrick(10), ChangeXByNBrick(10)), + // SetX long→long (long modified, stacks), Hide unchanged, SetY 50 added. + whenScript(SetXBrick(longOld), HideBrick()) + ) + val proposed = spriteOf( + "proposed", + startScript(SetXBrick(100), WaitBrick(1000), SetYBrick(200)), + whenScript(SetXBrick(longNew), HideBrick(), SetYBrick(50)) + ) + AiTutorDiffScreen( + currentSprite = current, + newSpriteXml = XstreamSerializer.getInstance().getXmlAsStringFromSprite(proposed), + onAccept = {}, + onReject = {} + ) +} + +@Preview(name = "No changes", showBackground = true) +@Composable +private fun AiTutorDiffScreenNoChangesPreview() { + val sprite = spriteOf("sprite", startScript(SetXBrick(100), WaitBrick(1000))) + AiTutorDiffScreen( + currentSprite = sprite, + newSpriteXml = XstreamSerializer.getInstance().getXmlAsStringFromSprite(sprite), + onAccept = {}, + onReject = {} + ) +} diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt new file mode 100644 index 00000000000..98e2e54d8af --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickDiffDialog.kt @@ -0,0 +1,169 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.diff + +import android.util.Log +import android.widget.TextView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import org.catrobat.catroid.R +import org.catrobat.catroid.content.bricks.Brick + +@Composable +internal fun BrickDiffDialog( + row: DiffRow, + white: Color, + accent: Color, + actionColor: Color, + onDismiss: () -> Unit +) { + val brick = row.new ?: row.old ?: return + val tint = statusColor(row.status) + val context = LocalContext.current + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(12.dp), + color = colorResource(R.color.button_background) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Text( + text = brickEditorLabel(brick, context), + color = white, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + Text( + text = statusLabel(row.status), + color = tint ?: accent, + fontWeight = FontWeight.Medium, + fontSize = 13.sp + ) + Spacer(Modifier.height(16.dp)) + + DialogSection( + label = "Before", + brick = row.old, + emptyText = "Not in the original", + white = white + ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_forward_vector), + contentDescription = null, + tint = white.copy(alpha = 0.5f), + modifier = Modifier + .size(18.dp) + .rotate(90f) + ) + } + DialogSection( + label = "After", + brick = row.new, + emptyText = "Removed", + white = white + ) + + Spacer(modifier = Modifier.height(20.dp)) + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = actionColor, + contentColor = white + ) + ) { Text(text = "Close", fontWeight = FontWeight.Bold) } + } + } + } +} + +@Composable +private fun DialogSection( + label: String, + brick: Brick?, + emptyText: String, + white: Color +) { + Text(label, color = white.copy(alpha = 0.6f), fontSize = 12.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.height(4.dp)) + if (brick == null) { + Text( + text = emptyText, + color = white.copy(alpha = 0.5f), + fontSize = 14.sp, + modifier = Modifier.padding(vertical = 8.dp) + ) + } else { + AndroidView( + modifier = Modifier.fillMaxWidth(), + factory = { context -> + try { + brick.getPrototypeView(context) + } catch (e: Exception) { + Log.e( + "BrickDiffDialog", + "Couldn't create prototype view for brick ${brick.javaClass.simpleName}", + e + ) + TextView(context).apply { + text = humanizeBrickName(brick.javaClass.simpleName) + } + } + } + ) + } +} diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt new file mode 100644 index 00000000000..22a7452001f --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/BrickTextFormatter.kt @@ -0,0 +1,264 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.diff + +import android.content.Context +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Spinner +import android.widget.TextView +import org.catrobat.catroid.common.Nameable +import org.catrobat.catroid.content.bricks.Brick +import org.catrobat.catroid.content.bricks.BrickBaseType +import org.catrobat.catroid.content.bricks.FormulaBrick +import org.catrobat.catroid.content.bricks.UserDataBrick +import org.catrobat.catroid.content.bricks.UserListBrick +import org.catrobat.catroid.content.bricks.UserVariableBrickInterface +import java.lang.reflect.Modifier + +// ── Brick titles ── + +/** + * The editor label for a script-header brick (e.g. "When scene starts", "When tapped"). + * A brick's layout resource name matches its label string resource name, so we resolve the layout + * via [BrickBaseType.getViewResource] and look up the same-named string. Falls back to the + * humanized class name for anything unrecognized. + */ +internal fun scriptHeaderTitle(brick: Brick, context: Context): String = try { + val layoutId = (brick as? BrickBaseType)?.getViewResource() + ?: throw IllegalArgumentException("Brick ${brick.javaClass.simpleName} is not a BrickBaseType") + val entryName = context.resources.getResourceEntryName(layoutId) + val stringId = context.resources.getIdentifier(entryName, "string", context.packageName) + if (stringId != 0) context.getString(stringId) else humanizeBrickName(brick.javaClass.simpleName) +} catch (e: Exception) { + Log.e( + DIFF_TAG, + "Error resolving script header title for ${brick.javaClass.simpleName}", + e + ) + humanizeBrickName(brick.javaClass.simpleName) +} + +/** + * The full editor phrase for a brick as ordered [DiffToken]s: the static label words plus, inline at + * their real position, each formula field's value and each spinner selection's name. When [oldBrick] is + * given, every value/name token is flagged [DiffToken.changed] if it differs (for old→new diffing) and + * carries `dynamic = true` so the UI can style it like an input field. + * + * Static words (including runtime-only labels such as Wait's "seconds") come from the inflated + * [Brick.getView]; values come from the model ([formulaText]) and selections from [dataNames], so they + * stay correct even when the brick belongs to a parsed (non-current) sprite and even when the raw-layout + * fallback is used because [Brick.getView] failed. + * + * In [inspectionMode] (a Compose `@Preview`/layoutlib render) no real view is inflated at all — the + * preview renderer cannot load the themed font assets, so inflation crashes. There we derive the phrase + * from the model only via [modelOnlyPhraseTokens]; the same model-only path is the deep fallback when + * inflation fails on a real device. + */ +internal fun brickPhraseTokens( + brick: Brick, + context: Context, + oldBrick: Brick?, + inspectionMode: Boolean = false +): List { + if (inspectionMode) return modelOnlyPhraseTokens(brick, context, oldBrick) + + val formulaBrick = brick as? FormulaBrick + val fieldIds = formulaBrick?.brickFieldToTextViewIdMap?.values?.toSet() ?: emptySet() + val names = dataNames(brick) + val oldNames = oldBrick?.let { dataNames(it) } ?: emptyList() + val oldFormula = oldBrick as? FormulaBrick + + val root = try { + brick.getView(context) + } catch (e: Exception) { + Log.e(DIFF_TAG, "Error inflating view for ${brick.javaClass.simpleName}", e) + rawLayout(brick, context) + } ?: return modelOnlyPhraseTokens(brick, context, oldBrick) + + val tokens = mutableListOf() + var spinnerIndex = 0 + fun walk(node: View) { + if (node.visibility != View.VISIBLE) return + when { + // A spinner shows a selection; take its name from the model, never the (sprite-dependent) view. + node is Spinner -> { + addNameToken(tokens, names, oldNames, oldBrick, spinnerIndex) + spinnerIndex++ + } + + node is TextView && node.id != View.NO_ID && node.id in fieldIds -> { + val field = formulaBrick?.getBrickFieldFromTextViewId(node.id) ?: return + val value = formulaText(formulaBrick, field, context).trim() + if (value.isBlank()) return + val oldValue = oldFormula?.takeIf { it.formulaMap.containsKey(field) } + ?.let { formulaText(it, field, context).trim() } + tokens.add(DiffToken(value, oldBrick != null && value != oldValue, dynamic = true)) + } + + node is TextView -> node.text?.toString()?.trim()?.takeIf { it.isNotBlank() } + ?.let { tokens.add(DiffToken(it, changed = false, dynamic = false)) } + + node is ViewGroup -> for (i in 0 until node.childCount) walk(node.getChildAt(i)) + } + } + walk(root) + // Selections whose widget isn't a Spinner (rare) are never dropped: append them after the walk. + while (spinnerIndex < names.size) { + addNameToken(tokens, names, oldNames, oldBrick, spinnerIndex) + spinnerIndex++ + } + return tokens +} + +/** Adds the [index]-th selected name (if any) as a dynamic token, flagged changed vs [oldBrick]. */ +private fun addNameToken( + tokens: MutableList, + names: List, + oldNames: List, + oldBrick: Brick?, + index: Int +) { + val name = names.getOrNull(index) ?: return + tokens.add( + DiffToken( + name, + oldBrick != null && oldNames.getOrNull(index) != name, + dynamic = true + ) + ) +} + +/** + * The brick phrase derived from the model alone, never inflating a view: a humanized class-name label + * followed by each formula field's value and each selected name. Used in Compose preview/inspection mode + * (where view inflation crashes on missing font assets) and as the deep fallback when inflation fails. + * Less precise than the inflated phrase (static words come from the class name, not the real layout), but + * never crashes and stays correct for parsed sprites. + */ +private fun modelOnlyPhraseTokens(brick: Brick, context: Context, oldBrick: Brick?): List { + val tokens = mutableListOf( + DiffToken(humanizeBrickName(brick.javaClass.simpleName), changed = false, dynamic = false) + ) + val formulaBrick = brick as? FormulaBrick + val oldFormula = oldBrick as? FormulaBrick + formulaBrick?.formulaMap?.forEach { (field, _) -> + val value = formulaText(formulaBrick, field, context).trim() + if (value.isNotBlank()) { + val oldValue = oldFormula?.takeIf { it.formulaMap.containsKey(field) } + ?.let { formulaText(it, field, context).trim() } + tokens.add(DiffToken(value, oldBrick != null && value != oldValue, dynamic = true)) + } + } + val names = dataNames(brick) + val oldNames = oldBrick?.let { dataNames(it) } ?: emptyList() + names.indices.forEach { addNameToken(tokens, names, oldNames, oldBrick, it) } + return tokens +} + +/** Inflates only the brick's layout (no [Brick.getView] population) as a fallback view source. */ +private fun rawLayout(brick: Brick, context: Context): View? = try { + (brick as? BrickBaseType)?.getViewResource() + ?.let { LayoutInflater.from(context).inflate(it, null, false) } +} catch (e: Exception) { + Log.e(DIFF_TAG, "Error inflating layout for ${brick.javaClass.simpleName}", e) + null +} + +/** The brick's editor phrase as a plain string, e.g. "Place at x: 0 y: 500". */ +internal fun brickEditorLabel(brick: Brick, context: Context, inspectionMode: Boolean = false): String { + val label = brickPhraseTokens(brick, context, null, inspectionMode) + .joinToString(" ") { it.text }.trim() + return label.ifBlank { humanizeBrickName(brick.javaClass.simpleName) } +} + +/** Human-readable title from the class name, e.g. "SetXBrick" -> "Set X". */ +internal fun humanizeBrickName(simpleName: String): String { + val base = simpleName.removeSuffix("Brick") + if (base.isEmpty()) return simpleName + return base + .replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") + .replace(Regex("([A-Z]+)([A-Z][a-z])"), "$1 $2") + .trim() +} + +// ── Helpers ── + +/** + * Names of the variables/lists/looks/sounds a brick selects (stored outside the formula map). The + * explicit cases cover the variable/list/userdata bricks; the reflective sweep adds any other + * [Nameable] selection (a look, sound, scene, instrument, …) held in a spinner-backed brick field, so + * those bricks surface their value too. A [LinkedHashSet] de-duplicates the userVariable/userList + * overlap between the two passes. + */ +internal fun dataNames(brick: Brick): List { + val names = LinkedHashSet() + (brick as? UserVariableBrickInterface)?.userVariable?.name?.let(names::add) + (brick as? UserListBrick)?.userList?.name?.let(names::add) + (brick as? UserDataBrick)?.userDataMap?.values?.forEach { it?.name?.let(names::add) } + nameableFieldNames(brick).forEach(names::add) + return names.filter { it.isNotBlank() } +} + +/** + * The names of every non-static instance field value that is a [Nameable], walking up the brick's + * class hierarchy. Matched by type (not field name) so it is ProGuard-safe; per-field reads are + * guarded so an inaccessible field never breaks the whole sweep. + */ +private fun nameableFieldNames(brick: Brick): List { + val names = mutableListOf() + var cls: Class<*>? = brick.javaClass + while (cls != null && cls != Any::class.java) { + for (field in cls.declaredFields) { + if (Modifier.isStatic(field.modifiers)) continue + try { + field.isAccessible = true + (field.get(brick) as? Nameable)?.name?.let(names::add) + } catch (e: Exception) { + Log.e( + DIFF_TAG, + "Error reading field ${field.name} of ${brick.javaClass.simpleName}", + e + ) + } + } + cls = cls.superclass + } + return names +} + +/** The editor's trimmed formula string for [field], or "" if it cannot be read. */ +internal fun formulaText(brick: FormulaBrick, field: Brick.FormulaField, context: Context): String = + try { + brick.formulaMap[field]?.getTrimmedFormulaString(context).orEmpty() + } catch (e: Exception) { + Log.e( + DIFF_TAG, + "Error getting formula text for ${brick.javaClass.simpleName} field $field", + e + ) + "" + } diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffModel.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffModel.kt new file mode 100644 index 00000000000..5eaacabb221 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffModel.kt @@ -0,0 +1,42 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.diff + +import org.catrobat.catroid.content.bricks.Brick + +enum class DiffStatus { ADDED, REMOVED, MODIFIED, UNCHANGED } + +internal data class DiffRow(val old: Brick?, val new: Brick?, val status: DiffStatus) + +/** + * A single chunk of a brick's editor phrase. [dynamic] marks a value/spinner-selection chunk (styled + * like an input field) as opposed to a static label word; [changed] flags it differing from the old brick. + */ +internal data class DiffToken( + val text: String, + val changed: Boolean, + val dynamic: Boolean = false +) + +internal const val DIFF_TAG = "AiTutorDiffScreen" diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffStatusStyle.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffStatusStyle.kt new file mode 100644 index 00000000000..33dc17d5ee5 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/DiffStatusStyle.kt @@ -0,0 +1,57 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.diff + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import org.catrobat.catroid.R + +internal val STRIPE_WIDTH = 4.dp +internal const val ROW_TINT_ALPHA = 0.28f + +internal val CONTENT_INDENT = 12.dp + +@Composable +internal fun statusColor(status: DiffStatus): Color? = when (status) { + DiffStatus.ADDED -> colorResource(R.color.brick_color_green) + DiffStatus.REMOVED -> colorResource(R.color.brick_color_red) + DiffStatus.MODIFIED -> colorResource(R.color.brick_color_yellow) + DiffStatus.UNCHANGED -> null +} + +internal fun statusIcon(status: DiffStatus): Int? = when (status) { + DiffStatus.ADDED -> R.drawable.ic_plus + DiffStatus.REMOVED -> R.drawable.ic_minus + DiffStatus.MODIFIED -> R.drawable.ic_edit_pencil + DiffStatus.UNCHANGED -> null +} + +internal fun statusLabel(status: DiffStatus): String = when (status) { + DiffStatus.ADDED -> "Added" + DiffStatus.REMOVED -> "Removed" + DiffStatus.MODIFIED -> "Modified" + DiffStatus.UNCHANGED -> "Unchanged" +} diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/SpriteDiffer.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/SpriteDiffer.kt new file mode 100644 index 00000000000..f5c97233059 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/diff/SpriteDiffer.kt @@ -0,0 +1,178 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.diff + +import android.content.Context +import org.catrobat.catroid.content.Sprite +import org.catrobat.catroid.content.bricks.Brick +import org.catrobat.catroid.content.bricks.FormulaBrick +import org.catrobat.catroid.content.bricks.ScriptBrick + +internal fun isScriptHeaderRow(row: DiffRow): Boolean = + (row.new ?: row.old) is ScriptBrick + +/** + * Flattens both sprites to a single ordered brick list each (script heads, nested bricks, else/end + * markers — exactly what the editor shows), aligns them with an LCS so unchanged bricks line up on the + * same row, then merges a removed-then-added pair of the same brick type into a single MODIFIED row. + */ +internal fun buildDiffRows(oldSprite: Sprite, newSprite: Sprite, context: Context): List { + val oldFlat = flatten(oldSprite) + val newFlat = flatten(newSprite) + val signaturesOld = oldFlat.map { brickSignature(it, context) } + val signaturesNew = newFlat.map { brickSignature(it, context) } + + // lcsLengths[oldIndex][newIndex] = length of the longest common run of signatures starting at + // those positions. Filled bottom-up so the forward walk below can read matches ahead of it. + val oldCount = oldFlat.size + val newCount = newFlat.size + val lcsLengths = Array(oldCount + 1) { IntArray(newCount + 1) } + for (oldIndex in oldCount - 1 downTo 0) { + for (newIndex in newCount - 1 downTo 0) { + lcsLengths[oldIndex][newIndex] = + if (signaturesOld[oldIndex] == signaturesNew[newIndex]) { + lcsLengths[oldIndex + 1][newIndex + 1] + 1 + } else { + maxOf(lcsLengths[oldIndex + 1][newIndex], lcsLengths[oldIndex][newIndex + 1]) + } + } + } + + // Walk both lists in order: matching signatures pair up as UNCHANGED; otherwise advance the side + // whose skip keeps the most matches, emitting REMOVED (old) or ADDED (new). + val aligned = mutableListOf() + var oldIndex = 0 + var newIndex = 0 + while (oldIndex < oldCount && newIndex < newCount) { + val droppingOldKeepsMoreMatches = + lcsLengths[oldIndex + 1][newIndex] >= lcsLengths[oldIndex][newIndex + 1] + when { + signaturesOld[oldIndex] == signaturesNew[newIndex] -> { + aligned.add(DiffRow(oldFlat[oldIndex], newFlat[newIndex], DiffStatus.UNCHANGED)) + oldIndex++ + newIndex++ + } + + droppingOldKeepsMoreMatches -> { + aligned.add(DiffRow(oldFlat[oldIndex], null, DiffStatus.REMOVED)) + oldIndex++ + } + + else -> { + aligned.add(DiffRow(null, newFlat[newIndex], DiffStatus.ADDED)) + newIndex++ + } + } + } + // Leftover tail on either side: all removals or all additions. + while (oldIndex < oldCount) aligned.add(DiffRow(oldFlat[oldIndex++], null, DiffStatus.REMOVED)) + while (newIndex < newCount) aligned.add(DiffRow(null, newFlat[newIndex++], DiffStatus.ADDED)) + + return reconcileChangeBlocks(aligned) +} + +/** + * Pairs in-place edits as MODIFIED. The LCS emits all removes of a change region before all adds, so a + * removed brick and its matching added brick (same class, changed value) are usually not adjacent. We + * therefore reconcile per **change block** — a maximal run of consecutive non-UNCHANGED rows (UNCHANGED + * rows are anchors that also keep script boundaries intact) — pairing each removed brick with the first + * unused added brick of the same class. + */ +private fun reconcileChangeBlocks(rows: List): List { + val out = mutableListOf() + var k = 0 + while (k < rows.size) { + if (rows[k].status == DiffStatus.UNCHANGED) { + out.add(rows[k]) + k++ + continue + } + val block = mutableListOf() + while (k < rows.size && rows[k].status != DiffStatus.UNCHANGED) { + block.add(rows[k]) + k++ + } + out.addAll(reconcileBlock(block)) + } + return out +} + +/** + * Within one change block, pairs each removed brick with the first unused added brick of the same + * class into a MODIFIED row (in original order); unmatched removes and the leftover adds stay as-is. + */ +private fun reconcileBlock(block: List): List { + val removed = block.filter { it.status == DiffStatus.REMOVED } + val added = block.filter { it.status == DiffStatus.ADDED } + val usedAdded = BooleanArray(added.size) + val result = mutableListOf() + + // Removed bricks first (in original order): MODIFIED if a same-class added brick is still available. + for (r in removed) { + val matchIdx = added.indices.firstOrNull { a -> + !usedAdded[a] && added[a].new?.javaClass == r.old?.javaClass + } + if (matchIdx != null) { + usedAdded[matchIdx] = true + result.add(DiffRow(r.old, added[matchIdx].new, DiffStatus.MODIFIED)) + } else { + result.add(r) + } + } + // Leftover purely-added bricks, in original order. + for (a in added.indices) { + if (!usedAdded[a]) { + result.add(added[a]) + } + } + return result +} + +private fun flatten(sprite: Sprite): List { + val list = mutableListOf() + for (script in sprite.scriptList) { + script.addToFlatList(list) + } + return list +} + +/** + * A brick's equality key for the LCS: its class name plus the selected data names and each formula + * field's text, so two bricks compare equal only when they would look identical in the editor. + */ +private fun brickSignature(brick: Brick, context: Context): String { + val sb = StringBuilder(brick.javaClass.simpleName) + // Include the selected variable/list names so changing only the data selection is a real change. + for (name in dataNames(brick)) { + sb.append("|data=").append(name) + } + if (brick is FormulaBrick) { + val map = brick.formulaMap + for (field in map.keys.sortedBy { it.toString() }) { + sb.append('|').append(field.toString()).append('=') + .append(formulaText(brick, field, context)) + } + } + return sb.toString() +} diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/error/AiTutorErrorDialog.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/error/AiTutorErrorDialog.kt new file mode 100644 index 00000000000..a98edce3092 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/error/AiTutorErrorDialog.kt @@ -0,0 +1,91 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.error + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.catrobat.catroid.BuildConfig +import org.catrobat.catroid.R + +@Composable +fun AiTutorErrorDialog( + technicalReason: String, + onBack: () -> Unit, + onAskAgain: () -> Unit +) { + val white = colorResource(R.color.solid_white) + val accent = colorResource(R.color.accent) + val errorColor = colorResource(R.color.brick_color_red) + val buttonBackgroundColor = colorResource(R.color.button_background) + val actionButtonColor = colorResource(R.color.action_button) + + AlertDialog( + shape = RoundedCornerShape(12.dp), + onDismissRequest = onBack, + containerColor = buttonBackgroundColor, + titleContentColor = white, + textContentColor = white, + title = { Text("This change couldn't be applied", fontWeight = FontWeight.SemiBold) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Text( + "A brick in the AI's response is missing or malformed, so it can't be added to " + + "your project. You can ask the AI to fix it and paste the corrected sprite again." + ) + if (BuildConfig.DEBUG) { + Spacer(Modifier.height(12.dp)) + Text("Details: $technicalReason", color = errorColor) + } + } + }, + confirmButton = { + Button( + onClick = onAskAgain, + colors = ButtonDefaults.buttonColors( + containerColor = actionButtonColor, + contentColor = white + ) + ) { Text("Ask AI again", fontWeight = FontWeight.Bold) } + }, + dismissButton = { + TextButton(onClick = onBack) { Text("Back", color = accent) } + } + ) +} \ No newline at end of file diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/overlay/AiAssistOverlay.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/overlay/AiAssistOverlay.kt new file mode 100644 index 00000000000..00b208b3ec3 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/overlay/AiAssistOverlay.kt @@ -0,0 +1,148 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.overlay + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.ViewCompositionStrategy +import org.catrobat.aitutor.ui.public.AiTutorView +import org.catrobat.catroid.content.Sprite +import org.catrobat.catroid.io.XstreamSerializer +import org.catrobat.catroid.ui.aiassist.diff.AiTutorDiffScreen +import org.catrobat.catroid.ui.aiassist.error.AiTutorErrorDialog +import org.catrobat.catroid.ui.aiassist.validation.AiTutorSpriteValidator + +private sealed class Stage { + /** Showing the library's AiTutorView. [outputContext] carries a prior error to feed back to the AI. */ + data class Tutor( + val outputContext: String?, + val modifiedSpriteXml: String? = null + ) : Stage() + + /** Showing the side-by-side diff for a valid sprite. */ + data class Diff(val xml: String) : Stage() + + /** Showing the validation-error dialog for an unrenderable sprite. */ + data class Error(val xml: String, val reason: String) : Stage() +} + +/** + * Hosts the full AI Assist flow as a single overlay: AiTutorView → (on paste) validate → + * either the full-screen diff preview or a compact error dialog. + */ +@Composable +private fun AiAssistFlow( + spriteXml: String?, + currentSprite: Sprite, + callbacks: AiAssistOverlayCallbacks +) { + val context = LocalContext.current + var stage by remember { mutableStateOf(Stage.Tutor(null)) } + + val tutorStage = stage as? Stage.Tutor + AiTutorView( + show = tutorStage != null, + onDismissRequest = { if (stage is Stage.Tutor) callbacks.close() }, + codeContext = if (tutorStage?.modifiedSpriteXml != null) { + "The AI previously suggested the following sprite, but it couldn't be applied: " + + "\n\n${tutorStage.modifiedSpriteXml}\n\n" + + "The original sprite before modification was:\n\n$spriteXml\n\n" + + "Please fix the issues and return only a valid sprite XML." + } else { + spriteXml + }, + outputContext = tutorStage?.outputContext, + onClipboardPaste = { pastedText -> + val result = try { + val sprite = XstreamSerializer.getInstance().getSpriteFromXmlString(pastedText) + if (sprite == null) { + AiTutorSpriteValidator.Result.Invalid("The pasted text is not a valid Pocket Code sprite.") + } else { + AiTutorSpriteValidator.validate(sprite, context) + } + } catch (e: Exception) { + AiTutorSpriteValidator.Result.Invalid( + "Couldn't read the sprite XML: ${e.message ?: e.javaClass.simpleName}" + ) + } + stage = if (result is AiTutorSpriteValidator.Result.Invalid) { + Stage.Error(pastedText, result.reason) + } else { + Stage.Diff(pastedText) + } + } + ) + + when (val current = stage) { + is Stage.Tutor -> Unit // already handled by AiTutorView's show parameter + + is Stage.Diff -> AiTutorDiffScreen( + currentSprite = currentSprite, + newSpriteXml = current.xml, + onAccept = { + callbacks.applySprite(current.xml) + callbacks.close() + }, + onReject = { callbacks.close() } + ) + + is Stage.Error -> AiTutorErrorDialog( + technicalReason = current.reason, + onBack = { callbacks.close() }, + onAskAgain = { + // Re-open the tutor, feeding the validation error back to the AI as output context. + stage = Stage.Tutor( + outputContext = + "The previous response could not be applied to Pocket Code. " + + "Please fix it and return only a valid sprite. Reason: ${ + current + .reason + }", + modifiedSpriteXml = current.xml + ) + } + ) + } +} + +/** Java-facing bridge so `SpriteActivity` can drive the Compose overlay. */ +object AiAssistOverlayHelper { + @JvmStatic + fun show( + composeView: ComposeView, + spriteXml: String?, + currentSprite: Sprite, + callbacks: AiAssistOverlayCallbacks + ) { + composeView.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + composeView.setContent { + AiAssistFlow(spriteXml, currentSprite, callbacks) + } + } +} diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/overlay/AiAssistOverlayCallbacks.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/overlay/AiAssistOverlayCallbacks.kt new file mode 100644 index 00000000000..26b83e2707b --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/overlay/AiAssistOverlayCallbacks.kt @@ -0,0 +1,36 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.overlay + +/** + * Callbacks from the AI Assist overlay back to the host activity. + * Implemented in Java (`SpriteActivity`). + */ +interface AiAssistOverlayCallbacks { + /** Apply the validated AI sprite XML to the project. */ + fun applySprite(spriteXml: String) + + /** Hide the overlay (e.g. user cancelled, rejected, or finished applying). */ + fun close() +} diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/validation/AiTutorSpriteValidator.kt b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/validation/AiTutorSpriteValidator.kt new file mode 100644 index 00000000000..1b5622b6d39 --- /dev/null +++ b/catroid/src/main/java/org/catrobat/catroid/ui/aiassist/validation/AiTutorSpriteValidator.kt @@ -0,0 +1,111 @@ +/* + * Catroid: An on-device visual programming system for Android devices + * Copyright (C) 2010-2026 The Catrobat Team + * () + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * An additional term exception under section 7 of the GNU Affero + * General Public License, version 3, is available at + * http://developer.catrobat.org/license_additional_term + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.catrobat.catroid.ui.aiassist.validation + +import android.content.Context +import android.util.Log +import org.catrobat.catroid.content.Sprite +import org.catrobat.catroid.content.bricks.Brick +import org.catrobat.catroid.content.bricks.FormulaBrick + +/** + * Validates a sprite produced by the AI Tutor before it is applied to the project. + * + * Deserialization via [org.catrobat.catroid.io.XstreamSerializer.getSpriteFromXmlString] only proves + * the XML *parses* — it does not prove the sprite is *renderable*. An AI can emit XML that deserializes + * fine but is semantically incomplete (e.g. a brick missing a required formula), which then crashes when + * the brick list renders. This validator reuses the exact code paths the UI runs so it catches precisely + * those cases. + * + * Must be called on the main thread: the render dry-run inflates views via [Brick.getView]. + */ +object AiTutorSpriteValidator { + + sealed class Result { + object Valid : Result() + data class Invalid(val reason: String) : Result() + } + + const val TAG = "AiTutorSpriteValidator" + + @JvmStatic + fun validate(sprite: Sprite, context: Context): Result { + // 1. Reconstruct parent references exactly like ProjectManager.initializeScripts() does. + try { + for (script in sprite.scriptList) { + script.setParents() + } + } catch (t: Throwable) { + Log.e(TAG, "Error setting parent references for sprite ${sprite.name}: ${t.message}", t) + return Result.Invalid("setParents failed: ${t.message}") + } + + for (script in sprite.scriptList) { + val flat = mutableListOf() + try { + script.addToFlatList(flat) + } catch (t: Throwable) { + Log.e( + TAG, + "Error flattening script ${script.javaClass.simpleName} in sprite ${sprite.name}: ${t.message}", + t + ) + return Result.Invalid("Malformed script structure: ${t.message}") + } + + // 2. Explicit formula-completeness check — the dominant crash cause, with a specific + // reason. + // FormulaBrick.getView() iterates brickFieldToTextViewIdMap and looks each field up in + // formulaMap; a field present in the former but missing from the latter throws at render time. + for (brick in flat) { + if (brick is FormulaBrick) { + for (field in brick.brickFieldToTextViewIdMap.keys) { + if (brick.formulaMap[field] == null) { + return Result.Invalid( + "${brick.javaClass.simpleName} is missing a required value ($field)" + ) + } + } + } + } + + // 3. Render dry-run — catch-all safety net. Mirrors BrickAdapter: getView() on every + // brick. + for (brick in flat) { + try { + brick.getView(context) + } catch (t: Throwable) { + Log.e( + TAG, + "Error rendering brick ${brick.javaClass.simpleName} in sprite ${sprite.name}: ${t.message}", + t + ) + return Result.Invalid("${brick.javaClass.simpleName} failed to render: ${t.message}") + } + } + } + + return Result.Valid + } +} diff --git a/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java b/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java index 059d7bdb59e..2bb20239138 100644 --- a/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java +++ b/catroid/src/main/java/org/catrobat/catroid/ui/recyclerview/fragment/ScriptFragment.java @@ -180,6 +180,10 @@ public void onCreate(@Nullable Bundle savedInstanceState) { private transient List savedLocalUserVariables; private transient List savedLocalLists; + private enum LoadSource {UNDO, AI_TUTOR} + + private LoadSource pendingLoadSource = null; + @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { MenuInflater inflater = mode.getMenuInflater(); @@ -926,7 +930,8 @@ public void loadProjectAfterUndoOption() { spriteActivity.setUndoMenuItemVisibility(false); spriteActivity.showUndo(false); } - new ProjectLoader(project.getDirectory(), context).setListener(this).loadProjectAsync(); + pendingLoadSource = LoadSource.UNDO; + reloadProjectFromDisk(); } catch (IOException exception) { Log.e(TAG, "Replacing project " + project.getName() + " failed.", exception); ToastUtil.showError(context, R.string.error_load_project); @@ -939,6 +944,37 @@ public void loadProjectAfterUndoOption() { } } + private void reloadProjectFromDisk() { + Project project = ProjectManager.getInstance().getCurrentProject(); + Context context = getContext(); + if (!isAdded() || context == null) { + return; + } + new ProjectLoader(project.getDirectory(), context).setListener(this).loadProjectAsync(); + } + + public void applyProjectFromAiTutor(String spriteXml) { + if (!copyProjectForUndoOption()) { + ToastUtil.showError(getContext(), R.string.error_load_project); + return; + } + showUndo(true); + + Sprite newSprite = XstreamSerializer.getInstance().getSpriteFromXmlString(spriteXml); + ProjectManager pm = ProjectManager.getInstance(); + Scene currentScene = pm.getCurrentlyEditedScene(); + Sprite currentSprite = pm.getCurrentSprite(); + int index = currentScene.getSpriteList().indexOf(currentSprite); + if (index >= 0) { + currentScene.getSpriteList().set(index, newSprite); + pm.setCurrentSprite(newSprite); + } + + XstreamSerializer.getInstance().saveProject(pm.getCurrentProject()); + + pendingLoadSource = LoadSource.AI_TUTOR; + reloadProjectFromDisk(); + } @Override public void onLoadFinished(boolean success) { @@ -963,20 +999,29 @@ public void onLoadFinished(boolean success) { loadVariables(); - if (spriteActivity != null) { - spriteActivity.setUndoMenuItemVisibility(false); - spriteActivity.showUndo(false); - } + boolean isAiTutorLoad = (pendingLoadSource == LoadSource.AI_TUTOR); + pendingLoadSource = null; - File undoCodeFile = new File(ProjectManager.getInstance().getCurrentProject().getDirectory(), UNDO_CODE_XML_FILE_NAME); - if (undoCodeFile.exists() && !undoCodeFile.delete()) { - Log.w(TAG, "Could not delete undo code file: " + undoCodeFile.getAbsolutePath()); + if (!isAiTutorLoad) { + if (spriteActivity != null) { + spriteActivity.setUndoMenuItemVisibility(false); + spriteActivity.showUndo(false); + } + File undoCodeFile = new File(ProjectManager.getInstance().getCurrentProject().getDirectory(), UNDO_CODE_XML_FILE_NAME); + if (undoCodeFile.exists() && !undoCodeFile.delete()) { + Log.w(TAG, "Could not delete undo code file: " + undoCodeFile.getAbsolutePath()); + } } if (getView() == null || listView == null) { return; } refreshFragmentAfterUndo(); + + if (isAiTutorLoad && spriteActivity != null) { + spriteActivity.setUndoMenuItemVisibility(true); + spriteActivity.showUndo(true); + } } private void saveVariables() { diff --git a/catroid/src/main/java/org/catrobat/catroid/utils/LiveDataExtensions.kt b/catroid/src/main/java/org/catrobat/catroid/utils/LiveDataExtensions.kt index 052bd104225..a7c64c139b6 100644 --- a/catroid/src/main/java/org/catrobat/catroid/utils/LiveDataExtensions.kt +++ b/catroid/src/main/java/org/catrobat/catroid/utils/LiveDataExtensions.kt @@ -68,8 +68,8 @@ fun LiveData.getOrAwaitValue( var data: T? = null val latch = CountDownLatch(1) val observer = object : Observer { - override fun onChanged(o: T?) { - data = o + override fun onChanged(value: T) { + data = value latch.countDown() this@getOrAwaitValue.removeObserver(this) } diff --git a/catroid/src/main/java/org/catrobat/catroid/web/CatrobatServerCalls.kt b/catroid/src/main/java/org/catrobat/catroid/web/CatrobatServerCalls.kt index 39f0f9580f6..de42361b21a 100644 --- a/catroid/src/main/java/org/catrobat/catroid/web/CatrobatServerCalls.kt +++ b/catroid/src/main/java/org/catrobat/catroid/web/CatrobatServerCalls.kt @@ -31,6 +31,8 @@ import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import okio.Okio +import okio.buffer +import okio.sink import org.catrobat.catroid.common.Constants import org.catrobat.catroid.web.ServerAuthenticationConstants.CHECK_EMAIL_AVAILABLE_URL import org.catrobat.catroid.web.ServerAuthenticationConstants.CHECK_GOOGLE_TOKEN_URL @@ -232,7 +234,7 @@ class CatrobatServerCalls(private val okHttpClient: OkHttpClient = CatrobatWebCl val originalResponse = chain.proceed(chain.request()) val body = ProgressResponseBody( - originalResponse.body(), + originalResponse.body, progressCallback ) originalResponse.newBuilder().body(body).build() @@ -248,13 +250,13 @@ class CatrobatServerCalls(private val okHttpClient: OkHttpClient = CatrobatWebCl try { val response = httpClient.newCall(request).execute() if (response.isSuccessful) { - val bufferedSink = Okio.buffer(Okio.sink(destination)) - response.body()?.let { bufferedSink.writeAll(it.source()) } + val bufferedSink = destination.sink().buffer() + response.body?.let { bufferedSink.writeAll(it.source()) } bufferedSink.close() successCallback.onSuccess() } else { Log.v(tag, "Download not successful") - errorCallback.onError(response.code(), "Download failed! HTTP Status code was " + response.code()) + errorCallback.onError(response.code, "Download failed! HTTP Status code was " + response.code) } } catch (ioException: IOException) { Log.e(tag, Log.getStackTraceString(ioException)) diff --git a/catroid/src/main/java/org/catrobat/catroid/web/CatrobatWebClient.kt b/catroid/src/main/java/org/catrobat/catroid/web/CatrobatWebClient.kt index 1d7e02af247..9ecc87ef49e 100644 --- a/catroid/src/main/java/org/catrobat/catroid/web/CatrobatWebClient.kt +++ b/catroid/src/main/java/org/catrobat/catroid/web/CatrobatWebClient.kt @@ -41,11 +41,11 @@ fun OkHttpClient.performCallWith(request: Request): String { var statusCode = WebConnectionException.ERROR_NETWORK try { val response = this.newCall(request).execute() - response.body()?.let { + response.body?.let { return it.string() } - statusCode = response.code() - message = response.message() + statusCode = response.code + message = response.message } catch (e: IOException) { e.message?.let { message = it diff --git a/catroid/src/main/java/org/catrobat/catroid/web/WebConnection.kt b/catroid/src/main/java/org/catrobat/catroid/web/WebConnection.kt index 2b6be735d91..04a2b5aaadc 100644 --- a/catroid/src/main/java/org/catrobat/catroid/web/WebConnection.kt +++ b/catroid/src/main/java/org/catrobat/catroid/web/WebConnection.kt @@ -84,7 +84,7 @@ class WebConnection(private val okHttpClient: OkHttpClient, listener: WebRequest if (response.isSuccessful) { onRequestSuccess(response) } else { - onRequestError(response.code().toString()) + onRequestError(response.code.toString()) } } } @@ -92,7 +92,7 @@ class WebConnection(private val okHttpClient: OkHttpClient, listener: WebRequest } fun cancelCall() { - okHttpClient.dispatcher()?.executorService()?.execute { + okHttpClient.dispatcher.executorService.execute { call?.cancel() } } diff --git a/catroid/src/main/java/org/catrobat/catroid/web/WebConnectionHolder.kt b/catroid/src/main/java/org/catrobat/catroid/web/WebConnectionHolder.kt index 37b544924f9..d8272eb2cc1 100644 --- a/catroid/src/main/java/org/catrobat/catroid/web/WebConnectionHolder.kt +++ b/catroid/src/main/java/org/catrobat/catroid/web/WebConnectionHolder.kt @@ -22,9 +22,10 @@ */ package org.catrobat.catroid.web -import okhttp3.ConnectionSpec.CLEARTEXT -import okhttp3.ConnectionSpec.COMPATIBLE_TLS -import okhttp3.ConnectionSpec.MODERN_TLS +import okhttp3.ConnectionSpec +import okhttp3.ConnectionSpec.Companion.CLEARTEXT +import okhttp3.ConnectionSpec.Companion.COMPATIBLE_TLS +import okhttp3.ConnectionSpec.Companion.MODERN_TLS import okhttp3.Dispatcher import okhttp3.OkHttpClient import java.util.ArrayList @@ -48,8 +49,8 @@ class WebConnectionHolder { .dispatcher(Dispatcher()) .build() - okHttpClient.dispatcher().maxRequests = MAX_CONNECTIONS - okHttpClient.dispatcher().maxRequestsPerHost = MAX_CONNECTIONS + okHttpClient.dispatcher.maxRequests = MAX_CONNECTIONS + okHttpClient.dispatcher.maxRequestsPerHost = MAX_CONNECTIONS } @Synchronized diff --git a/catroid/src/main/java/org/catrobat/catroid/web/requests/HttpRequests.kt b/catroid/src/main/java/org/catrobat/catroid/web/requests/HttpRequests.kt index 8b5b48c29f0..8a189055fb8 100644 --- a/catroid/src/main/java/org/catrobat/catroid/web/requests/HttpRequests.kt +++ b/catroid/src/main/java/org/catrobat/catroid/web/requests/HttpRequests.kt @@ -25,6 +25,7 @@ package org.catrobat.catroid.web.requests import android.util.Log import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.Request import okhttp3.RequestBody @@ -41,7 +42,7 @@ private const val PROJECT_DESCRIPTION_TAG = "projectDescription" private const val PROJECT_CHECKSUM_TAG = "fileChecksum" private const val USER_EMAIL = "userEmail" private const val DEVICE_LANGUAGE = "deviceLanguage" -private val MEDIA_TYPE_ZIPFILE = MediaType.parse("application/zip") +private val MEDIA_TYPE_ZIPFILE = "application/zip".toMediaTypeOrNull() private const val FILE_UPLOAD_URL = FlavoredConstants.BASE_UPLOAD_URL + "api/upload/upload.json" fun createUploadRequest( diff --git a/catroid/src/main/res/drawable/ic_edit_pencil.xml b/catroid/src/main/res/drawable/ic_edit_pencil.xml new file mode 100644 index 00000000000..c568816360d --- /dev/null +++ b/catroid/src/main/res/drawable/ic_edit_pencil.xml @@ -0,0 +1,33 @@ + + + + + diff --git a/catroid/src/main/res/drawable/ic_minus.xml b/catroid/src/main/res/drawable/ic_minus.xml new file mode 100644 index 00000000000..81b1651183a --- /dev/null +++ b/catroid/src/main/res/drawable/ic_minus.xml @@ -0,0 +1,32 @@ + + + + + diff --git a/catroid/src/main/res/layout/activity_sprite.xml b/catroid/src/main/res/layout/activity_sprite.xml index 252910463d2..62f0be01a08 100644 --- a/catroid/src/main/res/layout/activity_sprite.xml +++ b/catroid/src/main/res/layout/activity_sprite.xml @@ -22,26 +22,39 @@ ~ along with this program. If not, see . --> - + android:layout_height="match_parent"> - - - + android:layout_height="match_parent" + android:orientation="vertical" + android:id="@+id/activity_sprite"> + + - + android:layout_height="match_parent"> + + - - - \ No newline at end of file + + + + + + + \ No newline at end of file