Commit 5c68ce74 authored by Tiago Cunha's avatar Tiago Cunha

Some interactors

parent dc6dd5ec
...@@ -7,8 +7,23 @@ import org.json.JSONArray; ...@@ -7,8 +7,23 @@ import org.json.JSONArray;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
import chat.rocket.core.models.Permission;
import chat.rocket.core.models.Role;
public class RealmPermission extends RealmObject { public class RealmPermission extends RealmObject {
public interface Columns {
String ID = "_id";
String NAME = "name";
String ROLES = "roles";
}
@PrimaryKey private String _id;
private String name;
private RealmList<RealmRole> roles;
public static JSONObject customizeJson(JSONObject permissionsJson) throws JSONException { public static JSONObject customizeJson(JSONObject permissionsJson) throws JSONException {
permissionsJson.put(Columns.NAME, permissionsJson.getString(Columns.ID)); permissionsJson.put(Columns.NAME, permissionsJson.getString(Columns.ID));
...@@ -24,16 +39,6 @@ public class RealmPermission extends RealmObject { ...@@ -24,16 +39,6 @@ public class RealmPermission extends RealmObject {
return permissionsJson; return permissionsJson;
} }
public interface Columns {
String ID = "_id";
String NAME = "name";
String ROLES = "roles";
}
@PrimaryKey private String _id;
private String name;
private RealmList<RealmRole> roles;
public String getId() { public String getId() {
return _id; return _id;
} }
...@@ -57,4 +62,19 @@ public class RealmPermission extends RealmObject { ...@@ -57,4 +62,19 @@ public class RealmPermission extends RealmObject {
public void setRoles(RealmList<RealmRole> roles) { public void setRoles(RealmList<RealmRole> roles) {
this.roles = roles; this.roles = roles;
} }
public Permission asPermission() {
int size = this.roles.size();
List<Role> roles = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
roles.add(this.roles.get(i).asRole());
}
return Permission.builder()
.setId(_id)
.setName(name)
.setRoles(roles)
.build();
}
} }
...@@ -5,8 +5,18 @@ import io.realm.annotations.PrimaryKey; ...@@ -5,8 +5,18 @@ import io.realm.annotations.PrimaryKey;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
import chat.rocket.core.models.Role;
public class RealmRole extends RealmObject { public class RealmRole extends RealmObject {
public interface Columns {
String ID = "id";
String NAME = "name";
}
@PrimaryKey private String id;
private String name;
public static JSONObject customizeJson(String roleString) throws JSONException { public static JSONObject customizeJson(String roleString) throws JSONException {
JSONObject roleObject = new JSONObject(); JSONObject roleObject = new JSONObject();
...@@ -16,14 +26,6 @@ public class RealmRole extends RealmObject { ...@@ -16,14 +26,6 @@ public class RealmRole extends RealmObject {
return roleObject; return roleObject;
} }
public interface Columns {
String ID = "id";
String NAME = "name";
}
@PrimaryKey private String id;
private String name;
public String getId() { public String getId() {
return id; return id;
} }
...@@ -39,4 +41,11 @@ public class RealmRole extends RealmObject { ...@@ -39,4 +41,11 @@ public class RealmRole extends RealmObject {
public void setName(String name) { public void setName(String name) {
this.name = name; this.name = name;
} }
public Role asRole() {
return Role.builder()
.setId(id)
.setName(name)
.build();
}
} }
...@@ -7,8 +7,25 @@ import org.json.JSONArray; ...@@ -7,8 +7,25 @@ import org.json.JSONArray;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
import chat.rocket.core.models.Role;
import chat.rocket.core.models.RoomRole;
public class RealmRoomRole extends RealmObject { public class RealmRoomRole extends RealmObject {
public interface Columns {
String ID = "_id";
String ROOM_ID = "rid";
String USER = "u";
String ROLES = "roles";
}
@PrimaryKey private String _id;
private String rid;
private RealmUser u;
private RealmList<RealmRole> roles;
public static JSONObject customizeJson(JSONObject roomRoles) throws JSONException { public static JSONObject customizeJson(JSONObject roomRoles) throws JSONException {
JSONArray roleStrings = roomRoles.getJSONArray(Columns.ROLES); JSONArray roleStrings = roomRoles.getJSONArray(Columns.ROLES);
JSONArray roles = new JSONArray(); JSONArray roles = new JSONArray();
...@@ -22,18 +39,6 @@ public class RealmRoomRole extends RealmObject { ...@@ -22,18 +39,6 @@ public class RealmRoomRole extends RealmObject {
return roomRoles; return roomRoles;
} }
public interface Columns {
String ID = "_id";
String ROOM_ID = "rid";
String USER = "u";
String ROLES = "roles";
}
@PrimaryKey private String _id;
private String rid;
private RealmUser u;
private RealmList<RealmRole> roles;
public String getId() { public String getId() {
return _id; return _id;
} }
...@@ -65,4 +70,20 @@ public class RealmRoomRole extends RealmObject { ...@@ -65,4 +70,20 @@ public class RealmRoomRole extends RealmObject {
public void setRoles(RealmList<RealmRole> roles) { public void setRoles(RealmList<RealmRole> roles) {
this.roles = roles; this.roles = roles;
} }
public RoomRole asRoomRole() {
int size = this.roles.size();
List<Role> roles = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
roles.add(this.roles.get(i).asRole());
}
return RoomRole.builder()
.setId(_id)
.setRoomId(rid)
.setUser(u.asUser())
.setRoles(roles)
.build();
}
} }
plugins { plugins {
id "org.jetbrains.kotlin.jvm" version "1.1.1" id "org.jetbrains.kotlin.jvm" version "1.1.2"
} }
apply plugin: 'idea' apply plugin: 'idea'
...@@ -8,7 +8,7 @@ apply plugin: 'java' ...@@ -8,7 +8,7 @@ apply plugin: 'java'
dependencies { dependencies {
compile fileTree(dir: 'libs', include: ['*.jar']) compile fileTree(dir: 'libs', include: ['*.jar'])
compile 'org.jetbrains.kotlin:kotlin-stdlib-jre7:1.1.1' compile 'org.jetbrains.kotlin:kotlin-stdlib-jre7:1.1.2'
compile 'com.google.code.findbugs:jsr305:3.0.1' compile 'com.google.code.findbugs:jsr305:3.0.1'
...@@ -21,7 +21,7 @@ dependencies { ...@@ -21,7 +21,7 @@ dependencies {
kapt 'com.gabrielittner.auto.value:auto-value-with:1.0.0' kapt 'com.gabrielittner.auto.value:auto-value-with:1.0.0'
testCompile 'junit:junit:4.12' testCompile 'junit:junit:4.12'
testCompile "org.mockito:mockito-core:2.7.19" testCompile "org.mockito:mockito-inline:2.8.9"
} }
sourceCompatibility = "1.7" sourceCompatibility = "1.7"
......
package chat.rocket.core;
public interface PermissionsConstants {
String ACCESS_MAILER = "access-mailer";
String ACCESS_PERMISSIONS = "access-permissions";
String ACCESS_ROCKET_MAILER = "access-rocket-mailer";
String ADD_OATH_SERVICE = "add-oath-service";
String ADD_OAUTH_SERVICE = "add-oauth-service";
String ADD_USER = "add-user";
String ADD_USER_TO_ANY_C_ROOM = "add-user-to-any-c-room";
String ADD_USER_TO_ANY_P_ROOM = "add-user-to-any-p-room";
String ADD_USER_TO_JOINED_ROOM = "add-user-to-joined-room";
String ARCHIVE_ROOM = "archive-room";
String ASSIGN_ADMIN_ROLE = "assign-admin-role";
String AUTO_TRANSLATE = "auto-translate";
String BAN_USER = "ban-user";
String BULK_CREATE_C = "bulk-create-c";
String BULK_REGISTER_USER = "bulk-register-user";
String CLEAN_CHANNEL_HISTORY = "clean-channel-history";
String CLOSE_LIVECHAT_ROOM = "close-livechat-room";
String CLOSE_OTHERS_LIVECHAT_ROOM = "close-others-livechat-room";
String CREATE_C = "create-c";
String CREATE_D = "create-d";
String CREATE_P = "create-p";
String CREATE_USER = "create-user";
String DELETE_C = "delete-c";
String DELETE_D = "delete-d";
String DELETE_MESSAGE = "delete-message";
String DELETE_P = "delete-p";
String DELETE_USER = "delete-user";
String EDIT_LIVECHAT_SETTINGS = "edit-livechat-settings";
String EDIT_MESSAGE = "edit-message";
String EDIT_OTHER_USER_ACTIVE_STATUS = "edit-other-user-active-status";
String EDIT_OTHER_USER_INFO = "edit-other-user-info";
String EDIT_OTHER_USER_PASSWORD = "edit-other-user-password";
String EDIT_PRIVILEGED_SETTING = "edit-privileged-setting";
String EDIT_ROOM = "edit-room";
String JOIN_WITHOUT_JOIN_CODE = "join-without-join-code";
String MAIL_MESSAGES = "mail-messages";
String MANAGE_ASSETS = "manage-assets";
String MANAGE_EMOJI = "manage-emoji";
String MANAGE_INTEGRATIONS = "manage-integrations";
String MANAGE_OAUTH_APPS = "manage-oauth-apps";
String MANAGE_OWN_INTEGRATIONS = "manage-own-integrations";
String MANAGE_SOUNDS = "manage-sounds";
String MENTION_ALL = "mention-all";
String MUTE_USER = "mute-user";
String PIN_MESSAGE = "pin-message";
String POST_READONLY = "post-readonly";
String PREVIEW_C_ROOM = "preview-c-room";
String RECEIVE_LIVECHAT = "receive-livechat";
String REMOVE_USER = "remove-user";
String RUN_IMPORT = "run-import";
String RUN_MIGRATION = "run-migration";
String SAVE_OTHERS_LIVECHAT_ROOM_INFO = "save-others-livechat-room-info";
String SET_MODERATOR = "set-moderator";
String SET_OWNER = "set-owner";
String SET_REACT_WHEN_READONLY = "set-react-when-readonly";
String SET_READONLY = "set-readonly";
String SNIPPET_MESSAGE = "snippet-message";
String UNARCHIVE_ROOM = "unarchive-room";
String VIEW_C_ROOM = "view-c-room";
String VIEW_D_ROOM = "view-d-room";
String VIEW_FULL_OTHER_USER_INFO = "view-full-other-user-info";
String VIEW_HISTORY = "view-history";
String VIEW_JOIN_CODE = "view-join-code";
String VIEW_JOINED_ROOM = "view-joined-room";
String VIEW_L_ROOM = "view-l-room";
String VIEW_LIVECHAT_MANAGER = "view-livechat-manager";
String VIEW_LIVECHAT_ROOMS = "view-livechat-rooms";
String VIEW_LOGS = "view-logs";
String VIEW_OTHER_USER_CHANNELS = "view-other-user-channels";
String VIEW_P_ROOM = "view-p-room";
String VIEW_PRIVILEGED_SETTING = "view-privileged-setting";
String VIEW_ROOM_ADMINISTRATION = "view-room-administration";
String VIEW_STATISTICS = "view-statistics";
String VIEW_USER_ADMINISTRATION = "view-user-administration";
}
package chat.rocket.core.interactors
import chat.rocket.core.PermissionsConstants
import chat.rocket.core.PublicSettingsConstants
import chat.rocket.core.models.*
import chat.rocket.core.repositories.*
import chat.rocket.core.utils.Pair
import com.fernandocejas.arrow.optional.Optional
import io.reactivex.Single
import io.reactivex.functions.Function4
class EditMessageInteractor(private val permissionInteractor: PermissionInteractor,
private val userRepository: UserRepository,
private val messageRepository: MessageRepository,
private val roomRepository: RoomRepository,
private val publicSettingRepository: PublicSettingRepository) {
fun isAllowed(message: Message): Single<Boolean> {
return Single.zip<Optional<User>, Optional<Room>, Optional<PublicSetting>, Optional<PublicSetting>, Pair<Optional<Room>, Boolean>>(
userRepository.current.first(Optional.absent()),
roomRepository.getById(message.roomId).first(Optional.absent()),
publicSettingRepository.getById(PublicSettingsConstants.Message.ALLOW_EDITING),
publicSettingRepository.getById(PublicSettingsConstants.Message.ALLOW_EDITING_BLOCK_TIMEOUT),
Function4 { user, room, allowEdit, editTimeout ->
val editAllowed = allowEdit.isPresent && allowEdit.get().valueAsBoolean
val editTimeLimitInMinutes = editTimeout.longValue()
val editAllowedInTime = if (editTimeLimitInMinutes > 0) {
message.timestamp.millisToMinutes() < editTimeLimitInMinutes
} else {
true
}
val editOwn = user.isPresent && user.get().id == message.user?.id
Pair.create(room, editAllowed && editAllowedInTime && editOwn)
}
)
.flatMap { (room, editAllowed) ->
if (!room.isPresent) {
return@flatMap Single.just(false)
}
permissionInteractor.isAllowed(PermissionsConstants.EDIT_MESSAGE, room.get())
.map { it || editAllowed }
}
}
}
fun Optional<PublicSetting>.longValue(defaultValue: Long = 0) = if (this.isPresent) {
this.get().valueAsLong
} else {
defaultValue
}
fun Long.millisToMinutes() = this / 60_000
\ No newline at end of file
...@@ -12,7 +12,8 @@ import chat.rocket.core.models.User ...@@ -12,7 +12,8 @@ import chat.rocket.core.models.User
import chat.rocket.core.repositories.MessageRepository import chat.rocket.core.repositories.MessageRepository
import chat.rocket.core.repositories.RoomRepository import chat.rocket.core.repositories.RoomRepository
class MessageInteractor(private val messageRepository: MessageRepository, private val roomRepository: RoomRepository) { class MessageInteractor(private val messageRepository: MessageRepository,
private val roomRepository: RoomRepository) {
fun loadMessages(room: Room): Single<Boolean> { fun loadMessages(room: Room): Single<Boolean> {
val roomHistoryState = RoomHistoryState.builder() val roomHistoryState = RoomHistoryState.builder()
......
package chat.rocket.core.interactors
import chat.rocket.core.models.Permission
import chat.rocket.core.models.Room
import chat.rocket.core.models.RoomRole
import chat.rocket.core.repositories.*
import chat.rocket.core.utils.Pair
import com.fernandocejas.arrow.optional.Optional
import io.reactivex.Single
import io.reactivex.functions.BiFunction
class PermissionInteractor(private val userRepository: UserRepository,
private val roomRoleRepository: RoomRoleRepository,
private val permissionRepository: PermissionRepository) {
fun isAllowed(permissionId: String, room: Room): Single<Boolean> {
return userRepository.current
.first(Optional.absent())
.flatMap {
if (!it.isPresent) {
return@flatMap Single.just(false)
}
Single.zip<Optional<RoomRole>, Optional<Permission>, Pair<Optional<RoomRole>, Optional<Permission>>>(
roomRoleRepository.getFor(room, it.get()),
permissionRepository.getById(permissionId),
BiFunction { a, b -> Pair.create(a, b) }
)
.flatMap innerFlatMap@ {
if (!it.first.isPresent || !it.second.isPresent) {
return@innerFlatMap Single.just(false)
}
val commonRoles = it.first.get().roles.intersect(
it.second.get().roles
)
Single.just(commonRoles.isNotEmpty())
}
}
}
}
package chat.rocket.core.models;
import com.google.auto.value.AutoValue;
import java.util.List;
@AutoValue
public abstract class Permission {
public abstract String getId();
public abstract String getName();
public abstract List<Role> getRoles();
public static Builder builder() {
return new AutoValue_Permission.Builder();
}
@AutoValue.Builder
public abstract static class Builder {
public abstract Builder setId(String id);
public abstract Builder setName(String name);
public abstract Builder setRoles(List<Role> roles);
public abstract Permission build();
}
}
...@@ -23,6 +23,10 @@ public abstract class PublicSetting { ...@@ -23,6 +23,10 @@ public abstract class PublicSetting {
return Boolean.parseBoolean(getValue()); return Boolean.parseBoolean(getValue());
} }
public long getValueAsLong() {
return Long.parseLong(getValue());
}
public static Builder builder() { public static Builder builder() {
return new AutoValue_PublicSetting.Builder(); return new AutoValue_PublicSetting.Builder();
} }
......
package chat.rocket.core.models;
import com.google.auto.value.AutoValue;
@AutoValue
public abstract class Role {
public abstract String getId();
public abstract String getName();
public static Builder builder() {
return new AutoValue_Role.Builder();
}
@AutoValue.Builder
public abstract static class Builder {
public abstract Builder setId(String id);
public abstract Builder setName(String name);
public abstract Role build();
}
}
package chat.rocket.core.models;
import com.google.auto.value.AutoValue;
import java.util.List;
@AutoValue
public abstract class RoomRole {
public abstract String getId();
public abstract String getRoomId();
public abstract User getUser();
public abstract List<Role> getRoles();
public static Builder builder() {
return new AutoValue_RoomRole.Builder();
}
@AutoValue.Builder
public abstract static class Builder {
public abstract Builder setId(String id);
public abstract Builder setRoomId(String roomId);
public abstract Builder setUser(User user);
public abstract Builder setRoles(List<Role> roles);
public abstract RoomRole build();
}
}
package chat.rocket.core.repositories;
import com.fernandocejas.arrow.optional.Optional;
import io.reactivex.Single;
import java.util.List;
import chat.rocket.core.models.Permission;
import chat.rocket.core.models.Role;
public interface PermissionRepository {
Single<List<Permission>> getFor(Role role);
Single<Optional<Permission>> getById(String id);
}
package chat.rocket.core.repositories;
import com.fernandocejas.arrow.optional.Optional;
import io.reactivex.Single;
import chat.rocket.core.models.Room;
import chat.rocket.core.models.RoomRole;
import chat.rocket.core.models.User;
public interface RoomRoleRepository {
Single<Optional<RoomRole>> getFor(Room room, User user);
}
...@@ -37,6 +37,14 @@ public class Pair<F, S> { ...@@ -37,6 +37,14 @@ public class Pair<F, S> {
this.second = second; this.second = second;
} }
public F component1() {
return first;
}
public S component2() {
return second;
}
/** /**
* Checks the two objects for equality by delegating to their respective * Checks the two objects for equality by delegating to their respective
* {@link Object#equals(Object)} methods. * {@link Object#equals(Object)} methods.
......
package chat.rocket.core.interactors
import chat.rocket.core.models.*
import chat.rocket.core.repositories.PermissionRepository
import chat.rocket.core.repositories.RoomRoleRepository
import chat.rocket.core.repositories.UserRepository
import com.fernandocejas.arrow.optional.Optional
import io.reactivex.Flowable
import io.reactivex.Single
import io.reactivex.observers.TestObserver
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.*
import org.mockito.junit.MockitoJUnitRunner
@RunWith(MockitoJUnitRunner::class)
class PermissionInteractorTest {
@Mock
lateinit var userRepository: UserRepository
@Mock
lateinit var roomRoleRepository: RoomRoleRepository
@Mock
lateinit var permissionRepository: PermissionRepository
@Mock
lateinit var room: Room
@Mock
lateinit var user: User
@Mock
lateinit var roomRole: RoomRole
@Mock
lateinit var permission: Permission
lateinit var permissionInteractor: PermissionInteractor
@Before
fun setUp() {
permissionInteractor = PermissionInteractor(
userRepository, roomRoleRepository, permissionRepository
)
}
@Test
fun isAllowedReturnsFalseWhenWithoutCurrentUser() {
`when`(userRepository.current)
.thenReturn(Flowable.just(Optional.absent()))
val testObserver = TestObserver<Boolean>()
permissionInteractor.isAllowed("permission", room)
.subscribe(testObserver)
testObserver.assertResult(false)
}
@Test
fun isAllowedReturnsFalseWhenWithoutRoomRoleAndPermission() {
val permissionId = "permission"
`when`(userRepository.current)
.thenReturn(Flowable.just(Optional.of(user)))
`when`(roomRoleRepository.getFor(any(Room::class.java), any(User::class.java)))
.thenReturn(Single.just(Optional.absent()))
`when`(permissionRepository.getById(permissionId))
.thenReturn(Single.just(Optional.absent()))
val testObserver = TestObserver<Boolean>()
permissionInteractor.isAllowed(permissionId, room)
.subscribe(testObserver)
testObserver.assertResult(false)
}
@Test
fun isAllowedReturnsFalseWhenWithoutRoomRole() {
val permissionId = "permission"
`when`(userRepository.current)
.thenReturn(Flowable.just(Optional.of(user)))
`when`(roomRoleRepository.getFor(any(Room::class.java), any(User::class.java)))
.thenReturn(Single.just(Optional.absent()))
`when`(permissionRepository.getById(permissionId))
.thenReturn(Single.just(Optional.of(permission)))
val testObserver = TestObserver<Boolean>()
permissionInteractor.isAllowed(permissionId, room)
.subscribe(testObserver)
testObserver.assertResult(false)
}
@Test
fun isAllowedReturnsFalseWhenWithoutPermission() {
val permissionId = "permission"
`when`(userRepository.current)
.thenReturn(Flowable.just(Optional.of(user)))
`when`(roomRoleRepository.getFor(any(Room::class.java), any(User::class.java)))
.thenReturn(Single.just(Optional.of(roomRole)))
`when`(permissionRepository.getById(permissionId))
.thenReturn(Single.just(Optional.absent()))
val testObserver = TestObserver<Boolean>()
permissionInteractor.isAllowed(permissionId, room)
.subscribe(testObserver)
testObserver.assertResult(false)
}
@Test
fun isAllowedReturnsFalseWhenRoomRoleAndPermissionDoesNotMatchWithEmptyRoles() {
val permissionId = "permission"
`when`(userRepository.current)
.thenReturn(Flowable.just(Optional.of(user)))
`when`(roomRoleRepository.getFor(any(Room::class.java), any(User::class.java)))
.thenReturn(Single.just(Optional.of(roomRole)))
`when`(permissionRepository.getById(permissionId))
.thenReturn(Single.just(Optional.of(permission)))
val testObserver = TestObserver<Boolean>()
permissionInteractor.isAllowed(permissionId, room)
.subscribe(testObserver)
testObserver.assertResult(false)
}
@Test
fun isAllowedReturnsFalseWhenRoomRoleAndPermissionDoesNotMatchWithRoles() {
val permissionId = "permission"
`when`(userRepository.current)
.thenReturn(Flowable.just(Optional.of(user)))
`when`(roomRole.roles).thenReturn(getSomeRoles())
`when`(roomRoleRepository.getFor(any(Room::class.java), any(User::class.java)))
.thenReturn(Single.just(Optional.of(roomRole)))
`when`(permission.roles).thenReturn(getOtherRoles())
`when`(permissionRepository.getById(permissionId))
.thenReturn(Single.just(Optional.of(permission)))
val testObserver = TestObserver<Boolean>()
permissionInteractor.isAllowed(permissionId, room)
.subscribe(testObserver)
testObserver.assertResult(false)
}
@Test
fun isAllowedReturnsTrueWhenRoomRoleAndPermissionDoesMatch() {
val permissionId = "permission"
`when`(userRepository.current)
.thenReturn(Flowable.just(Optional.of(user)))
`when`(roomRole.roles).thenReturn(getMoreRoles())
`when`(roomRoleRepository.getFor(any(Room::class.java), any(User::class.java)))
.thenReturn(Single.just(Optional.of(roomRole)))
`when`(permission.roles).thenReturn(getOtherRoles())
`when`(permissionRepository.getById(permissionId))
.thenReturn(Single.just(Optional.of(permission)))
val testObserver = TestObserver<Boolean>()
permissionInteractor.isAllowed(permissionId, room)
.subscribe(testObserver)
testObserver.assertResult(true)
}
private fun getSomeRoles() = listOf(
Role.builder().setId("one role id").setName("one role name").build()
)
private fun getOtherRoles() = listOf(
Role.builder().setId("other role id").setName("other role name").build(),
Role.builder().setId("another role id").setName("another role name").build()
)
private fun getMoreRoles() = getSomeRoles() + listOf(
Role.builder().setId("other role id").setName("other role name").build(),
Role.builder().setId("another role id").setName("another role name").build()
)
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment