Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in
Toggle navigation
F
Front-End
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
abdullh.alsoleman
Front-End
Commits
69ecca55
Unverified
Commit
69ecca55
authored
Feb 06, 2020
by
Jonah Williams
Committed by
GitHub
Feb 06, 2020
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
[flutter_tools] iOS fallback discovery protocol (#49735)
parent
d94ff4bd
Changes
5
Show whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
323 additions
and
51 deletions
+323
-51
devices.dart
packages/flutter_tools/lib/src/ios/devices.dart
+31
-42
fallback_discovery.dart
packages/flutter_tools/lib/src/ios/fallback_discovery.dart
+158
-0
mdns_discovery.dart
packages/flutter_tools/lib/src/mdns_discovery.dart
+3
-4
devices_test.dart
...es/flutter_tools/test/general.shard/ios/devices_test.dart
+13
-5
fallback_discovery_test.dart
...tools/test/general.shard/ios/fallback_discovery_test.dart
+118
-0
No files found.
packages/flutter_tools/lib/src/ios/devices.dart
View file @
69ecca55
...
...
@@ -3,6 +3,7 @@
// found in the LICENSE file.
import
'dart:async'
;
import
'dart:math'
as
math
;
import
'package:meta/meta.dart'
;
import
'package:platform/platform.dart'
;
...
...
@@ -23,9 +24,9 @@ import '../macos/xcode.dart';
import
'../mdns_discovery.dart'
;
import
'../project.dart'
;
import
'../protocol_discovery.dart'
;
import
'../reporting/reporting.dart'
;
import
'../vmservice.dart'
;
import
'code_signing.dart'
;
import
'fallback_discovery.dart'
;
import
'ios_workflow.dart'
;
import
'mac.dart'
;
...
...
@@ -270,7 +271,6 @@ class IOSDevice extends Device {
bool
prebuiltApplication
=
false
,
bool
ipv6
=
false
,
})
async
{
String
packageId
;
if
(!
prebuiltApplication
)
{
...
...
@@ -307,11 +307,21 @@ class IOSDevice extends Device {
return
LaunchResult
.
failed
();
}
// Step 2.5: Generate a potential open port using the provided argument,
// or randomly with the package name as a seed. Intentionally choose
// ports within the ephemeral port range.
final
int
assumedObservatoryPort
=
debuggingOptions
?.
deviceVmServicePort
??
math
.
Random
(
packageId
.
hashCode
).
nextInt
(
16383
)
+
49152
;
// Step 3: Attempt to install the application on the device.
final
List
<
String
>
launchArguments
=
<
String
>[
'--enable-dart-profiling'
,
// These arguments are required to support the fallback connection strategy
// described in fallback_discovery.dart.
'--enable-service-port-fallback'
,
'--disable-service-auth-codes'
,
'--observatory-port=
$assumedObservatoryPort
'
,
if
(
debuggingOptions
.
startPaused
)
'--start-paused'
,
if
(
debuggingOptions
.
disableServiceAuthCodes
)
'--disable-service-auth-codes'
,
if
(
debuggingOptions
.
dartFlags
.
isNotEmpty
)
'--dart-flags="
${debuggingOptions.dartFlags}
"'
,
if
(
debuggingOptions
.
useTestFonts
)
'--use-test-fonts'
,
// "--enable-checked-mode" and "--verify-entry-points" should always be
...
...
@@ -331,8 +341,6 @@ class IOSDevice extends Device {
if
(
debuggingOptions
.
dumpSkpOnShaderCompilation
)
'--dump-skp-on-shader-compilation'
,
if
(
debuggingOptions
.
verboseSystemLogs
)
'--verbose-logging'
,
if
(
debuggingOptions
.
cacheSkSL
)
'--cache-sksl'
,
if
(
debuggingOptions
.
deviceVmServicePort
!=
null
)
'--observatory-port=
${debuggingOptions.deviceVmServicePort}
'
,
if
(
platformArgs
[
'trace-startup'
]
as
bool
??
false
)
'--trace-startup'
,
];
...
...
@@ -342,11 +350,7 @@ class IOSDevice extends Device {
try
{
ProtocolDiscovery
observatoryDiscovery
;
if
(
debuggingOptions
.
debuggingEnabled
)
{
// Debugging is enabled, look for the observatory server port post launch.
globals
.
printTrace
(
'Debugging is enabled, connecting to observatory'
);
// TODO(danrubel): The Android device class does something similar to this code below.
// The various Device subclasses should be refactored and common code moved into the superclass.
observatoryDiscovery
=
ProtocolDiscovery
.
observatory
(
getLogReader
(
app:
package
),
portForwarder:
portForwarder
,
...
...
@@ -372,40 +376,25 @@ class IOSDevice extends Device {
return
LaunchResult
.
succeeded
();
}
Uri
localUri
;
try
{
globals
.
printTrace
(
'Application launched on the device. Waiting for observatory port.'
);
localUri
=
await
MDnsObservatoryDiscovery
.
instance
.
getObservatoryUri
(
packageId
,
this
,
final
FallbackDiscovery
fallbackDiscovery
=
FallbackDiscovery
(
logger:
globals
.
logger
,
mDnsObservatoryDiscovery:
MDnsObservatoryDiscovery
.
instance
,
portForwarder:
portForwarder
,
protocolDiscovery:
observatoryDiscovery
,
);
final
Uri
localUri
=
await
fallbackDiscovery
.
discover
(
assumedDevicePort:
assumedObservatoryPort
,
deivce:
this
,
usesIpv6:
ipv6
,
hostVmservicePort:
debuggingOptions
.
hostVmServicePort
,
packageId:
packageId
,
packageName:
FlutterProject
.
current
().
manifest
.
appName
,
);
if
(
localUri
!=
null
)
{
UsageEvent
(
'ios-mdns'
,
'success'
).
send
();
return
LaunchResult
.
succeeded
(
observatoryUri:
localUri
);
}
}
catch
(
error
)
{
globals
.
printError
(
'Failed to establish a debug connection with
$id
using mdns:
$error
'
);
if
(
localUri
==
null
)
{
return
LaunchResult
.
failed
();
}
// Fallback to manual protocol discovery.
UsageEvent
(
'ios-mdns'
,
'failure'
).
send
();
globals
.
printTrace
(
'mDNS lookup failed, attempting fallback to reading device log.'
);
try
{
globals
.
printTrace
(
'Waiting for observatory port.'
);
localUri
=
await
observatoryDiscovery
.
uri
;
if
(
localUri
!=
null
)
{
UsageEvent
(
'ios-mdns'
,
'fallback-success'
).
send
();
return
LaunchResult
.
succeeded
(
observatoryUri:
localUri
);
}
}
catch
(
error
)
{
globals
.
printError
(
'Failed to establish a debug connection with
$id
using logs:
$error
'
);
}
finally
{
await
observatoryDiscovery
?.
cancel
();
}
UsageEvent
(
'ios-mdns'
,
'fallback-failure'
).
send
();
return
LaunchResult
.
failed
();
}
finally
{
installStatus
.
stop
();
}
...
...
packages/flutter_tools/lib/src/ios/fallback_discovery.dart
0 → 100644
View file @
69ecca55
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import
'package:meta/meta.dart'
;
import
'package:vm_service/vm_service.dart'
;
import
'package:vm_service/vm_service_io.dart'
as
vm_service_io
;
import
'../base/logger.dart'
;
import
'../device.dart'
;
import
'../mdns_discovery.dart'
;
import
'../protocol_discovery.dart'
;
import
'../reporting/reporting.dart'
;
/// A protocol for discovery of a vmservice on an attached iOS device with
/// multiple fallbacks.
///
/// On versions of iOS 13 and greater, libimobiledevice can no longer listen to
/// logs directly. The only way to discover an active observatory is through the
/// mDNS protocol. However, there are a number of circumstances where this breaks
/// down, such as when the device is connected to certain wifi networks or with
/// certain hotspot connections enabled.
///
/// Another approach to discover a vmservice is to attempt to assign a
/// specific port and then attempt to connect. This may fail if the port is
/// not available. This port value should be either random, or otherwise
/// generated with application specific input. This reduces the chance of
/// accidentally connecting to another running flutter application.
///
/// Finally, if neither of the above approaches works, we can still attempt
/// to parse logs.
///
/// To improve the overall resilience of the process, this class combines the
/// three discovery strategies. First it assigns a port and attempts to connect.
/// Then if this fails it falls back to mDNS, then finally attempting to scan
/// logs.
class
FallbackDiscovery
{
FallbackDiscovery
({
@required
DevicePortForwarder
portForwarder
,
@required
MDnsObservatoryDiscovery
mDnsObservatoryDiscovery
,
@required
Logger
logger
,
@required
ProtocolDiscovery
protocolDiscovery
,
Future
<
VmService
>
Function
(
String
wsUri
,
{
Log
log
})
vmServiceConnectUri
=
vm_service_io
.
vmServiceConnectUri
,
})
:
_logger
=
logger
,
_mDnsObservatoryDiscovery
=
mDnsObservatoryDiscovery
,
_portForwarder
=
portForwarder
,
_protocolDiscovery
=
protocolDiscovery
,
_vmServiceConnectUri
=
vmServiceConnectUri
;
static
const
String
_kEventName
=
'ios-handshake'
;
final
DevicePortForwarder
_portForwarder
;
final
MDnsObservatoryDiscovery
_mDnsObservatoryDiscovery
;
final
Logger
_logger
;
final
ProtocolDiscovery
_protocolDiscovery
;
final
Future
<
VmService
>
Function
(
String
wsUri
,
{
Log
log
})
_vmServiceConnectUri
;
/// Attempt to discover the observatory port.
Future
<
Uri
>
discover
({
@required
int
assumedDevicePort
,
@required
String
packageId
,
@required
Device
deivce
,
@required
bool
usesIpv6
,
@required
int
hostVmservicePort
,
@required
String
packageName
,
})
async
{
final
Uri
result
=
await
_attemptServiceConnection
(
assumedDevicePort:
assumedDevicePort
,
hostVmservicePort:
hostVmservicePort
,
packageName:
packageName
,
);
if
(
result
!=
null
)
{
return
result
;
}
try
{
final
Uri
result
=
await
_mDnsObservatoryDiscovery
.
getObservatoryUri
(
packageId
,
deivce
,
usesIpv6:
usesIpv6
,
hostVmservicePort:
hostVmservicePort
,
);
if
(
result
!=
null
)
{
UsageEvent
(
_kEventName
,
'mdns-success'
).
send
();
return
result
;
}
}
on
Exception
catch
(
err
)
{
_logger
.
printTrace
(
err
.
toString
());
}
_logger
.
printTrace
(
'Failed to connect with mDNS, falling back to log scanning'
);
UsageEvent
(
_kEventName
,
'mdns-failure'
).
send
();
try
{
final
Uri
result
=
await
_protocolDiscovery
.
uri
;
UsageEvent
(
_kEventName
,
'fallback-success'
).
send
();
return
result
;
}
on
ArgumentError
{
// In the event of an invalid InternetAddress, this code attempts to catch
// an ArgumentError from protocol_discovery.dart
}
on
Exception
catch
(
err
)
{
_logger
.
printTrace
(
err
.
toString
());
}
_logger
.
printTrace
(
'Failed to connect with log scanning'
);
UsageEvent
(
_kEventName
,
'fallback-failure'
).
send
();
return
null
;
}
// Attempt to connect to the VM service and find an isolate with a matching `packageName`.
// Returns `null` if no connection can be made.
Future
<
Uri
>
_attemptServiceConnection
({
@required
int
assumedDevicePort
,
@required
int
hostVmservicePort
,
@required
String
packageName
,
})
async
{
int
hostPort
;
Uri
assumedWsUri
;
try
{
hostPort
=
await
_portForwarder
.
forward
(
assumedDevicePort
,
hostPort:
hostVmservicePort
);
assumedWsUri
=
Uri
.
parse
(
'ws://localhost:
$hostPort
/ws'
);
}
on
Exception
catch
(
err
)
{
_logger
.
printTrace
(
err
.
toString
());
_logger
.
printTrace
(
'Failed to connect directly, falling back to mDNS'
);
UsageEvent
(
_kEventName
,
'failure'
).
send
();
return
null
;
}
// Attempt to connect to the VM service 5 times.
int
attempts
=
0
;
const
int
kDelaySeconds
=
2
;
while
(
attempts
<
5
)
{
try
{
final
VmService
vmService
=
await
_vmServiceConnectUri
(
assumedWsUri
.
toString
());
final
VM
vm
=
await
vmService
.
getVM
();
for
(
final
IsolateRef
isolateRefs
in
vm
.
isolates
)
{
final
Isolate
isolate
=
await
vmService
.
getIsolate
(
isolateRefs
.
id
)
as
Isolate
;
final
LibraryRef
library
=
isolate
.
rootLib
;
if
(
library
.
uri
.
startsWith
(
'package:
$packageName
'
))
{
UsageEvent
(
_kEventName
,
'success'
).
send
();
return
Uri
.
parse
(
'http://localhost:
$hostPort
'
);
}
}
}
on
Exception
catch
(
err
)
{
// No action, we might have failed to connect.
_logger
.
printTrace
(
err
.
toString
());
}
// No exponential backoff is used here to keep the amount of time the
// tool waits for a connection to be reasonable. If the vmservice cannot
// be connected to in this way, the mDNS discovery must be reached
// sooner rather than later.
await
Future
<
void
>.
delayed
(
const
Duration
(
seconds:
kDelaySeconds
));
attempts
+=
1
;
}
_logger
.
printTrace
(
'Failed to connect directly, falling back to mDNS'
);
UsageEvent
(
_kEventName
,
'failure'
).
send
();
return
null
;
}
}
packages/flutter_tools/lib/src/mdns_discovery.dart
View file @
69ecca55
...
...
@@ -63,15 +63,14 @@ class MDnsObservatoryDiscovery {
)
.
toList
();
if
(
pointerRecords
.
isEmpty
)
{
globals
.
printTrace
(
'No pointer records found.'
);
globals
.
printTrace
(
'No pointer records found.'
);
return
null
;
}
// We have no guarantee that we won't get multiple hits from the same
// service on this.
final
Lis
t
<
String
>
uniqueDomainNames
=
pointerRecords
final
Se
t
<
String
>
uniqueDomainNames
=
pointerRecords
.
map
<
String
>((
PtrResourceRecord
record
)
=>
record
.
domainName
)
.
toSet
()
.
toList
();
.
toSet
();
String
domainName
;
if
(
applicationId
!=
null
)
{
...
...
packages/flutter_tools/test/general.shard/ios/devices_test.dart
View file @
69ecca55
...
...
@@ -227,6 +227,10 @@ void main() {
when
(
mockPortForwarder
.
unforward
(
any
))
.
thenAnswer
((
_
)
async
=>
null
);
final
MemoryFileSystem
memoryFileSystem
=
MemoryFileSystem
();
when
(
mockFileSystem
.
currentDirectory
)
.
thenReturn
(
memoryFileSystem
.
currentDirectory
);
const
String
bundlePath
=
'/path/to/bundle'
;
final
List
<
String
>
installArgs
=
<
String
>[
installerPath
,
'-i'
,
bundlePath
];
when
(
mockApp
.
deviceBundlePath
).
thenReturn
(
bundlePath
);
...
...
@@ -277,7 +281,7 @@ void main() {
debuggingOptions:
DebuggingOptions
.
enabled
(
const
BuildInfo
(
BuildMode
.
debug
,
null
,
treeShakeIcons:
false
)),
platformArgs:
<
String
,
dynamic
>{},
);
verify
(
mockUsage
.
sendEvent
(
'ios-
mdns'
,
'
success'
)).
called
(
1
);
verify
(
mockUsage
.
sendEvent
(
'ios-
handshake'
,
'mdns-
success'
)).
called
(
1
);
expect
(
launchResult
.
started
,
isTrue
);
expect
(
launchResult
.
hasObservatory
,
isTrue
);
expect
(
await
device
.
stopApp
(
mockApp
),
isFalse
);
...
...
@@ -347,8 +351,8 @@ void main() {
debuggingOptions:
DebuggingOptions
.
enabled
(
const
BuildInfo
(
BuildMode
.
debug
,
null
,
treeShakeIcons:
false
)),
platformArgs:
<
String
,
dynamic
>{},
);
verify
(
mockUsage
.
sendEvent
(
'ios-
mdns'
,
'
failure'
)).
called
(
1
);
verify
(
mockUsage
.
sendEvent
(
'ios-
mdns
'
,
'fallback-success'
)).
called
(
1
);
verify
(
mockUsage
.
sendEvent
(
'ios-
handshake'
,
'mdns-
failure'
)).
called
(
1
);
verify
(
mockUsage
.
sendEvent
(
'ios-
handshake
'
,
'fallback-success'
)).
called
(
1
);
expect
(
launchResult
.
started
,
isTrue
);
expect
(
launchResult
.
hasObservatory
,
isTrue
);
expect
(
await
device
.
stopApp
(
mockApp
),
isFalse
);
...
...
@@ -380,8 +384,9 @@ void main() {
debuggingOptions:
DebuggingOptions
.
enabled
(
const
BuildInfo
(
BuildMode
.
debug
,
null
,
treeShakeIcons:
false
)),
platformArgs:
<
String
,
dynamic
>{},
);
verify
(
mockUsage
.
sendEvent
(
'ios-mdns'
,
'failure'
)).
called
(
1
);
verify
(
mockUsage
.
sendEvent
(
'ios-mdns'
,
'fallback-failure'
)).
called
(
1
);
verify
(
mockUsage
.
sendEvent
(
'ios-handshake'
,
'failure'
)).
called
(
1
);
verify
(
mockUsage
.
sendEvent
(
'ios-handshake'
,
'mdns-failure'
)).
called
(
1
);
verify
(
mockUsage
.
sendEvent
(
'ios-handshake'
,
'fallback-failure'
)).
called
(
1
);
expect
(
launchResult
.
started
,
isFalse
);
expect
(
launchResult
.
hasObservatory
,
isFalse
);
},
overrides:
<
Type
,
Generator
>{
...
...
@@ -669,6 +674,9 @@ void main() {
mockCache
=
MockCache
();
when
(
mockCache
.
dyLdLibEntry
).
thenReturn
(
libraryEntry
);
mockFileSystem
=
MockFileSystem
();
final
MemoryFileSystem
memoryFileSystem
=
MemoryFileSystem
();
when
(
mockFileSystem
.
currentDirectory
)
.
thenReturn
(
memoryFileSystem
.
currentDirectory
);
mockProcessManager
=
MockProcessManager
();
when
(
mockArtifacts
.
getArtifactPath
(
...
...
packages/flutter_tools/test/general.shard/ios/fallback_discovery_test.dart
0 → 100644
View file @
69ecca55
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import
'package:flutter_tools/src/base/logger.dart'
;
import
'package:flutter_tools/src/base/terminal.dart'
;
import
'package:flutter_tools/src/device.dart'
;
import
'package:flutter_tools/src/ios/fallback_discovery.dart'
;
import
'package:flutter_tools/src/mdns_discovery.dart'
;
import
'package:flutter_tools/src/protocol_discovery.dart'
;
import
'package:mockito/mockito.dart'
;
import
'package:platform/platform.dart'
;
import
'package:vm_service/vm_service.dart'
;
import
'../../src/common.dart'
;
import
'../../src/context.dart'
;
import
'../../src/mocks.dart'
;
// This test still uses `testUsingContext` due to analytics usage.
void
main
(
)
{
BufferLogger
logger
;
FallbackDiscovery
fallbackDiscovery
;
MockMDnsObservatoryDiscovery
mockMDnsObservatoryDiscovery
;
MockPrototcolDiscovery
mockPrototcolDiscovery
;
MockPortForwarder
mockPortForwarder
;
MockVmService
mockVmService
;
setUp
(()
{
logger
=
BufferLogger
(
terminal:
AnsiTerminal
(
stdio:
MockStdio
(),
platform:
const
LocalPlatform
()),
outputPreferences:
OutputPreferences
.
test
(),
);
mockVmService
=
MockVmService
();
mockMDnsObservatoryDiscovery
=
MockMDnsObservatoryDiscovery
();
mockPrototcolDiscovery
=
MockPrototcolDiscovery
();
mockPortForwarder
=
MockPortForwarder
();
fallbackDiscovery
=
FallbackDiscovery
(
logger:
logger
,
mDnsObservatoryDiscovery:
mockMDnsObservatoryDiscovery
,
portForwarder:
mockPortForwarder
,
protocolDiscovery:
mockPrototcolDiscovery
,
vmServiceConnectUri:
(
String
uri
,
{
Log
log
})
async
{
return
mockVmService
;
},
);
when
(
mockPortForwarder
.
forward
(
23
,
hostPort:
anyNamed
(
'hostPort'
)))
.
thenAnswer
((
Invocation
invocation
)
async
=>
1
);
});
testUsingContext
(
'Selects assumed port if VM service connection is successful'
,
()
async
{
when
(
mockVmService
.
getVM
()).
thenAnswer
((
Invocation
invocation
)
async
{
return
VM
()..
isolates
=
<
IsolateRef
>[
IsolateRef
(),
];
});
when
(
mockVmService
.
getIsolate
(
any
)).
thenAnswer
((
Invocation
invocation
)
async
{
return
Isolate
()
..
rootLib
=
(
LibraryRef
()..
uri
=
'package:hello/main.dart'
);
});
expect
(
await
fallbackDiscovery
.
discover
(
assumedDevicePort:
23
,
deivce:
null
,
hostVmservicePort:
1
,
packageId:
null
,
usesIpv6:
false
,
packageName:
'hello'
,
),
Uri
.
parse
(
'http://localhost:1'
));
});
testUsingContext
(
'Selects mdns discovery if VM service connecton fails'
,
()
async
{
when
(
mockVmService
.
getVM
()).
thenThrow
(
Exception
());
when
(
mockMDnsObservatoryDiscovery
.
getObservatoryUri
(
'hello'
,
null
,
// Device
usesIpv6:
false
,
hostVmservicePort:
1
,
)).
thenAnswer
((
Invocation
invocation
)
async
{
return
Uri
.
parse
(
'http://localhost:1234'
);
});
expect
(
await
fallbackDiscovery
.
discover
(
assumedDevicePort:
23
,
deivce:
null
,
hostVmservicePort:
1
,
packageId:
'hello'
,
usesIpv6:
false
,
packageName:
'hello'
,
),
Uri
.
parse
(
'http://localhost:1234'
));
});
testUsingContext
(
'Selects log scanning if both VM Service and mDNS fails'
,
()
async
{
when
(
mockVmService
.
getVM
()).
thenThrow
(
Exception
());
when
(
mockMDnsObservatoryDiscovery
.
getObservatoryUri
(
'hello'
,
null
,
// Device
usesIpv6:
false
,
hostVmservicePort:
1
,
)).
thenThrow
(
Exception
());
when
(
mockPrototcolDiscovery
.
uri
).
thenAnswer
((
Invocation
invocation
)
async
{
return
Uri
.
parse
(
'http://localhost:5678'
);
});
expect
(
await
fallbackDiscovery
.
discover
(
assumedDevicePort:
23
,
deivce:
null
,
hostVmservicePort:
1
,
packageId:
'hello'
,
usesIpv6:
false
,
packageName:
'hello'
,
),
Uri
.
parse
(
'http://localhost:5678'
));
});
}
class
MockMDnsObservatoryDiscovery
extends
Mock
implements
MDnsObservatoryDiscovery
{}
class
MockPrototcolDiscovery
extends
Mock
implements
ProtocolDiscovery
{}
class
MockPortForwarder
extends
Mock
implements
DevicePortForwarder
{}
class
MockVmService
extends
Mock
implements
VmService
{}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment