TODO.md & Update dependencies

This commit is contained in:
Matéo Duparc 2023-04-26 16:02:07 +02:00
parent 2bbf003df5
commit e51bd2ceba
Signed by untrusted user: hardcoresushi
GPG Key ID: AFE384344A45E13A
9 changed files with 184 additions and 40 deletions

26
TODO.md Normal file
View File

@ -0,0 +1,26 @@
# TODO
Here is a list of features that it would be nice to have in DroidFS.
## Security
- [hardened_malloc](https://github.com/GrapheneOS/hardened_malloc) compatibility ([#181](https://github.com/hardcore-sushi/DroidFS/issues/181))
- Internal keyboard for passwords
## UX
- File associations editor
- Modifiable CryFS scrypt parameters
- Alert dialog showing details of file operations
- Internal file browser to select volumes
## Health
- F-Droid ABI split
- OpenSSL & FFmpeg as git submodules (useful for F-Droid)
- Remove all android:configChanges from AndroidManifest.xml
- More efficient thumbnails cache
- Guide for translators
- Usage & code documentation
- Automated tests
## And:
- All the [feature requests on the GitHub repo](https://github.com/hardcore-sushi/DroidFS/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement)
- All the [feature requests on the Gitea repo](https://forge.chapril.org/hardcoresushi/DroidFS/issues?q=&state=open&labels=748)

View File

@ -110,16 +110,16 @@ dependencies {
implementation "androidx.preference:preference-ktx:1.2.0" implementation "androidx.preference:preference-ktx:1.2.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'com.google.android.material:material:1.8.0' implementation 'com.google.android.material:material:1.8.0'
implementation "com.github.bumptech.glide:glide:4.13.2" implementation 'com.github.bumptech.glide:glide:4.15.1'
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
def exoplayer_version = "2.18.5" def exoplayer_version = "2.18.6"
implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version" implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version"
implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version" implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version"
implementation "androidx.concurrent:concurrent-futures:1.1.0" implementation "androidx.concurrent:concurrent-futures:1.1.0"
def camerax_version = "1.3.0-alpha05" def camerax_version = "1.3.0-alpha06"
implementation "androidx.camera:camera-camera2:$camerax_version" implementation "androidx.camera:camera-camera2:$camerax_version"
implementation "androidx.camera:camera-lifecycle:$camerax_version" implementation "androidx.camera:camera-lifecycle:$camerax_version"
implementation "androidx.camera:camera-view:$camerax_version" implementation "androidx.camera:camera-view:$camerax_version"

View File

@ -223,9 +223,9 @@ public final class SucklessRecorder implements VideoOutput {
*/ */
DISABLED, DISABLED,
/** /**
* The recording is being recorded with audio. * Audio recording is enabled for the running recording.
*/ */
ACTIVE, ENABLED,
/** /**
* The audio encoder encountered errors. * The audio encoder encountered errors.
*/ */
@ -817,6 +817,24 @@ public final class SucklessRecorder implements VideoOutput {
} }
} }
void mute(@NonNull SucklessRecording activeRecording, boolean muted) {
synchronized (mLock) {
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
activeRecording, mActiveRecordingRecord)) {
// If this Recording is no longer active, log and treat as a no-op.
// This is not technically an error since the recording can be finalized
// asynchronously.
Logger.d(TAG,
"mute() called on a recording that is no longer active: "
+ activeRecording.getOutputOptions());
return;
}
RecordingRecord finalRecordingRecord = isSameRecording(activeRecording,
mPendingRecordingRecord) ? mPendingRecordingRecord : mActiveRecordingRecord;
mSequentialExecutor.execute(() -> muteInternal(finalRecordingRecord, muted));
}
}
private void finalizePendingRecording(@NonNull RecordingRecord recordingToFinalize, private void finalizePendingRecording(@NonNull RecordingRecord recordingToFinalize,
@VideoRecordError int error, @Nullable Throwable cause) { @VideoRecordError int error, @Nullable Throwable cause) {
recordingToFinalize.finalizeRecording(Uri.EMPTY); recordingToFinalize.finalizeRecording(Uri.EMPTY);
@ -949,8 +967,8 @@ public final class SucklessRecorder implements VideoOutput {
(transformationInfo) -> mSurfaceTransformationInfo = transformationInfo); (transformationInfo) -> mSurfaceTransformationInfo = transformationInfo);
Size surfaceSize = surfaceRequest.getResolution(); Size surfaceSize = surfaceRequest.getResolution();
// Fetch and cache nearest encoder profiles, if one exists. // Fetch and cache nearest encoder profiles, if one exists.
VideoCapabilities capabilities = LegacyVideoCapabilities capabilities =
VideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo()); LegacyVideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo());
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize); Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize);
Logger.d(TAG, "Using supported quality of " + highestSupportedQuality Logger.d(TAG, "Using supported quality of " + highestSupportedQuality
+ " for surface size " + surfaceSize); + " for surface size " + surfaceSize);
@ -1368,13 +1386,13 @@ public final class SucklessRecorder implements VideoOutput {
// Fall-through // Fall-through
case ERROR_SOURCE: case ERROR_SOURCE:
// Fall-through // Fall-through
case ACTIVE: case ENABLED:
// Fall-through // Fall-through
case DISABLED: case DISABLED:
throw new AssertionError( throw new AssertionError(
"Incorrectly invoke startInternal in audio state " + mAudioState); "Incorrectly invoke startInternal in audio state " + mAudioState);
case IDLING: case IDLING:
setAudioState(recordingToStart.hasAudioEnabled() ? AudioState.ACTIVE setAudioState(recordingToStart.hasAudioEnabled() ? AudioState.ENABLED
: AudioState.DISABLED); : AudioState.DISABLED);
break; break;
case INITIALIZING: case INITIALIZING:
@ -1385,7 +1403,7 @@ public final class SucklessRecorder implements VideoOutput {
} }
try { try {
setupAudio(recordingToStart); setupAudio(recordingToStart);
setAudioState(AudioState.ACTIVE); setAudioState(AudioState.ENABLED);
} catch (AudioSourceAccessException | InvalidConfigException e) { } catch (AudioSourceAccessException | InvalidConfigException e) {
Logger.e(TAG, "Unable to create audio resource with error: ", e); Logger.e(TAG, "Unable to create audio resource with error: ", e);
AudioState audioState; AudioState audioState;
@ -1403,7 +1421,7 @@ public final class SucklessRecorder implements VideoOutput {
initEncoderAndAudioSourceCallbacks(recordingToStart); initEncoderAndAudioSourceCallbacks(recordingToStart);
if (isAudioEnabled()) { if (isAudioEnabled()) {
mAudioSource.start(); mAudioSource.start(recordingToStart.isMuted());
mAudioEncoder.start(); mAudioEncoder.start();
} }
mVideoEncoder.start(); mVideoEncoder.start();
@ -1535,8 +1553,6 @@ public final class SucklessRecorder implements VideoOutput {
public void onSilenceStateChanged(boolean silenced) { public void onSilenceStateChanged(boolean silenced) {
if (mIsAudioSourceSilenced != silenced) { if (mIsAudioSourceSilenced != silenced) {
mIsAudioSourceSilenced = silenced; mIsAudioSourceSilenced = silenced;
mAudioErrorCause = silenced ? new IllegalStateException(
"The audio source has been silenced.") : null;
updateInProgressStatusEvent(); updateInProgressStatusEvent();
} else { } else {
Logger.w(TAG, "Audio source silenced transitions" Logger.w(TAG, "Audio source silenced transitions"
@ -1579,9 +1595,9 @@ public final class SucklessRecorder implements VideoOutput {
@Override @Override
public void onEncodedData(@NonNull EncodedData encodedData) { public void onEncodedData(@NonNull EncodedData encodedData) {
if (mAudioState == AudioState.DISABLED) { if (mAudioState == AudioState.DISABLED) {
throw new AssertionError( encodedData.close();
"Audio is not enabled but audio encoded data is " throw new AssertionError("Audio is not enabled but audio "
+ "produced."); + "encoded data is being produced.");
} }
// If the media muxer doesn't yet exist, we may need to create and // If the media muxer doesn't yet exist, we may need to create and
@ -1847,6 +1863,19 @@ public final class SucklessRecorder implements VideoOutput {
} }
} }
@ExecutedBy("mSequentialExecutor")
private void muteInternal(@NonNull RecordingRecord recordingToMute, boolean muted) {
if (recordingToMute.isMuted() == muted) {
return;
}
recordingToMute.mute(muted);
// Only mute/unmute audio source if recording is in-progress and it is not already stopping.
if (mInProgressRecording == recordingToMute && !mInProgressRecordingStopping
&& mAudioSource != null) {
mAudioSource.mute(muted);
}
}
@SuppressWarnings("WeakerAccess") /* synthetic accessor */ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
static void notifyEncoderSourceStopped(@NonNull Encoder encoder) { static void notifyEncoderSourceStopped(@NonNull Encoder encoder) {
if (encoder instanceof SucklessEncoderImpl) { if (encoder instanceof SucklessEncoderImpl) {
@ -1941,8 +1970,10 @@ public final class SucklessRecorder implements VideoOutput {
// Audio will not be initialized until the first recording with audio enabled is // Audio will not be initialized until the first recording with audio enabled is
// started. So if the audio state is INITIALIZING, consider the audio is disabled. // started. So if the audio state is INITIALIZING, consider the audio is disabled.
return AudioStats.AUDIO_STATE_DISABLED; return AudioStats.AUDIO_STATE_DISABLED;
case ACTIVE: case ENABLED:
if (mIsAudioSourceSilenced) { if (mInProgressRecording != null && mInProgressRecording.isMuted()) {
return AudioStats.AUDIO_STATE_MUTED;
} else if (mIsAudioSourceSilenced) {
return AudioStats.AUDIO_STATE_SOURCE_SILENCED; return AudioStats.AUDIO_STATE_SOURCE_SILENCED;
} else { } else {
return AudioStats.AUDIO_STATE_ACTIVE; return AudioStats.AUDIO_STATE_ACTIVE;
@ -1971,7 +2002,7 @@ public final class SucklessRecorder implements VideoOutput {
@SuppressWarnings("WeakerAccess") /* synthetic accessor */ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
@ExecutedBy("mSequentialExecutor") @ExecutedBy("mSequentialExecutor")
boolean isAudioEnabled() { boolean isAudioEnabled() {
return mAudioState == AudioState.ACTIVE; return mAudioState == AudioState.ENABLED;
} }
@SuppressWarnings("WeakerAccess") /* synthetic accessor */ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
@ -2043,7 +2074,7 @@ public final class SucklessRecorder implements VideoOutput {
break; break;
case DISABLED: case DISABLED:
// Fall-through // Fall-through
case ACTIVE: case ENABLED:
setAudioState(AudioState.IDLING); setAudioState(AudioState.IDLING);
mAudioSource.stop(); mAudioSource.stop();
break; break;
@ -2497,6 +2528,8 @@ public final class SucklessRecorder implements VideoOutput {
/* no-op by default */ /* no-op by default */
}); });
private final AtomicBoolean mMuted = new AtomicBoolean(false);
@NonNull @NonNull
static RecordingRecord from(@NonNull SucklessPendingRecording pendingRecording, long recordingId) { static RecordingRecord from(@NonNull SucklessPendingRecording pendingRecording, long recordingId) {
return new AutoValue_SucklessRecorder_RecordingRecord( return new AutoValue_SucklessRecorder_RecordingRecord(
@ -2769,6 +2802,14 @@ public final class SucklessRecorder implements VideoOutput {
finalizeRecordingInternal(mRecordingFinalizer.getAndSet(null), uri); finalizeRecordingInternal(mRecordingFinalizer.getAndSet(null), uri);
} }
void mute(boolean muted) {
mMuted.set(muted);
}
boolean isMuted() {
return mMuted.get();
}
/** /**
* Close this recording, as if calling {@link #finalizeRecording(Uri)} with parameter * Close this recording, as if calling {@link #finalizeRecording(Uri)} with parameter
* {@link Uri#EMPTY}. * {@link Uri#EMPTY}.

View File

@ -159,6 +159,25 @@ public final class SucklessRecording implements AutoCloseable {
close(); close();
} }
/**
* Mutes or un-mutes the current recording.
*
* <p>The output file will contain an audio track even the whole recording is muted. Create a
* recording without calling {@link PendingRecording#withAudioEnabled()} to record a file
* with no audio track.
*
* <p>Muting or unmuting a recording that isn't created
* {@link PendingRecording#withAudioEnabled()} with audio enabled is no-op.
*
* @param muted mutes the recording if {@code true}, un-mutes otherwise.
*/
public void mute(boolean muted) {
if (mIsClosed.get()) {
throw new IllegalStateException("The recording has been stopped.");
}
mRecorder.mute(this, muted);
}
/** /**
* Close this recording. * Close this recording.
* *

View File

@ -1675,4 +1675,3 @@ public class SucklessEncoderImpl implements Encoder {
} }
} }

View File

@ -1683,4 +1683,3 @@ public class EncoderImpl implements Encoder {
} }
} }

View File

@ -226,9 +226,9 @@ public final class Recorder implements VideoOutput {
*/ */
DISABLED, DISABLED,
/** /**
* The recording is being recorded with audio. * Audio recording is enabled for the running recording.
*/ */
ACTIVE, ENABLED,
/** /**
* The audio encoder encountered errors. * The audio encoder encountered errors.
*/ */
@ -907,6 +907,24 @@ public final class Recorder implements VideoOutput {
} }
} }
void mute(@NonNull Recording activeRecording, boolean muted) {
synchronized (mLock) {
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
activeRecording, mActiveRecordingRecord)) {
// If this Recording is no longer active, log and treat as a no-op.
// This is not technically an error since the recording can be finalized
// asynchronously.
Logger.d(TAG,
"mute() called on a recording that is no longer active: "
+ activeRecording.getOutputOptions());
return;
}
RecordingRecord finalRecordingRecord = isSameRecording(activeRecording,
mPendingRecordingRecord) ? mPendingRecordingRecord : mActiveRecordingRecord;
mSequentialExecutor.execute(() -> muteInternal(finalRecordingRecord, muted));
}
}
private void finalizePendingRecording(@NonNull RecordingRecord recordingToFinalize, private void finalizePendingRecording(@NonNull RecordingRecord recordingToFinalize,
@VideoRecordError int error, @Nullable Throwable cause) { @VideoRecordError int error, @Nullable Throwable cause) {
recordingToFinalize.finalizeRecording(Uri.EMPTY); recordingToFinalize.finalizeRecording(Uri.EMPTY);
@ -1039,8 +1057,8 @@ public final class Recorder implements VideoOutput {
(transformationInfo) -> mSurfaceTransformationInfo = transformationInfo); (transformationInfo) -> mSurfaceTransformationInfo = transformationInfo);
Size surfaceSize = surfaceRequest.getResolution(); Size surfaceSize = surfaceRequest.getResolution();
// Fetch and cache nearest encoder profiles, if one exists. // Fetch and cache nearest encoder profiles, if one exists.
VideoCapabilities capabilities = LegacyVideoCapabilities capabilities =
VideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo()); LegacyVideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo());
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize); Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize);
Logger.d(TAG, "Using supported quality of " + highestSupportedQuality Logger.d(TAG, "Using supported quality of " + highestSupportedQuality
+ " for surface size " + surfaceSize); + " for surface size " + surfaceSize);
@ -1473,13 +1491,13 @@ public final class Recorder implements VideoOutput {
// Fall-through // Fall-through
case ERROR_SOURCE: case ERROR_SOURCE:
// Fall-through // Fall-through
case ACTIVE: case ENABLED:
// Fall-through // Fall-through
case DISABLED: case DISABLED:
throw new AssertionError( throw new AssertionError(
"Incorrectly invoke startInternal in audio state " + mAudioState); "Incorrectly invoke startInternal in audio state " + mAudioState);
case IDLING: case IDLING:
setAudioState(recordingToStart.hasAudioEnabled() ? AudioState.ACTIVE setAudioState(recordingToStart.hasAudioEnabled() ? AudioState.ENABLED
: AudioState.DISABLED); : AudioState.DISABLED);
break; break;
case INITIALIZING: case INITIALIZING:
@ -1490,7 +1508,7 @@ public final class Recorder implements VideoOutput {
} }
try { try {
setupAudio(recordingToStart); setupAudio(recordingToStart);
setAudioState(AudioState.ACTIVE); setAudioState(AudioState.ENABLED);
} catch (AudioSourceAccessException | InvalidConfigException e) { } catch (AudioSourceAccessException | InvalidConfigException e) {
Logger.e(TAG, "Unable to create audio resource with error: ", e); Logger.e(TAG, "Unable to create audio resource with error: ", e);
AudioState audioState; AudioState audioState;
@ -1508,7 +1526,7 @@ public final class Recorder implements VideoOutput {
initEncoderAndAudioSourceCallbacks(recordingToStart); initEncoderAndAudioSourceCallbacks(recordingToStart);
if (isAudioEnabled()) { if (isAudioEnabled()) {
mAudioSource.start(); mAudioSource.start(recordingToStart.isMuted());
mAudioEncoder.start(); mAudioEncoder.start();
} }
mVideoEncoder.start(); mVideoEncoder.start();
@ -1640,8 +1658,6 @@ public final class Recorder implements VideoOutput {
public void onSilenceStateChanged(boolean silenced) { public void onSilenceStateChanged(boolean silenced) {
if (mIsAudioSourceSilenced != silenced) { if (mIsAudioSourceSilenced != silenced) {
mIsAudioSourceSilenced = silenced; mIsAudioSourceSilenced = silenced;
mAudioErrorCause = silenced ? new IllegalStateException(
"The audio source has been silenced.") : null;
updateInProgressStatusEvent(); updateInProgressStatusEvent();
} else { } else {
Logger.w(TAG, "Audio source silenced transitions" Logger.w(TAG, "Audio source silenced transitions"
@ -1684,9 +1700,9 @@ public final class Recorder implements VideoOutput {
@Override @Override
public void onEncodedData(@NonNull EncodedData encodedData) { public void onEncodedData(@NonNull EncodedData encodedData) {
if (mAudioState == AudioState.DISABLED) { if (mAudioState == AudioState.DISABLED) {
throw new AssertionError( encodedData.close();
"Audio is not enabled but audio encoded data is " throw new AssertionError("Audio is not enabled but audio "
+ "produced."); + "encoded data is being produced.");
} }
// If the media muxer doesn't yet exist, we may need to create and // If the media muxer doesn't yet exist, we may need to create and
@ -1952,6 +1968,19 @@ public final class Recorder implements VideoOutput {
} }
} }
@ExecutedBy("mSequentialExecutor")
private void muteInternal(@NonNull RecordingRecord recordingToMute, boolean muted) {
if (recordingToMute.isMuted() == muted) {
return;
}
recordingToMute.mute(muted);
// Only mute/unmute audio source if recording is in-progress and it is not already stopping.
if (mInProgressRecording == recordingToMute && !mInProgressRecordingStopping
&& mAudioSource != null) {
mAudioSource.mute(muted);
}
}
@SuppressWarnings("WeakerAccess") /* synthetic accessor */ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
static void notifyEncoderSourceStopped(@NonNull Encoder encoder) { static void notifyEncoderSourceStopped(@NonNull Encoder encoder) {
if (encoder instanceof EncoderImpl) { if (encoder instanceof EncoderImpl) {
@ -2046,8 +2075,10 @@ public final class Recorder implements VideoOutput {
// Audio will not be initialized until the first recording with audio enabled is // Audio will not be initialized until the first recording with audio enabled is
// started. So if the audio state is INITIALIZING, consider the audio is disabled. // started. So if the audio state is INITIALIZING, consider the audio is disabled.
return AudioStats.AUDIO_STATE_DISABLED; return AudioStats.AUDIO_STATE_DISABLED;
case ACTIVE: case ENABLED:
if (mIsAudioSourceSilenced) { if (mInProgressRecording != null && mInProgressRecording.isMuted()) {
return AudioStats.AUDIO_STATE_MUTED;
} else if (mIsAudioSourceSilenced) {
return AudioStats.AUDIO_STATE_SOURCE_SILENCED; return AudioStats.AUDIO_STATE_SOURCE_SILENCED;
} else { } else {
return AudioStats.AUDIO_STATE_ACTIVE; return AudioStats.AUDIO_STATE_ACTIVE;
@ -2076,7 +2107,7 @@ public final class Recorder implements VideoOutput {
@SuppressWarnings("WeakerAccess") /* synthetic accessor */ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
@ExecutedBy("mSequentialExecutor") @ExecutedBy("mSequentialExecutor")
boolean isAudioEnabled() { boolean isAudioEnabled() {
return mAudioState == AudioState.ACTIVE; return mAudioState == AudioState.ENABLED;
} }
@SuppressWarnings("WeakerAccess") /* synthetic accessor */ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
@ -2148,7 +2179,7 @@ public final class Recorder implements VideoOutput {
break; break;
case DISABLED: case DISABLED:
// Fall-through // Fall-through
case ACTIVE: case ENABLED:
setAudioState(AudioState.IDLING); setAudioState(AudioState.IDLING);
mAudioSource.stop(); mAudioSource.stop();
break; break;
@ -2602,6 +2633,8 @@ public final class Recorder implements VideoOutput {
/* no-op by default */ /* no-op by default */
}); });
private final AtomicBoolean mMuted = new AtomicBoolean(false);
@NonNull @NonNull
static RecordingRecord from(@NonNull PendingRecording pendingRecording, long recordingId) { static RecordingRecord from(@NonNull PendingRecording pendingRecording, long recordingId) {
return new AutoValue_Recorder_RecordingRecord( return new AutoValue_Recorder_RecordingRecord(
@ -2930,6 +2963,14 @@ public final class Recorder implements VideoOutput {
finalizeRecordingInternal(mRecordingFinalizer.getAndSet(null), uri); finalizeRecordingInternal(mRecordingFinalizer.getAndSet(null), uri);
} }
void mute(boolean muted) {
mMuted.set(muted);
}
boolean isMuted() {
return mMuted.get();
}
/** /**
* Close this recording, as if calling {@link #finalizeRecording(Uri)} with parameter * Close this recording, as if calling {@link #finalizeRecording(Uri)} with parameter
* {@link Uri#EMPTY}. * {@link Uri#EMPTY}.

View File

@ -156,6 +156,25 @@ public final class Recording implements AutoCloseable {
close(); close();
} }
/**
* Mutes or un-mutes the current recording.
*
* <p>The output file will contain an audio track even the whole recording is muted. Create a
* recording without calling {@link PendingRecording#withAudioEnabled()} to record a file
* with no audio track.
*
* <p>Muting or unmuting a recording that isn't created
* {@link PendingRecording#withAudioEnabled()} with audio enabled is no-op.
*
* @param muted mutes the recording if {@code true}, un-mutes otherwise.
*/
public void mute(boolean muted) {
if (mIsClosed.get()) {
throw new IllegalStateException("The recording has been stopped.");
}
mRecorder.mute(this, muted);
}
/** /**
* Close this recording. * Close this recording.
* *

View File

@ -1,5 +1,5 @@
buildscript { buildscript {
ext.kotlin_version = "1.8.10" ext.kotlin_version = '1.8.21'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()