Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117866049
D5472.1775321984.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
25 KB
Referenced Files
None
Subscribers
None
D5472.1775321984.diff
View Options
diff --git a/src/app/Backends/Roundcube.php b/src/app/Backends/Roundcube.php
--- a/src/app/Backends/Roundcube.php
+++ b/src/app/Backends/Roundcube.php
@@ -5,6 +5,7 @@
use App\User;
use App\UserAlias;
use Illuminate\Database\ConnectionInterface;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
@@ -293,6 +294,24 @@
return true;
}
+ /**
+ * Reset the cache entry for webmail configuration passed to the Kolab plugin.
+ *
+ * @param User $user User
+ */
+ public static function resetConfigCache(User $user): void
+ {
+ $user_id = self::userId($user->email, false);
+
+ if (!$user_id) {
+ return;
+ }
+
+ $cache_key = "{$user_id}:kolab_client:get:api/v4/config/webmail";
+
+ Cache::store('roundcube')->forget($cache_key);
+ }
+
/**
* Validate user identities, remove those that user no longer has access to.
*
diff --git a/src/app/Console/Commands/User/DebugStartCommand.php b/src/app/Console/Commands/User/DebugStartCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/User/DebugStartCommand.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Console\Commands\User;
+
+use App\Console\Command;
+
+class DebugStartCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'user:debug-start {user} {mode?}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Enable debug for user.';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $user = $this->getUser($this->argument('user'));
+
+ if (!$user) {
+ $this->error("User not found.");
+ return 1;
+ }
+
+ $mode = \strtolower($this->argument('mode') ?: 'roundcube,syncroton,chwala');
+
+ $user->setSetting('debug', $mode);
+ }
+}
diff --git a/src/app/Console/Commands/User/DebugStopCommand.php b/src/app/Console/Commands/User/DebugStopCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/User/DebugStopCommand.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Console\Commands\User;
+
+use App\Console\Command;
+
+class DebugStopCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'user:debug-stop {user}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Disable debug for user.';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $user = $this->getUser($this->argument('user'));
+
+ if (!$user) {
+ $this->error("User not found.");
+ return 1;
+ }
+
+ $user->removeSetting('debug');
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
--- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
@@ -411,16 +411,20 @@
return $this->errorResponse(403);
}
- // For now admins can change only user external email address
+ // For now admins can change only user external email address and debug mode
$rules = [];
+ $input = $request->input();
- if (array_key_exists('external_email', $request->input())) {
+ if (array_key_exists('external_email', $input)) {
$rules['external_email'] = 'email';
}
+ if (array_key_exists('debug', $input) && $this->guard()->user()->role == User::ROLE_ADMIN) {
+ $rules['debug'] = 'nullable|string|max:255';
+ }
// Validate input
- $v = Validator::make($request->all(), $rules);
+ $v = Validator::make($input, $rules);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
diff --git a/src/app/Http/Controllers/API/V4/ConfigController.php b/src/app/Http/Controllers/API/V4/ConfigController.php
--- a/src/app/Http/Controllers/API/V4/ConfigController.php
+++ b/src/app/Http/Controllers/API/V4/ConfigController.php
@@ -3,11 +3,14 @@
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
+use App\UserSetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ConfigController extends Controller
{
+ public const DEBUG_TTL = 12; // hours
+
/**
* Get the per-user webmail configuration.
*
@@ -42,6 +45,16 @@
$config['kolab-configuration-overlays'][] = 'groupware';
}
+ if ($debug_setting = $user->settings()->where('key', 'debug')->first()) {
+ /** @var UserSetting $debug_setting */
+ // Make sure the setting didn't expire
+ if ($debug_setting->updated_at->isBefore(now()->subHours(self::DEBUG_TTL))) {
+ $debug_setting->delete();
+ } else {
+ $config['debug'] = $debug_setting->value;
+ }
+ }
+
// TODO: Per-domain configuration, e.g. skin/logo
// $config['skin'] = 'apostrophy';
// $config['skin_logo'] = 'data:image/svg+xml;base64,'
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -459,7 +459,7 @@
$response['isLocked'] = !$user->isActive() && $wallet->plan()?->mode == Plan::MODE_MANDATE;
// Settings
- $keys = array_merge(self::USER_SETTINGS, ['password_expired']);
+ $keys = array_merge(self::USER_SETTINGS, ['password_expired', 'debug']);
$response['settings'] = $user->settings()->whereIn('key', $keys)->pluck('value', 'key')->all();
// Status info
diff --git a/src/app/Observers/UserSettingObserver.php b/src/app/Observers/UserSettingObserver.php
--- a/src/app/Observers/UserSettingObserver.php
+++ b/src/app/Observers/UserSettingObserver.php
@@ -3,6 +3,7 @@
namespace App\Observers;
use App\Jobs\User\UpdateJob;
+use App\Support\Facades\Roundcube;
use App\UserSetting;
class UserSettingObserver
@@ -47,5 +48,9 @@
if ($userSetting->isBackendSetting()) {
UpdateJob::dispatch($userSetting->user_id);
}
+
+ if ($userSetting->key === 'debug') {
+ Roundcube::resetConfigCache($userSetting->user);
+ }
}
}
diff --git a/src/app/UserSetting.php b/src/app/UserSetting.php
--- a/src/app/UserSetting.php
+++ b/src/app/UserSetting.php
@@ -17,6 +17,12 @@
{
use BelongsToUserTrait;
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'created_at' => 'datetime:Y-m-d H:i:s',
+ 'updated_at' => 'datetime:Y-m-d H:i:s',
+ ];
+
/** @var list<string> The attributes that are mass assignable */
protected $fillable = ['user_id', 'key', 'value'];
diff --git a/src/config/cache.php b/src/config/cache.php
--- a/src/config/cache.php
+++ b/src/config/cache.php
@@ -74,6 +74,15 @@
'lock_connection' => 'default',
],
+ 'roundcube' => [
+ 'driver' => 'redis',
+ 'connection' => 'roundcube',
+ 'lock_connection' => null,
+ // Unset the Laravel's cache prefix
+ // Note: It seems it have to be in both config/database.php and here
+ 'prefix' => '',
+ ],
+
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
diff --git a/src/config/database.php b/src/config/database.php
--- a/src/config/database.php
+++ b/src/config/database.php
@@ -144,5 +144,17 @@
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_CACHE_DB', 1),
],
+
+ 'roundcube' => [
+ 'url' => env('REDIS_URL'),
+ 'host' => env('REDIS_HOST', '127.0.0.1'),
+ 'username' => env('REDIS_USERNAME'),
+ 'password' => env('REDIS_PASSWORD'),
+ 'port' => env('REDIS_PORT', 6379),
+ 'database' => env('REDIS_CACHE_DB', 1),
+ // Unset Laravel's cache prefix
+ // FIXME: It seems it have to be in both config/cache.php and here
+ 'prefix' => '',
+ ],
],
];
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -20,6 +20,7 @@
],
'btn' => [
+ 'actions' => "Actions",
'add' => "Add",
'accept' => "Accept",
'back' => "Back",
@@ -481,6 +482,8 @@
'suspended' => "Suspended",
'notready' => "Not Ready",
'active' => "Active",
+ 'on' => "On",
+ 'off' => "Off",
],
'support' => [
@@ -513,6 +516,7 @@
'country' => "Country",
'create' => "Create user",
'custno' => "Customer No.",
+ 'debug-mode' => "Debug mode",
'degraded-warning' => "The account is degraded. Some features have been disabled.",
'degraded-hint' => "Please, make a payment.",
'delegation' => "Delegation",
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -2,7 +2,20 @@
<div class="container">
<div class="card" id="user-info">
<div class="card-body">
- <h1 class="card-title">{{ user.email }}</h1>
+ <h1 class="card-title lh-base">
+ {{ user.email }}
+ <div class="dropdown float-end">
+ <btn class="btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">{{ $t('btn.actions') }}</btn>
+ <ul class="dropdown-menu">
+ <li><btn :id="`button-${suspendAction}`" class="dropdown-item" @click="setSuspendState">{{ $t(`btn.${suspendAction}`) }}</btn></li>
+ <li><btn id="button-resync" class="dropdown-item" @click="resyncUser">{{ $t('btn.resync') }}</btn></li>
+ <li v-if="isAdmin"><btn id="button-debug" class="dropdown-item" @click="$refs.debugDialog.show()">
+ {{ $t('user.debug-mode') }}
+ <span :class="'float-end badge bg-danger rounded-pill' + (user.settings.debug ? '' : ' d-none')" style="top:1px">{{ $t('status.on') }}</span>
+ </btn></li>
+ </ul>
+ </div>
+ </h1>
<div class="card-text">
<form class="read-only short">
<div v-if="user.wallet.user_id != user.id" class="row plaintext">
@@ -77,14 +90,6 @@
</div>
</div>
</form>
- <div class="mt-2 buttons">
- <btn :id="`button-${suspendAction}`" class="btn-outline-primary" @click="setSuspendState">
- {{ $t(`btn.${suspendAction}`) }}
- </btn>
- <btn id="button-resync" class="btn-outline-primary" @click="resyncUser">
- {{ $t('btn.resync') }}
- </btn>
- </div>
</div>
</div>
</div>
@@ -258,6 +263,13 @@
<textarea v-model="comment" name="comment" class="form-control" :placeholder="$t('form.comment')" rows="3"></textarea>
</modal-dialog>
+ <modal-dialog id="debug-dialog" ref="debugDialog" :title="$t('user.debug-mode')" @click="submitDebug()" :buttons="['submit']">
+ <div class="form-check" v-for="mode in debug_modes" :key="mode">
+ <input :id="`debug_${mode}`" :value="mode" type="checkbox" class="form-check-input" :checked="hasDebug(mode)">
+ <label :for="`debug_${mode}`" class="form-check-label">{{ mode }}</label>
+ </div>
+ </modal-dialog>
+
<modal-dialog id="oneoff-dialog" ref="oneoffDialog" @click="submitOneOff()" :buttons="['submit']"
:title="$t(oneoff_negative ? 'user.add-penalty-title' : 'user.add-bonus-title')"
>
@@ -338,6 +350,7 @@
footLabel: 'user.aliases-none'
},
comment: '',
+ debug_modes: [ 'Roundcube', 'Syncroton', 'Chwala' ],
discount: 0,
discount_description: '',
discounts: [],
@@ -353,6 +366,7 @@
walletReload: false,
distlists: [],
domains: [],
+ isAdmin: window.isAdmin,
resources: [],
sku2FA: null,
skus: [],
@@ -552,6 +566,9 @@
this.$root.clearFormValidation($('#email-dialog'))
this.$refs.emailDialog.show()
},
+ hasDebug(mode) {
+ return String(this.user.settings.debug).includes(mode.toLowerCase())
+ },
setMandateState() {
let mandate = this.wallet.mandate
if (mandate && mandate.id) {
@@ -624,6 +641,22 @@
}
})
},
+ submitDebug() {
+ let debug = []
+ $(this.$refs.debugDialog.$el).find('input:checked').each((i, elem) => debug.push(elem.value.toLowerCase()))
+ debug = debug.length ? debug.join() : null
+
+ axios.put('/api/v4/users/' + this.user.id, { debug })
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$refs.debugDialog.hide()
+ this.$toast.success(response.data.message)
+ this.user.settings.debug = debug
+ // we have to update the badge manually (because of some Bootstrap dropdown magic?)
+ $('#button-debug .badge')[debug ? 'removeClass' : 'addClass']('d-none')
+ }
+ })
+ },
submitEmail() {
axios.put('/api/v4/users/' + this.user.id, { external_email: this.external_email })
.then(response => {
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -11,6 +11,7 @@
use App\Utils;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
+use Tests\Browser\Components\Dropdown;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
@@ -29,6 +30,7 @@
'phone' => '+48123123123',
'external_email' => 'john.doe.external@gmail.com',
'password_expired' => '2020-01-01 10:10:10',
+ 'greylist_policy' => null,
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
@@ -91,6 +93,8 @@
'limit_geo' => null,
'organization' => null,
'guam_enabled' => null,
+ 'greylist_enabled' => null,
+ 'greylist_policy' => null,
]);
$event1 = EventLog::createFor($jack, EventLog::TYPE_SUSPENDED, 'Event 1');
@@ -208,7 +212,7 @@
->whenAvailable('@user-settings form', static function (Browser $browser) {
$browser->assertElementsCount('.row', 3)
->assertSeeIn('.row:first-child label', 'Greylisting')
- ->assertSeeIn('.row:first-child .text-success', 'enabled')
+ ->assertSeeIn('.row:first-child .text-danger', 'disabled')
->assertSeeIn('.row:nth-child(2) label', 'IMAP proxy')
->assertSeeIn('.row:nth-child(2) .text-danger', 'disabled')
->assertSeeIn('.row:nth-child(3) label', 'Geo-lockin')
@@ -604,9 +608,10 @@
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
- ->assertVisible('@user-info #button-suspend')
- ->assertMissing('@user-info #button-unsuspend')
- ->click('@user-info #button-suspend')
+ ->with(new Dropdown('h1 div.dropdown'), static function (Browser $browser) {
+ $browser->assertButton('Actions', 'btn-outline-primary')
+ ->clickDropdownItem('#button-suspend', 'Suspend');
+ })
->with(new Dialog('#suspend-dialog'), static function (Browser $browser) {
$browser->assertSeeIn('@title', 'Suspend')
->assertSeeIn('@button-cancel', 'Cancel')
@@ -615,13 +620,14 @@
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.')
- ->assertSeeIn('@user-info #status span.text-warning', 'Suspended')
- ->assertMissing('@user-info #button-suspend');
+ ->assertSeeIn('@user-info #status span.text-warning', 'Suspended');
$event = EventLog::where('type', EventLog::TYPE_SUSPENDED)->first();
$this->assertSame('test suspend', $event->comment);
- $browser->click('@user-info #button-unsuspend')
+ $browser->with(new Dropdown('h1 div.dropdown'), static function (Browser $browser) {
+ $browser->clickDropdownItem('#button-unsuspend', 'Unsuspend');
+ })
->with(new Dialog('#suspend-dialog'), static function (Browser $browser) {
$browser->assertSeeIn('@title', 'Unsuspend')
->assertSeeIn('@button-cancel', 'Cancel')
@@ -630,8 +636,12 @@
})
->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.')
->assertSeeIn('@user-info #status span.text-success', 'Active')
- ->assertVisible('@user-info #button-suspend')
- ->assertMissing('@user-info #button-unsuspend');
+ ->with(new Dropdown('h1 div.dropdown'), static function (Browser $browser) {
+ $browser->clickDropdownItem('#button-suspend', 'Suspend');
+ })
+ ->with(new Dialog('#suspend-dialog'), static function (Browser $browser) {
+ $browser->click('@button-cancel');
+ });
$event = EventLog::where('type', EventLog::TYPE_UNSUSPENDED)->first();
$this->assertNull($event->comment);
@@ -647,8 +657,10 @@
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
- ->assertSeeIn('@user-info #button-resync', 'Resync')
- ->click('@user-info #button-resync')
+ ->with(new Dropdown('h1 div.dropdown'), static function (Browser $browser) {
+ $browser->assertButton('Actions', 'btn-outline-primary')
+ ->clickDropdownItem('#button-resync', 'Resync');
+ })
->assertToast(Toast::TYPE_SUCCESS, "User synchronization has been started.");
});
}
@@ -686,6 +698,61 @@
});
}
+ /**
+ * Test setting debug mode for the user
+ */
+ public function testDebugMode(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $user = $this->getTestUser('userstest1@kolabnow.com');
+
+ $browser->visit(new UserPage($user->id))
+ ->with(new Dropdown('h1 div.dropdown'), static function (Browser $browser) {
+ $browser->click('@button')
+ ->assertMissing('#button-debug > span.badge')
+ ->click('@button')
+ ->clickDropdownItem('#button-debug', 'Debug mode');
+ })
+ ->with(new Dialog('#debug-dialog'), static function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Debug mode')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Submit')
+ ->assertNotChecked('input#debug_Roundcube')
+ ->assertNotChecked('input#debug_Syncroton')
+ ->assertNotChecked('input#debug_Chwala')
+ ->click('input#debug_Syncroton')
+ ->click('input#debug_Chwala')
+ ->click('@button-action');
+ })
+ ->assertToast(Toast::TYPE_SUCCESS, "User data updated successfully.");
+
+ $this->assertSame('syncroton,chwala', $user->getSetting('debug'));
+
+ $browser->with(new Dropdown('h1 div.dropdown'), static function (Browser $browser) {
+ $browser->click('@button')
+ ->assertSeeIn('#button-debug > span.badge', 'On')
+ ->click('@button')
+ ->clickDropdownItem('#button-debug', 'Debug mode');
+ })
+ ->with(new Dialog('#debug-dialog'), static function (Browser $browser) {
+ $browser->assertNotChecked('input#debug_Roundcube')
+ ->assertChecked('input#debug_Syncroton')
+ ->assertChecked('input#debug_Chwala')
+ ->click('input#debug_Syncroton')
+ ->click('input#debug_Chwala')
+ ->click('@button-action');
+ })
+ ->assertToast(Toast::TYPE_SUCCESS, "User data updated successfully.")
+ ->with(new Dropdown('h1 div.dropdown'), static function (Browser $browser) {
+ $browser->click('@button')
+ ->assertMissing('#button-debug > span.badge')
+ ->click('@button');
+ });
+
+ $this->assertNull($user->getSetting('debug'));
+ });
+ }
+
/**
* Test resetting Geo-Lock for the user
*/
diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php
--- a/src/tests/Feature/Controller/Admin/UsersTest.php
+++ b/src/tests/Feature/Controller/Admin/UsersTest.php
@@ -622,7 +622,7 @@
$this->assertCount(2, $json);
// Test real update
- $post = ['external_email' => 'modified@test.com'];
+ $post = ['external_email' => 'modified@test.com', 'debug' => 'roundcube'];
$response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(200);
@@ -632,5 +632,13 @@
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertSame('modified@test.com', $user->getSetting('external_email'));
+ $this->assertSame('roundcube', $user->getSetting('debug'));
+
+ // Test unsetting debug
+ $post = ['debug' => null];
+ $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post);
+ $response->assertStatus(200);
+
+ $this->assertNull($user->getSetting('debug'));
}
}
diff --git a/src/tests/Feature/Controller/ConfigTest.php b/src/tests/Feature/Controller/ConfigTest.php
--- a/src/tests/Feature/Controller/ConfigTest.php
+++ b/src/tests/Feature/Controller/ConfigTest.php
@@ -2,6 +2,7 @@
namespace Tests\Feature\Controller;
+use App\Http\Controllers\API\V4\ConfigController;
use Tests\TestCase;
class ConfigTest extends TestCase
@@ -24,6 +25,7 @@
$json = $response->json();
$this->assertSame(['kolab4', 'groupware'], $json['kolab-configuration-overlays']);
+ $this->assertArrayNotHasKey('debug', $json);
// Ned has groupware, activesync and 2FA
$response = $this->actingAs($ned)->get('api/v4/config/webmail');
@@ -34,11 +36,25 @@
$this->assertSame(['kolab4', 'activesync', '2fa', 'groupware'], $json['kolab-configuration-overlays']);
// Joe has no groupware subscription
+ $setting = $joe->settings()->updateOrCreate(['key' => 'debug'], ['value' => 'roundcube,syncroton']);
$response = $this->actingAs($joe)->get('api/v4/config/webmail');
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(['kolab4'], $json['kolab-configuration-overlays']);
+ $this->assertSame($setting->value, $json['debug']);
+
+ // Test that the debug mode expires
+ $setting->timestamps = false;
+ $setting->updated_at = now()->subHours(ConfigController::DEBUG_TTL + 1);
+ $setting->save();
+ $response = $this->actingAs($joe)->get('api/v4/config/webmail');
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertArrayNotHasKey('debug', $json);
+ $this->assertNull($joe->getSetting('debug'));
}
}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 4:59 PM (5 h, 41 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18828284
Default Alt Text
D5472.1775321984.diff (25 KB)
Attached To
Mode
D5472: User debug mode
Attached
Detach File
Event Timeline